diff --git a/mobile/.metadata b/mobile/.metadata index 48c07ef..e2ff7d7 100644 --- a/mobile/.metadata +++ b/mobile/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 - - platform: android + - platform: web create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 diff --git a/mobile/lib/db/app_database.dart b/mobile/lib/db/app_database.dart index d8b8a20..b13c01a 100644 --- a/mobile/lib/db/app_database.dart +++ b/mobile/lib/db/app_database.dart @@ -1,128 +1,5 @@ -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; -} +/// Platform-specific local database entrypoint. +/// +/// - IO (Android / iOS / Linux / …): Drift + SQLCipher (`app_database_native.dart`) +/// - Web (Chrome): in-memory stub (`app_database_web.dart`) — FFI cannot compile +export 'app_database_native.dart' if (dart.library.html) 'app_database_web.dart'; diff --git a/mobile/lib/db/app_database.g.dart b/mobile/lib/db/app_database.g.dart index b64050c..83d67d7 100644 --- a/mobile/lib/db/app_database.g.dart +++ b/mobile/lib/db/app_database.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'app_database.dart'; +part of 'app_database_native.dart'; // ignore_for_file: type=lint class $ToneSamplesTable extends ToneSamples diff --git a/mobile/lib/db/app_database_native.dart b/mobile/lib/db/app_database_native.dart new file mode 100644 index 0000000..f490694 --- /dev/null +++ b/mobile/lib/db/app_database_native.dart @@ -0,0 +1,131 @@ +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, {this.encrypted = false}); + + /// True when backed by on-device SQLCipher (not memory / web stub). + final bool encrypted; + + @override + int get schemaVersion => 1; + + /// In-memory DB for unit tests (no SQLCipher / filesystem). + factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false); + + /// Production opener: encrypted on-device file (tech-design.md §8). + static Future open() async { + final db = AppDatabase(_openEncryptedExecutor(), encrypted: true); + // 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_web.dart b/mobile/lib/db/app_database_web.dart new file mode 100644 index 0000000..f7d54ed --- /dev/null +++ b/mobile/lib/db/app_database_web.dart @@ -0,0 +1,42 @@ +/// Web/Chrome stub for [AppDatabase]. +/// +/// SQLCipher / dart:ffi cannot compile for Flutter Web. Chrome runs use an +/// in-tab memory map so UI + API flows still work. Persistence is not encrypted +/// and is lost on refresh (web is a preview surface, not the release target). +class AppDatabase { + AppDatabase({this.encrypted = false}); + + /// Always false on web — there is no SQLCipher path. + final bool encrypted; + + final Map _kv = {}; + List _toneSamples = []; + + factory AppDatabase.memory() => AppDatabase(encrypted: false); + + static Future open() async => AppDatabase(encrypted: false); + + Future> loadToneSamples() async => List.from(_toneSamples); + + Future replaceToneSamples(List samples) async { + _toneSamples = List.from(samples); + } + + Future getKv(String key) async => _kv[key]; + + Future setKv(String key, String value) async { + _kv[key] = 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'); + } + + Future close() async {} +} diff --git a/mobile/lib/screens/data_flow_screen.dart b/mobile/lib/screens/data_flow_screen.dart index ab994a8..f8b4fa2 100644 --- a/mobile/lib/screens/data_flow_screen.dart +++ b/mobile/lib/screens/data_flow_screen.dart @@ -32,7 +32,9 @@ class DataFlowScreen extends StatelessWidget { subtitle: Text( [ samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '), - session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)', + session.localDbEncrypted + ? '저장: drift + SQLCipher(암호화)' + : '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)', ].join('\n'), ), isThreeLine: true, diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart index 9032af1..ca94f8f 100644 --- a/mobile/lib/state/session_state.dart +++ b/mobile/lib/state/session_state.dart @@ -60,11 +60,12 @@ class SessionState extends ChangeNotifier { Future _openDb() async { try { final db = await AppDatabase.open(); - localDbEncrypted = true; + localDbEncrypted = db.encrypted; return db; } catch (e) { // Linux CI / hosts without libsqlcipher.so — fall back to memory so the // app still boots; Android release path uses SQLCipher. + // Web uses an in-memory stub via conditional import (no FFI). debugPrint('Encrypted DB unavailable ($e); using in-memory fallback'); localDbEncrypted = false; return AppDatabase.memory(); diff --git a/mobile/web/favicon.png b/mobile/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/mobile/web/favicon.png differ diff --git a/mobile/web/icons/Icon-192.png b/mobile/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/mobile/web/icons/Icon-192.png differ diff --git a/mobile/web/icons/Icon-512.png b/mobile/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/mobile/web/icons/Icon-512.png differ diff --git a/mobile/web/icons/Icon-maskable-192.png b/mobile/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/mobile/web/icons/Icon-maskable-192.png differ diff --git a/mobile/web/icons/Icon-maskable-512.png b/mobile/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/mobile/web/icons/Icon-maskable-512.png differ diff --git a/mobile/web/index.html b/mobile/web/index.html new file mode 100644 index 0000000..1e7c1e7 --- /dev/null +++ b/mobile/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + bunsin_mobile + + + + + + diff --git a/mobile/web/manifest.json b/mobile/web/manifest.json new file mode 100644 index 0000000..9875ec0 --- /dev/null +++ b/mobile/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "bunsin_mobile", + "short_name": "bunsin_mobile", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}