nomadcode/push-notification-todo.md

15 KiB

Push Notification Migration TODO

배경

mattermost-mobile(React Native) 프로젝트의 안드로이드 푸시 알림 시스템을 이 Flutter 프로젝트(nomadcode-app)로 마이그레이션한 작업 기록 및 잔여 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 상태:

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<Bundle>?
private fun saveToDatabase(db: MattermostDatabase, data: Bundle, teamId: String?, channelId: String?, isCRTEnabled: Boolean)

구현 방법:

  1. ServerEntities.kt에 아래 Entity + DAO 추가:
// 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<PostEntity>

    @Query("SELECT * FROM Post WHERE root_id = :rootId ORDER BY create_at ASC")
    fun getThreadPosts(rootId: String): List<PostEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun upsert(entity: PostEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun upsertAll(entities: List<PostEntity>)
}

// 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)
}
  1. MattermostDatabase.kt에 새 DAO 등록:
@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
    ...
}
  1. PushNotificationDataHelper.kt stub 메서드 구현:
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<Bundle>? {
    // 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() 호출 시 이후 쿼리가 실패함. 해당 줄 제거 필요.

// 제거 대상 (PushNotificationDataHelper.kt:99)
db?.close()  // ← 삭제

[TODO-2] Flutter: 앱 라우터 및 화면 구현

파일: lib/main.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 콜백 연결 예시:

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 로그인 성공 시 반드시 아래 호출 필요:

// 로그인 성공 후
await PushNotificationService().setAuthToken(serverUrl, bearerToken);

// 로그아웃 시
await PushNotificationService().clearAuthToken(serverUrl);

이 호출이 없으면 인라인 답장 API와 ACK API 요청이 401로 실패함.


[TODO-4] Flutter: 포그라운드 알림 UI

앱이 포그라운드 상태일 때 FCM 메시지는 시스템 알림 대신 인앱 UI로 표시해야 함. push_notification_service.dartonNotification 스트림을 구독해서 구현:

// 예시: 메인 화면 또는 글로벌 오버레이에서
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를 사용하면 더 명시적인 초기화 가능:

# 프로젝트 루트에서
dart pub global activate flutterfire_cli
flutterfire configure --project=<firebase-project-id>

실행 후 생성된 lib/firebase_options.dart를 사용:

await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

현재도 동작에는 문제 없음. 멀티플랫폼(iOS 추가 시) 필수.


Flutter에서 사용 가능한 인터페이스 (push_notification_service.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<String, dynamic> 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