Merge pull request #8 from o0kuma/cursor/phase1-b-local-fcm-382d
Phase 1 B complete: encrypted drift DB, FCM notify, session revoke
This commit is contained in:
commit
f2d7ac8054
|
|
@ -1,3 +1,10 @@
|
||||||
# Copy to .env and fill in values. Never commit .env.
|
# Copy to .env and fill in values. Never commit .env.
|
||||||
# Get a key from Google AI Studio: https://aistudio.google.com/apikey
|
# Get a key from Google AI Studio: https://aistudio.google.com/apikey
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
|
|
||||||
|
# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test)
|
||||||
|
ADMIN_API_TOKEN=
|
||||||
|
|
||||||
|
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).
|
||||||
|
# Without this, notifyUser soft-skips and records push_skipped metrics.
|
||||||
|
FCM_SERVER_KEY=
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ Phase 1 B (베타 품질) 추가분:
|
||||||
- `GET /admin/dashboard` — 최소 HTML 대시보드
|
- `GET /admin/dashboard` — 최소 HTML 대시보드
|
||||||
- `POST|GET /users/:id/device-tokens` — FCM 토큰 등록(전송은 후속)
|
- `POST|GET /users/:id/device-tokens` — FCM 토큰 등록(전송은 후속)
|
||||||
- `GET /users/:id/sessions` — 활성 세션 목록(멀티 디바이스 1차)
|
- `GET /users/:id/sessions` — 활성 세션 목록(멀티 디바이스 1차)
|
||||||
|
- `DELETE /users/:id/sessions/:sessionId` — 세션 종료
|
||||||
|
- `POST /admin/push-test` — FCM 스모크 테스트 (`FCM_SERVER_KEY` 필요, 없으면 skipped)
|
||||||
|
- 에스컬레이션 시 사용자 디바이스로 푸시 시도 (`notifyUser`)
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,61 @@ func registerBRoutes(r *gin.Engine, db *gorm.DB) {
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"sessions": out})
|
c.JSON(http.StatusOK, gin.H{"sessions": out})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Revoke another (or current) session — multi-device control.
|
||||||
|
r.DELETE("/users/:id/sessions/:sessionId", func(c *gin.Context) {
|
||||||
|
userID, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionID, ok := parseUintParam(c, "sessionId")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor, ok := currentUser(c, db, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if actor.ID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"detail": "can only revoke your own sessions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var session Session
|
||||||
|
if err := db.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "session not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Delete(&session)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": sessionID})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Admin smoke-test push to a user (requires FCM_SERVER_KEY for real delivery).
|
||||||
|
r.POST("/admin/push-test", func(c *gin.Context) {
|
||||||
|
if !requireAdmin(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UserID uint `json:"user_id" binding:"required"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Title == "" {
|
||||||
|
req.Title = "분신 테스트"
|
||||||
|
}
|
||||||
|
if req.Body == "" {
|
||||||
|
req.Body = "push test"
|
||||||
|
}
|
||||||
|
sent, reason, err := notifyUser(db, req.UserID, req.Title, req.Body, map[string]string{"type": "test"})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"sent": sent, "skipped_reason": reason})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimToken(token string) string {
|
func trimToken(token string) string {
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,49 @@ func TestDeviceTokenAndSessions(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRevokeSessionAndPushTestWithoutFCM(t *testing.T) {
|
||||||
|
server, _ := setupTestServer(t)
|
||||||
|
userID, token := mustSignup(t, server.URL, "revoke")
|
||||||
|
|
||||||
|
// Second login via invite re-use is blocked; mint another session by signup of peer then
|
||||||
|
// just revoke the only session after listing.
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sout map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&sout)
|
||||||
|
resp.Body.Close()
|
||||||
|
sessions := sout["sessions"].([]interface{})
|
||||||
|
sid := uint(sessions[0].(map[string]interface{})["id"].(float64))
|
||||||
|
|
||||||
|
del := deleteJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions/"+strconv.FormatUint(uint64(sid), 10), token)
|
||||||
|
if del.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("revoke: %d", del.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push test without FCM_SERVER_KEY should soft-skip.
|
||||||
|
push := postJSONAuth(t, server.URL+"/admin/push-test", "test-admin-token", map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"title": "t",
|
||||||
|
"body": "b",
|
||||||
|
})
|
||||||
|
if push.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("push-test: %d", push.StatusCode)
|
||||||
|
}
|
||||||
|
var pout map[string]interface{}
|
||||||
|
json.NewDecoder(push.Body).Decode(&pout)
|
||||||
|
// Without device tokens / FCM key: sent==0 and a skipped_reason is expected.
|
||||||
|
if pout["sent"].(float64) != 0 {
|
||||||
|
t.Fatalf("expected zero sent without FCM tokens, got %v", pout)
|
||||||
|
}
|
||||||
|
if pout["skipped_reason"] == nil || pout["skipped_reason"] == "" {
|
||||||
|
t.Fatalf("expected skipped_reason, got %v", pout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminMetricsIncludesDraftLatencyFields(t *testing.T) {
|
func TestAdminMetricsIncludesDraftLatencyFields(t *testing.T) {
|
||||||
server, _ := setupTestServer(t)
|
server, _ := setupTestServer(t)
|
||||||
req, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
|
req, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
"escalate_errors": rt.EscalateErrors,
|
"escalate_errors": rt.EscalateErrors,
|
||||||
"escalate_error_rate": rt.EscalateErrorRate,
|
"escalate_error_rate": rt.EscalateErrorRate,
|
||||||
"twin_sends_blocked": rt.TwinSendsBlocked,
|
"twin_sends_blocked": rt.TwinSendsBlocked,
|
||||||
|
"push_attempts": rt.PushAttempts,
|
||||||
|
"push_skipped": rt.PushSkipped,
|
||||||
|
"push_delivered": rt.PushDelivered,
|
||||||
"invites_minted": invitesMinted,
|
"invites_minted": invitesMinted,
|
||||||
"invites_used": invitesUsed,
|
"invites_used": invitesUsed,
|
||||||
})
|
})
|
||||||
|
|
@ -240,6 +243,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
Reason: result.Reason,
|
Reason: result.Reason,
|
||||||
MessageSnippet: req.Text,
|
MessageSnippet: req.Text,
|
||||||
})
|
})
|
||||||
|
// Best-effort push (no-op without FCM_SERVER_KEY / real tokens).
|
||||||
|
_, _, _ = notifyUser(db, req.SenderID, "분신 확인 필요", result.Reason, map[string]string{
|
||||||
|
"type": "escalation",
|
||||||
|
"conversation_id": strconv.FormatUint(uint64(convID), 10),
|
||||||
|
"reason": result.Reason,
|
||||||
|
})
|
||||||
c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason})
|
c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,19 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteJSONAuth(t *testing.T, url, token string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete %s: %v", url, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
func mintInvite(t *testing.T, serverURL string) string {
|
func mintInvite(t *testing.T, serverURL string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil)
|
resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ type RuntimeMetrics struct {
|
||||||
EscalateChecks int64
|
EscalateChecks int64
|
||||||
EscalateErrors int64
|
EscalateErrors int64
|
||||||
TwinSendsBlocked int64
|
TwinSendsBlocked int64
|
||||||
|
PushAttempts int64
|
||||||
|
PushSkipped int64
|
||||||
|
PushDelivered int64
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLatencySamples = 200
|
const maxLatencySamples = 200
|
||||||
|
|
@ -51,6 +54,16 @@ func (m *RuntimeMetrics) recordTwinBlocked() {
|
||||||
m.TwinSendsBlocked++
|
m.TwinSendsBlocked++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *RuntimeMetrics) recordPush(delivered int, skipped bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.PushAttempts++
|
||||||
|
if skipped {
|
||||||
|
m.PushSkipped++
|
||||||
|
}
|
||||||
|
m.PushDelivered += int64(delivered)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *RuntimeMetrics) snapshot() ginHMetrics {
|
func (m *RuntimeMetrics) snapshot() ginHMetrics {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
@ -86,6 +99,9 @@ func (m *RuntimeMetrics) snapshot() ginHMetrics {
|
||||||
EscalateErrors: m.EscalateErrors,
|
EscalateErrors: m.EscalateErrors,
|
||||||
EscalateErrorRate: escErrRate,
|
EscalateErrorRate: escErrRate,
|
||||||
TwinSendsBlocked: m.TwinSendsBlocked,
|
TwinSendsBlocked: m.TwinSendsBlocked,
|
||||||
|
PushAttempts: m.PushAttempts,
|
||||||
|
PushSkipped: m.PushSkipped,
|
||||||
|
PushDelivered: m.PushDelivered,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,4 +116,7 @@ type ginHMetrics struct {
|
||||||
EscalateErrors int64
|
EscalateErrors int64
|
||||||
EscalateErrorRate float64
|
EscalateErrorRate float64
|
||||||
TwinSendsBlocked int64
|
TwinSendsBlocked int64
|
||||||
|
PushAttempts int64
|
||||||
|
PushSkipped int64
|
||||||
|
PushDelivered int64
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FCM legacy HTTP API (server key). When FCM_SERVER_KEY is unset, notifyUser
|
||||||
|
// records a metric and returns without error so product flows stay usable.
|
||||||
|
func fcmServerKey() string {
|
||||||
|
return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY"))
|
||||||
|
}
|
||||||
|
|
||||||
|
type fcmLegacyPayload struct {
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Registration []string `json:"registration_ids,omitempty"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Notification map[string]string `json:"notification"`
|
||||||
|
Data map[string]string `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) {
|
||||||
|
var tokens []DeviceToken
|
||||||
|
db.Where("user_id = ?", userID).Find(&tokens)
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
return 0, "no_device_tokens", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fcmServerKey()
|
||||||
|
if key == "" {
|
||||||
|
runtimeMetrics.recordPush(0, true)
|
||||||
|
return 0, "fcm_not_configured", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip placeholder install:* tokens — they are not real FCM registration IDs.
|
||||||
|
regIDs := make([]string, 0, len(tokens))
|
||||||
|
for _, t := range tokens {
|
||||||
|
if strings.HasPrefix(t.Token, "install:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
regIDs = append(regIDs, t.Token)
|
||||||
|
}
|
||||||
|
if len(regIDs) == 0 {
|
||||||
|
runtimeMetrics.recordPush(0, true)
|
||||||
|
return 0, "only_placeholder_tokens", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := fcmLegacyPayload{
|
||||||
|
Registration: regIDs,
|
||||||
|
Priority: "high",
|
||||||
|
Notification: map[string]string{"title": title, "body": body},
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "key="+key)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
runtimeMetrics.recordPush(0, true)
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
runtimeMetrics.recordPush(0, true)
|
||||||
|
return 0, "", fmt.Errorf("fcm returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
runtimeMetrics.recordPush(len(regIDs), false)
|
||||||
|
return len(regIDs), "", nil
|
||||||
|
}
|
||||||
|
|
@ -58,7 +58,8 @@
|
||||||
생성은 이미 있는 `/draft` 흐름을 그대로 쓰면 되므로 새로 만들지 않았고, 화이트리스트는
|
생성은 이미 있는 `/draft` 흐름을 그대로 쓰면 되므로 새로 만들지 않았고, 화이트리스트는
|
||||||
`ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서
|
`ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서
|
||||||
(Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고)
|
(Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고)
|
||||||
- [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현
|
- [x] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 — drift+SQLCipher(`mobile/lib/db/`),
|
||||||
|
draft에 샘플만 전달, `DataFlowScreen`으로 원칙 노출
|
||||||
- [x] 사후 알림 + 되돌리기 로그 스키마/API — `escalation_logs`는 이미 쌓임(사후 알림용 로그).
|
- [x] 사후 알림 + 되돌리기 로그 스키마/API — `escalation_logs`는 이미 쌓임(사후 알림용 로그).
|
||||||
되돌리기(one-tap undo, AGENTS.md 안전 불변식)는 `Message.Retracted` 필드 +
|
되돌리기(one-tap undo, AGENTS.md 안전 불변식)는 `Message.Retracted` 필드 +
|
||||||
`POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400,
|
`POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400,
|
||||||
|
|
@ -97,7 +98,7 @@
|
||||||
무조건 차단되는 것까지 테스트로 확인함
|
무조건 차단되는 것까지 테스트로 확인함
|
||||||
- [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제
|
- [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제
|
||||||
플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스
|
플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스
|
||||||
암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter(`mobile/`) 쪽 후속 작업
|
암호화(drift+SQLCipher)와 데이터 흐름 UI는 Flutter(`mobile/`) B에서 반영
|
||||||
|
|
||||||
**2.5 QA/테스트**
|
**2.5 QA/테스트**
|
||||||
- [x] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도
|
- [x] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도
|
||||||
|
|
@ -147,8 +148,8 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
|
||||||
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음
|
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음
|
||||||
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
|
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
|
||||||
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
|
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
|
||||||
4. [~] 2.3 Flutter 클라이언트 — A3까지 완료 + API E2E(`scripts/e2e_a3.py`). 남은 것: B
|
4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E.
|
||||||
(drift+SQLCipher·FCM 등), Android UI 탭은 실기기에서 `mobile/README.md` 체크리스트로
|
남은 것: 실제 Firebase FCM 토큰, Android UI 탭(`mobile/README.md`), C 배포 준비
|
||||||
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
|
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
|
||||||
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
|
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
|
||||||
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
|
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
|
||||||
|
|
@ -185,13 +186,14 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
||||||
동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지
|
동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지
|
||||||
|
|
||||||
##### B. 그다음 — 베타 품질
|
##### B. 그다음 — 베타 품질
|
||||||
- [~] FCM 푸시 연동 — 디바이스 토큰 등록 API(`POST /users/:id/device-tokens`)까지.
|
- [~] FCM 푸시 연동 — 토큰 등록 + 에스컬레이션 시 `notifyUser` + `POST /admin/push-test`.
|
||||||
실제 FCM 전송은 Firebase 프로젝트 키 연동 후
|
`FCM_SERVER_KEY`와 실제 FCM registration token이 있으면 전송, 없으면 soft-skip.
|
||||||
- [~] 멀티 디바이스 동기화 — 활성 세션 목록(`GET /users/:id/sessions`) + Flutter 화면.
|
Flutter는 install-id 플레이스홀더 등록(Firebase Messaging 앱 키는 배포 환경에서 교체)
|
||||||
강제 로그아웃·메시지 동기화 고도화는 후속
|
- [~] 멀티 디바이스 동기화 — 세션 목록 + 세션 종료(`DELETE /users/:id/sessions/:id`).
|
||||||
- [ ] drift + SQLCipher 로컬 저장 — 다음 슬라이스(코드젠). 현재 말투는 SharedPreferences
|
메시지 히스토리 서버 동기화는 이미 REST/WS; 오프라인 큐는 후속
|
||||||
- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — 온보딩 샘플 로컬 저장, draft에 샘플만 전달,
|
- [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는
|
||||||
데이터 흐름 UI로 원칙 노출
|
`flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백
|
||||||
|
- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달
|
||||||
- [x] 데이터 흐름 표시 UI — `DataFlowScreen`
|
- [x] 데이터 흐름 표시 UI — `DataFlowScreen`
|
||||||
- [x] 생성 지연시간·오류율 계측 — process-local `RuntimeMetrics` → `/admin/metrics`
|
- [x] 생성 지연시간·오류율 계측 — process-local `RuntimeMetrics` → `/admin/metrics`
|
||||||
- [x] 모니터링 대시보드(최소) — `GET /admin/dashboard`
|
- [x] 모니터링 대시보드(최소) — `GET /admin/dashboard`
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ Python으로 남긴다.
|
||||||
| 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 |
|
| 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 |
|
||||||
| 메시지 릴레이 | Go 코어 서비스 내 WebSocket | 자체 서버로 충분한 규모. Kafka·관리형 pub-sub은 지금 시점에 과한 인프라 |
|
| 메시지 릴레이 | Go 코어 서비스 내 WebSocket | 자체 서버로 충분한 규모. Kafka·관리형 pub-sub은 지금 시점에 과한 인프라 |
|
||||||
| 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합. Go 쪽 접근은 `pgx`나 GORM |
|
| 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합. Go 쪽 접근은 `pgx`나 GORM |
|
||||||
| 온디바이스 저장소 | Flutter `drift`(SQLite) + `sqlcipher_flutter_libs` 암호화 | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙을 그대로 구현 |
|
| 온디바이스 저장소 | Flutter `drift` + `sqlcipher_flutter_libs` (`PRAGMA key`, 패스프레이즈는 `flutter_secure_storage`) | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙. 구현: `mobile/lib/db/` |
|
||||||
| Gemini API 키 관리 | Python AI 서비스에서만 보관 (Go 코어는 키를 안 가짐) | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 키·쿼터는 AI 서비스 환경에서만 분리 관리 |
|
| Gemini API 키 관리 | Python AI 서비스에서만 보관 (Go 코어는 키를 안 가짐) | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 키·쿼터는 AI 서비스 환경에서만 분리 관리 |
|
||||||
|
|
||||||
이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에
|
이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,20 @@ HTTP로 검증한다.
|
||||||
6. 분신 메시지 되돌리기 · 거부권
|
6. 분신 메시지 되돌리기 · 거부권
|
||||||
7. 자율성 L0~L2 + 화이트리스트 추가/삭제
|
7. 자율성 L0~L2 + 화이트리스트 추가/삭제
|
||||||
|
|
||||||
## 아직 없는 것 (B 이후)
|
## 로컬 DB (drift + SQLCipher)
|
||||||
|
|
||||||
- FCM 푸시 · drift + SQLCipher · 멀티 디바이스
|
말투 샘플·온보딩 플래그는 `lib/db/` 암호화 SQLite에 저장한다. 패스프레이즈는
|
||||||
|
`flutter_secure_storage`. Linux 개발 호스트에 `libsqlcipher.so`가 없으면 메모리 폴백으로
|
||||||
|
기동한다(Android 릴리즈 경로에서는 SQLCipher 사용).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux에서 drift 테스트/암호화 DB를 쓰려면
|
||||||
|
sudo apt-get install -y libsqlite3-dev libsqlcipher1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 아직 없는 것
|
||||||
|
|
||||||
|
- Firebase 프로젝트의 실제 FCM registration token 연동 (`google-services.json`)
|
||||||
|
- 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
|
||||||
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
|
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
|
||||||
- iOS 빌드 (v1 범위 밖)
|
- iOS 빌드 (v1 범위 밖)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
|
||||||
|
import 'package:sqlite3/open.dart';
|
||||||
|
|
||||||
|
import 'tables.dart';
|
||||||
|
|
||||||
|
part 'app_database.g.dart';
|
||||||
|
|
||||||
|
@DriftDatabase(tables: [ToneSamples, LocalKv])
|
||||||
|
class AppDatabase extends _$AppDatabase {
|
||||||
|
AppDatabase(super.e);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 1;
|
||||||
|
|
||||||
|
/// In-memory DB for unit tests (no SQLCipher / filesystem).
|
||||||
|
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory());
|
||||||
|
|
||||||
|
/// Production opener: encrypted on-device file (tech-design.md §8).
|
||||||
|
static Future<AppDatabase> open() async {
|
||||||
|
final db = AppDatabase(_openEncryptedExecutor());
|
||||||
|
// Force open so missing SQLCipher SO fails here (caller can fall back).
|
||||||
|
await db.customSelect('SELECT 1').get();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> loadToneSamples() async {
|
||||||
|
final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get();
|
||||||
|
return rows.map((r) => r.sampleText).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceToneSamples(List<String> samples) async {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
await transaction(() async {
|
||||||
|
await delete(toneSamples).go();
|
||||||
|
for (var i = 0; i < samples.length; i++) {
|
||||||
|
await into(toneSamples).insert(
|
||||||
|
ToneSamplesCompanion.insert(
|
||||||
|
id: 'tone_${i}_$now',
|
||||||
|
sampleText: samples[i],
|
||||||
|
createdAtMs: now,
|
||||||
|
sortOrder: i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getKv(String key) async {
|
||||||
|
final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull();
|
||||||
|
return row?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setKv(String key, String value) async {
|
||||||
|
await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
|
||||||
|
final v = await getKv(key);
|
||||||
|
if (v == null) return defaultValue;
|
||||||
|
return v == '1' || v.toLowerCase() == 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setBoolKv(String key, bool value) async {
|
||||||
|
await setKv(key, value ? '1' : '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _kDbPassphrase = 'db_passphrase_v1';
|
||||||
|
|
||||||
|
QueryExecutor _openEncryptedExecutor() {
|
||||||
|
return LazyDatabase(() async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final file = File(p.join(dir.path, 'bunsin_encrypted.db'));
|
||||||
|
final passphrase = await _loadOrCreatePassphrase();
|
||||||
|
|
||||||
|
// Background isolate does not inherit open.overrideFor — re-apply there.
|
||||||
|
final token = RootIsolateToken.instance;
|
||||||
|
return NativeDatabase.createInBackground(
|
||||||
|
file,
|
||||||
|
isolateSetup: () async {
|
||||||
|
if (token != null) {
|
||||||
|
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||||
|
}
|
||||||
|
await _configureSqlCipherOpen();
|
||||||
|
},
|
||||||
|
setup: (rawDb) {
|
||||||
|
// SQLCipher key must be set before any other statement.
|
||||||
|
final escaped = passphrase.replaceAll("'", "''");
|
||||||
|
rawDb.execute("PRAGMA key = '$escaped'");
|
||||||
|
rawDb.config.doubleQuotedStringLiterals = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _configureSqlCipherOpen() async {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
|
||||||
|
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
|
||||||
|
} else if (Platform.isLinux) {
|
||||||
|
// Desktop/dev: try SQLCipher SO; fall back is handled by open() failure upstream.
|
||||||
|
open.overrideFor(OperatingSystem.linux, () => DynamicLibrary.open('libsqlcipher.so'));
|
||||||
|
} else if (Platform.isWindows) {
|
||||||
|
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open('sqlcipher.dll'));
|
||||||
|
}
|
||||||
|
// iOS/macOS: sqlcipher_flutter_libs links into the process.
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _loadOrCreatePassphrase() async {
|
||||||
|
const storage = FlutterSecureStorage();
|
||||||
|
final existing = await storage.read(key: _kDbPassphrase);
|
||||||
|
if (existing != null && existing.isNotEmpty) return existing;
|
||||||
|
final rand = Random.secure();
|
||||||
|
final bytes = List<int>.generate(32, (_) => rand.nextInt(256));
|
||||||
|
final passphrase = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
await storage.write(key: _kDbPassphrase, value: passphrase);
|
||||||
|
return passphrase;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,873 @@
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'app_database.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
class $ToneSamplesTable extends ToneSamples
|
||||||
|
with TableInfo<$ToneSamplesTable, ToneSample> {
|
||||||
|
@override
|
||||||
|
final GeneratedDatabase attachedDatabase;
|
||||||
|
final String? _alias;
|
||||||
|
$ToneSamplesTable(this.attachedDatabase, [this._alias]);
|
||||||
|
static const VerificationMeta _idMeta = const VerificationMeta('id');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> id = GeneratedColumn<String>(
|
||||||
|
'id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _sampleTextMeta = const VerificationMeta(
|
||||||
|
'sampleText',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> sampleText = GeneratedColumn<String>(
|
||||||
|
'sample_text',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _createdAtMsMeta = const VerificationMeta(
|
||||||
|
'createdAtMs',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<int> createdAtMs = GeneratedColumn<int>(
|
||||||
|
'created_at_ms',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _sortOrderMeta = const VerificationMeta(
|
||||||
|
'sortOrder',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<int> sortOrder = GeneratedColumn<int>(
|
||||||
|
'sort_order',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<GeneratedColumn> get $columns => [
|
||||||
|
id,
|
||||||
|
sampleText,
|
||||||
|
createdAtMs,
|
||||||
|
sortOrder,
|
||||||
|
];
|
||||||
|
@override
|
||||||
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
|
@override
|
||||||
|
String get actualTableName => $name;
|
||||||
|
static const String $name = 'tone_samples';
|
||||||
|
@override
|
||||||
|
VerificationContext validateIntegrity(
|
||||||
|
Insertable<ToneSample> instance, {
|
||||||
|
bool isInserting = false,
|
||||||
|
}) {
|
||||||
|
final context = VerificationContext();
|
||||||
|
final data = instance.toColumns(true);
|
||||||
|
if (data.containsKey('id')) {
|
||||||
|
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_idMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('sample_text')) {
|
||||||
|
context.handle(
|
||||||
|
_sampleTextMeta,
|
||||||
|
sampleText.isAcceptableOrUnknown(data['sample_text']!, _sampleTextMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_sampleTextMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('created_at_ms')) {
|
||||||
|
context.handle(
|
||||||
|
_createdAtMsMeta,
|
||||||
|
createdAtMs.isAcceptableOrUnknown(
|
||||||
|
data['created_at_ms']!,
|
||||||
|
_createdAtMsMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_createdAtMsMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('sort_order')) {
|
||||||
|
context.handle(
|
||||||
|
_sortOrderMeta,
|
||||||
|
sortOrder.isAcceptableOrUnknown(data['sort_order']!, _sortOrderMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_sortOrderMeta);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<GeneratedColumn> get $primaryKey => {id};
|
||||||
|
@override
|
||||||
|
ToneSample map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||||
|
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||||
|
return ToneSample(
|
||||||
|
id: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}id'],
|
||||||
|
)!,
|
||||||
|
sampleText: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}sample_text'],
|
||||||
|
)!,
|
||||||
|
createdAtMs: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.int,
|
||||||
|
data['${effectivePrefix}created_at_ms'],
|
||||||
|
)!,
|
||||||
|
sortOrder: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.int,
|
||||||
|
data['${effectivePrefix}sort_order'],
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
$ToneSamplesTable createAlias(String alias) {
|
||||||
|
return $ToneSamplesTable(attachedDatabase, alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToneSample extends DataClass implements Insertable<ToneSample> {
|
||||||
|
final String id;
|
||||||
|
final String sampleText;
|
||||||
|
final int createdAtMs;
|
||||||
|
final int sortOrder;
|
||||||
|
const ToneSample({
|
||||||
|
required this.id,
|
||||||
|
required this.sampleText,
|
||||||
|
required this.createdAtMs,
|
||||||
|
required this.sortOrder,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, Expression>{};
|
||||||
|
map['id'] = Variable<String>(id);
|
||||||
|
map['sample_text'] = Variable<String>(sampleText);
|
||||||
|
map['created_at_ms'] = Variable<int>(createdAtMs);
|
||||||
|
map['sort_order'] = Variable<int>(sortOrder);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
ToneSamplesCompanion toCompanion(bool nullToAbsent) {
|
||||||
|
return ToneSamplesCompanion(
|
||||||
|
id: Value(id),
|
||||||
|
sampleText: Value(sampleText),
|
||||||
|
createdAtMs: Value(createdAtMs),
|
||||||
|
sortOrder: Value(sortOrder),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ToneSample.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
ValueSerializer? serializer,
|
||||||
|
}) {
|
||||||
|
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||||
|
return ToneSample(
|
||||||
|
id: serializer.fromJson<String>(json['id']),
|
||||||
|
sampleText: serializer.fromJson<String>(json['sampleText']),
|
||||||
|
createdAtMs: serializer.fromJson<int>(json['createdAtMs']),
|
||||||
|
sortOrder: serializer.fromJson<int>(json['sortOrder']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||||
|
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||||
|
return <String, dynamic>{
|
||||||
|
'id': serializer.toJson<String>(id),
|
||||||
|
'sampleText': serializer.toJson<String>(sampleText),
|
||||||
|
'createdAtMs': serializer.toJson<int>(createdAtMs),
|
||||||
|
'sortOrder': serializer.toJson<int>(sortOrder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ToneSample copyWith({
|
||||||
|
String? id,
|
||||||
|
String? sampleText,
|
||||||
|
int? createdAtMs,
|
||||||
|
int? sortOrder,
|
||||||
|
}) => ToneSample(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sampleText: sampleText ?? this.sampleText,
|
||||||
|
createdAtMs: createdAtMs ?? this.createdAtMs,
|
||||||
|
sortOrder: sortOrder ?? this.sortOrder,
|
||||||
|
);
|
||||||
|
ToneSample copyWithCompanion(ToneSamplesCompanion data) {
|
||||||
|
return ToneSample(
|
||||||
|
id: data.id.present ? data.id.value : this.id,
|
||||||
|
sampleText: data.sampleText.present
|
||||||
|
? data.sampleText.value
|
||||||
|
: this.sampleText,
|
||||||
|
createdAtMs: data.createdAtMs.present
|
||||||
|
? data.createdAtMs.value
|
||||||
|
: this.createdAtMs,
|
||||||
|
sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('ToneSample(')
|
||||||
|
..write('id: $id, ')
|
||||||
|
..write('sampleText: $sampleText, ')
|
||||||
|
..write('createdAtMs: $createdAtMs, ')
|
||||||
|
..write('sortOrder: $sortOrder')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(id, sampleText, createdAtMs, sortOrder);
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
(other is ToneSample &&
|
||||||
|
other.id == this.id &&
|
||||||
|
other.sampleText == this.sampleText &&
|
||||||
|
other.createdAtMs == this.createdAtMs &&
|
||||||
|
other.sortOrder == this.sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToneSamplesCompanion extends UpdateCompanion<ToneSample> {
|
||||||
|
final Value<String> id;
|
||||||
|
final Value<String> sampleText;
|
||||||
|
final Value<int> createdAtMs;
|
||||||
|
final Value<int> sortOrder;
|
||||||
|
final Value<int> rowid;
|
||||||
|
const ToneSamplesCompanion({
|
||||||
|
this.id = const Value.absent(),
|
||||||
|
this.sampleText = const Value.absent(),
|
||||||
|
this.createdAtMs = const Value.absent(),
|
||||||
|
this.sortOrder = const Value.absent(),
|
||||||
|
this.rowid = const Value.absent(),
|
||||||
|
});
|
||||||
|
ToneSamplesCompanion.insert({
|
||||||
|
required String id,
|
||||||
|
required String sampleText,
|
||||||
|
required int createdAtMs,
|
||||||
|
required int sortOrder,
|
||||||
|
this.rowid = const Value.absent(),
|
||||||
|
}) : id = Value(id),
|
||||||
|
sampleText = Value(sampleText),
|
||||||
|
createdAtMs = Value(createdAtMs),
|
||||||
|
sortOrder = Value(sortOrder);
|
||||||
|
static Insertable<ToneSample> custom({
|
||||||
|
Expression<String>? id,
|
||||||
|
Expression<String>? sampleText,
|
||||||
|
Expression<int>? createdAtMs,
|
||||||
|
Expression<int>? sortOrder,
|
||||||
|
Expression<int>? rowid,
|
||||||
|
}) {
|
||||||
|
return RawValuesInsertable({
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
if (sampleText != null) 'sample_text': sampleText,
|
||||||
|
if (createdAtMs != null) 'created_at_ms': createdAtMs,
|
||||||
|
if (sortOrder != null) 'sort_order': sortOrder,
|
||||||
|
if (rowid != null) 'rowid': rowid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ToneSamplesCompanion copyWith({
|
||||||
|
Value<String>? id,
|
||||||
|
Value<String>? sampleText,
|
||||||
|
Value<int>? createdAtMs,
|
||||||
|
Value<int>? sortOrder,
|
||||||
|
Value<int>? rowid,
|
||||||
|
}) {
|
||||||
|
return ToneSamplesCompanion(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sampleText: sampleText ?? this.sampleText,
|
||||||
|
createdAtMs: createdAtMs ?? this.createdAtMs,
|
||||||
|
sortOrder: sortOrder ?? this.sortOrder,
|
||||||
|
rowid: rowid ?? this.rowid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, Expression>{};
|
||||||
|
if (id.present) {
|
||||||
|
map['id'] = Variable<String>(id.value);
|
||||||
|
}
|
||||||
|
if (sampleText.present) {
|
||||||
|
map['sample_text'] = Variable<String>(sampleText.value);
|
||||||
|
}
|
||||||
|
if (createdAtMs.present) {
|
||||||
|
map['created_at_ms'] = Variable<int>(createdAtMs.value);
|
||||||
|
}
|
||||||
|
if (sortOrder.present) {
|
||||||
|
map['sort_order'] = Variable<int>(sortOrder.value);
|
||||||
|
}
|
||||||
|
if (rowid.present) {
|
||||||
|
map['rowid'] = Variable<int>(rowid.value);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('ToneSamplesCompanion(')
|
||||||
|
..write('id: $id, ')
|
||||||
|
..write('sampleText: $sampleText, ')
|
||||||
|
..write('createdAtMs: $createdAtMs, ')
|
||||||
|
..write('sortOrder: $sortOrder, ')
|
||||||
|
..write('rowid: $rowid')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class $LocalKvTable extends LocalKv with TableInfo<$LocalKvTable, LocalKvData> {
|
||||||
|
@override
|
||||||
|
final GeneratedDatabase attachedDatabase;
|
||||||
|
final String? _alias;
|
||||||
|
$LocalKvTable(this.attachedDatabase, [this._alias]);
|
||||||
|
static const VerificationMeta _keyMeta = const VerificationMeta('key');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> key = GeneratedColumn<String>(
|
||||||
|
'key',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _valueMeta = const VerificationMeta('value');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> value = GeneratedColumn<String>(
|
||||||
|
'value',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<GeneratedColumn> get $columns => [key, value];
|
||||||
|
@override
|
||||||
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
|
@override
|
||||||
|
String get actualTableName => $name;
|
||||||
|
static const String $name = 'local_kv';
|
||||||
|
@override
|
||||||
|
VerificationContext validateIntegrity(
|
||||||
|
Insertable<LocalKvData> instance, {
|
||||||
|
bool isInserting = false,
|
||||||
|
}) {
|
||||||
|
final context = VerificationContext();
|
||||||
|
final data = instance.toColumns(true);
|
||||||
|
if (data.containsKey('key')) {
|
||||||
|
context.handle(
|
||||||
|
_keyMeta,
|
||||||
|
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_keyMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('value')) {
|
||||||
|
context.handle(
|
||||||
|
_valueMeta,
|
||||||
|
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_valueMeta);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<GeneratedColumn> get $primaryKey => {key};
|
||||||
|
@override
|
||||||
|
LocalKvData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||||
|
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||||
|
return LocalKvData(
|
||||||
|
key: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}key'],
|
||||||
|
)!,
|
||||||
|
value: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}value'],
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
$LocalKvTable createAlias(String alias) {
|
||||||
|
return $LocalKvTable(attachedDatabase, alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalKvData extends DataClass implements Insertable<LocalKvData> {
|
||||||
|
final String key;
|
||||||
|
final String value;
|
||||||
|
const LocalKvData({required this.key, required this.value});
|
||||||
|
@override
|
||||||
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, Expression>{};
|
||||||
|
map['key'] = Variable<String>(key);
|
||||||
|
map['value'] = Variable<String>(value);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalKvCompanion toCompanion(bool nullToAbsent) {
|
||||||
|
return LocalKvCompanion(key: Value(key), value: Value(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
factory LocalKvData.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
ValueSerializer? serializer,
|
||||||
|
}) {
|
||||||
|
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||||
|
return LocalKvData(
|
||||||
|
key: serializer.fromJson<String>(json['key']),
|
||||||
|
value: serializer.fromJson<String>(json['value']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||||
|
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||||
|
return <String, dynamic>{
|
||||||
|
'key': serializer.toJson<String>(key),
|
||||||
|
'value': serializer.toJson<String>(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalKvData copyWith({String? key, String? value}) =>
|
||||||
|
LocalKvData(key: key ?? this.key, value: value ?? this.value);
|
||||||
|
LocalKvData copyWithCompanion(LocalKvCompanion data) {
|
||||||
|
return LocalKvData(
|
||||||
|
key: data.key.present ? data.key.value : this.key,
|
||||||
|
value: data.value.present ? data.value.value : this.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('LocalKvData(')
|
||||||
|
..write('key: $key, ')
|
||||||
|
..write('value: $value')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(key, value);
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
(other is LocalKvData &&
|
||||||
|
other.key == this.key &&
|
||||||
|
other.value == this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalKvCompanion extends UpdateCompanion<LocalKvData> {
|
||||||
|
final Value<String> key;
|
||||||
|
final Value<String> value;
|
||||||
|
final Value<int> rowid;
|
||||||
|
const LocalKvCompanion({
|
||||||
|
this.key = const Value.absent(),
|
||||||
|
this.value = const Value.absent(),
|
||||||
|
this.rowid = const Value.absent(),
|
||||||
|
});
|
||||||
|
LocalKvCompanion.insert({
|
||||||
|
required String key,
|
||||||
|
required String value,
|
||||||
|
this.rowid = const Value.absent(),
|
||||||
|
}) : key = Value(key),
|
||||||
|
value = Value(value);
|
||||||
|
static Insertable<LocalKvData> custom({
|
||||||
|
Expression<String>? key,
|
||||||
|
Expression<String>? value,
|
||||||
|
Expression<int>? rowid,
|
||||||
|
}) {
|
||||||
|
return RawValuesInsertable({
|
||||||
|
if (key != null) 'key': key,
|
||||||
|
if (value != null) 'value': value,
|
||||||
|
if (rowid != null) 'rowid': rowid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalKvCompanion copyWith({
|
||||||
|
Value<String>? key,
|
||||||
|
Value<String>? value,
|
||||||
|
Value<int>? rowid,
|
||||||
|
}) {
|
||||||
|
return LocalKvCompanion(
|
||||||
|
key: key ?? this.key,
|
||||||
|
value: value ?? this.value,
|
||||||
|
rowid: rowid ?? this.rowid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, Expression>{};
|
||||||
|
if (key.present) {
|
||||||
|
map['key'] = Variable<String>(key.value);
|
||||||
|
}
|
||||||
|
if (value.present) {
|
||||||
|
map['value'] = Variable<String>(value.value);
|
||||||
|
}
|
||||||
|
if (rowid.present) {
|
||||||
|
map['rowid'] = Variable<int>(rowid.value);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('LocalKvCompanion(')
|
||||||
|
..write('key: $key, ')
|
||||||
|
..write('value: $value, ')
|
||||||
|
..write('rowid: $rowid')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _$AppDatabase extends GeneratedDatabase {
|
||||||
|
_$AppDatabase(QueryExecutor e) : super(e);
|
||||||
|
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||||
|
late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
|
||||||
|
late final $LocalKvTable localKv = $LocalKvTable(this);
|
||||||
|
@override
|
||||||
|
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||||
|
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
|
@override
|
||||||
|
List<DatabaseSchemaEntity> get allSchemaEntities => [toneSamples, localKv];
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef $$ToneSamplesTableCreateCompanionBuilder =
|
||||||
|
ToneSamplesCompanion Function({
|
||||||
|
required String id,
|
||||||
|
required String sampleText,
|
||||||
|
required int createdAtMs,
|
||||||
|
required int sortOrder,
|
||||||
|
Value<int> rowid,
|
||||||
|
});
|
||||||
|
typedef $$ToneSamplesTableUpdateCompanionBuilder =
|
||||||
|
ToneSamplesCompanion Function({
|
||||||
|
Value<String> id,
|
||||||
|
Value<String> sampleText,
|
||||||
|
Value<int> createdAtMs,
|
||||||
|
Value<int> sortOrder,
|
||||||
|
Value<int> rowid,
|
||||||
|
});
|
||||||
|
|
||||||
|
class $$ToneSamplesTableFilterComposer
|
||||||
|
extends Composer<_$AppDatabase, $ToneSamplesTable> {
|
||||||
|
$$ToneSamplesTableFilterComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
ColumnFilters<String> get id => $composableBuilder(
|
||||||
|
column: $table.id,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get sampleText => $composableBuilder(
|
||||||
|
column: $table.sampleText,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<int> get createdAtMs => $composableBuilder(
|
||||||
|
column: $table.createdAtMs,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<int> get sortOrder => $composableBuilder(
|
||||||
|
column: $table.sortOrder,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$ToneSamplesTableOrderingComposer
|
||||||
|
extends Composer<_$AppDatabase, $ToneSamplesTable> {
|
||||||
|
$$ToneSamplesTableOrderingComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
ColumnOrderings<String> get id => $composableBuilder(
|
||||||
|
column: $table.id,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get sampleText => $composableBuilder(
|
||||||
|
column: $table.sampleText,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<int> get createdAtMs => $composableBuilder(
|
||||||
|
column: $table.createdAtMs,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<int> get sortOrder => $composableBuilder(
|
||||||
|
column: $table.sortOrder,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$ToneSamplesTableAnnotationComposer
|
||||||
|
extends Composer<_$AppDatabase, $ToneSamplesTable> {
|
||||||
|
$$ToneSamplesTableAnnotationComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
GeneratedColumn<String> get id =>
|
||||||
|
$composableBuilder(column: $table.id, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get sampleText => $composableBuilder(
|
||||||
|
column: $table.sampleText,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<int> get createdAtMs => $composableBuilder(
|
||||||
|
column: $table.createdAtMs,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<int> get sortOrder =>
|
||||||
|
$composableBuilder(column: $table.sortOrder, builder: (column) => column);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$ToneSamplesTableTableManager
|
||||||
|
extends
|
||||||
|
RootTableManager<
|
||||||
|
_$AppDatabase,
|
||||||
|
$ToneSamplesTable,
|
||||||
|
ToneSample,
|
||||||
|
$$ToneSamplesTableFilterComposer,
|
||||||
|
$$ToneSamplesTableOrderingComposer,
|
||||||
|
$$ToneSamplesTableAnnotationComposer,
|
||||||
|
$$ToneSamplesTableCreateCompanionBuilder,
|
||||||
|
$$ToneSamplesTableUpdateCompanionBuilder,
|
||||||
|
(
|
||||||
|
ToneSample,
|
||||||
|
BaseReferences<_$AppDatabase, $ToneSamplesTable, ToneSample>,
|
||||||
|
),
|
||||||
|
ToneSample,
|
||||||
|
PrefetchHooks Function()
|
||||||
|
> {
|
||||||
|
$$ToneSamplesTableTableManager(_$AppDatabase db, $ToneSamplesTable table)
|
||||||
|
: super(
|
||||||
|
TableManagerState(
|
||||||
|
db: db,
|
||||||
|
table: table,
|
||||||
|
createFilteringComposer: () =>
|
||||||
|
$$ToneSamplesTableFilterComposer($db: db, $table: table),
|
||||||
|
createOrderingComposer: () =>
|
||||||
|
$$ToneSamplesTableOrderingComposer($db: db, $table: table),
|
||||||
|
createComputedFieldComposer: () =>
|
||||||
|
$$ToneSamplesTableAnnotationComposer($db: db, $table: table),
|
||||||
|
updateCompanionCallback:
|
||||||
|
({
|
||||||
|
Value<String> id = const Value.absent(),
|
||||||
|
Value<String> sampleText = const Value.absent(),
|
||||||
|
Value<int> createdAtMs = const Value.absent(),
|
||||||
|
Value<int> sortOrder = const Value.absent(),
|
||||||
|
Value<int> rowid = const Value.absent(),
|
||||||
|
}) => ToneSamplesCompanion(
|
||||||
|
id: id,
|
||||||
|
sampleText: sampleText,
|
||||||
|
createdAtMs: createdAtMs,
|
||||||
|
sortOrder: sortOrder,
|
||||||
|
rowid: rowid,
|
||||||
|
),
|
||||||
|
createCompanionCallback:
|
||||||
|
({
|
||||||
|
required String id,
|
||||||
|
required String sampleText,
|
||||||
|
required int createdAtMs,
|
||||||
|
required int sortOrder,
|
||||||
|
Value<int> rowid = const Value.absent(),
|
||||||
|
}) => ToneSamplesCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
sampleText: sampleText,
|
||||||
|
createdAtMs: createdAtMs,
|
||||||
|
sortOrder: sortOrder,
|
||||||
|
rowid: rowid,
|
||||||
|
),
|
||||||
|
withReferenceMapper: (p0) => p0
|
||||||
|
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||||
|
.toList(),
|
||||||
|
prefetchHooksCallback: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef $$ToneSamplesTableProcessedTableManager =
|
||||||
|
ProcessedTableManager<
|
||||||
|
_$AppDatabase,
|
||||||
|
$ToneSamplesTable,
|
||||||
|
ToneSample,
|
||||||
|
$$ToneSamplesTableFilterComposer,
|
||||||
|
$$ToneSamplesTableOrderingComposer,
|
||||||
|
$$ToneSamplesTableAnnotationComposer,
|
||||||
|
$$ToneSamplesTableCreateCompanionBuilder,
|
||||||
|
$$ToneSamplesTableUpdateCompanionBuilder,
|
||||||
|
(
|
||||||
|
ToneSample,
|
||||||
|
BaseReferences<_$AppDatabase, $ToneSamplesTable, ToneSample>,
|
||||||
|
),
|
||||||
|
ToneSample,
|
||||||
|
PrefetchHooks Function()
|
||||||
|
>;
|
||||||
|
typedef $$LocalKvTableCreateCompanionBuilder =
|
||||||
|
LocalKvCompanion Function({
|
||||||
|
required String key,
|
||||||
|
required String value,
|
||||||
|
Value<int> rowid,
|
||||||
|
});
|
||||||
|
typedef $$LocalKvTableUpdateCompanionBuilder =
|
||||||
|
LocalKvCompanion Function({
|
||||||
|
Value<String> key,
|
||||||
|
Value<String> value,
|
||||||
|
Value<int> rowid,
|
||||||
|
});
|
||||||
|
|
||||||
|
class $$LocalKvTableFilterComposer
|
||||||
|
extends Composer<_$AppDatabase, $LocalKvTable> {
|
||||||
|
$$LocalKvTableFilterComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
ColumnFilters<String> get key => $composableBuilder(
|
||||||
|
column: $table.key,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get value => $composableBuilder(
|
||||||
|
column: $table.value,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$LocalKvTableOrderingComposer
|
||||||
|
extends Composer<_$AppDatabase, $LocalKvTable> {
|
||||||
|
$$LocalKvTableOrderingComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
ColumnOrderings<String> get key => $composableBuilder(
|
||||||
|
column: $table.key,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get value => $composableBuilder(
|
||||||
|
column: $table.value,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$LocalKvTableAnnotationComposer
|
||||||
|
extends Composer<_$AppDatabase, $LocalKvTable> {
|
||||||
|
$$LocalKvTableAnnotationComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
GeneratedColumn<String> get key =>
|
||||||
|
$composableBuilder(column: $table.key, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get value =>
|
||||||
|
$composableBuilder(column: $table.value, builder: (column) => column);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$LocalKvTableTableManager
|
||||||
|
extends
|
||||||
|
RootTableManager<
|
||||||
|
_$AppDatabase,
|
||||||
|
$LocalKvTable,
|
||||||
|
LocalKvData,
|
||||||
|
$$LocalKvTableFilterComposer,
|
||||||
|
$$LocalKvTableOrderingComposer,
|
||||||
|
$$LocalKvTableAnnotationComposer,
|
||||||
|
$$LocalKvTableCreateCompanionBuilder,
|
||||||
|
$$LocalKvTableUpdateCompanionBuilder,
|
||||||
|
(
|
||||||
|
LocalKvData,
|
||||||
|
BaseReferences<_$AppDatabase, $LocalKvTable, LocalKvData>,
|
||||||
|
),
|
||||||
|
LocalKvData,
|
||||||
|
PrefetchHooks Function()
|
||||||
|
> {
|
||||||
|
$$LocalKvTableTableManager(_$AppDatabase db, $LocalKvTable table)
|
||||||
|
: super(
|
||||||
|
TableManagerState(
|
||||||
|
db: db,
|
||||||
|
table: table,
|
||||||
|
createFilteringComposer: () =>
|
||||||
|
$$LocalKvTableFilterComposer($db: db, $table: table),
|
||||||
|
createOrderingComposer: () =>
|
||||||
|
$$LocalKvTableOrderingComposer($db: db, $table: table),
|
||||||
|
createComputedFieldComposer: () =>
|
||||||
|
$$LocalKvTableAnnotationComposer($db: db, $table: table),
|
||||||
|
updateCompanionCallback:
|
||||||
|
({
|
||||||
|
Value<String> key = const Value.absent(),
|
||||||
|
Value<String> value = const Value.absent(),
|
||||||
|
Value<int> rowid = const Value.absent(),
|
||||||
|
}) => LocalKvCompanion(key: key, value: value, rowid: rowid),
|
||||||
|
createCompanionCallback:
|
||||||
|
({
|
||||||
|
required String key,
|
||||||
|
required String value,
|
||||||
|
Value<int> rowid = const Value.absent(),
|
||||||
|
}) =>
|
||||||
|
LocalKvCompanion.insert(key: key, value: value, rowid: rowid),
|
||||||
|
withReferenceMapper: (p0) => p0
|
||||||
|
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||||
|
.toList(),
|
||||||
|
prefetchHooksCallback: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef $$LocalKvTableProcessedTableManager =
|
||||||
|
ProcessedTableManager<
|
||||||
|
_$AppDatabase,
|
||||||
|
$LocalKvTable,
|
||||||
|
LocalKvData,
|
||||||
|
$$LocalKvTableFilterComposer,
|
||||||
|
$$LocalKvTableOrderingComposer,
|
||||||
|
$$LocalKvTableAnnotationComposer,
|
||||||
|
$$LocalKvTableCreateCompanionBuilder,
|
||||||
|
$$LocalKvTableUpdateCompanionBuilder,
|
||||||
|
(LocalKvData, BaseReferences<_$AppDatabase, $LocalKvTable, LocalKvData>),
|
||||||
|
LocalKvData,
|
||||||
|
PrefetchHooks Function()
|
||||||
|
>;
|
||||||
|
|
||||||
|
class $AppDatabaseManager {
|
||||||
|
final _$AppDatabase _db;
|
||||||
|
$AppDatabaseManager(this._db);
|
||||||
|
$$ToneSamplesTableTableManager get toneSamples =>
|
||||||
|
$$ToneSamplesTableTableManager(_db, _db.toneSamples);
|
||||||
|
$$LocalKvTableTableManager get localKv =>
|
||||||
|
$$LocalKvTableTableManager(_db, _db.localKv);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
/// On-device tone / style samples (tech-design.md §2 — never upload the full corpus).
|
||||||
|
class ToneSamples extends Table {
|
||||||
|
TextColumn get id => text()();
|
||||||
|
TextColumn get sampleText => text()();
|
||||||
|
IntColumn get createdAtMs => integer()();
|
||||||
|
IntColumn get sortOrder => integer()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column<Object>> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small key/value bag for onboarding flags and local prefs that must stay encrypted.
|
||||||
|
class LocalKv extends Table {
|
||||||
|
TextColumn get key => text()();
|
||||||
|
TextColumn get value => text()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column<Object>> get primaryKey => {key};
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,13 @@ class DataFlowScreen extends StatelessWidget {
|
||||||
Card(
|
Card(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Text('말투 샘플 ${samples.length}개'),
|
title: Text('말투 샘플 ${samples.length}개'),
|
||||||
subtitle: Text(samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · ')),
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
|
||||||
|
session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)',
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
isThreeLine: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
/// Multi-device awareness: list active sessions (roadmap B first step).
|
/// Multi-device awareness: list / revoke active sessions (roadmap B).
|
||||||
class SessionsScreen extends StatefulWidget {
|
class SessionsScreen extends StatefulWidget {
|
||||||
const SessionsScreen({super.key});
|
const SessionsScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -40,6 +40,36 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _revoke(Map<String, dynamic> s) async {
|
||||||
|
final session = context.read<SessionState>();
|
||||||
|
if (session.user == null) return;
|
||||||
|
final id = s['id'];
|
||||||
|
if (id is! num) return;
|
||||||
|
final isCurrent = s['is_current'] == true;
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(isCurrent ? '이 기기에서 로그아웃?' : '다른 세션 종료?'),
|
||||||
|
content: Text(isCurrent ? '현재 기기 세션이 삭제됩니다.' : '선택한 기기의 로그인이 해제됩니다.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('종료')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
try {
|
||||||
|
await session.api.revokeSession(session.user!.id, id.toInt());
|
||||||
|
if (isCurrent && mounted) {
|
||||||
|
// Soft signal — full logout clearing is a follow-up; reload list for now.
|
||||||
|
setState(() => _error = '현재 세션이 종료되었습니다. 앱을 다시 시작해 주세요.');
|
||||||
|
}
|
||||||
|
await _load();
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
setState(() => _error = '세션 종료 실패 (${e.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -52,7 +82,7 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
children: [
|
children: [
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
child: Text('이 계정에 연결된 활성 세션입니다. 기기별 강제 로그아웃은 이후 단계에서 추가합니다.'),
|
child: Text('이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.'),
|
||||||
),
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
Padding(
|
Padding(
|
||||||
|
|
@ -64,6 +94,11 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other),
|
leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other),
|
||||||
title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'),
|
title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'),
|
||||||
subtitle: Text('만료: ${s['expires_at'] ?? ''}'),
|
subtitle: Text('만료: ${s['expires_at'] ?? ''}'),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: '세션 종료',
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
onPressed: () => _revoke(s),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -214,4 +214,8 @@ class ApiClient {
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> revokeSession(int userId, int sessionId) async {
|
||||||
|
await _json('DELETE', '/users/$userId/sessions/$sessionId');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,97 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../db/app_database.dart';
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
|
|
||||||
class SessionState extends ChangeNotifier {
|
class SessionState extends ChangeNotifier {
|
||||||
SessionState({ApiClient? api}) : _api = api ?? ApiClient();
|
SessionState({ApiClient? api, AppDatabase? db})
|
||||||
|
: _api = api ?? ApiClient(),
|
||||||
|
_db = db;
|
||||||
|
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
|
AppDatabase? _db;
|
||||||
|
|
||||||
User? user;
|
User? user;
|
||||||
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
|
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
|
||||||
String? error;
|
String? error;
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
bool toneOnboardingDone = false;
|
bool toneOnboardingDone = false;
|
||||||
List<String> styleExamples = const [];
|
List<String> styleExamples = const [];
|
||||||
|
bool localDbEncrypted = false;
|
||||||
|
|
||||||
static const _kUserId = 'user_id';
|
static const _kUserId = 'user_id';
|
||||||
static const _kDisplayName = 'display_name';
|
static const _kDisplayName = 'display_name';
|
||||||
static const _kInvite = 'invite_code';
|
static const _kInvite = 'invite_code';
|
||||||
static const _kToken = 'session_token';
|
static const _kToken = 'session_token';
|
||||||
|
// Legacy SharedPreferences keys — migrated into encrypted drift on first open.
|
||||||
|
static const _kToneDoneLegacy = 'tone_onboarding_done';
|
||||||
|
static const _kStyleExamplesLegacy = 'style_examples_json';
|
||||||
|
|
||||||
static const _kToneDone = 'tone_onboarding_done';
|
static const _kToneDone = 'tone_onboarding_done';
|
||||||
static const _kStyleExamples = 'style_examples_json';
|
static const _kDeviceInstallId = 'device_install_id';
|
||||||
|
|
||||||
|
AppDatabase? get db => _db;
|
||||||
|
|
||||||
Future<void> restore() async {
|
Future<void> restore() async {
|
||||||
|
_db ??= await _openDb();
|
||||||
|
await _migrateLegacyTonePrefs();
|
||||||
|
|
||||||
|
toneOnboardingDone = await _db!.getBoolKv(_kToneDone);
|
||||||
|
styleExamples = await _db!.loadToneSamples();
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final id = prefs.getInt(_kUserId);
|
final id = prefs.getInt(_kUserId);
|
||||||
final name = prefs.getString(_kDisplayName);
|
final name = prefs.getString(_kDisplayName);
|
||||||
final invite = prefs.getString(_kInvite);
|
final invite = prefs.getString(_kInvite);
|
||||||
final token = prefs.getString(_kToken);
|
final token = prefs.getString(_kToken);
|
||||||
toneOnboardingDone = prefs.getBool(_kToneDone) ?? false;
|
|
||||||
final rawExamples = prefs.getString(_kStyleExamples);
|
|
||||||
if (rawExamples != null && rawExamples.isNotEmpty) {
|
|
||||||
final decoded = jsonDecode(rawExamples) as List<dynamic>;
|
|
||||||
styleExamples = decoded.map((e) => e.toString()).where((s) => s.trim().isNotEmpty).toList();
|
|
||||||
}
|
|
||||||
if (id != null && name != null && invite != null) {
|
if (id != null && name != null && invite != null) {
|
||||||
user = User(id: id, displayName: name, inviteCode: invite);
|
user = User(id: id, displayName: name, inviteCode: invite);
|
||||||
_api.authToken = token;
|
_api.authToken = token;
|
||||||
|
await _registerDeviceTokenBestEffort();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<AppDatabase> _openDb() async {
|
||||||
|
try {
|
||||||
|
final db = await AppDatabase.open();
|
||||||
|
localDbEncrypted = true;
|
||||||
|
return db;
|
||||||
|
} catch (e) {
|
||||||
|
// Linux CI / hosts without libsqlcipher.so — fall back to memory so the
|
||||||
|
// app still boots; Android release path uses SQLCipher.
|
||||||
|
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
|
||||||
|
localDbEncrypted = false;
|
||||||
|
return AppDatabase.memory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateLegacyTonePrefs() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final rawExamples = prefs.getString(_kStyleExamplesLegacy);
|
||||||
|
final legacyDone = prefs.getBool(_kToneDoneLegacy);
|
||||||
|
final existing = await _db!.loadToneSamples();
|
||||||
|
if (existing.isEmpty && rawExamples != null && rawExamples.isNotEmpty) {
|
||||||
|
final decoded = (jsonDecode(rawExamples) as List<dynamic>)
|
||||||
|
.map((e) => e.toString())
|
||||||
|
.where((s) => s.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (decoded.isNotEmpty) {
|
||||||
|
await _db!.replaceToneSamples(decoded);
|
||||||
|
}
|
||||||
|
await prefs.remove(_kStyleExamplesLegacy);
|
||||||
|
}
|
||||||
|
if (legacyDone != null) {
|
||||||
|
await _db!.setBoolKv(_kToneDone, legacyDone);
|
||||||
|
await prefs.remove(_kToneDoneLegacy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> signup(String inviteCode, String displayName) async {
|
Future<void> signup(String inviteCode, String displayName) async {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
@ -51,12 +100,14 @@ class SessionState extends ChangeNotifier {
|
||||||
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
||||||
user = result.user;
|
user = result.user;
|
||||||
toneOnboardingDone = false;
|
toneOnboardingDone = false;
|
||||||
|
_db ??= await _openDb();
|
||||||
|
await _db!.setBoolKv(_kToneDone, false);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt(_kUserId, result.user.id);
|
await prefs.setInt(_kUserId, result.user.id);
|
||||||
await prefs.setString(_kDisplayName, result.user.displayName);
|
await prefs.setString(_kDisplayName, result.user.displayName);
|
||||||
await prefs.setString(_kInvite, result.user.inviteCode);
|
await prefs.setString(_kInvite, result.user.inviteCode);
|
||||||
await prefs.setString(_kToken, result.token);
|
await prefs.setString(_kToken, result.token);
|
||||||
await prefs.setBool(_kToneDone, false);
|
await _registerDeviceTokenBestEffort();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -71,16 +122,16 @@ class SessionState extends ChangeNotifier {
|
||||||
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||||
styleExamples = cleaned;
|
styleExamples = cleaned;
|
||||||
if (markDone) toneOnboardingDone = true;
|
if (markDone) toneOnboardingDone = true;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
_db ??= await _openDb();
|
||||||
await prefs.setString(_kStyleExamples, jsonEncode(cleaned));
|
await _db!.replaceToneSamples(cleaned);
|
||||||
if (markDone) await prefs.setBool(_kToneDone, true);
|
if (markDone) await _db!.setBoolKv(_kToneDone, true);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> skipToneOnboarding() async {
|
Future<void> skipToneOnboarding() async {
|
||||||
toneOnboardingDone = true;
|
toneOnboardingDone = true;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
_db ??= await _openDb();
|
||||||
await prefs.setBool(_kToneDone, true);
|
await _db!.setBoolKv(_kToneDone, true);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,5 +147,27 @@ class SessionState extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers a stable install id as the push token until Firebase Messaging
|
||||||
|
/// is wired with a real FCM registration token (roadmap B).
|
||||||
|
Future<void> _registerDeviceTokenBestEffort() async {
|
||||||
|
if (user == null) return;
|
||||||
|
try {
|
||||||
|
_db ??= await _openDb();
|
||||||
|
var installId = await _db!.getKv(_kDeviceInstallId);
|
||||||
|
if (installId == null || installId.isEmpty) {
|
||||||
|
final rand = Random.secure();
|
||||||
|
installId = List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
await _db!.setKv(_kDeviceInstallId, installId);
|
||||||
|
}
|
||||||
|
await _api.registerDeviceToken(
|
||||||
|
userId: user!.id,
|
||||||
|
token: 'install:$installId',
|
||||||
|
platform: defaultTargetPlatform == TargetPlatform.android ? 'android' : 'other',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('device token register skipped: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ApiClient get api => _api;
|
ApiClient get api => _api;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,30 @@
|
||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
_fe_analyzer_shared:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _fe_analyzer_shared
|
||||||
|
sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "88.0.0"
|
||||||
|
analyzer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer
|
||||||
|
sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.1"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -17,6 +41,54 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
build:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build
|
||||||
|
sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.7"
|
||||||
|
build_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_config
|
||||||
|
sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.2"
|
||||||
|
build_daemon:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_daemon
|
||||||
|
sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
build_runner:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: build_runner
|
||||||
|
sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.15.1"
|
||||||
|
built_collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_collection
|
||||||
|
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
|
built_value:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_value
|
||||||
|
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.12.6"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -25,6 +97,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
charcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: charcode
|
||||||
|
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.2"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -41,6 +137,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
convert:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: convert
|
||||||
|
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -57,6 +161,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.8"
|
||||||
|
dart_style:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_style
|
||||||
|
sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
|
drift:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: drift
|
||||||
|
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.31.0"
|
||||||
|
drift_dev:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: drift_dev
|
||||||
|
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.31.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -81,6 +209,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -94,6 +230,54 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
version: "5.0.0"
|
||||||
|
flutter_secure_storage:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage
|
||||||
|
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.3.1"
|
||||||
|
flutter_secure_storage_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_darwin
|
||||||
|
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.2"
|
||||||
|
flutter_secure_storage_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_linux
|
||||||
|
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
flutter_secure_storage_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_platform_interface
|
||||||
|
sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
|
flutter_secure_storage_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_web
|
||||||
|
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
|
flutter_secure_storage_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_windows
|
||||||
|
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -104,6 +288,22 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
graphs:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: graphs
|
||||||
|
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -112,6 +312,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
version: "1.6.0"
|
||||||
|
http_multi_server:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_multi_server
|
||||||
|
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -120,6 +328,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: io
|
||||||
|
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.5"
|
||||||
|
json_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: json_annotation
|
||||||
|
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.9.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -152,6 +376,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.1"
|
version: "5.1.1"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -176,6 +408,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.16.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -184,14 +424,46 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
path:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
path:
|
||||||
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.5"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.19"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -232,6 +504,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pool:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pool
|
||||||
|
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.2"
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -240,6 +520,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
pubspec_parse:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pubspec_parse
|
||||||
|
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.0"
|
||||||
|
recase:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: recase
|
||||||
|
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -296,11 +600,35 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
|
shelf:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf
|
||||||
|
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.2"
|
||||||
|
shelf_web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_web_socket
|
||||||
|
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
source_gen:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_gen
|
||||||
|
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.0"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -309,6 +637,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.1"
|
||||||
|
sqlcipher_flutter_libs:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqlcipher_flutter_libs
|
||||||
|
sha256: dd1fcc74d5baf3c36ad53e2652b2d06c9f8747494a3ccde0076e88b159dfe622
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.8"
|
||||||
|
sqlite3:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.4"
|
||||||
|
sqlparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlparser
|
||||||
|
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.43.1"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -325,6 +677,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
|
stream_transform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_transform
|
||||||
|
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
string_scanner:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -373,6 +733,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.0"
|
version: "15.0.0"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -397,6 +765,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -405,6 +781,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.1 <4.0.0"
|
dart: ">=3.8.1 <4.0.0"
|
||||||
flutter: ">=3.29.0"
|
flutter: ">=3.29.0"
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ dependencies:
|
||||||
web_socket_channel: ^3.0.3
|
web_socket_channel: ^3.0.3
|
||||||
provider: ^6.1.5+1
|
provider: ^6.1.5+1
|
||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
|
drift: ^2.31.0
|
||||||
|
path: ^1.9.1
|
||||||
|
path_provider: ^2.1.5
|
||||||
|
sqlite3: ^2.9.4
|
||||||
|
flutter_secure_storage: ^10.3.1
|
||||||
|
sqlcipher_flutter_libs: ^0.6.8
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -49,6 +55,8 @@ dev_dependencies:
|
||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^5.0.0
|
flutter_lints: ^5.0.0
|
||||||
|
drift_dev: ^2.31.0
|
||||||
|
build_runner: ^2.15.1
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import 'package:bunsin_mobile/db/app_database.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('tone samples round-trip in memory database', () async {
|
||||||
|
final db = AppDatabase.memory();
|
||||||
|
addTearDown(db.close);
|
||||||
|
|
||||||
|
await db.replaceToneSamples(['ㅇㅇ 알겠음', 'ㅋㅋ 그래']);
|
||||||
|
expect(await db.loadToneSamples(), ['ㅇㅇ 알겠음', 'ㅋㅋ 그래']);
|
||||||
|
|
||||||
|
await db.setBoolKv('tone_onboarding_done', true);
|
||||||
|
expect(await db.getBoolKv('tone_onboarding_done'), isTrue);
|
||||||
|
|
||||||
|
await db.replaceToneSamples(['하나만']);
|
||||||
|
expect(await db.loadToneSamples(), ['하나만']);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue