diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 2a4bc13..5ea108f 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -29,8 +29,9 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. ### NOW - 앱 코드는 클로즈드 베타 직전 수준 -- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK**. 다음: N4 FCM·Android QA (선택) -- 실 FCM · Android UI 수동 QA · 사람 PoC 실행은 남음 +- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료** +- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA +- 실 FCM 전송·Android 실기기 탭 · 사람 PoC 실행은 남음 ### NEXT 순서 @@ -159,25 +160,28 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **N4-B3** | 가입 화면 페어링 안내 | done | Signup 데모 패널에 한 줄 팁 | | **N4-B4** | 프로덕션 core+web 재배포 | done | `/demo` pairing_steps OK (`c7029ab`) | -### FCM +### FCM — [`fcm-setup.md`](./fcm-setup.md) | ID | 작업 | Status | 완료 조건 | |----|------|--------|-----------| -| **N4-1** | Firebase + `google-services.json` | todo | Android 앱 연결 | -| **N4-2** | 실 FCM registration token | todo | install-id 플레이스홀더 제거 | -| **N4-3** | 서버 FCM 자격증명 | todo | env만 (`FCM_SERVER_KEY` 또는 HTTP v1) | -| **N4-4** | 푸시 수신 | todo | `/admin/push-test` + 에스컬레이션 수신 | +| **N4-1** | Firebase + `google-services.json` | blocked | Master: Console 앱 + `mobile/android/app/google-services.json` (git 금지) | +| **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) | +| **N4-3** | 서버 FCM 자격증명 | blocked | Master: 호스트 `.env`의 `FCM_SERVER_KEY`만 | +| **N4-4** | 푸시 수신 | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 | ### Android UI 탭 (`mobile/README.md`) -| ID | 작업 | Status | -|----|------|--------| -| **N4-5** | 가입 → 말투 저장 | todo | -| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | todo | -| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | todo | -| **N4-8** | 에스컬레이션 → 사후알림 함 | todo | -| **N4-9** | 되돌리기·거부권 | todo | -| **N4-10** | 자율성 L0~L2 + 화이트리스트 | todo | +API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.com` → e2e **16/16**, 2026-07-31). +실기기/에뮬레이터 **화면 탭**은 Master 로컬에서 1회. + +| ID | 작업 | Status | 비고 | +|----|------|--------|------| +| **N4-5** | 가입 → 말투 저장 | api-done | 실기기 UI 탭 남음 | +| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | api-done | 실기기 UI 탭 남음 | +| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | api-done | 실기기 UI 탭 남음 | +| **N4-8** | 에스컬레이션 → 사후알림 함 | api-done | 실기기 UI 탭 남음 | +| **N4-9** | 되돌리기·거부권 | api-done | 실기기 UI 탭 남음 | +| **N4-10** | 자율성 L0~L2 + 화이트리스트 | api-done | 실기기 UI 탭 남음 | ### 후순위 @@ -213,7 +217,7 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터) 3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) 4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브) -5. ~~N3 배포 안정화~~ **done** — 다음: **N4 FCM/Android QA** 또는 서버에 `GEMINI_API_KEY` 주입 +5. ~~N3 안정화 + Track A/B~~ **done** — 다음: **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10) 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/fcm-setup.md b/docs/fcm-setup.md new file mode 100644 index 0000000..f705f68 --- /dev/null +++ b/docs/fcm-setup.md @@ -0,0 +1,69 @@ +# FCM 설정 (N4-1 ~ N4-4) + +서버·클라이언트의 푸시 **코드 경로는 준비됨**. 실제 전송은 Master가 Firebase 시크릿을 +넣기 전까지 soft-skip 한다 (`install:` 토큰·`FCM_SERVER_KEY` 없음). + +## 이미 된 것 (코드) + +| 계층 | 동작 | +|------|------| +| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 | +| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 | +| core-backend | `notifyUser` + `POST /admin/push-test` — `FCM_SERVER_KEY` + 실 토큰일 때만 전송 | + +## Master가 할 일 + +### N4-1 — Firebase 앱 + +1. [Firebase Console](https://console.firebase.google.com/)에서 프로젝트 생성 (또는 기존 사용). +2. Android 앱 추가 — package name: **`com.ykavu.ykavu_mobile`** +3. 받은 `google-services.json`을 로컬에만 배치 (git 금지): + +```bash +cp ~/Downloads/google-services.json mobile/android/app/google-services.json +``` + +템플릿: `mobile/android/app/google-services.json.example` + +### N4-3 — 서버 키 (env only) + +Cloud Messaging **레거시 서버 키**(또는 호환 서버 키)를 호스트 `.env`에만 설정: + +```bash +# 서버 ~/project/ykavu/.env +FCM_SERVER_KEY=AAAA... +``` + +```bash +cd ~/project/ykavu +docker compose up -d core-backend +``` + +**git / 이미지에 키를 넣지 않는다** (N2-A5). + +> 레거시 HTTP API가 Console에서 비활성이면 HTTP v1 마이그레이션이 필요하다. +> 그 전까지는 레거시 키가 있는 프로젝트로 N4-4 스모크를 완료한다. + +### N4-2 / N4-4 — 실기기 스모크 + +```bash +cd mobile +flutter run --release --dart-define=CORE_API_BASE=https://msn.iykyka.com +# 가입 → 로그에 "device token registered (FCM)" 확인 +``` + +```bash +curl -sS -X POST https://msn.iykyka.com/admin/push-test \ + -H "Authorization: Bearer $ADMIN_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"user_id": , "title":"와카뷰","body":"push smoke"}' +``` + +기대: `sent >= 1`, `skipped_reason` 없음. 기기에 알림 표시. + +`only_placeholder_tokens` / `fcm_not_configured` 이면 위 N4-1·N4-3을 다시 확인. + +## Web + +현재 프로덕션 UI는 Flutter Web. Web 푸시는 Firebase Web 설정 + VAPID가 추가로 필요하며 +이번 N4 범위에서는 **Android 실푸시**를 우선한다. Web은 계속 `install:` 플레이스홀더를 등록한다. diff --git a/docs/tester-guide.md b/docs/tester-guide.md index 82f99b3..81f0e62 100644 --- a/docs/tester-guide.md +++ b/docs/tester-guide.md @@ -30,7 +30,7 @@ API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포 - **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. - **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송. -- 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다. +- 푸시(FCM): 코드 경로는 준비됨. Master가 Firebase/`FCM_SERVER_KEY`를 넣기 전에는 플레이스홀더(`docs/fcm-setup.md`). - 문제/스크린샷은 Master에게 전달해 주세요. - 민감 정보·실명 대화는 베타 특성상 최소화해 주세요. diff --git a/mobile/README.md b/mobile/README.md index 899c8e1..8e73b14 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -63,9 +63,14 @@ HTTP로 검증한다. sudo apt-get install -y libsqlite3-dev libsqlcipher1 ``` +## 푸시 (FCM) + +- 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:` +- Master 설정: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json` + `FCM_SERVER_KEY`) + ## 아직 없는 것 -- Firebase 프로젝트의 실제 FCM registration token 연동 (`google-services.json`) +- Master Firebase 시크릿 주입 후 실기기 푸시 스모크 (N4-1/3/4) - 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화 - 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) - iOS 빌드 (v1 범위 밖) diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore index a291f3c..b262afb 100644 --- a/mobile/android/.gitignore +++ b/mobile/android/.gitignore @@ -14,3 +14,6 @@ key.properties **/*.jks *.jks *.keystore + +# Firebase — never commit real project credentials (see docs/fcm-setup.md). +app/google-services.json diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 48a5c6f..bd3b313 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -8,6 +8,11 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +// Apply only when Master drops google-services.json (see docs/fcm-setup.md). +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} + val keystoreProperties = Properties() val keystorePropertiesFile = rootProject.file("key.properties") val hasReleaseKeystore = keystorePropertiesFile.exists() diff --git a/mobile/android/app/google-services.json.example b/mobile/android/app/google-services.json.example new file mode 100644 index 0000000..d535fee --- /dev/null +++ b/mobile/android/app/google-services.json.example @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "REPLACE_WITH_FIREBASE_PROJECT_NUMBER", + "project_id": "ykavu-REPLACE", + "storage_bucket": "ykavu-REPLACE.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:REPLACE:android:REPLACE", + "android_client_info": { + "package_name": "com.ykavu.ykavu_mobile" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "REPLACE_WITH_ANDROID_API_KEY" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c2a24b9..cf477bb 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + Function(String installId)? storeInstallId; + + /// Load a previously stored install id. + final Future Function()? readInstallId; + + Future<({String token, String platform, bool isFcm})> resolve() async { + final platform = _platformLabel(); + final fcm = await _tryFirebaseToken(); + if (fcm != null && fcm.isNotEmpty) { + return (token: fcm, platform: platform, isFcm: true); + } + final installId = await _ensureInstallId(); + return (token: 'install:$installId', platform: platform, isFcm: false); + } + + Future _tryFirebaseToken() async { + // Web push needs a separate Firebase web config + VAPID; skip until provided. + if (kIsWeb) return null; + if (defaultTargetPlatform != TargetPlatform.android && + defaultTargetPlatform != TargetPlatform.iOS) { + return null; + } + try { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(); + } + final messaging = FirebaseMessaging.instance; + await messaging.requestPermission(alert: true, badge: true, sound: true); + final token = await messaging.getToken(); + if (token == null || token.isEmpty) return null; + return token; + } catch (e) { + debugPrint('FCM token unavailable (using install placeholder): $e'); + return null; + } + } + + Future _ensureInstallId() async { + final existing = await readInstallId?.call(); + if (existing != null && existing.isNotEmpty) return existing; + final rand = Random.secure(); + final installId = + List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join(); + await storeInstallId?.call(installId); + return installId; + } + + String _platformLabel() { + if (kIsWeb) return 'web'; + if (defaultTargetPlatform == TargetPlatform.android) return 'android'; + if (defaultTargetPlatform == TargetPlatform.iOS) return 'ios'; + return 'other'; + } +} diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart index ca94f8f..1f6da82 100644 --- a/mobile/lib/state/session_state.dart +++ b/mobile/lib/state/session_state.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -7,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../db/app_database.dart'; import '../models/models.dart'; import '../services/api_client.dart'; +import '../services/push_token_service.dart'; class SessionState extends ChangeNotifier { SessionState({ApiClient? api, AppDatabase? db}) @@ -148,22 +148,25 @@ 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). + /// Registers a real FCM token when Firebase is configured; otherwise a stable + /// `install:` placeholder (server skips placeholders for delivery). 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); - } + final resolved = await PushTokenService( + readInstallId: () => _db!.getKv(_kDeviceInstallId), + storeInstallId: (id) => _db!.setKv(_kDeviceInstallId, id), + ).resolve(); await _api.registerDeviceToken( userId: user!.id, - token: 'install:$installId', - platform: defaultTargetPlatform == TargetPlatform.android ? 'android' : 'other', + token: resolved.token, + platform: resolved.platform, + ); + debugPrint( + resolved.isFcm + ? 'device token registered (FCM)' + : 'device token registered (install placeholder)', ); } catch (e) { debugPrint('device token register skipped: $e'); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index b4916df..1955274 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "88.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c" + url: "https://pub.dev" + source: hosted + version: "1.3.75" analyzer: dependency: transitive description: @@ -93,10 +101,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -209,6 +217,54 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "6f22d1c62e0c20976f02cd842c7b7cd3c0f561cc2052586411871045c08860c9" + url: "https://pub.dev" + source: hosted + version: "4.12.1" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: f74d1d6fabccf7743b0144c2ed363d81049e258e22428ebb6fb0faec2fa7d938 + url: "https://pub.dev" + source: hosted + version: "8.0.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: ddab99d709b8c27dd47576eb05a1e719e07a2aa45c009a49ae92ac4d2a8ca555 + url: "https://pub.dev" + source: hosted + version: "3.9.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "30ad2d59bcd86117dc49d278c8998a0fb390c5a3202f6e43e4bd215d3f1d0556" + url: "https://pub.dev" + source: hosted + version: "16.4.3" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "4d144cb42b9a5a42855596be2d7682d32c50169f42e7df2cb3278ecf935e7d63" + url: "https://pub.dev" + source: hosted + version: "4.9.2" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: fcd25d0b9da55766ef4d28ae05a7460f5189635d6b42607adcd9f08818fd35f0 + url: "https://pub.dev" + source: hosted + version: "4.2.3" fixnum: dependency: transitive description: @@ -356,26 +412,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -396,26 +452,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.16.0" mime: dependency: transitive description: @@ -713,10 +769,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.4" typed_data: dependency: transitive description: @@ -729,10 +785,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" vm_service: dependency: transitive description: @@ -798,5 +854,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.8.1 <4.0.0" flutter: ">=3.29.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a47508f..7ecdae3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: flutter_secure_storage: ^10.3.1 sqlcipher_flutter_libs: ^0.6.8 google_fonts: ^6.3.2 + firebase_core: ^4.12.1 + firebase_messaging: ^16.4.3 dev_dependencies: flutter_test: