Skip to content

feat: FCM 푸시 알림 환경 설정 - #62

Open
WAcAW9 wants to merge 19 commits into
developfrom
feat/51-fcm-alert
Open

feat: FCM 푸시 알림 환경 설정#62
WAcAW9 wants to merge 19 commits into
developfrom
feat/51-fcm-alert

Conversation

@WAcAW9

@WAcAW9 WAcAW9 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

📄 작업 내용 요약

FCM 푸시 알림 환경 설정하고 토큰 발급을 구현합니다.


📎 Issue 번호


✅ 작업 목록

  • 기능 구현
  • 코드 리뷰 반영
  • 테스트 코드 작성
  • 문서 업데이트

📝 기타 참고사항

Summary by CodeRabbit

  • 새로운 기능
    • 채팅, 공지, 수신 전화 유형의 푸시 알림을 지원합니다.
    • Android 13 이상에서 알림 권한을 요청합니다.
    • 로그인 시 기기 알림 토큰을 등록하고, 로그아웃·회원 탈퇴 시 해제합니다.
    • 알림 채널을 자동 생성하고 중요도, 진동, 배지를 설정합니다.
  • 개선
    • 푸시 토큰 발급·등록 실패 시에도 앱 사용이 중단되지 않습니다.
    • 알림 권한 요청 이력을 기억해 불필요한 재요청을 방지합니다.

@WAcAW9 WAcAW9 self-assigned this Jul 31, 2026
@WAcAW9 WAcAW9 linked an issue Jul 31, 2026 that may be closed by this pull request
1 task
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Firebase Cloud Messaging 의존성과 메시징 서비스를 추가했습니다. FCM 토큰을 로컬 및 서버에 저장하고 인증 상태에 따라 등록·해제합니다. 채팅, 공지 및 수신 전화 payload를 파싱해 Android 알림으로 표시합니다.

Changes

FCM 푸시 알림

Layer / File(s) Summary
Firebase 및 알림 플랫폼 설정
build.gradle.kts, app/build.gradle.kts, gradle/libs.versions.toml, app/src/main/AndroidManifest.xml, app/src/main/java/kr/co/call/callfromai/App.kt, app/src/main/java/kr/co/call/callfromai/MainActivity.kt, app/src/main/java/kr/co/call/callfromai/notification/*
Firebase Messaging 의존성과 Google Services 플러그인을 추가했습니다. FCM 서비스를 매니페스트에 등록했습니다. 알림 채널과 Android 13 알림 권한 처리를 추가했습니다.
푸시 데이터 계약 및 파서
core/domain/src/main/java/kr/co/call/domain/model/push/*, core/domain/src/main/java/kr/co/call/domain/push/*
CHAT, CALL, NOTICE payload 모델과 데이터 키를 추가했습니다. 필수 값과 숫자 형식을 검증해 PushPayload로 변환합니다.
토큰 저장 및 서버 연동
core/datastore/.../FcmTokenDataStore.kt, core/network/.../push/*, core/network/.../NetworkModule.kt, core/data/.../push/*, core/data/.../RepositoryModule.kt
FCM 토큰의 DataStore 저장·조회·삭제를 추가했습니다. 토큰 등록·삭제 API, DTO, Retrofit 제공자 및 저장소 구현을 추가했습니다.
토큰 발급 및 인증 수명 주기
app/src/main/java/kr/co/call/callfromai/push/FirebaseFcmTokenProvider.kt, app/src/main/java/kr/co/call/callfromai/di/PushModule.kt, app/src/main/java/kr/co/call/callfromai/AppViewModel.kt, core/data/.../LoginRepositoryImpl.kt, core/data/.../MyPageRepositoryImpl.kt
Firebase 토큰 발급을 Hilt로 연결했습니다. 앱 시작과 로그인 후 토큰을 등록합니다. 로그아웃과 계정 삭제 후 토큰을 해제하고 로컬 인증 토큰을 삭제합니다.
푸시 메시지 수신 및 알림 표시
app/src/main/java/kr/co/call/callfromai/push/CallFromAiFirebaseMessagingService.kt, app/src/main/java/kr/co/call/callfromai/push/PushNotificationHelper.kt
FCM 메시지를 payload 유형별로 처리합니다. 채팅, 공지 및 수신 전화 알림을 GENERAL 채널에 표시합니다.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant LoginRepositoryImpl
  participant PushTokenManager
  participant FirebaseFcmTokenProvider
  participant PushTokenRepository
  participant FcmTokenDataStore
  User->>LoginRepositoryImpl: 로그인
  LoginRepositoryImpl->>PushTokenManager: registerCurrentDevice()
  PushTokenManager->>FirebaseFcmTokenProvider: getToken()
  FirebaseFcmTokenProvider-->>PushTokenManager: FCM 토큰
  PushTokenManager->>PushTokenRepository: register(token)
  PushTokenRepository-->>PushTokenManager: Result<Unit>
  PushTokenManager->>FcmTokenDataStore: saveToken(token)
Loading
sequenceDiagram
  participant Firebase
  participant CallFromAiFirebaseMessagingService
  participant PushPayloadParser
  participant PushNotificationHelper
  participant AndroidNotificationManager
  Firebase->>CallFromAiFirebaseMessagingService: onMessageReceived(message)
  CallFromAiFirebaseMessagingService->>PushPayloadParser: parse(data, title, body)
  PushPayloadParser-->>CallFromAiFirebaseMessagingService: PushPayload
  CallFromAiFirebaseMessagingService->>PushNotificationHelper: 유형별 알림 표시
  PushNotificationHelper->>AndroidNotificationManager: GENERAL 채널 알림 게시
Loading

Possibly related PRs

Suggested labels: 🧩 Component, ✨ Feature

🚥 Pre-merge checks | ✅ 5 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
모듈 의존성 방향 검증 ⚠️ Warning 현재 코드에 core/domain/.../ChatRepository.kt:3import androidx.paging.PagingData가 있고, feature/login/impl:core:data:core:datastore에 직접 의존합니다. ChatRepository에서 AndroidX 타입을 제거하고, feature 구현의 core:data·core:datastore 직접 의존을 domain 인터페이스와 적절한 상위 조합으로 이동하세요.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed [51] FCM 환경 설정과 FCM 토큰 발급을 위한 의존성, 토큰 제공, 서버 등록 및 삭제 기능을 구현했습니다.
Out of Scope Changes check ✅ Passed 변경 사항은 [51]의 FCM 푸시 알림 환경 설정과 토큰 발급 objectives에 직접 관련됩니다.
Ui 변경 시 스크린샷 첨부 확인 ✅ Passed PR 변경 파일에는 Jetpack Compose 화면 파일이나 feature/*/impl 하위 UI 파일이 없습니다. 따라서 스크린샷 또는 화면 녹화 첨부 확인 대상이 아닙니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 feat: <description> 형식을 따르며 FCM 푸시 알림 환경 설정이라는 주요 변경 사항을 정확히 설명합니다.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/51-fcm-alert

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Gemini AI 코드리뷰

안녕하세요, 시니어 Android 개발자입니다. Firebase Cloud Messaging (FCM) 연동과 푸시 알림 기능을 추가하는 이번 PR, 전반적으로 매우 꼼꼼하고 모범적인 구현 사례가 많아 인상 깊습니다. 특히, 아키텍처 원칙과 비즈니스 로직 분리에 신경 쓴 점이 돋보입니다.

자세한 코드 리뷰는 아래와 같습니다.


전반적인 피드백 (긍정적인 부분)

  • FCM 연동의 모범 사례: Android 13+ 알림 권한, 알림 채널 생성, 토큰 발급 및 서버 등록/해제 로직, 백그라운드 푸시 수신 처리, 페이로드 파싱 분리까지 FCM 관련 구현이 매우 체계적이고 견고합니다.
  • 아키텍처 분리:
    • PushPayloadParser를 domain 레이어에 두어 Android SDK 의존 없이 푸시 데이터를 파싱하는 것은 훌륭한 추상화입니다.
    • PushTokenManager를 통해 FCM 토큰 관리 로직을 캡슐화하고, LoginRepositoryMyPageRepository에서 이를 활용하는 방식이 깔끔합니다.
    • FcmTokenProvider 인터페이스를 통해 Firebase 구현체를 분리한 점도 좋습니다.
  • Hilt DI 활용: 필요한 곳에 정확하게 의존성을 주입하고, @Binds를 사용하여 인터페이스와 구현체를 연결한 점이 Hilt를 잘 활용하고 있음을 보여줍니다.
  • Result 래핑: PushTokenRepositoryImpl에서 safeApiResultUnit을 사용하여 Retrofit 에러를 Result 타입으로 안전하게 래핑하는 것은 좋은 에러 핸들링 전략입니다.
  • 널 안전성: ?.toLongOrNull(), .orEmpty(), takeIf { it.isNotBlank() } 등 Kotlin의 널 안전 기능을 적극적으로 활용하여 !! 사용을 지양한 점이 훌륭합니다.

⚠️ 개선이 필요한 부분 & 질문

1. Kotlin 코드 리뷰

  • 하드코딩된 문자열/매직 넘버:
    • app/src/main/java/kr/co/call/callfromai/notification/NotificationChannels.kt
      • 알림 채널 이름 ("앱 알림")과 설명 ("채팅/공지/전화 푸시 알림")이 하드코딩되어 있습니다. 추후 다국어 지원이나 변경 가능성을 고려하여 strings.xml 리소스에서 관리하는 것이 더 좋습니다.
    • app/src/main/java/kr/co/call/callfromai/push/PushNotificationHelper.kt
      • NOTICE_NOTIFICATION_ID = 1001
      • chatNotificationId(chatRoomId: Long)callNotificationId(callId: Long)
      • 알림 ID를 위한 매직 넘버(예: 1001, 2_000_000)와 ID 생성 로직(% Int.MAX_VALUE, % 100_000)이 사용되고 있습니다.
      • chatNotificationId의 경우 (chatRoomId % Int.MAX_VALUE).toInt().let { if (it == 0) 1 else it } 로직은 chatRoomIdInt.MAX_VALUE의 배수일 때 0이 되어 1을 사용하는 것을 방지하지만, chatRoomId 자체의 값이 매우 커질 경우 여전히 고유성을 보장하기 어렵거나 충돌 가능성이 있습니다. 또한, Int.MAX_VALUE가 아닌 임의의 숫자를 사용하는 것이 더 명확할 수 있습니다.
      • callNotificationId2_000_000이라는 오프셋을 사용하고 있는데, 이 숫자의 의미나 범위에 대한 주석이 추가되면 좋을 것 같습니다.
      • 개선 제안: 알림 ID는 앱 내에서 고유해야 하므로, 간단한 상수보다는 알림 유형별로 특정 범위를 할당하거나, ID 관리 유틸리티(예: AtomicInteger를 사용하여 동적으로 생성하거나, enum으로 명시적으로 관리)를 도입하는 것을 고려해볼 수 있습니다. 현재 방식도 동작은 하겠지만, 잠재적인 ID 충돌이나 가독성 측면에서 개선의 여지가 있습니다.
  • data class에 불필요한 var 사용 여부: 모든 data classval을 사용하고 있어 불필요한 var 사용은 없습니다. (Good)
  • Coroutine 사용 시 Dispatcher 명시 여부: AppViewModelinit 블록과 CallFromAiFirebaseMessagingServiceonNewToken에서 viewModelScope.launchapplicationScope.launch를 사용하고 있습니다. viewModelScope는 기본적으로 Dispatchers.Main.immediate를 사용하며, 내부 suspend 함수들은 적절한 Dispatcher(Dispatchers.IO 등)로 전환되므로 문제는 없습니다. ApplicationScope가 어떤 Dispatcher를 사용하는지 DI 모듈에 명시되어 있다면 더 명확할 수 있습니다 (일반적으로 Dispatchers.Default 또는 Dispatchers.IO로 주입).
  • Null 안전성: FirebaseFcmTokenProvidergetToken()에서 takeIf { it.isNotBlank() }를 사용하여 빈 문자열 토큰을 null로 취급하는 점이 좋습니다. (Good)

2. Jetpack Compose 코드

  • MainActivity.kt에서 notificationPermissionLauncher를 사용하여 Android 13+ 알림 권한을 요청하는 방식이 적절합니다. (Good)
  • 리뷰 대상에 새로운 Composable 함수는 포함되어 있지 않습니다.

3. Repository/DataSource 레이어

  • Retrofit 에러 핸들링: PushTokenRepositoryImpl에서 safeApiResultUnit을 사용하여 네트워크 호출 결과를 Result로 래핑하고 ErrorResponseParser를 주입받아 사용하는 방식은 모범적인 Retrofit 에러 핸들링입니다. (Good)
  • 네트워크 응답과 도메인 모델 매핑 분리 여부: PushTokenDto는 네트워크 레이어에, PushPayload는 도메인 레이어에 분리되어 있으며 PushPayloadParser가 매핑을 담당합니다. 이는 계층 분리 원칙을 잘 따르고 있습니다. (Good)
  • 캐싱 전략: FcmTokenDataStore를 사용하여 FCM 토큰을 로컬에 저장하고 관리하는 전략은 적절합니다. TokenDataStore와 동일한 DataStore<Preferences> 인스턴스를 공유하여 효율적으로 관리하는 점도 좋습니다. (Good)

4. ViewModel

  • UI 상태와 비즈니스 로직 분리: AppViewModel은 앱 전역의 푸시 토큰 등록 비즈니스 로직을 담당하며, UI 상태를 직접적으로 변경하지 않습니다. (Good)
  • Orbit ContainerHost 패턴 준수 여부: AppViewModelContainerHost를 구현하고 있지만, 이번 PR에서는 intent를 직접 처리하거나 reduce 로직이 추가되지 않았습니다. init 블록에서 registerPushTokenIfLoggedIn()을 호출하여 앱 시작 시점에 로직을 수행하는 방식은 유효합니다.
  • postSideEffect 남용 여부: AppViewModel에서 postSideEffect를 사용하지 않고 있습니다. (Good)
  • blocking 작업을 intent 내부에서 직접 수행하지 않는지: registerPushTokenIfLoggedIn() 내부에서 tokenDataStore.getAccessToken().isNullOrBlank() 같은 비동기 작업을 viewModelScope.launch 내에서 처리하고 있습니다. 네트워크/디스크 I/O 작업은 suspend 함수로 처리되어 blocking 작업을 방지합니다. (Good)

5. FCM/SSE/실시간 통신 관련 코드

  • 연결 재시도 로직: FirebaseMessaging.getInstance().token.await()는 Google Play Services 라이브러리 내부적으로 토큰 발급 실패 시 재시도 메커니즘을 포함하고 있습니다. PushTokenManager에서 서버 등록 실패 시 Timber.w로 로그만 남기고 재시도를 하지 않는 부분은, 로그인/로그아웃의 성공을 우선시하고 푸시 토큰 등록은 부가적인 작업으로 보는 접근 방식이라면 합리적입니다. FCM 토큰은 변경될 때 onNewToken으로 다시 알려주므로, 앱 시작 시 한 번의 시도 후 실패해도 치명적이지는 않습니다. (Good)
  • 백그라운드 제약 (Doze 모드, 배터리 최적화) 대응 여부: FirebaseMessagingService는 Doze 모드에서도 제한적으로 작동하며, onMessageReceived는 백그라운드에서 직접 메시지를 수신합니다. ApplicationScope를 사용하여 onNewToken에서 오래 걸리는 작업을 백그라운드에서 처리하는 점이 좋습니다. (Good)
  • 리소스 정리 (연결 해제, 리스너 해제):
    • MyPageRepositoryImpllogout()deleteAccount()에서 pushTokenManager.unregisterCurrentDevice()를 호출하여 서버에 등록된 FCM 토큰을 해제하고 fcmTokenDataStore.clearToken()으로 로컬 토큰을 정리하는 로직이 잘 구현되어 있습니다. (Good)

📝 기타 사소한 개선점

  • No newline at end of file:
    • app/src/main/AndroidManifest.xml
    • app/src/main/java/kr/co/call/callfromai/App.kt
    • app/src/main/java/kr/co/call/callfromai/MainActivity.kt
    • app/src/main/java/kr/co/call/callfromai/di/PushModule.kt
    • core/data/src/main/java/kr/co/call/data/push/PushTokenManager.kt
    • core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt
    • core/network/src/main/java/kr/co/call/network/dto/push/PushTokenDto.kt
    • gradle/libs.versions.toml
    • 파일 마지막에 개행 문자가 없는 경우가 있습니다. Git diff에서 \ No newline at end of file로 표시되므로, 일반적으로 파일 마지막에 한 줄의 개행 문자를 추가하는 것이 좋습니다.

결론

이번 PR은 FCM 연동에 필요한 모든 핵심 기능을 안정적이고 아키텍처 원칙을 준수하여 구현한 훌륭한 작업물입니다. 위에서 언급한 몇 가지 개선 사항(하드코딩된 문자열/매직 넘버)을 고려하여 수정한다면 더욱 완벽해질 것입니다.

수고 많으셨습니다! 이 PR은 Approve 합니다.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
app/src/main/java/kr/co/call/callfromai/AppViewModel.kt (1)

39-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

로그인 여부 확인 후 토큰 등록 로직이 두 곳에 중복됩니다. tokenDataStore.getAccessToken().isNullOrBlank() 체크와 그에 따른 pushTokenManager.registerCurrentDevice() 호출이 두 파일에 동일하게 구현되어 있습니다. 근본 원인은 이 가드 로직이 PushTokenManager 내부가 아니라 각 호출부에 있다는 점입니다.

  • app/src/main/java/kr/co/call/callfromai/AppViewModel.kt#L39-L45: 이 가드 로직을 제거하고, PushTokenManager에 로그인 여부를 확인하는 진입점(예: registerCurrentDeviceIfLoggedIn())을 추가해 호출하십시오.
  • app/src/main/java/kr/co/call/callfromai/push/CallFromAiFirebaseMessagingService.kt#L34-L40: 동일하게 PushTokenManager의 새 진입점을 호출하도록 변경하십시오.

이렇게 하면 로그인 가드 로직이 한 곳에만 존재하게 되어, 향후 변경 시 두 곳을 동시에 수정할 필요가 없습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/kr/co/call/callfromai/AppViewModel.kt` around lines 39 -
45, 중복된 로그인 가드를 PushTokenManager 내부로 이동하십시오. AppViewModel.kt의
registerPushTokenIfLoggedIn과 CallFromAiFirebaseMessagingService.kt의 해당 토큰 등록 호출을
새 진입점(예: registerCurrentDeviceIfLoggedIn())으로 변경하고, PushTokenManager에서 access
token 확인 후 로그인 상태일 때만 registerCurrentDevice()를 호출하도록 구현하십시오.
core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt (1)

51-53: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

FCM 토큰 등록이 로그인 완료를 지연시킵니다.

pushTokenManager.registerCurrentDevice()는 네트워크 호출을 포함하는 suspend 함수입니다. JWT는 이미 저장되었으므로, 이 호출의 완료 여부는 로그인 결과에 영향을 주지 않습니다. 하지만 현재 코드는 이 호출이 끝날 때까지 LoginToken 반환을 지연시킵니다. 이는 로그인 완료까지 걸리는 시간을 불필요하게 늘립니다.

registerCurrentDevice()를 별도 스코프(예: 애플리케이션 스코프)에서 fire-and-forget 방식으로 실행하는 것을 권장합니다.

♻️ 제안하는 리팩터링 방향
-            // AuthInterceptor 가 JWT 를 쓸 수 있는 상태에서 등록
-            pushTokenManager.registerCurrentDevice()
+            // AuthInterceptor 가 JWT 를 쓸 수 있는 상태에서 등록 (로그인 응답을 지연시키지 않도록 비동기 실행)
+            applicationScope.launch { pushTokenManager.registerCurrentDevice() }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt`
around lines 51 - 53, Update the login completion flow around
pushTokenManager.registerCurrentDevice() so the suspend network call runs
asynchronously in an application-level or equivalent independent scope. Return
LoginToken without awaiting token registration, while preserving the existing
JWT persistence and registration trigger behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/java/kr/co/call/callfromai/MainActivity.kt`:
- Line 36: 알림 권한 요청 완료 상태를 영속 저장하도록 requestNotificationPermissionIfNeeded()와
shouldRequest() 흐름을 수정하세요. 최초 요청 여부와 사용자 거부를 구분해 onCreate() 재실행마다 자동 요청되지 않게 하고,
거부 후 재요청은 앱 설정 화면 등 명시적인 사용자 동작에서만 수행되도록 보장하세요.

In `@app/src/main/java/kr/co/call/callfromai/push/FirebaseFcmTokenProvider.kt`:
- Around line 17-23: The catch block in getToken() currently catches all
Exceptions including CancellationException, which suppresses coroutine
cancellation signals. Add a separate catch block for CancellationException
before the existing catch (e: Exception) that immediately re-throws it, then
preserve the existing Exception handler that logs the error and returns null for
actual Firebase token failures.

In `@app/src/main/java/kr/co/call/callfromai/push/PushNotificationHelper.kt`:
- Around line 12-95: 알림에 탭 동작이 연결되지 않았습니다. PushNotificationHelper의 showChat과
showCall에서 각각 chatRoomId와 callId/characterName을 포함한 명시적 PendingIntent를 생성해
notify 호출에 전달하고, notify의 NotificationCompat.Builder에 setContentIntent를 설정하십시오.
채팅 알림은 해당 채팅방으로 이동하게 하고, 통화 알림은 최소한 채팅방 이동을 제공하며 기존 PushNotificationExtras 키를
재사용해 수신 화면이 필요한 데이터를 받을 수 있도록 하십시오.
- Around line 97-101: Update chatNotificationId in PushNotificationHelper so
chat notification IDs use a dedicated range that cannot overlap
NOTICE_NOTIFICATION_ID or the range produced by callNotificationId. Apply a
consistent offset or range mapping while preserving deterministic, valid Int IDs
for chat rooms.

In `@core/data/src/main/java/kr/co/call/data/push/PushTokenManager.kt`:
- Around line 50-60: Update unregisterCurrentDevice so local FCM/session data is
not cleared when repository deletion fails; preserve the authentication state or
persist an explicit pending-deletion state for retry/cancellation, and clear
tokens only after successful deletion. Coordinate MyPageRepositoryImpl.logout
with this outcome so JWT credentials remain available for the required retry
policy. Serialize deletion and registration within the same session lifecycle,
or make CallFromAiFirebaseMessagingService.onNewToken block
registerCurrentDevice during and after logout to prevent a post-logout
re-registration.
- Around line 30-60: Handle FcmTokenDataStore write failures in
registerCurrentDevice and unregisterCurrentDevice by wrapping saveToken and
clearToken in try-catch blocks, logging non-cancellation exceptions, and
allowing the methods to return without propagating them. Always rethrow
CancellationException; keep PushTokenRepository Result handling unchanged.

In `@core/datastore/src/main/java/kr/co/call/datastore/FcmTokenDataStore.kt`:
- Around line 50-54: Update saveToken so the FCM token is encrypted through the
project’s established SecretBox or equivalent encryption layer before writing to
FCM_TOKEN, and add the required module dependency/imports. Ensure existing
plaintext fcm_token values are migrated to encrypted form when read or during
initialization, preserving token retrieval behavior.

In `@core/domain/src/main/java/kr/co/call/domain/model/push/PushPayload.kt`:
- Around line 14-22: Update PushPayloadParser.parseChat to read title and body
from the data map using new PushDataKeys.TITLE and PushDataKeys.BODY constants,
while preserving notificationTitle/notificationBody as fallbacks when data
values are absent; ensure data-only CHAT messages populate Chat.title and
Chat.body correctly.

---

Nitpick comments:
In `@app/src/main/java/kr/co/call/callfromai/AppViewModel.kt`:
- Around line 39-45: 중복된 로그인 가드를 PushTokenManager 내부로 이동하십시오. AppViewModel.kt의
registerPushTokenIfLoggedIn과 CallFromAiFirebaseMessagingService.kt의 해당 토큰 등록 호출을
새 진입점(예: registerCurrentDeviceIfLoggedIn())으로 변경하고, PushTokenManager에서 access
token 확인 후 로그인 상태일 때만 registerCurrentDevice()를 호출하도록 구현하십시오.

In
`@core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt`:
- Around line 51-53: Update the login completion flow around
pushTokenManager.registerCurrentDevice() so the suspend network call runs
asynchronously in an application-level or equivalent independent scope. Return
LoginToken without awaiting token registration, while preserving the existing
JWT persistence and registration trigger behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c4de18b3-ce5d-4e94-a8ac-092968c1b846

📥 Commits

Reviewing files that changed from the base of the PR and between ee8b34e and bcf5d6e.

📒 Files selected for processing (26)
  • app/build.gradle.kts
  • app/src/main/AndroidManifest.xml
  • app/src/main/java/kr/co/call/callfromai/App.kt
  • app/src/main/java/kr/co/call/callfromai/AppViewModel.kt
  • app/src/main/java/kr/co/call/callfromai/MainActivity.kt
  • app/src/main/java/kr/co/call/callfromai/di/PushModule.kt
  • app/src/main/java/kr/co/call/callfromai/notification/NotificationChannels.kt
  • app/src/main/java/kr/co/call/callfromai/notification/NotificationPermission.kt
  • app/src/main/java/kr/co/call/callfromai/push/CallFromAiFirebaseMessagingService.kt
  • app/src/main/java/kr/co/call/callfromai/push/FirebaseFcmTokenProvider.kt
  • app/src/main/java/kr/co/call/callfromai/push/PushNotificationHelper.kt
  • build.gradle.kts
  • core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt
  • core/data/src/main/java/kr/co/call/data/push/PushTokenManager.kt
  • core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt
  • core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt
  • core/data/src/main/java/kr/co/call/data/repositoryImpl/PushTokenRepositoryImpl.kt
  • core/datastore/src/main/java/kr/co/call/datastore/FcmTokenDataStore.kt
  • core/domain/src/main/java/kr/co/call/domain/model/push/PushPayload.kt
  • core/domain/src/main/java/kr/co/call/domain/model/push/PushPayloadParser.kt
  • core/domain/src/main/java/kr/co/call/domain/push/FcmTokenProvider.kt
  • core/domain/src/main/java/kr/co/call/domain/repository/PushTokenRepository.kt
  • core/network/src/main/java/kr/co/call/network/api/PushTokenApi.kt
  • core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt
  • core/network/src/main/java/kr/co/call/network/dto/push/PushTokenDto.kt
  • gradle/libs.versions.toml

Comment thread app/src/main/java/kr/co/call/callfromai/MainActivity.kt
Comment on lines +12 to +95
object PushNotificationExtras {
const val PUSH_TYPE = "push_type"
const val CHAT_ROOM_ID = "chatRoomId"
const val CALL_ID = "callId"
const val CHARACTER_ID = "characterId"
}

/**
* CHAT / NOTICE / CALL 시스템 배너 (채널 [PushChannels.GENERAL])
*/
object PushNotificationHelper {

private const val NOTICE_NOTIFICATION_ID = 1001

fun showChat(
context: Context,
title: String,
body: String,
chatRoomId: Long,
) {
notify(
context = context,
notificationId = chatNotificationId(chatRoomId),
title = title.ifBlank { "새 메시지" },
body = body,
priority = NotificationCompat.PRIORITY_HIGH,
)
}

fun showNotice(
context: Context,
title: String,
body: String,
) {
notify(
context = context,
notificationId = NOTICE_NOTIFICATION_ID,
title = title.ifBlank { "알림" },
body = body,
priority = NotificationCompat.PRIORITY_HIGH,
)
}

fun showCall(
context: Context,
characterName: String,
callId: Long,
) {
notify(
context = context,
notificationId = callNotificationId(callId),
title = characterName.ifBlank { "전화" },
body = "수신 전화가 왔습니다",
priority = NotificationCompat.PRIORITY_MAX,
category = NotificationCompat.CATEGORY_CALL,
)
}

private fun notify(
context: Context,
notificationId: Int,
title: String,
body: String,
priority: Int,
category: String? = null,
) {
val builder = NotificationCompat.Builder(context, PushChannels.GENERAL)
.setSmallIcon(kr.co.call.designsystem.R.drawable.ic_chat_call)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setPriority(priority)

if (category != null) {
builder.setCategory(category)
}

try {
NotificationManagerCompat.from(context).notify(notificationId, builder.build())
} catch (e: SecurityException) {
Timber.w(e, "알림 표시 실패 (권한 확인)")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

알림에 클릭 동작(PendingIntent)이 없습니다.

PushNotificationExtras는 CHAT_ROOM_ID, CALL_ID, CHARACTER_ID 키를 정의합니다. 하지만 notify()(Line 70-95)는 이 키들을 어디에도 사용하지 않습니다. setContentIntent를 호출하지 않으므로, 사용자가 알림을 탭해도 아무 화면도 열리지 않습니다.

PushPayload.kt의 문서 주석은 CHAT 알림 클릭 시 채팅방으로 이동해야 한다고 명시합니다. CALL 알림은 수락/거절/채팅 이동 UI를 제공해야 한다고 명시합니다. 현재 구현은 두 요구사항을 모두 충족하지 못합니다.

showChat에는 chatRoomId를 담은 콘텐츠 인텐트를 추가하십시오. showCall에는 최소한 채팅방 이동을 위한 콘텐츠 인텐트를 추가하고, 수락/거절 액션은 addAction으로 추가하는 것을 검토하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/kr/co/call/callfromai/push/PushNotificationHelper.kt`
around lines 12 - 95, 알림에 탭 동작이 연결되지 않았습니다. PushNotificationHelper의 showChat과
showCall에서 각각 chatRoomId와 callId/characterName을 포함한 명시적 PendingIntent를 생성해
notify 호출에 전달하고, notify의 NotificationCompat.Builder에 setContentIntent를 설정하십시오.
채팅 알림은 해당 채팅방으로 이동하게 하고, 통화 알림은 최소한 채팅방 이동을 제공하며 기존 PushNotificationExtras 키를
재사용해 수신 화면이 필요한 데이터를 받을 수 있도록 하십시오.

Comment thread app/src/main/java/kr/co/call/callfromai/push/PushNotificationHelper.kt Outdated
Comment thread core/data/src/main/java/kr/co/call/data/push/PushTokenManager.kt Outdated
Comment on lines +50 to +60
suspend fun unregisterCurrentDevice() {
val token = fcmTokenDataStore.getToken()
?: fcmTokenProvider.getToken()

if (!token.isNullOrBlank()) {
pushTokenRepository.delete(token)
.onSuccess { Timber.d("FCM 토큰 서버 삭제 성공") }
.onFailure { error -> Timber.w(error, "FCM 토큰 서버 삭제 실패") }
}

fcmTokenDataStore.clearToken()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)PushTokenManager\.kt$|CallFromAiFirebaseMessagingService|MyPageRepositoryImpl|pushTokenRepository|FcmToken' || true

echo "== PushTokenManager outline =="
if [ -f core/data/src/main/java/kr/co/call/data/push/PushTokenManager.kt ]; then
  ast-grep outline core/data/src/main/java/kr/co/call/data/push/PushTokenManager.kt --view expanded || true
  echo "== PushTokenManager =="
  cat -n core/data/src/main/java/kr/co/call/data/push/PushTokenManager.kt
fi

echo "== firebase messaging service files =="
fd -i 'FirebaseMessagingService' .
fd -i 'CallFromAiFirebaseMessagingService' .
for f in $(fd -i 'CallFromAiFirebaseMessagingService' .); do
  echo "== $f outline =="
  ast-grep outline "$f" --view expanded || true
  echo "== $f =="
  cat -n "$f"
done

echo "== repository logout usages =="
for f in $(fd -i 'MyPageRepositoryImpl' .); do
  echo "== $f outline =="
  ast-grep outline "$f" --view expanded || true
  echo "== $f logout sections =="
  rg -n -C 8 'logout|deleteToken|unregister|deleteTokenFromServer|fcmToken' "$f" || true
done

echo "== token provider/datastore definitions =="
fd -i 'FcmTokenDataStore|FcmTokenProvider|PushTokenRepository' .
for f in $(fd -i 'FcmTokenDataStore|FcmTokenProvider|PushTokenRepository' .); do
  echo "== $f =="
  cat -n "$f"
done

Repository: call-from-ai/CallFromAi_Android

Length of output: 15448


FCM 삭제 실패 시 로컬 세션 정보를 유지하세요.

현재 unregisterCurrentDevice()는 실패 여부와 상관없이 fcmTokenDataStore.clearToken()을 호출하고, 이후 MyPageRepositoryImpl.logout()tokenDataStore.clearTokens()로 JWT와 FCM 토큰을 모두 제거합니다. 이 경우 토큰 삭제 실패 후 재시도에 사용할 계정 인증 정보가 없으므로, 삭제 재시도/취소 정책이나 토큰 해제 상태를 별도 플래그로 유지하도록 수정하세요.

또한 CallFromAiFirebaseMessagingService.onNewToken()은 로그인 상태만 확인한 뒤 별도 런타임 스코프에서 pushTokenManager.registerCurrentDevice()를 호출하므로, 로그아웃 DELETE 직후 새 토큰 등록이 완료되면 서버에 다시 연결됩니다. 토큰 삭제/등록과 onNewToken을 같은 세션 수명 주기로 직렬화하거나 로그아웃 중/후 재등록 요청을 막도록 설계해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/data/src/main/java/kr/co/call/data/push/PushTokenManager.kt` around
lines 50 - 60, Update unregisterCurrentDevice so local FCM/session data is not
cleared when repository deletion fails; preserve the authentication state or
persist an explicit pending-deletion state for retry/cancellation, and clear
tokens only after successful deletion. Coordinate MyPageRepositoryImpl.logout
with this outcome so JWT credentials remain available for the required retry
policy. Serialize deletion and registration within the same session lifecycle,
or make CallFromAiFirebaseMessagingService.onNewToken block
registerCurrentDevice during and after logout to prevent a post-logout
re-registration.

Comment on lines +50 to +54
suspend fun saveToken(token: String) {
require(token.isNotBlank()) { "FCM 토큰은 비어 있을 수 없습니다." }
dataStore.edit { preferences ->
preferences[FCM_TOKEN] = token
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate candidate files =="
fd -a 'FcmTokenDataStore.kt|datastore|DataStore|Protobuf' . | sed 's#^\./##' | head -200

echo
echo "== inspect FcmTokenDataStore.kt =="
if [ -f core/datastore/src/main/java/kr/co/call/datastore/FcmTokenDataStore.kt ]; then
  wc -l core/datastore/src/main/java/kr/co/call/datastore/FcmTokenDataStore.kt
  cat -n core/datastore/src/main/java/kr/co/call/datastore/FcmTokenDataStore.kt
fi

echo
echo "== search datastore construction/config/encryption references =="
rg -n "DataStore|dataStore|proto|preferences|FCM_TOKEN|fcm_token|EncryptedSharedPreferences|SecretBox|Cipher|encryption|KeyProvider|provideDataStore" -S core || true

Repository: call-from-ai/CallFromAi_Android

Length of output: 12987


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== build files with app/datastore dependencies =="
fd -a 'build.gradle.kts|libs.versions.toml|gradle.properties' . | sed 's#^\./##' | while read -r f; do
  if rg -n "datastore|androidx.datastore|preferences|security-crypto|EncryptedSharedPreferences|SecretBox|Hilt|dagger" "$f" >/dev/null 2>&1; then
    echo "--- $f"
    sed -n '1,220p' "$f"
  fi
done

echo
echo "== repository-wide encryption/API terms =="
rg -n "EncryptedSharedPreferences|SecretBox|SecretBoxKeyProvider|androidx-security|security-crypto|Cipher|encrypt|decrypt|aes|AES|KeyProvider|credential|credentials|token-encryption|fcm_token" -S . || true

Repository: call-from-ai/CallFromAi_Android

Length of output: 18151


FCM 토큰을 암호화된 저장소에 저장하세요.

DataStore<Preferences>는 저장값을 암호화하지 않습니다. 현재 모듈 의존성에도 암호화 저장소 의존성이 없어 saveToken(token)fcm_token 원문을 저장합니다. SecretBox처럼 암호화 계층을 쓰고, 기존 fcm_token 값은 밀리거나 암호화 마이그레이션 처리하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/datastore/src/main/java/kr/co/call/datastore/FcmTokenDataStore.kt`
around lines 50 - 54, Update saveToken so the FCM token is encrypted through the
project’s established SecretBox or equivalent encryption layer before writing to
FCM_TOKEN, and add the required module dependency/imports. Ensure existing
plaintext fcm_token values are migrated to encrypted form when read or during
initialization, preserving token retrieval behavior.

Sources: Coding guidelines, Path instructions

Comment on lines +14 to +22
/**
* 채팅 알림. 클릭 시 [chatRoomId] 방으로 이동한다.
* title/body는 OS notification 또는 data에서 올 수 있다.
*/
data class Chat(
val chatRoomId: Long,
val title: String,
val body: String,
) : PushPayload

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

주석과 실제 파서 동작이 일치하지 않습니다.

Line 16 주석은 title/body가 data에서도 올 수 있다고 설명합니다. 하지만 PushPayloadParser.parseChat(다른 파일)은 notificationTitle/notificationBody 매개변수만 사용합니다. data 맵에서 title/body를 읽는 로직이 없습니다. data-only CHAT 메시지가 오면 title/body는 항상 빈 문자열이 됩니다.

PushDataKeys에 TITLE/BODY 키를 추가하고 파서에서 이를 사용하십시오. 또는 주석을 실제 동작에 맞게 수정하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/domain/src/main/java/kr/co/call/domain/model/push/PushPayload.kt` around
lines 14 - 22, Update PushPayloadParser.parseChat to read title and body from
the data map using new PushDataKeys.TITLE and PushDataKeys.BODY constants, while
preserving notificationTitle/notificationBody as fallbacks when data values are
absent; ensure data-only CHAT messages populate Chat.title and Chat.body
correctly.

@github-actions

Copy link
Copy Markdown

Gemini AI 코드리뷰

안녕하세요! 시니어 Android 개발자로서 GitHub PR을 꼼꼼히 리뷰해 드리겠습니다. 이번 PR은 Firebase Cloud Messaging(FCM) 통합에 초점을 맞추고 있네요. 전반적으로 FCM 통합이 잘 이루어졌고, 여러 모범 사례를 따르고 있어 매우 인상적입니다. 특히 PushPayloadParser의 null 안정성 처리와 PushTokenManager의 견고한 로직은 훌륭합니다. 몇 가지 개선 사항과 궁금증을 정리해 보았습니다.


PR 요약

이 PR은 앱에 Firebase Cloud Messaging (FCM) 기능을 통합합니다. 주요 변경 사항은 다음과 같습니다:

  • Firebase SDK 및 google-services 플러그인 추가.
  • Android 13+ 알림 권한 (POST_NOTIFICATIONS) 요청 로직 추가.
  • FCM 수신 및 처리(CallFromAiFirebaseMessagingService).
  • 알림 채널 생성 (NotificationChannels).
  • FCM 토큰 관리 (FirebaseFcmTokenProvider, PushTokenManager, PushTokenRepositoryImpl, FcmTokenDataStore).
  • 로그인/로그아웃 시 FCM 토큰 서버 등록/해제 로직 연동.
  • 수신된 푸시 메시지 페이로드 파싱 및 시스템 알림 표시 (PushPayloadParser, PushNotificationHelper).
  • MainActivitysingleTop launchMode 적용.

전반적인 피드백

FCM 통합 작업을 매우 깔끔하고 체계적으로 진행하셨습니다. 도메인, 데이터, 네트워크 레이어를 명확하게 분리하고, Hilt를 통한 의존성 주입, DataStore를 이용한 토큰 캐싱 등 모범적인 Android 아키텍처 패턴을 잘 적용하셨습니다. 특히 FCM 토큰의 등록/해제 로직을 로그인/로그아웃 플로우에 통합한 점은 칭찬할 만합니다.


세부 코드 리뷰

1. Kotlin 코드 리뷰

  • Coroutine 사용 시 Dispatcher 명시 여부, viewModelScope/lifecycleScope 오남용

    • AppViewModel.kt: viewModelScope.launch를 사용했습니다. UI 관련 비즈니스 로직을 ViewModel에서 처리하는 데 적절하며, blocking 작업을 직접 수행하지 않아 좋습니다.
    • CallFromAiFirebaseMessagingService.kt: applicationScope.launch를 사용했습니다. ApplicationScope@ApplicationScope 어노테이션이 붙어있고 DI를 통해 주입되는 것으로 보입니다. 이 CoroutineScope가 어떤 Dispatcher를 사용하는지 명시되어 있지 않은데, 백그라운드 작업을 위해 Dispatchers.IODispatchers.Default를 사용하도록 설정되어 있다면 좋습니다. 만약 그렇지 않다면 명시적으로 withContext(Dispatchers.IO)를 사용하는 것을 고려해볼 수 있습니다.
    • FirebaseFcmTokenProvider.kt: FirebaseMessaging.getInstance().token.await()kotlinx-coroutines-play-services 라이브러리를 통해 Task를 suspend 함수로 변환하는 좋은 예시입니다. 내부적으로 적절한 Dispatcher를 사용하므로 명시하지 않아도 괜찮습니다.
    • FcmTokenDataStore.kt: dataStore.datadataStore.edit는 DataStore의 장점으로, 내부적으로 Dispatchers.IO를 사용하여 비동기 I/O 작업을 처리하므로 적절합니다.
  • Flow/StateFlow 사용 시 collect 시점 (repeatOnLifecycle 사용 여부)

    • 이 PR에는 Compose UI collect 로직이 직접 포함되어 있지 않아 해당 사항은 적용되지 않습니다.
  • null 안전성 (!! 사용 지양)

    • 아주 훌륭합니다! PushPayloadParser.kt에서 ?.toLongOrNull() ?: return null과 같이 안전 호출과 엘비스 연산자를 적극적으로 사용하여 null 케이스를 명확하게 처리하고, orEmpty()를 통해 null이 아닌 빈 문자열을 보장하는 등 !! 연산자의 사용을 지양하고 null 안전성을 철저히 지키고 있습니다. FcmTokenDataStore.kt에서도 String? 타입을 사용하여 null 가능성을 명확히 표시하고 처리합니다.
  • data class에 불필요한 var 사용 여부

    • PushTokenRegisterRequestDto, PushTokenDeleteRequestDto, PushPayload 내부 클래스 등 모든 data class 필드가 val로 선언되어 불변성을 유지합니다. 아주 좋습니다.
  • Hilt DI 시 생성자 주입 원칙 준수 여부

    • AppViewModel, FirebaseFcmTokenProvider, PushTokenManager, LoginRepositoryImpl, MyPageRepositoryImpl, PushTokenRepositoryImpl, FcmTokenDataStore 등 대부분의 클래스에서 생성자 주입 원칙을 잘 준수하고 있습니다.
    • CallFromAiFirebaseMessagingService: Service는 Android 시스템에 의해 인스턴스화되므로, 필드 주입(@Inject lateinit var)을 사용하는 것이 일반적입니다. 잘 적용하셨습니다.
    • PushModule.kt: FcmTokenProvider 인터페이스를 FirebaseFcmTokenProvider 구현체로 @Binds하여 제공하는 방식도 훌륭합니다.
  • 하드코딩된 문자열/매직 넘버

    • NotificationChannels.kt: "앱 알림", "채팅/공지/전화 푸시 알림"과 같은 사용자에게 노출되는 문자열은 strings.xml과 같은 리소스 파일로 추출하여 관리하는 것이 좋습니다. 다국어 지원 및 유지보수 측면에서 유리합니다.
    • PushNotificationHelper.kt: NOTICE_NOTIFICATION_ID, CHAT_ID_BASE, CALL_ID_BASE, ID_SPAN 등 알림 ID를 위한 상수는 내부적인 매직 넘버로 볼 수 있으나, private const val로 잘 정의하여 가독성을 높였습니다. ID_SPAN1_000_000으로 크게 잡아서 동시에 여러 채팅방이나 전화 알림이 올 경우 ID 충돌 가능성을 낮춘 점도 좋습니다.
    • PushDataKeysPushChannels.GENERAL, PLATFORM_ANDROID: API 계약이나 내부 상수로 사용되는 문자열이므로 하드코딩해도 무방합니다.

2. Jetpack Compose 코드 확인

  • 이 PR은 Jetpack Compose UI 코드 변경이 포함되어 있지 않습니다. AppScreen 컴포저블은 존재하지만, 해당 구현 상세는 포함되어 있지 않습니다.

3. Repository/DataSource 레이어 확인

  • Retrofit 에러 핸들링 (try-catch, Result 래핑)

    • PushTokenRepositoryImpl.kt: safeApiResultUnit(errorResponseParser) { ... } 패턴을 사용하여 Retrofit 통신 시 발생할 수 있는 예외를 Result로 래핑하고 ErrorResponseParser를 통해 에러를 처리하는 방식은 매우 견고합니다.
    • FirebaseFcmTokenProvider.kt: try-catch 블록으로 FirebaseMessaging.getInstance().token.await() 호출 중 발생할 수 있는 예외를 잘 처리하고, CancellationException은 rethrow하여 코루틴 취소 시점을 존중하며, 그 외 예외 발생 시 null을 반환하여 호출자에게 알리는 방식은 좋습니다.
    • PushTokenManager.kt: pushTokenRepository.register/delete().onSuccess/onFailure { ... }를 사용하여 Result의 성공/실패 케이스를 명확하게 분리하여 처리하는 점이 좋습니다. 실패 시 Timber.w로 로그를 남겨 디버깅에 도움이 됩니다.
  • 네트워크 응답과 도메인 모델 매핑 분리 여부

    • PushTokenRegisterRequestDto, PushTokenDeleteRequestDto는 네트워크 계층의 DTO이고, PushPayload는 도메인 계층의 모델로 잘 분리되어 있습니다. PushPayloadParserMap<String, String> 형태의 네트워크 응답 데이터를 도메인 모델로 변환하는 역할을 하여 매핑이 잘 분리되어 있습니다. 훌륭합니다.
  • 캐싱 전략 (로컬 DB vs 메모리)

    • FCM 토큰은 FcmTokenDataStore를 통해 DataStore Preferences에 영구적으로 저장됩니다. 이는 FCM 토큰의 특성을 고려할 때 적절한 캐싱 전략입니다.

4. ViewModel 확인

  • UI 상태와 비즈니스 로직 분리

    • AppViewModelregisterPushTokenIfLoggedIn() 메서드는 로그인 상태 확인 및 FCM 토큰 등록이라는 비즈니스 로직을 수행합니다. 이는 UI 상태 변경(reduce)이 아닌 비즈니스 로직으로, PushTokenManagerTokenDataStore를 통해 데이터를 관리합니다. 이러한 분리는 ViewModel의 역할을 명확히 하고 UI 상태 관리에 집중할 수 있게 합니다.
  • Orbit ContainerHost 패턴 준수 여부

    • AppViewModelContainerHost<AppState, AppSideEffect> 인터페이스를 구현하고 container(initialState = AppState())를 통해 Orbit의 패턴을 잘 준수하고 있습니다.
  • intent 내부에서 상태 변경(reduce)만 수행하는지

    • handleIntent 메서드는 현재 비어 있거나 Unit을 반환하는 else 브랜치만 있어, 상태 변경 로직이 직접적으로 추가되지 않았습니다. registerPushTokenIfLoggedIn()init 블록에서 호출되므로 intent 내부에서 직접적인 상태 변경을 일으키지 않습니다. 좋습니다.
  • postSideEffect 남용 여부

    • 이 PR에서는 postSideEffect가 사용되지 않았습니다.
  • blocking 작업을 intent 내부에서 직접 수행하지 않는지

    • registerPushTokenIfLoggedIn() 메서드는 viewModelScope.launch를 통해 코루틴으로 비동기 실행되므로, ViewModel의 Main Thread를 blocking하지 않습니다. 훌륭합니다.

5. FCM/SSE/실시간 통신 관련 코드 확인

  • 연결 재시도 로직 (exponential backoff)

    • FCM 토큰 등록(pushTokenManager.registerCurrentDevice()) 자체에는 명시적인 exponential backoff 재시도 로직이 포함되어 있지 않습니다.
    • onNewToken 콜백은 Firebase SDK 자체의 로직에 따라 재시도를 포함할 수 있습니다.
    • 만약 registerCurrentDevice() 호출이 실패했을 때, 앱의 핵심 기능에 영향을 미치는 경우라면, 간단한 재시도 로직(ex. 특정 횟수만큼 재시도 후 포기)을 추가하는 것을 고려해볼 수 있습니다. 현재는 로그인/앱 기동 시 한 번 시도하고 실패하면 로그만 남기는 방식인데, FCM 토큰 등록이 핵심 비즈니스 로직이라면 좀 더 견고한 처리가 필요할 수도 있습니다. 하지만 보통 푸시 알림 등록은 비동기적으로 실패해도 앱 사용에는 지장이 없도록 설계하는 경우가 많으므로 현재 방식도 허용 가능합니다.
  • 백그라운드 제약 (Doze 모드, 배터리 최적화) 대응 여부

    • Firebase Cloud Messaging 자체는 Doze 모드와 같은 Android의 백그라운드 제약사항에 대해 높은 우선순위 메시지(High-priority messages)를 통해 어느 정도 대응합니다.
    • POST_NOTIFICATIONS 권한 처리를 Android 13+ (Tiramisu)에 맞게 구현하여 알림이 사용자에게 잘 전달될 수 있도록 조치한 점은 훌륭합니다.
    • 알림 채널을 IMPORTANCE_HIGHPRIORITY_MAX로 설정하여 시스템의 알림 제약사항 속에서도 알림이 중요하게 다뤄지도록 한 점도 좋습니다.
  • 리소스 정리 (연결 해제, 리스너 해제)

    • MyPageRepositoryImpl.ktlogout()deleteAccount() 메서드에서 pushTokenManager.unregisterCurrentDevice()를 호출하여 서버에 토큰 해제를 요청하고 로컬 FcmTokenDataStore에서 토큰을 삭제하는 로직이 잘 구현되어 있습니다. 이는 리소스 정리 측면에서 매우 중요하며, 로그인 관련 토큰과 FCM 토큰을 함께 관리하는 좋은 예시입니다.

개선 및 제안 사항

  1. 사용자향 문자열 리소스화:
    • NotificationChannels.ktPushNotificationHelper.kt에 있는 사용자에게 노출되는 문자열들(예: "앱 알림", "채팅/공지/전화 푸시 알림", "새 메시지", "알림", "수신 전화가 왔습니다")을 strings.xml로 추출하여 관리하는 것을 강력히 권장합니다.
  2. ApplicationScope Dispatcher 명확화:
    • CallFromAiFirebaseMessagingService.kt에서 주입받는 applicationScope가 어떤 Dispatcher를 사용하는지 common/di/ApplicationScope.kt 혹은 관련 DI Module에서 명시적으로 정의하는 것이 좋습니다. 일반적으로 Dispatchers.IODispatchers.Default가 백그라운드 작업에 적합합니다.
  3. Call Notification ID 유니크함 보장:
    • PushNotificationHelper.kt에서 callNotificationId를 생성할 때 callId.mod(ID_SPAN.toLong()).toInt()를 사용하고 있습니다. ID_SPAN이 크기 때문에 실제 충돌 가능성은 낮겠지만, 특정 시나리오(예: 아주 빠르게 여러 번의 전화가 걸려오고 그 callId들이 modulo 연산 결과 같은 값을 가지는 경우)에서는 이전 알림을 덮어쓸 가능성이 있습니다. 전화 알림은 매우 중요하므로, 가능하면 callId 자체를 알림 ID로 사용하거나, ID_SPAN을 충분히 크게 설정하여 동시에 표시될 수 있는 최대 알림 개수를 고려해야 합니다. (현재 1_000_000은 충분히 커 보입니다.)
  4. MainActivityrequestNotificationPermissionIfNeeded() 호출 시점:
    • 현재 onCreate에서 알림 권한을 요청하고 있는데, 이는 앱을 처음 시작할 때 바로 사용자에게 권한을 요청하게 됩니다. 일부 사용자는 이런 즉각적인 권한 요청에 거부감을 느낄 수 있습니다. 만약 앱의 핵심 기능이 알림과 밀접하게 관련되어 있지 않다면, 사용자가 알림이 필요한 기능(예: 채팅 설정)에 진입했을 때 권한을 요청하는 "Just-in-Time" 방식을 고려해볼 수 있습니다. 다만, 이 앱의 특성상(전화, 채팅) 시작 시 바로 요청하는 것이 합리적일 수도 있습니다. 이는 기획적인 판단에 따라 달라질 수 있습니다.

결론

전반적으로 매우 완성도 높은 PR입니다. FCM 통합의 모든 주요 측면을 잘 다루었고, 아키텍처 원칙을 충실히 따랐습니다. 위에서 제시된 몇 가지 개선 및 제안 사항들을 반영한다면 더욱 완벽한 코드가 될 것입니다.

승인 전에 위 제안 사항들을 검토해 주시면 감사하겠습니다. 수고하셨습니다!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/src/main/java/kr/co/call/callfromai/AppViewModel.kt (1)

23-25: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

푸시 등록 정책을 core:domain UseCase로 위임하세요.

AppViewModelTokenDataStore에서 인증 상태를 읽고 PushTokenManager를 직접 호출합니다. 인증 확인과 토큰 등록은 비즈니스 정책입니다. LoginRepositoryImpl.kt Line 33-60에서도 같은 등록을 직접 호출하므로 정책이 여러 계층에 분산됩니다. core:domain UseCase가 등록 정책을 소유하고 ViewModel은 해당 UseCase만 호출하도록 정리하세요.

As per path instructions: **/*ViewModel.kt의 비즈니스 로직은 UseCase(core:domain) 호출로 위임해야 합니다.

Also applies to: 46-53

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/kr/co/call/callfromai/AppViewModel.kt` around lines 23 -
25, AppViewModel의 인증 상태 확인과 PushTokenManager 직접 호출을 제거하고, 푸시 등록 정책을 담당하는
core:domain UseCase에 위임하세요. 생성자 의존성을 해당 UseCase로 교체하고, ViewModel의 관련 로직이
UseCase만 호출하도록 수정하세요. LoginRepositoryImpl의 중복 등록 호출도 동일한 UseCase를 재사용하도록 정리해 정책이
한 곳에만 존재하게 하세요.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app/src/main/java/kr/co/call/callfromai/notification/NotificationPermission.kt`:
- Around line 37-41: Update requestNotificationPermissionIfNeeded() so
markRequested(this) is not called before launching the permission request;
persist KEY_HAS_REQUESTED from the launcher result callback after the request
completes, regardless of the granted value, while preserving the existing
permission-check behavior.

---

Nitpick comments:
In `@app/src/main/java/kr/co/call/callfromai/AppViewModel.kt`:
- Around line 23-25: AppViewModel의 인증 상태 확인과 PushTokenManager 직접 호출을 제거하고, 푸시 등록
정책을 담당하는 core:domain UseCase에 위임하세요. 생성자 의존성을 해당 UseCase로 교체하고, ViewModel의 관련
로직이 UseCase만 호출하도록 수정하세요. LoginRepositoryImpl의 중복 등록 호출도 동일한 UseCase를 재사용하도록 정리해
정책이 한 곳에만 존재하게 하세요.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6cb11844-f139-4cf3-9c0f-a45e0992af22

📥 Commits

Reviewing files that changed from the base of the PR and between bcf5d6e and 539dbc6.

📒 Files selected for processing (11)
  • app/src/main/java/kr/co/call/callfromai/AppViewModel.kt
  • app/src/main/java/kr/co/call/callfromai/MainActivity.kt
  • app/src/main/java/kr/co/call/callfromai/notification/NotificationPermission.kt
  • app/src/main/java/kr/co/call/callfromai/push/CallFromAiFirebaseMessagingService.kt
  • app/src/main/java/kr/co/call/callfromai/push/FirebaseFcmTokenProvider.kt
  • app/src/main/java/kr/co/call/callfromai/push/PushNotificationHelper.kt
  • core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt
  • core/data/src/main/java/kr/co/call/data/push/PushTokenManager.kt
  • core/domain/src/main/java/kr/co/call/domain/model/push/PushPayload.kt
  • core/domain/src/main/java/kr/co/call/domain/model/push/PushPayloadParser.kt
  • gradle/libs.versions.toml
💤 Files with no reviewable changes (1)
  • core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt
🚧 Files skipped from review as they are similar to previous changes (7)
  • gradle/libs.versions.toml
  • app/src/main/java/kr/co/call/callfromai/MainActivity.kt
  • app/src/main/java/kr/co/call/callfromai/push/FirebaseFcmTokenProvider.kt
  • core/domain/src/main/java/kr/co/call/domain/model/push/PushPayload.kt
  • core/domain/src/main/java/kr/co/call/domain/model/push/PushPayloadParser.kt
  • app/src/main/java/kr/co/call/callfromai/push/CallFromAiFirebaseMessagingService.kt
  • app/src/main/java/kr/co/call/callfromai/push/PushNotificationHelper.kt

Comment on lines +37 to +41
fun markRequested(context: Context) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_HAS_REQUESTED, true)
.apply()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 8 \
  'NotificationPermission\.(shouldAutoRequest|markRequested)|notificationPermissionLauncher\.(launch|unregister)|registerForActivityResult' \
  --glob '*.kt' || true

Repository: call-from-ai/CallFromAi_Android

Length of output: 169


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "Kotlin files:"
git ls-files '*.kt' | sed -n '1,120p'

echo
echo "Find Notification-related files:"
fd -i 'notification|permission|MainActivity' . -e kt || true

echo
echo "Search relevant identifiers broadly:"
rg -n -i 'notificationpermission|notification permission|shouldautorequest|markrequested|permissionlauncher|registerforactivityresult' \
  --glob '*.kt' . || true

Repository: call-from-ai/CallFromAi_Android

Length of output: 11827


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "NotificationPermission.kt:"
cat -n app/src/main/java/kr/co/call/callfromai/notification/NotificationPermission.kt

echo
echo "MainActivity.kt top section:"
cat -n app/src/main/java/kr/co/call/callfromai/MainActivity.kt | sed -n '1,80p'

Repository: call-from-ai/CallFromAi_Android

Length of output: 4301


요청 이력을 실행 가능한 상태가 되면 저장하세요.

requestNotificationPermissionIfNeeded()launch(...) 실행 전에 markRequested(this)를 호출합니다. 콜백이 실행되더라도 granted 여부와 상관없이 KEY_HAS_REQUESTED가 저장되므로, 현재 흐름은 요청 실패/결과 전달 실패 시 재요청이 영구 차단됩니다. 런처 결과 콜백에서 이력을 저장하거나, 실행 전에는 저장하지 않도록 조정하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/kr/co/call/callfromai/notification/NotificationPermission.kt`
around lines 37 - 41, Update requestNotificationPermissionIfNeeded() so
markRequested(this) is not called before launching the permission request;
persist KEY_HAS_REQUESTED from the launcher result callback after the request
completes, regardless of the granted value, while preserving the existing
permission-check behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] FCM 푸시 알림 환경 설정

1 participant