git-subtree-dir: apps/mobile git-subtree-mainline:11490df648git-subtree-split:c7bc4fea56
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.pngmipmap-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)
구현 방법:
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)
}
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
...
}
PushNotificationDataHelper.ktstub 메서드 구현:
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');
};
구현 방향:
-
go_router 또는 Navigator 2.0 기반 라우터 설정
-
최소 필요 화면:
LoginScreen— 서버 URL 입력 + 로그인 →pushService.setAuthToken()호출ChannelScreen— 채널 메시지 목록ThreadScreen— 스레드 메시지 목록
-
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.dart의 onNotification 스트림을 구독해서 구현:
// 예시: 메인 화면 또는 글로벌 오버레이에서
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 |