# Push Notification Migration TODO ## 배경 mattermost-mobile(React Native) 프로젝트의 안드로이드 푸시 알림 시스템을 이 Flutter 프로젝트(nomadcode)로 마이그레이션한 작업 기록 및 잔여 TODO. **패키지명:** `com.tokilabs.mattermost` (google-services.json과 일치) **원본 참조:** `/config/workspace/mattermost-mobile` --- ## 전체 아키텍처 요약 ``` FCM 서버 │ ▼ MattermostFirebaseMessagingService.kt ← 백그라운드/포그라운드 FCM 수신 │ ├─ JWT 서명 검증 (CustomPushNotificationHelper.java) ├─ ReceiptDelivery.java → /api/v4/notifications/ack POST ├─ PushNotificationDataHelper.kt → Room DB에서 채널/포스트/유저 조회 ├─ CustomPushNotificationHelper.java → 시스템 알림 표시 │ └─ EventChannel (com.tokilabs.mattermost/notifications) │ ▼ push_notification_service.dart ← Flutter에서 이벤트 수신 │ ├─ onNotification stream → UI에서 포그라운드 알림 표시 └─ onNotificationOpened stream → 알림 탭 시 채널/스레드 이동 │ ▼ main.dart (onNavigateToChannel / onNavigateToThread 콜백) ``` --- ## 완료된 작업 (건드리지 말 것) ### Android 네이티브 레이어 ✅ | 파일 | 역할 | |------|------| | `MattermostFirebaseMessagingService.kt` | FCM 수신, JWT 검증, Flutter EventSink로 전달 | | `helpers/CustomPushNotificationHelper.java` | 알림 채널 생성, 시스템 알림 빌드, 아바타 처리 | | `helpers/NotificationHelper.kt` | 알림 ID 관리, 그룹 알림 요약 | | `helpers/DatabaseHelper.kt` | GlobalDatabase + MattermostDatabase 접근 | | `helpers/Network.kt` | OkHttp 기반 HTTP 클라이언트 (토큰 캐시 포함) | | `helpers/db/GlobalDatabase.kt` | deviceToken, Servers 테이블 | | `helpers/db/MattermostDatabase.kt` | Config, User, System 테이블 (서버별) | | `helpers/db/GlobalEntities.kt` | GlobalEntity, ServerEntity, DAO 정의 | | `helpers/db/ServerEntities.kt` | ConfigEntity, UserEntity, SystemEntity, DAO 정의 | | `ReceiptDelivery.java` | `/api/v4/notifications/ack` ACK 전송 | | `NotificationReplyBroadcastReceiver.java` | 알림에서 인라인 답장 처리 | | `NotificationDismissService.java` | 알림 삭제 처리 | | `AppLifecycleTracker.kt` | 앱 포그라운드/백그라운드 상태 추적 | | `MainApplication.kt` | DB 초기화, 알림 채널 생성, 라이프사이클 추적 | | `MainActivity.kt` | EventChannel + MethodChannel 등록 | ### Flutter 레이어 ✅ | 파일 | 역할 | |------|------| | `lib/services/push_notification_service.dart` | 싱글톤, 채널 수신, 스트림 노출, MethodChannel 호출 | | `lib/services/push_notification_background.dart` | 백그라운드 FCM 핸들러 | | `lib/main.dart` | Firebase 초기화, PushNotificationService 초기화, 네비게이션 콜백 등록 | ### 리소스 ✅ - `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` + `ic_launcher_background.png` + `ic_launcher_foreground.png` - `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` (Adaptive Icon) - `drawable-*/ic_notif_action_reply.png` (답장 버튼 아이콘) - `mipmap-*/ic_notification.png` (알림 상태바 아이콘) - `AndroidManifest.xml` — 권한, 서비스, 리시버 등록 완료 --- ## 잔여 TODO --- ### [TODO-1] Android: PushNotificationDataHelper.kt — Room DAO 실제 쿼리 구현 **파일:** `android/app/src/main/java/com/tokilabs/mattermost/helpers/PushNotificationDataHelper.kt` 현재 다음 메서드들이 `Log.i(...) return null` stub 상태: ```kotlin private fun fetchTeamIfNeeded(db: MattermostDatabase, serverUrl: String, teamId: String): Bundle? private fun fetchMyChannel(db: MattermostDatabase, channelId: String): Bundle? private fun fetchPosts(db: MattermostDatabase, serverUrl: String, channelId: String, isCRTEnabled: Boolean, rootId: String?): Bundle? private fun fetchThread(db: MattermostDatabase, serverUrl: String, rootId: String): Bundle? private fun fetchNeededUsers(serverUrl: String, channelId: String): List? private fun saveToDatabase(db: MattermostDatabase, data: Bundle, teamId: String?, channelId: String?, isCRTEnabled: Boolean) ``` **구현 방법:** 1. `ServerEntities.kt`에 아래 Entity + DAO 추가: ```kotlin // Channel 테이블 @Entity(tableName = "Channel") data class ChannelEntity( @PrimaryKey val id: String, val display_name: String? = null, val type: String? = null, // O=public, P=private, D=DM, G=Group val team_id: String? = null, val delete_at: Long = 0 ) @Dao interface ChannelDao { @Query("SELECT * FROM Channel WHERE id = :id LIMIT 1") fun getChannel(id: String): ChannelEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) fun upsert(entity: ChannelEntity) } // Post 테이블 @Entity(tableName = "Post") data class PostEntity( @PrimaryKey val id: String, val channel_id: String? = null, val root_id: String? = null, val user_id: String? = null, val message: String? = null, val create_at: Long = 0, val delete_at: Long = 0 ) @Dao interface PostDao { @Query("SELECT * FROM Post WHERE channel_id = :channelId ORDER BY create_at DESC LIMIT 20") fun getPostsForChannel(channelId: String): List @Query("SELECT * FROM Post WHERE root_id = :rootId ORDER BY create_at ASC") fun getThreadPosts(rootId: String): List @Insert(onConflict = OnConflictStrategy.REPLACE) fun upsert(entity: PostEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) fun upsertAll(entities: List) } // Team 테이블 @Entity(tableName = "Team") data class TeamEntity( @PrimaryKey val id: String, val display_name: String? = null, val name: String? = null ) @Dao interface TeamDao { @Query("SELECT * FROM Team WHERE id = :id LIMIT 1") fun getTeam(id: String): TeamEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) fun upsert(entity: TeamEntity) } ``` 2. `MattermostDatabase.kt`에 새 DAO 등록: ```kotlin @Database( entities = [ConfigEntity::class, UserEntity::class, SystemEntity::class, ChannelEntity::class, PostEntity::class, TeamEntity::class], version = 2, // 버전 올리기 exportSchema = false ) abstract class MattermostDatabase : RoomDatabase() { abstract fun configDao(): ConfigDao abstract fun userDao(): UserDao abstract fun systemDao(): SystemDao abstract fun channelDao(): ChannelDao abstract fun postDao(): PostDao abstract fun teamDao(): TeamDao ... } ``` 3. `PushNotificationDataHelper.kt` stub 메서드 구현: ```kotlin private fun fetchMyChannel(db: MattermostDatabase, channelId: String): Bundle? { val channel = db.channelDao().getChannel(channelId) ?: return null return Bundle().apply { putString("id", channel.id) putString("display_name", channel.display_name) putString("type", channel.type) putString("team_id", channel.team_id) } } private fun fetchPosts(db: MattermostDatabase, serverUrl: String, channelId: String, isCRTEnabled: Boolean, rootId: String?): Bundle? { val posts = if (isCRTEnabled && rootId != null) db.postDao().getThreadPosts(rootId) else db.postDao().getPostsForChannel(channelId) if (posts.isEmpty()) return null return Bundle().apply { posts.forEachIndexed { i, post -> putBundle("post_$i", Bundle().apply { putString("id", post.id) putString("message", post.message) putString("user_id", post.user_id) putLong("create_at", post.create_at) }) } } } private fun fetchTeamIfNeeded(db: MattermostDatabase, serverUrl: String, teamId: String): Bundle? { val team = db.teamDao().getTeam(teamId) ?: return null return Bundle().apply { putString("id", team.id) putString("display_name", team.display_name) } } private fun fetchThread(db: MattermostDatabase, serverUrl: String, rootId: String): Bundle? { val posts = db.postDao().getThreadPosts(rootId) if (posts.isEmpty()) return null return Bundle().apply { putInt("reply_count", posts.size) } } private fun fetchNeededUsers(serverUrl: String, channelId: String): List? { // Note: 현재 DB에 channel_members 테이블 없음 → 추후 구현 return null } private fun saveToDatabase(db: MattermostDatabase, data: Bundle, teamId: String?, channelId: String?, isCRTEnabled: Boolean) { // Flutter 부팅 전 수신된 알림 데이터 저장 // System 테이블에 "pendingNotification_{channelId}" 키로 JSON 저장 권장 channelId ?: return val json = org.json.JSONObject().apply { data.keySet()?.forEach { key -> put(key, data.getString(key)) } } db.systemDao().upsert(SystemEntity("pendingNotification_$channelId", json.toString())) } ``` **주의:** `db.close()` 호출이 `PushNotificationDataHelper.kt` 99번째 줄 finally 블록에 있음. Room은 싱글톤이므로 `close()` 호출 시 이후 쿼리가 실패함. **해당 줄 제거 필요.** ```kotlin // 제거 대상 (PushNotificationDataHelper.kt:99) db?.close() // ← 삭제 ``` --- ### [TODO-2] Flutter: 앱 라우터 및 화면 구현 **파일:** `lib/main.dart` 현재 상태: ```dart pushService.onNavigateToChannel = (serverUrl, channelId) { // TODO: 채널 화면으로 이동 print('[Nav] Navigate to channel: $channelId on $serverUrl'); }; pushService.onNavigateToThread = (serverUrl, rootId) { // TODO: 스레드 화면으로 이동 print('[Nav] Navigate to thread: $rootId on $serverUrl'); }; ``` **구현 방향:** 1. go_router 또는 Navigator 2.0 기반 라우터 설정 2. 최소 필요 화면: - `LoginScreen` — 서버 URL 입력 + 로그인 → `pushService.setAuthToken()` 호출 - `ChannelScreen` — 채널 메시지 목록 - `ThreadScreen` — 스레드 메시지 목록 3. `main.dart` 콜백 연결 예시: ```dart pushService.onNavigateToChannel = (serverUrl, channelId) { _navigatorKey.currentState?.pushNamed( '/channel', arguments: {'serverUrl': serverUrl, 'channelId': channelId}, ); }; pushService.onNavigateToThread = (serverUrl, rootId) { _navigatorKey.currentState?.pushNamed( '/thread', arguments: {'serverUrl': serverUrl, 'rootId': rootId}, ); }; ``` --- ### [TODO-3] Flutter: 로그인 화면 및 setAuthToken 연결 알림 답장(NotificationReplyBroadcastReceiver)과 ACK(ReceiptDelivery)가 서버 인증 토큰을 필요로 함. Flutter 로그인 성공 시 반드시 아래 호출 필요: ```dart // 로그인 성공 후 await PushNotificationService().setAuthToken(serverUrl, bearerToken); // 로그아웃 시 await PushNotificationService().clearAuthToken(serverUrl); ``` 이 호출이 없으면 인라인 답장 API와 ACK API 요청이 401로 실패함. --- ### [TODO-4] Flutter: 포그라운드 알림 UI 앱이 포그라운드 상태일 때 FCM 메시지는 시스템 알림 대신 인앱 UI로 표시해야 함. `push_notification_service.dart`의 `onNotification` 스트림을 구독해서 구현: ```dart // 예시: 메인 화면 또는 글로벌 오버레이에서 PushNotificationService().onNotification.listen((data) { final type = data['type']; if (type == 'message') { // 상단 배너 또는 SnackBar 표시 ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(data['message'] ?? '새 메시지')), ); } }); ``` --- ### [TODO-5] Flutter: firebase_options.dart 생성 (선택 사항) 현재 `main.dart`에서 `Firebase.initializeApp()`을 options 없이 호출 중 (google-services.json 사용). FlutterFire CLI를 사용하면 더 명시적인 초기화 가능: ```bash # 프로젝트 루트에서 dart pub global activate flutterfire_cli flutterfire configure --project= ``` 실행 후 생성된 `lib/firebase_options.dart`를 사용: ```dart await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); ``` 현재도 동작에는 문제 없음. 멀티플랫폼(iOS 추가 시) 필수. --- ## Flutter에서 사용 가능한 인터페이스 (push_notification_service.dart) ```dart final service = PushNotificationService(); // 초기화 (main에서 1회 호출) service.init(); // 인증 토큰 관리 await service.setAuthToken(serverUrl, token, identifier: serverId); await service.clearAuthToken(serverUrl); // FCM 디바이스 토큰 조회 final token = await service.getDeviceToken(); // 포그라운드 알림 수신 스트림 service.onNotification.listen((Map data) { ... }); // 알림 탭 이벤트 스트림 service.onNotificationOpened.listen((NotificationOpenedEvent event) { // event.serverUrl, event.channelId, event.rootId, event.isCRTEnabled }); // 알림 탭 시 네비게이션 콜백 (main.dart builder에서 등록) service.onNavigateToChannel = (serverUrl, channelId) { ... }; service.onNavigateToThread = (serverUrl, rootId) { ... }; ``` --- ## MethodChannel 등록 목록 (MainActivity.kt) | 메서드명 | 인자 | 설명 | |---------|------|------| | `saveDeviceToken` | `token: String` | FCM 토큰 → Room GlobalDB 저장 | | `getDeviceToken` | 없음 | Room GlobalDB에서 토큰 반환 | | `setAuthToken` | `serverUrl`, `token`, `identifier?` | Network 캐시 + Room Servers 저장 | | `clearAuthToken` | `serverUrl` | Network 캐시에서 토큰 제거 | --- ## EventChannel 이벤트 타입 (com.tokilabs.mattermost/notifications) 네이티브에서 Flutter로 전달되는 Map의 `type` 필드: | type | 설명 | 주요 필드 | |------|------|---------| | `message` | 새 메시지 알림 | `server_url`, `channel_id`, `post_id`, `root_id`, `message`, `sender_name`, `is_crt_enabled` | | `clear` | 채널 읽음 처리 (알림 뱃지 초기화) | `server_url`, `channel_id` | | `session` | 세션 만료 | `server_url` | | `token_refresh` | FCM 토큰 갱신 | `token` | | `opened` | 알림 탭으로 앱 진입 | `server_url`, `channel_id`, `root_id`, `is_crt_enabled`, `userInteraction: true` | --- ## 우선순위 정리 | 우선순위 | 항목 | 영향 | |---------|------|------| | 🔴 필수 | TODO-2: 라우터/화면 | 알림 탭 후 이동 불가 | | 🔴 필수 | TODO-3: setAuthToken 연결 | 인라인 답장/ACK 인증 실패 | | 🟡 권장 | TODO-1: Room DAO 구현 | 알림 표시는 되나 채널명/메시지 미리보기 없음 | | 🟡 권장 | TODO-1의 db.close() 제거 | 간헐적 DB 접근 오류 | | 🟢 선택 | TODO-4: 포그라운드 알림 UI | 포그라운드에서 알림 표시 없음 | | 🟢 선택 | TODO-5: firebase_options.dart | iOS 추가 시 필요 | --- ## 참고: 원본 파일 위치 | 역할 | 원본 (mattermost-mobile) | |------|--------------------------| | FCM 서비스 | `android/app/src/main/java/com/tokilabs/mattermost/CustomPushNotification.kt` | | 알림 헬퍼 | `android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java` | | 데이터 헬퍼 | `android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt` | | ACK 전송 | `android/app/src/main/java/com/tokilabs/mattermost/ReceiptDelivery.java` | | Flutter 서비스 | `app/utils/push_notifications.ts` |