From 9b02c59dd681a720bc9d23dbfd222d28afa4d68e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 05:28:39 +0000 Subject: [PATCH] Complete Phase 1 B: encrypted drift DB, FCM notify, session revoke - Flutter: drift + SQLCipher local store for tone samples/KV with secure-storage passphrase; migrate legacy SharedPreferences - Core: FCM notifyUser on escalation, admin push-test, push metrics, DELETE session for multi-device logout - Docs/roadmap B checkboxes updated; Linux SQLCipher apt notes Co-authored-by: okuma --- core-backend/README.md | 3 + core-backend/b_routes.go | 55 ++ core-backend/b_test.go | 43 ++ core-backend/main.go | 9 + core-backend/main_test.go | 13 + core-backend/metrics.go | 49 +- core-backend/push.go | 82 +++ docs/roadmap.md | 15 +- docs/tech-design.md | 2 +- mobile/README.md | 16 +- mobile/lib/db/app_database.dart | 128 ++++ mobile/lib/db/app_database.g.dart | 873 +++++++++++++++++++++++ mobile/lib/db/tables.dart | 21 + mobile/lib/screens/data_flow_screen.dart | 8 +- mobile/lib/screens/sessions_screen.dart | 39 +- mobile/lib/services/api_client.dart | 4 + mobile/lib/state/session_state.dart | 101 ++- mobile/pubspec.lock | 386 +++++++++- mobile/pubspec.yaml | 8 + mobile/test/local_db_test.dart | 18 + 20 files changed, 1830 insertions(+), 43 deletions(-) create mode 100644 core-backend/push.go create mode 100644 mobile/lib/db/app_database.dart create mode 100644 mobile/lib/db/app_database.g.dart create mode 100644 mobile/lib/db/tables.dart create mode 100644 mobile/test/local_db_test.dart diff --git a/core-backend/README.md b/core-backend/README.md index 8f61d14..ce924b3 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -40,6 +40,9 @@ Phase 1 B (베타 품질) 추가분: - `GET /admin/dashboard` — 최소 HTML 대시보드 - `POST|GET /users/:id/device-tokens` — FCM 토큰 등록(전송은 후속) - `GET /users/:id/sessions` — 활성 세션 목록(멀티 디바이스 1차) +- `DELETE /users/:id/sessions/:sessionId` — 세션 종료 +- `POST /admin/push-test` — FCM 스모크 테스트 (`FCM_SERVER_KEY` 필요, 없으면 skipped) +- 에스컬레이션 시 사용자 디바이스로 푸시 시도 (`notifyUser`) ## 테스트 diff --git a/core-backend/b_routes.go b/core-backend/b_routes.go index d099622..dee1b01 100644 --- a/core-backend/b_routes.go +++ b/core-backend/b_routes.go @@ -120,6 +120,61 @@ func registerBRoutes(r *gin.Engine, db *gorm.DB) { } 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 { diff --git a/core-backend/b_test.go b/core-backend/b_test.go index 539d1fe..52b92b4 100644 --- a/core-backend/b_test.go +++ b/core-backend/b_test.go @@ -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) { server, _ := setupTestServer(t) req, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil) diff --git a/core-backend/main.go b/core-backend/main.go index a7409e0..7343000 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -136,6 +136,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi "escalate_errors": rt.EscalateErrors, "escalate_error_rate": rt.EscalateErrorRate, "twin_sends_blocked": rt.TwinSendsBlocked, + "push_attempts": rt.PushAttempts, + "push_skipped": rt.PushSkipped, + "push_delivered": rt.PushDelivered, "invites_minted": invitesMinted, "invites_used": invitesUsed, }) @@ -240,6 +243,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi Reason: result.Reason, 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}) return } diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 275ab89..4c49d86 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -105,6 +105,19 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo 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 { t.Helper() resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil) diff --git a/core-backend/metrics.go b/core-backend/metrics.go index f196800..df862d4 100644 --- a/core-backend/metrics.go +++ b/core-backend/metrics.go @@ -11,12 +11,15 @@ import ( type RuntimeMetrics struct { mu sync.Mutex - DraftRequests int64 - DraftErrors int64 - DraftLatencies []time.Duration // capped ring of recent samples - EscalateChecks int64 - EscalateErrors int64 + DraftRequests int64 + DraftErrors int64 + DraftLatencies []time.Duration // capped ring of recent samples + EscalateChecks int64 + EscalateErrors int64 TwinSendsBlocked int64 + PushAttempts int64 + PushSkipped int64 + PushDelivered int64 } const maxLatencySamples = 200 @@ -51,6 +54,16 @@ func (m *RuntimeMetrics) recordTwinBlocked() { 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 { m.mu.Lock() defer m.mu.Unlock() @@ -76,16 +89,19 @@ func (m *RuntimeMetrics) snapshot() ginHMetrics { escErrRate = float64(m.EscalateErrors) / float64(m.EscalateChecks) } return ginHMetrics{ - DraftRequests: m.DraftRequests, - DraftErrors: m.DraftErrors, - DraftErrorRate: errRate, - DraftLatencyAvgMs: avgMs, - DraftLatencyMaxMs: float64(max.Milliseconds()), - DraftLatencySamples: len(m.DraftLatencies), - EscalateChecks: m.EscalateChecks, - EscalateErrors: m.EscalateErrors, - EscalateErrorRate: escErrRate, - TwinSendsBlocked: m.TwinSendsBlocked, + DraftRequests: m.DraftRequests, + DraftErrors: m.DraftErrors, + DraftErrorRate: errRate, + DraftLatencyAvgMs: avgMs, + DraftLatencyMaxMs: float64(max.Milliseconds()), + DraftLatencySamples: len(m.DraftLatencies), + EscalateChecks: m.EscalateChecks, + EscalateErrors: m.EscalateErrors, + EscalateErrorRate: escErrRate, + TwinSendsBlocked: m.TwinSendsBlocked, + PushAttempts: m.PushAttempts, + PushSkipped: m.PushSkipped, + PushDelivered: m.PushDelivered, } } @@ -100,4 +116,7 @@ type ginHMetrics struct { EscalateErrors int64 EscalateErrorRate float64 TwinSendsBlocked int64 + PushAttempts int64 + PushSkipped int64 + PushDelivered int64 } diff --git a/core-backend/push.go b/core-backend/push.go new file mode 100644 index 0000000..d187004 --- /dev/null +++ b/core-backend/push.go @@ -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 +} diff --git a/docs/roadmap.md b/docs/roadmap.md index cd270c0..984be6d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -185,13 +185,14 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase 동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지 ##### B. 그다음 — 베타 품질 -- [~] FCM 푸시 연동 — 디바이스 토큰 등록 API(`POST /users/:id/device-tokens`)까지. - 실제 FCM 전송은 Firebase 프로젝트 키 연동 후 -- [~] 멀티 디바이스 동기화 — 활성 세션 목록(`GET /users/:id/sessions`) + Flutter 화면. - 강제 로그아웃·메시지 동기화 고도화는 후속 -- [ ] drift + SQLCipher 로컬 저장 — 다음 슬라이스(코드젠). 현재 말투는 SharedPreferences -- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — 온보딩 샘플 로컬 저장, draft에 샘플만 전달, - 데이터 흐름 UI로 원칙 노출 +- [~] FCM 푸시 연동 — 토큰 등록 + 에스컬레이션 시 `notifyUser` + `POST /admin/push-test`. + `FCM_SERVER_KEY`와 실제 FCM registration token이 있으면 전송, 없으면 soft-skip. + Flutter는 install-id 플레이스홀더 등록(Firebase Messaging 앱 키는 배포 환경에서 교체) +- [~] 멀티 디바이스 동기화 — 세션 목록 + 세션 종료(`DELETE /users/:id/sessions/:id`). + 메시지 히스토리 서버 동기화는 이미 REST/WS; 오프라인 큐는 후속 +- [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는 + `flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백 +- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달 - [x] 데이터 흐름 표시 UI — `DataFlowScreen` - [x] 생성 지연시간·오류율 계측 — process-local `RuntimeMetrics` → `/admin/metrics` - [x] 모니터링 대시보드(최소) — `GET /admin/dashboard` diff --git a/docs/tech-design.md b/docs/tech-design.md index 3e3ce4a..a193e57 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -130,7 +130,7 @@ Python으로 남긴다. | 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 | | 메시지 릴레이 | Go 코어 서비스 내 WebSocket | 자체 서버로 충분한 규모. Kafka·관리형 pub-sub은 지금 시점에 과한 인프라 | | 데이터베이스 | 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 서비스 환경에서만 분리 관리 | 이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에 diff --git a/mobile/README.md b/mobile/README.md index 9c644bf..22b0501 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -52,8 +52,20 @@ HTTP로 검증한다. 6. 분신 메시지 되돌리기 · 거부권 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 결과는 맨 마지막에 반영) - iOS 빌드 (v1 범위 밖) diff --git a/mobile/lib/db/app_database.dart b/mobile/lib/db/app_database.dart new file mode 100644 index 0000000..d8b8a20 --- /dev/null +++ b/mobile/lib/db/app_database.dart @@ -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 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> loadToneSamples() async { + final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get(); + return rows.map((r) => r.sampleText).toList(); + } + + Future replaceToneSamples(List 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 getKv(String key) async { + final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull(); + return row?.value; + } + + Future setKv(String key, String value) async { + await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value)); + } + + Future getBoolKv(String key, {bool defaultValue = false}) async { + final v = await getKv(key); + if (v == null) return defaultValue; + return v == '1' || v.toLowerCase() == 'true'; + } + + Future 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 _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 _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.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; +} diff --git a/mobile/lib/db/app_database.g.dart b/mobile/lib/db/app_database.g.dart new file mode 100644 index 0000000..b64050c --- /dev/null +++ b/mobile/lib/db/app_database.g.dart @@ -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 id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sampleTextMeta = const VerificationMeta( + 'sampleText', + ); + @override + late final GeneratedColumn sampleText = GeneratedColumn( + 'sample_text', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMsMeta = const VerificationMeta( + 'createdAtMs', + ); + @override + late final GeneratedColumn createdAtMs = GeneratedColumn( + 'created_at_ms', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _sortOrderMeta = const VerificationMeta( + 'sortOrder', + ); + @override + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List 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 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 get $primaryKey => {id}; + @override + ToneSample map(Map 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 { + 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 toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['sample_text'] = Variable(sampleText); + map['created_at_ms'] = Variable(createdAtMs); + map['sort_order'] = Variable(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 json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ToneSample( + id: serializer.fromJson(json['id']), + sampleText: serializer.fromJson(json['sampleText']), + createdAtMs: serializer.fromJson(json['createdAtMs']), + sortOrder: serializer.fromJson(json['sortOrder']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'sampleText': serializer.toJson(sampleText), + 'createdAtMs': serializer.toJson(createdAtMs), + 'sortOrder': serializer.toJson(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 { + final Value id; + final Value sampleText; + final Value createdAtMs; + final Value sortOrder; + final Value 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 custom({ + Expression? id, + Expression? sampleText, + Expression? createdAtMs, + Expression? sortOrder, + Expression? 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? id, + Value? sampleText, + Value? createdAtMs, + Value? sortOrder, + Value? 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 toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (sampleText.present) { + map['sample_text'] = Variable(sampleText.value); + } + if (createdAtMs.present) { + map['created_at_ms'] = Variable(createdAtMs.value); + } + if (sortOrder.present) { + map['sort_order'] = Variable(sortOrder.value); + } + if (rowid.present) { + map['rowid'] = Variable(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 key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List 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 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 get $primaryKey => {key}; + @override + LocalKvData map(Map 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 { + final String key; + final String value; + const LocalKvData({required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + LocalKvCompanion toCompanion(bool nullToAbsent) { + return LocalKvCompanion(key: Value(key), value: Value(value)); + } + + factory LocalKvData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalKvData( + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'value': serializer.toJson(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 { + final Value key; + final Value value; + final Value 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 custom({ + Expression? key, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + LocalKvCompanion copyWith({ + Value? key, + Value? value, + Value? rowid, + }) { + return LocalKvCompanion( + key: key ?? this.key, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(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> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [toneSamples, localKv]; +} + +typedef $$ToneSamplesTableCreateCompanionBuilder = + ToneSamplesCompanion Function({ + required String id, + required String sampleText, + required int createdAtMs, + required int sortOrder, + Value rowid, + }); +typedef $$ToneSamplesTableUpdateCompanionBuilder = + ToneSamplesCompanion Function({ + Value id, + Value sampleText, + Value createdAtMs, + Value sortOrder, + Value rowid, + }); + +class $$ToneSamplesTableFilterComposer + extends Composer<_$AppDatabase, $ToneSamplesTable> { + $$ToneSamplesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sampleText => $composableBuilder( + column: $table.sampleText, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAtMs => $composableBuilder( + column: $table.createdAtMs, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters 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 get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sampleText => $composableBuilder( + column: $table.sampleText, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAtMs => $composableBuilder( + column: $table.createdAtMs, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings 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 get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get sampleText => $composableBuilder( + column: $table.sampleText, + builder: (column) => column, + ); + + GeneratedColumn get createdAtMs => $composableBuilder( + column: $table.createdAtMs, + builder: (column) => column, + ); + + GeneratedColumn 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 id = const Value.absent(), + Value sampleText = const Value.absent(), + Value createdAtMs = const Value.absent(), + Value sortOrder = const Value.absent(), + Value 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 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 rowid, + }); +typedef $$LocalKvTableUpdateCompanionBuilder = + LocalKvCompanion Function({ + Value key, + Value value, + Value rowid, + }); + +class $$LocalKvTableFilterComposer + extends Composer<_$AppDatabase, $LocalKvTable> { + $$LocalKvTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters 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 get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings 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 get key => + $composableBuilder(column: $table.key, builder: (column) => column); + + GeneratedColumn 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 key = const Value.absent(), + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => LocalKvCompanion(key: key, value: value, rowid: rowid), + createCompanionCallback: + ({ + required String key, + required String value, + Value 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); +} diff --git a/mobile/lib/db/tables.dart b/mobile/lib/db/tables.dart new file mode 100644 index 0000000..9ae5ada --- /dev/null +++ b/mobile/lib/db/tables.dart @@ -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> 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> get primaryKey => {key}; +} diff --git a/mobile/lib/screens/data_flow_screen.dart b/mobile/lib/screens/data_flow_screen.dart index da01334..ab994a8 100644 --- a/mobile/lib/screens/data_flow_screen.dart +++ b/mobile/lib/screens/data_flow_screen.dart @@ -29,7 +29,13 @@ class DataFlowScreen extends StatelessWidget { Card( child: ListTile( 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), diff --git a/mobile/lib/screens/sessions_screen.dart b/mobile/lib/screens/sessions_screen.dart index 97f0fd3..097a1b0 100644 --- a/mobile/lib/screens/sessions_screen.dart +++ b/mobile/lib/screens/sessions_screen.dart @@ -4,7 +4,7 @@ import 'package:provider/provider.dart'; import '../services/api_client.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 { const SessionsScreen({super.key}); @@ -40,6 +40,36 @@ class _SessionsScreenState extends State { } } + Future _revoke(Map s) async { + final session = context.read(); + if (session.user == null) return; + final id = s['id']; + if (id is! num) return; + final isCurrent = s['is_current'] == true; + final ok = await showDialog( + 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 Widget build(BuildContext context) { return Scaffold( @@ -52,7 +82,7 @@ class _SessionsScreenState extends State { children: [ const Padding( padding: EdgeInsets.all(16), - child: Text('이 계정에 연결된 활성 세션입니다. 기기별 강제 로그아웃은 이후 단계에서 추가합니다.'), + child: Text('이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.'), ), if (_error != null) Padding( @@ -64,6 +94,11 @@ class _SessionsScreenState extends State { leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other), title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'), subtitle: Text('만료: ${s['expires_at'] ?? ''}'), + trailing: IconButton( + tooltip: '세션 종료', + icon: const Icon(Icons.logout), + onPressed: () => _revoke(s), + ), ), ], ), diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 04a03f0..0e27a35 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -214,4 +214,8 @@ class ApiClient { 'platform': platform, }); } + + Future revokeSession(int userId, int sessionId) async { + await _json('DELETE', '/users/$userId/sessions/$sessionId'); + } } diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart index b13e120..9032af1 100644 --- a/mobile/lib/state/session_state.dart +++ b/mobile/lib/state/session_state.dart @@ -1,48 +1,97 @@ import 'dart:convert'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../db/app_database.dart'; import '../models/models.dart'; import '../services/api_client.dart'; class SessionState extends ChangeNotifier { - SessionState({ApiClient? api}) : _api = api ?? ApiClient(); + SessionState({ApiClient? api, AppDatabase? db}) + : _api = api ?? ApiClient(), + _db = db; final ApiClient _api; + AppDatabase? _db; + User? user; AutonomyLevel autonomyLevel = AutonomyLevel.L0; String? error; bool loading = false; bool toneOnboardingDone = false; List styleExamples = const []; + bool localDbEncrypted = false; static const _kUserId = 'user_id'; static const _kDisplayName = 'display_name'; static const _kInvite = 'invite_code'; 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 _kStyleExamples = 'style_examples_json'; + static const _kDeviceInstallId = 'device_install_id'; + + AppDatabase? get db => _db; Future restore() async { + _db ??= await _openDb(); + await _migrateLegacyTonePrefs(); + + toneOnboardingDone = await _db!.getBoolKv(_kToneDone); + styleExamples = await _db!.loadToneSamples(); + final prefs = await SharedPreferences.getInstance(); final id = prefs.getInt(_kUserId); final name = prefs.getString(_kDisplayName); final invite = prefs.getString(_kInvite); 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; - styleExamples = decoded.map((e) => e.toString()).where((s) => s.trim().isNotEmpty).toList(); - } if (id != null && name != null && invite != null) { user = User(id: id, displayName: name, inviteCode: invite); _api.authToken = token; + await _registerDeviceTokenBestEffort(); notifyListeners(); } } + Future _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 _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) + .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 signup(String inviteCode, String displayName) async { loading = true; error = null; @@ -51,12 +100,14 @@ class SessionState extends ChangeNotifier { final result = await _api.signup(inviteCode: inviteCode, displayName: displayName); user = result.user; toneOnboardingDone = false; + _db ??= await _openDb(); + await _db!.setBoolKv(_kToneDone, false); final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_kUserId, result.user.id); await prefs.setString(_kDisplayName, result.user.displayName); await prefs.setString(_kInvite, result.user.inviteCode); await prefs.setString(_kToken, result.token); - await prefs.setBool(_kToneDone, false); + await _registerDeviceTokenBestEffort(); } on ApiException catch (e) { error = '가입 실패 (${e.statusCode}): ${e.body}'; } catch (e) { @@ -71,16 +122,16 @@ class SessionState extends ChangeNotifier { final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); styleExamples = cleaned; if (markDone) toneOnboardingDone = true; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kStyleExamples, jsonEncode(cleaned)); - if (markDone) await prefs.setBool(_kToneDone, true); + _db ??= await _openDb(); + await _db!.replaceToneSamples(cleaned); + if (markDone) await _db!.setBoolKv(_kToneDone, true); notifyListeners(); } Future skipToneOnboarding() async { toneOnboardingDone = true; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_kToneDone, true); + _db ??= await _openDb(); + await _db!.setBoolKv(_kToneDone, true); 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 _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; } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index e33e219..49b1427 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile 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: dependency: transitive description: @@ -17,6 +41,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -25,6 +97,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -41,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: @@ -57,6 +161,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -81,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -94,6 +230,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter @@ -104,6 +288,22 @@ packages: description: flutter source: sdk 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: dependency: "direct main" description: @@ -112,6 +312,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -120,6 +328,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -152,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -176,6 +408,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" nested: dependency: transitive description: @@ -184,14 +424,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - path: + package_config: dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -232,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" provider: dependency: "direct main" description: @@ -240,6 +520,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -296,11 +600,35 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: flutter source: sdk 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: dependency: transitive description: @@ -309,6 +637,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -325,6 +677,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -373,6 +733,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -397,6 +765,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -405,6 +781,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.8.1 <4.0.0" flutter: ">=3.29.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f10ba98..ec43028 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -38,6 +38,12 @@ dependencies: web_socket_channel: ^3.0.3 provider: ^6.1.5+1 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: flutter_test: @@ -49,6 +55,8 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. 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 # following page: https://dart.dev/tools/pub/pubspec diff --git a/mobile/test/local_db_test.dart b/mobile/test/local_db_test.dart new file mode 100644 index 0000000..056afe8 --- /dev/null +++ b/mobile/test/local_db_test.dart @@ -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(), ['하나만']); + }); +}