diff --git a/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt b/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt index e82f870d..36fe7808 100644 --- a/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt +++ b/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt @@ -1,12 +1,17 @@ package com.k9i.ccpocket import android.content.ComponentName +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel private const val APP_ICON_CHANNEL = "ccpocket/app_icon" +private const val APP_SETTINGS_CHANNEL = "ccpocket/app_settings" class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { @@ -30,6 +35,22 @@ class MainActivity : FlutterActivity() { else -> result.notImplemented() } } + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + APP_SETTINGS_CHANNEL, + ).setMethodCallHandler { call, result -> + when (call.method) { + "openNotificationSettings" -> { + try { + openNotificationSettings() + result.success(true) + } catch (error: Exception) { + result.error("settings_unavailable", error.message, null) + } + } + else -> result.notImplemented() + } + } } private fun getCurrentIcon(): String? { @@ -74,4 +95,18 @@ class MainActivity : FlutterActivity() { ) } } + + private fun openNotificationSettings() { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + } + } else { + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:$packageName"), + ) + } + startActivity(intent) + } } diff --git a/apps/mobile/android/build.gradle.kts b/apps/mobile/android/build.gradle.kts index dbee657b..f0ae7eec 100644 --- a/apps/mobile/android/build.gradle.kts +++ b/apps/mobile/android/build.gradle.kts @@ -1,3 +1,10 @@ +// Firebase Messaging 25.1.2 fixes FCM registration failures caused by reused +// Firebase installation IDs. Remove this override once FlutterFire pins a +// Firebase Android BoM version 34.18.0 or newer. +rootProject.extra["FlutterFire"] = mapOf( + "FirebaseSDKVersion" to "34.18.0", +) + allprojects { repositories { google() diff --git a/apps/mobile/lib/features/claude_session/claude_session_screen.dart b/apps/mobile/lib/features/claude_session/claude_session_screen.dart index 4d836f41..c7a783e3 100644 --- a/apps/mobile/lib/features/claude_session/claude_session_screen.dart +++ b/apps/mobile/lib/features/claude_session/claude_session_screen.dart @@ -23,6 +23,7 @@ import '../../services/notification_service.dart'; import '../../theme/app_theme.dart'; import '../../utils/diff_parser.dart'; import '../../utils/network_endpoint.dart'; +import '../../utils/platform_helper.dart'; import '../../utils/terminal_launcher.dart'; import '../session_list/workspace_shell_screen.dart'; import '../session_link/widgets/session_unavailable_view.dart'; @@ -673,7 +674,10 @@ class _ChatScreenBody extends HookWidget { effects, sessionId: sessionId, isBackground: isBackgroundRef.value, + localNotificationsAllowed: + !isAndroidPlatform || settingsCubit.state.fcmEnabled, remoteNotificationsReady: settingsCubit.state.fcmReady, + privacyMode: settingsCubit.state.fcmPrivacy, approval: chatSessionCubit.state.approval, l: l, collapseToolResults: collapseToolResults, @@ -1491,7 +1495,9 @@ void _executeSideEffects( Set effects, { required String sessionId, required bool isBackground, + required bool localNotificationsAllowed, required bool remoteNotificationsReady, + required bool privacyMode, required ApprovalState approval, required AppLocalizations l, required ValueNotifier collapseToolResults, @@ -1500,8 +1506,13 @@ void _executeSideEffects( }) { final useLocalNotification = shouldUseLocalNotificationFallback( isBackground: isBackground, + localNotificationsAllowed: localNotificationsAllowed, remoteNotificationsReady: remoteNotificationsReady, ); + final payload = sessionNotificationPayload( + sessionId: sessionId, + provider: 'claude', + ); for (final effect in effects) { switch (effect) { case ChatSideEffect.heavyHaptic: @@ -1523,8 +1534,13 @@ void _executeSideEffects( NotificationService.instance.showApprovalNotification( permission, l: l, - id: 1, - payload: sessionId, + privacyMode: privacyMode, + id: sessionNotificationId( + sessionId: sessionId, + provider: 'claude', + eventType: SessionNotificationEvent.approval, + ), + payload: payload, ); } } @@ -1535,17 +1551,27 @@ void _executeSideEffects( NotificationService.instance.showApprovalNotification( permission, l: l, - id: 2, - payload: sessionId, + privacyMode: privacyMode, + id: sessionNotificationId( + sessionId: sessionId, + provider: 'claude', + eventType: SessionNotificationEvent.question, + ), + payload: payload, ); } } case ChatSideEffect.notifySessionComplete: if (useLocalNotification) { NotificationService.instance.showSessionCompleteNotification( - body: 'Session done', - id: 3, - payload: sessionId, + l: l, + privacyMode: privacyMode, + id: sessionNotificationId( + sessionId: sessionId, + provider: 'claude', + eventType: SessionNotificationEvent.complete, + ), + payload: payload, ); } } diff --git a/apps/mobile/lib/features/codex_session/codex_session_screen.dart b/apps/mobile/lib/features/codex_session/codex_session_screen.dart index ff315269..6bb8b9e5 100644 --- a/apps/mobile/lib/features/codex_session/codex_session_screen.dart +++ b/apps/mobile/lib/features/codex_session/codex_session_screen.dart @@ -26,6 +26,7 @@ import '../../widgets/session_name_title.dart'; import '../../widgets/workspace_pane_chrome.dart'; import '../../utils/diff_parser.dart'; import '../../utils/network_endpoint.dart'; +import '../../utils/platform_helper.dart'; import '../../utils/terminal_launcher.dart'; import '../settings/state/settings_cubit.dart'; import '../../widgets/new_session_sheet.dart' @@ -743,7 +744,10 @@ class _CodexChatBody extends HookWidget { effects, sessionId: sessionId, isBackground: isBackgroundRef.value, + localNotificationsAllowed: + !isAndroidPlatform || settingsCubit.state.fcmEnabled, remoteNotificationsReady: settingsCubit.state.fcmReady, + privacyMode: settingsCubit.state.fcmPrivacy, approval: chatSessionCubit.state.approval, l: l, collapseToolResults: collapseToolResults, @@ -1657,7 +1661,9 @@ void _executeSideEffects( Set effects, { required String sessionId, required bool isBackground, + required bool localNotificationsAllowed, required bool remoteNotificationsReady, + required bool privacyMode, required ApprovalState approval, required AppLocalizations l, required TextEditingController planFeedbackController, @@ -1666,8 +1672,13 @@ void _executeSideEffects( }) { final useLocalNotification = shouldUseLocalNotificationFallback( isBackground: isBackground, + localNotificationsAllowed: localNotificationsAllowed, remoteNotificationsReady: remoteNotificationsReady, ); + final payload = sessionNotificationPayload( + sessionId: sessionId, + provider: 'codex', + ); for (final effect in effects) { switch (effect) { case ChatSideEffect.heavyHaptic: @@ -1689,8 +1700,13 @@ void _executeSideEffects( NotificationService.instance.showApprovalNotification( permission, l: l, - id: 1, - payload: sessionId, + privacyMode: privacyMode, + id: sessionNotificationId( + sessionId: sessionId, + provider: 'codex', + eventType: SessionNotificationEvent.approval, + ), + payload: payload, ); } } @@ -1701,17 +1717,27 @@ void _executeSideEffects( NotificationService.instance.showApprovalNotification( permission, l: l, - id: 2, - payload: sessionId, + privacyMode: privacyMode, + id: sessionNotificationId( + sessionId: sessionId, + provider: 'codex', + eventType: SessionNotificationEvent.question, + ), + payload: payload, ); } } case ChatSideEffect.notifySessionComplete: if (useLocalNotification) { NotificationService.instance.showSessionCompleteNotification( - body: 'Codex session done', - id: 3, - payload: sessionId, + l: l, + privacyMode: privacyMode, + id: sessionNotificationId( + sessionId: sessionId, + provider: 'codex', + eventType: SessionNotificationEvent.complete, + ), + payload: payload, ); } } diff --git a/apps/mobile/lib/features/settings/settings_screen.dart b/apps/mobile/lib/features/settings/settings_screen.dart index 0aa700a6..48bec845 100644 --- a/apps/mobile/lib/features/settings/settings_screen.dart +++ b/apps/mobile/lib/features/settings/settings_screen.dart @@ -24,6 +24,7 @@ import '../../services/bridge_service.dart'; import '../../services/in_app_review_service.dart'; import '../../services/machine_manager_service.dart'; import '../../services/platform_environment_service.dart'; +import '../../services/platform_settings_service.dart'; import '../../services/prompt_history_service.dart'; import '../../services/revenuecat_service.dart'; import '../../services/support_banner_service.dart'; @@ -63,7 +64,8 @@ class SettingsScreen extends StatefulWidget { State createState() => _SettingsScreenState(); } -class _SettingsScreenState extends State { +class _SettingsScreenState extends State + with WidgetsBindingObserver { final _scrollController = ScrollController(); final _connectionSectionKey = GlobalKey(); final _supportSectionKey = GlobalKey(); @@ -150,6 +152,7 @@ class _SettingsScreenState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); unawaited(_loadPlatformEnvironment()); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -159,6 +162,17 @@ class _SettingsScreenState extends State { }); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed || !mounted || !isAndroidPlatform) { + return; + } + final settings = context.read(); + if (settings.state.fcmStatusKey == FcmStatusKey.permissionDenied) { + unawaited(settings.retryFcmPermission()); + } + } + Future _loadPlatformEnvironment() async { final environment = PlatformEnvironmentService.instance; final isIOSAppOnMac = await environment.isIOSAppOnMac(); @@ -186,6 +200,7 @@ class _SettingsScreenState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _connectionHighlightTimer?.cancel(); _supportHighlightTimer?.cancel(); _scrollController.dispose(); @@ -706,6 +721,11 @@ class _SettingsScreenState extends State { state: state, onChanged: (enabled) => context.read().toggleFcm(enabled), + onOpenSettings: isAndroidPlatform + ? () => unawaited( + PlatformSettingsService.openNotificationSettings(), + ) + : null, ), if (state.fcmEnabled) ...[ Divider( @@ -1570,13 +1590,19 @@ class _BridgeUpdateSetupStep extends StatelessWidget { class _PushNotificationTile extends StatelessWidget { final SettingsState state; final ValueChanged onChanged; + final VoidCallback? onOpenSettings; - const _PushNotificationTile({required this.state, required this.onChanged}); + const _PushNotificationTile({ + required this.state, + required this.onChanged, + this.onOpenSettings, + }); static String? _resolveFcmStatus(AppLocalizations l, FcmStatusKey? key) { if (key == null) return null; return switch (key) { FcmStatusKey.unavailable => l.pushNotificationsUnavailable, + FcmStatusKey.permissionDenied => l.fcmPermissionDenied, FcmStatusKey.bridgeNotInitialized => l.fcmBridgeNotInitialized, FcmStatusKey.tokenFailed => l.fcmTokenFailed, FcmStatusKey.registrationFailed => l.fcmRegistrationFailed, @@ -1595,11 +1621,25 @@ class _PushNotificationTile extends StatelessWidget { : l.pushNotificationsUnavailable; final subtitle = _resolveFcmStatus(l, state.fcmStatusKey) ?? baseSubtitle; + final permissionDenied = + state.fcmStatusKey == FcmStatusKey.permissionDenied; + return SwitchListTile( value: state.fcmEnabled, onChanged: state.fcmSyncInProgress ? null : onChanged, title: Text(l.pushNotifications), - subtitle: Text(subtitle), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle), + if (permissionDenied && onOpenSettings != null) + TextButton( + key: const ValueKey('open_notification_settings_button'), + onPressed: onOpenSettings, + child: Text(l.openNotificationSettings), + ), + ], + ), secondary: state.fcmSyncInProgress ? const SizedBox( width: 20, diff --git a/apps/mobile/lib/features/settings/state/settings_cubit.dart b/apps/mobile/lib/features/settings/state/settings_cubit.dart index 5ff365f6..83821bb8 100644 --- a/apps/mobile/lib/features/settings/state/settings_cubit.dart +++ b/apps/mobile/lib/features/settings/state/settings_cubit.dart @@ -305,12 +305,27 @@ class SettingsCubit extends Cubit { emit( state.copyWith( fcmAvailable: available, - fcmStatusKey: available ? null : FcmStatusKey.unavailable, + fcmStatusKey: available + ? null + : _fcmService.permissionDenied + ? FcmStatusKey.permissionDenied + : FcmStatusKey.unavailable, ), ); if (!available) return; - _tokenRefreshSub?.cancel(); + _ensureTokenRefreshSubscription(); + + if (state.fcmEnabled) { + await _syncPushRegistration(); + } + } + + void _ensureTokenRefreshSubscription() { + if (_tokenRefreshSub != null) return; + final bridge = _bridge; + if (bridge == null) return; + _tokenRefreshSub = _fcmService.onTokenRefresh.listen((token) { final previousToken = _fcmService.cacheToken(token); _activeToken = token; @@ -324,10 +339,6 @@ class SettingsCubit extends Cubit { unawaited(_syncPushRegistration()); } }); - - if (state.fcmEnabled) { - await _syncPushRegistration(); - } } void setThemeMode(ThemeMode mode) { @@ -505,7 +516,9 @@ class SettingsCubit extends Cubit { emit( state.copyWith( fcmSyncInProgress: false, - fcmStatusKey: FcmStatusKey.unavailable, + fcmStatusKey: _fcmService.permissionDenied + ? FcmStatusKey.permissionDenied + : FcmStatusKey.unavailable, ), ); return; @@ -513,6 +526,11 @@ class SettingsCubit extends Cubit { await _syncPushRegistration(); } + Future retryFcmPermission() async { + if (!state.fcmEnabled || state.fcmSyncInProgress) return; + await toggleFcm(true); + } + Future toggleFcmPrivacy(bool enabled) async { final machineId = state.activeMachineId; if (machineId == null) return; @@ -579,12 +597,15 @@ class SettingsCubit extends Cubit { emit( state.copyWith( fcmSyncInProgress: false, - fcmStatusKey: FcmStatusKey.tokenFailed, + fcmStatusKey: _fcmService.permissionDenied + ? FcmStatusKey.permissionDenied + : FcmStatusKey.tokenFailed, ), ); return; } + _ensureTokenRefreshSubscription(); _activeToken = token; emit( state.copyWith( diff --git a/apps/mobile/lib/features/settings/state/settings_state.dart b/apps/mobile/lib/features/settings/state/settings_state.dart index 1bb9ae14..6e4c7c85 100644 --- a/apps/mobile/lib/features/settings/state/settings_state.dart +++ b/apps/mobile/lib/features/settings/state/settings_state.dart @@ -13,6 +13,7 @@ part 'settings_state.freezed.dart'; /// Keys for FCM status messages (resolved to localized strings in the UI). enum FcmStatusKey { unavailable, + permissionDenied, bridgeNotInitialized, tokenFailed, registrationFailed, diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 10c08e23..e583bf89 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -782,6 +782,7 @@ "changelogFetchError": "Failed to load changelog", "fcmBridgeNotInitialized": "Bridge not initialized", + "fcmPermissionDenied": "Notification permission denied — allow it in Android Settings", "fcmTokenFailed": "Failed to get FCM token", "fcmRegistrationFailed": "Failed to register notifications with Bridge", "fcmEnabled": "Notifications enabled", @@ -792,6 +793,7 @@ "pushPrivacyModeSubtitle": "Hide project names and content from notifications", "updateNotificationLanguage": "Update notification language", "notificationLanguageUpdated": "Notification language updated", + "openNotificationSettings": "Open notification settings", "defaultNotRecommended": "Default (not recommended)", @@ -1091,5 +1093,8 @@ "approvalQuestionNotificationTitle": "Question - ccpocket", "approvalRequiredNotificationTitle": "Approval Required - ccpocket", "exitPlanModeNotificationBody": "The generated plan needs your review", + "notificationPrivateBody": "Open CC Pocket to view details", + "sessionCompleteNotificationTitle": "Session Complete - ccpocket", + "sessionCompleteNotificationBody": "Your session has finished", "renderErrorFallback": "This content couldn't be displayed." } diff --git a/apps/mobile/lib/l10n/app_ja.arb b/apps/mobile/lib/l10n/app_ja.arb index 88566f88..a0e1de47 100644 --- a/apps/mobile/lib/l10n/app_ja.arb +++ b/apps/mobile/lib/l10n/app_ja.arb @@ -812,6 +812,7 @@ "changelogFetchError": "変更履歴の取得に失敗しました", "fcmBridgeNotInitialized": "Bridge が未初期化です", + "fcmPermissionDenied": "通知の権限が拒否されました — Android の設定で許可してください", "fcmTokenFailed": "FCM token を取得できませんでした", "fcmRegistrationFailed": "Bridge への通知登録に失敗しました", "fcmEnabled": "通知を有効化しました", @@ -822,6 +823,7 @@ "pushPrivacyModeSubtitle": "通知にプロジェクト名や内容を含めない", "updateNotificationLanguage": "通知言語を更新", "notificationLanguageUpdated": "通知言語を更新しました", + "openNotificationSettings": "通知設定を開く", "defaultNotRecommended": "Default(非推奨)", @@ -1121,5 +1123,8 @@ "approvalQuestionNotificationTitle": "質問があります - ccpocket", "approvalRequiredNotificationTitle": "承認待ち - ccpocket", "exitPlanModeNotificationBody": "作成したプランの確認が必要です", + "notificationPrivateBody": "詳細は CC Pocket で確認してください", + "sessionCompleteNotificationTitle": "セッション完了 - ccpocket", + "sessionCompleteNotificationBody": "セッションが完了しました", "renderErrorFallback": "このコンテンツを表示できませんでした" } diff --git a/apps/mobile/lib/l10n/app_ko.arb b/apps/mobile/lib/l10n/app_ko.arb index 6398b3ea..ea5e3e3a 100644 --- a/apps/mobile/lib/l10n/app_ko.arb +++ b/apps/mobile/lib/l10n/app_ko.arb @@ -723,6 +723,7 @@ "showAllMain": "모두 보기(main)", "changelogFetchError": "변경 로그를 불러오지 못했습니다", "fcmBridgeNotInitialized": "Bridge가 초기화되지 않음", + "fcmPermissionDenied": "알림 권한이 거부되었습니다. Android 설정에서 허용해 주세요", "fcmTokenFailed": "FCM 토큰을 가져오지 못했습니다", "fcmRegistrationFailed": "Bridge에 알림을 등록하지 못했습니다", "fcmEnabled": "알림 활성화됨", @@ -733,6 +734,7 @@ "pushPrivacyModeSubtitle": "알림에서 프로젝트 이름과 내용을 숨깁니다", "updateNotificationLanguage": "알림 언어 업데이트", "notificationLanguageUpdated": "알림 언어가 업데이트됨", + "openNotificationSettings": "알림 설정 열기", "defaultNotRecommended": "기본값(권장하지 않음)", "imageAttached": "이미지 첨부됨", "usageConnectToView": "사용량을 보려면 Bridge에 연결하세요", @@ -1055,5 +1057,8 @@ "approvalQuestionNotificationTitle": "질문이 있습니다 - ccpocket", "approvalRequiredNotificationTitle": "승인 대기 중 - ccpocket", "exitPlanModeNotificationBody": "작성된 계획을 확인해야 합니다", + "notificationPrivateBody": "자세한 내용은 CC Pocket에서 확인하세요", + "sessionCompleteNotificationTitle": "세션 완료 - ccpocket", + "sessionCompleteNotificationBody": "세션이 완료되었습니다", "renderErrorFallback": "이 콘텐츠를 표시할 수 없습니다." } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 810ed022..2df613b9 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -3696,6 +3696,12 @@ abstract class AppLocalizations { /// **'Bridge が未初期化です'** String get fcmBridgeNotInitialized; + /// No description provided for @fcmPermissionDenied. + /// + /// In ja, this message translates to: + /// **'通知の権限が拒否されました — Android の設定で許可してください'** + String get fcmPermissionDenied; + /// No description provided for @fcmTokenFailed. /// /// In ja, this message translates to: @@ -3756,6 +3762,12 @@ abstract class AppLocalizations { /// **'通知言語を更新しました'** String get notificationLanguageUpdated; + /// No description provided for @openNotificationSettings. + /// + /// In ja, this message translates to: + /// **'通知設定を開く'** + String get openNotificationSettings; + /// No description provided for @defaultNotRecommended. /// /// In ja, this message translates to: @@ -4920,6 +4932,24 @@ abstract class AppLocalizations { /// **'作成したプランの確認が必要です'** String get exitPlanModeNotificationBody; + /// No description provided for @notificationPrivateBody. + /// + /// In ja, this message translates to: + /// **'詳細は CC Pocket で確認してください'** + String get notificationPrivateBody; + + /// No description provided for @sessionCompleteNotificationTitle. + /// + /// In ja, this message translates to: + /// **'セッション完了 - ccpocket'** + String get sessionCompleteNotificationTitle; + + /// No description provided for @sessionCompleteNotificationBody. + /// + /// In ja, this message translates to: + /// **'セッションが完了しました'** + String get sessionCompleteNotificationBody; + /// No description provided for @renderErrorFallback. /// /// In ja, this message translates to: diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 61b4f8e7..36dac095 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -2025,6 +2025,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get fcmBridgeNotInitialized => 'Bridge not initialized'; + @override + String get fcmPermissionDenied => + 'Notification permission denied — allow it in Android Settings'; + @override String get fcmTokenFailed => 'Failed to get FCM token'; @@ -2057,6 +2061,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationLanguageUpdated => 'Notification language updated'; + @override + String get openNotificationSettings => 'Open notification settings'; + @override String get defaultNotRecommended => 'Default (not recommended)'; @@ -2718,6 +2725,15 @@ class AppLocalizationsEn extends AppLocalizations { String get exitPlanModeNotificationBody => 'The generated plan needs your review'; + @override + String get notificationPrivateBody => 'Open CC Pocket to view details'; + + @override + String get sessionCompleteNotificationTitle => 'Session Complete - ccpocket'; + + @override + String get sessionCompleteNotificationBody => 'Your session has finished'; + @override String get renderErrorFallback => 'This content couldn\'t be displayed.'; } diff --git a/apps/mobile/lib/l10n/app_localizations_ja.dart b/apps/mobile/lib/l10n/app_localizations_ja.dart index 0f7dd094..c3365e85 100644 --- a/apps/mobile/lib/l10n/app_localizations_ja.dart +++ b/apps/mobile/lib/l10n/app_localizations_ja.dart @@ -1952,6 +1952,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get fcmBridgeNotInitialized => 'Bridge が未初期化です'; + @override + String get fcmPermissionDenied => '通知の権限が拒否されました — Android の設定で許可してください'; + @override String get fcmTokenFailed => 'FCM token を取得できませんでした'; @@ -1982,6 +1985,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get notificationLanguageUpdated => '通知言語を更新しました'; + @override + String get openNotificationSettings => '通知設定を開く'; + @override String get defaultNotRecommended => 'Default(非推奨)'; @@ -2618,6 +2624,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get exitPlanModeNotificationBody => '作成したプランの確認が必要です'; + @override + String get notificationPrivateBody => '詳細は CC Pocket で確認してください'; + + @override + String get sessionCompleteNotificationTitle => 'セッション完了 - ccpocket'; + + @override + String get sessionCompleteNotificationBody => 'セッションが完了しました'; + @override String get renderErrorFallback => 'このコンテンツを表示できませんでした'; } diff --git a/apps/mobile/lib/l10n/app_localizations_ko.dart b/apps/mobile/lib/l10n/app_localizations_ko.dart index c2121ae3..7a5984ff 100644 --- a/apps/mobile/lib/l10n/app_localizations_ko.dart +++ b/apps/mobile/lib/l10n/app_localizations_ko.dart @@ -1967,6 +1967,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get fcmBridgeNotInitialized => 'Bridge가 초기화되지 않음'; + @override + String get fcmPermissionDenied => '알림 권한이 거부되었습니다. Android 설정에서 허용해 주세요'; + @override String get fcmTokenFailed => 'FCM 토큰을 가져오지 못했습니다'; @@ -1997,6 +2000,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get notificationLanguageUpdated => '알림 언어가 업데이트됨'; + @override + String get openNotificationSettings => '알림 설정 열기'; + @override String get defaultNotRecommended => '기본값(권장하지 않음)'; @@ -2643,6 +2649,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get exitPlanModeNotificationBody => '작성된 계획을 확인해야 합니다'; + @override + String get notificationPrivateBody => '자세한 내용은 CC Pocket에서 확인하세요'; + + @override + String get sessionCompleteNotificationTitle => '세션 완료 - ccpocket'; + + @override + String get sessionCompleteNotificationBody => '세션이 완료되었습니다'; + @override String get renderErrorFallback => '이 콘텐츠를 표시할 수 없습니다.'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 156c0615..a8217102 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -1930,6 +1930,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get fcmBridgeNotInitialized => 'Bridge 尚未初始化'; + @override + String get fcmPermissionDenied => '通知权限已被拒绝 — 请在 Android 设置中允许'; + @override String get fcmTokenFailed => '获取 FCM Token 失败'; @@ -1960,6 +1963,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get notificationLanguageUpdated => '通知语言已更新'; + @override + String get openNotificationSettings => '打开通知设置'; + @override String get defaultNotRecommended => '默认(不推荐)'; @@ -2588,6 +2594,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get exitPlanModeNotificationBody => '生成的计划需要你确认'; + @override + String get notificationPrivateBody => '请打开 CC Pocket 查看详情'; + + @override + String get sessionCompleteNotificationTitle => '会话已完成 - ccpocket'; + + @override + String get sessionCompleteNotificationBody => '会话已完成'; + @override String get renderErrorFallback => '无法显示此内容。'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 7975de1d..4dc38fc2 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -846,6 +846,7 @@ "changelogFetchError": "加载更新日志失败", "fcmBridgeNotInitialized": "Bridge 尚未初始化", + "fcmPermissionDenied": "通知权限已被拒绝 — 请在 Android 设置中允许", "fcmTokenFailed": "获取 FCM Token 失败", "fcmRegistrationFailed": "向 Bridge 注册通知失败", "fcmEnabled": "通知已启用", @@ -856,6 +857,7 @@ "pushPrivacyModeSubtitle": "在通知中隐藏项目名称和内容", "updateNotificationLanguage": "更新通知语言", "notificationLanguageUpdated": "通知语言已更新", + "openNotificationSettings": "打开通知设置", "defaultNotRecommended": "默认(不推荐)", @@ -1196,5 +1198,8 @@ "approvalQuestionNotificationTitle": "有一个问题 - ccpocket", "approvalRequiredNotificationTitle": "等待审批 - ccpocket", "exitPlanModeNotificationBody": "生成的计划需要你确认", + "notificationPrivateBody": "请打开 CC Pocket 查看详情", + "sessionCompleteNotificationTitle": "会话已完成 - ccpocket", + "sessionCompleteNotificationBody": "会话已完成", "renderErrorFallback": "无法显示此内容。" } diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index c2055462..1f60cbd4 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -13,7 +13,6 @@ library; import 'dart:async'; -import 'dart:convert'; import 'package:app_links/app_links.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -406,28 +405,30 @@ class _CcpocketAppState extends State { data['body']?.toString() ?? 'New update available'; final eventType = data['eventType']?.toString() ?? ''; - final payload = jsonEncode({'sessionId': sessionId, 'provider': provider}); + final payload = sessionNotificationPayload( + sessionId: sessionId, + provider: provider, + ); await NotificationService.instance.show( title: title, body: body, payload: payload, - id: _notificationId(sessionId, provider, eventType), + id: sessionNotificationId( + sessionId: sessionId, + provider: provider, + eventType: eventType, + ), ); } void _openSessionFromPayload(String? payload) { - if (payload == null || payload.isEmpty) return; - try { - final decoded = jsonDecode(payload); - if (decoded is Map) { - _openSessionFromData(decoded); - return; - } - } catch (_) { - // Backward compatibility: payload may be plain sessionId text. - } - _openSessionFromData({'sessionId': payload, 'provider': 'claude'}); + final target = parseSessionNotificationPayload(payload); + if (target == null) return; + _openSessionFromData({ + 'sessionId': target.sessionId, + 'provider': target.provider, + }); } void _openSessionFromData(Map data) { @@ -463,15 +464,6 @@ class _CcpocketAppState extends State { return provider == 'codex' ? 'codex' : 'claude'; } - int _notificationId(String sessionId, String provider, String eventType) { - final raw = '$provider:$sessionId:$eventType'; - var hash = 0; - for (final code in raw.codeUnits) { - hash = ((hash * 31) + code) & 0x7fffffff; - } - return hash; - } - void _initDeepLinks() { // app_links includes the cold-start URI as the first stream event. try { diff --git a/apps/mobile/lib/services/fcm_service.dart b/apps/mobile/lib/services/fcm_service.dart index 6c5c0632..3b287ff0 100644 --- a/apps/mobile/lib/services/fcm_service.dart +++ b/apps/mobile/lib/services/fcm_service.dart @@ -1,3 +1,4 @@ +import 'package:firebase_app_installations/firebase_app_installations.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; @@ -5,14 +6,22 @@ import 'package:flutter/foundation.dart'; import '../core/logger.dart'; class FcmService { - FcmService({FirebaseMessaging? messaging}) : _messaging = messaging; + FcmService(); + + static const _tokenRetryDelays = [ + Duration.zero, + Duration(milliseconds: 350), + Duration(seconds: 1), + ]; FirebaseMessaging? _messaging; - bool _initAttempted = false; + Future? _initInProgress; bool _available = false; String? _cachedToken; + bool _permissionDenied = false; bool get isAvailable => _available; + bool get permissionDenied => _permissionDenied; bool get isSupportedPlatform { if (kIsWeb) return false; @@ -35,40 +44,167 @@ class FcmService { } Future init() async { - if (_initAttempted) return _available; - _initAttempted = true; + if (_available) return true; if (!isSupportedPlatform) { _available = false; return false; } + final initInProgress = _initInProgress; + if (initInProgress != null) return initInProgress; + + final initialization = _initialize(); + _initInProgress = initialization; + try { + return await initialization; + } finally { + if (identical(_initInProgress, initialization)) { + _initInProgress = null; + } + } + } + + Future _initialize() async { try { - if (Firebase.apps.isEmpty) { - await Firebase.initializeApp(); + final token = (await initializeMessaging())?.trim(); + if (token == null || token.isEmpty) { + if (_permissionDenied) { + logger.warning('[fcm] notification permission denied'); + } else { + logger.warning('[fcm] registration returned no token'); + } + _cachedToken = null; + _available = false; + return false; } - await _instance.requestPermission(alert: true, badge: true, sound: true); - await _instance.setForegroundNotificationPresentationOptions( - alert: false, - badge: true, - sound: true, - ); - _cachedToken = await _instance.getToken(); + _cachedToken = token; _available = true; return true; } catch (e, st) { logger.error('[fcm] init failed', e, st); + _cachedToken = null; _available = false; return false; } } + @visibleForTesting + @protected + Future initializeMessaging() async { + _permissionDenied = false; + if (!await prepareMessaging()) { + _permissionDenied = true; + return null; + } + return _requestTokenWithRecovery(); + } + + @visibleForTesting + @protected + Future prepareMessaging() async { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(); + } + final settings = await _instance.requestPermission( + alert: true, + badge: true, + sound: true, + ); + final authorized = + settings.authorizationStatus == AuthorizationStatus.authorized || + settings.authorizationStatus == AuthorizationStatus.provisional; + if (!authorized) return false; + await _instance.setForegroundNotificationPresentationOptions( + alert: false, + badge: true, + sound: true, + ); + return true; + } + + @visibleForTesting + @protected + Future requestToken() => _instance.getToken(); + + @visibleForTesting + @protected + Future waitBeforeTokenRetry(Duration delay) => Future.delayed(delay); + + @visibleForTesting + @protected + Future repairInvalidInstallation() async { + try { + await _instance.deleteToken(); + } catch (e, st) { + logger.warning('[fcm] failed to clear stale messaging token', e, st); + } + await FirebaseInstallations.instance.delete(); + } + + Future _requestTokenWithRecovery() async { + var repairedInstallation = false; + + for (var attempt = 0; attempt < _tokenRetryDelays.length; attempt++) { + final delay = _tokenRetryDelays[attempt]; + if (delay > Duration.zero) { + await waitBeforeTokenRetry(delay); + } + + try { + final token = (await requestToken())?.trim(); + if (token != null && token.isNotEmpty) return token; + if (attempt < _tokenRetryDelays.length - 1) { + logger.warning( + '[fcm] registration returned no token; retrying ' + '(${attempt + 1}/${_tokenRetryDelays.length})', + ); + } + } catch (e, st) { + if (!repairedInstallation && + platform == 'android' && + _isInvalidInstallationError(e)) { + repairedInstallation = true; + try { + await repairInvalidInstallation(); + logger.info('[fcm] repaired invalid Firebase installation'); + } catch (repairError, repairStack) { + logger.warning( + '[fcm] failed to repair invalid Firebase installation', + repairError, + repairStack, + ); + } + } + + if (attempt == _tokenRetryDelays.length - 1) { + Error.throwWithStackTrace(e, st); + } + logger.warning( + '[fcm] registration failed; retrying ' + '(${attempt + 1}/${_tokenRetryDelays.length})', + e, + st, + ); + } + } + return null; + } + + bool _isInvalidInstallationError(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('fid_already_used') || + message.contains('fid already used') || + message.contains('invalid argument for the given fid') || + message.contains('invalid argument for given fid'); + } + Future getToken() async { - if (!_available) { - final ready = await init(); - if (!ready) return null; + final cachedToken = _cachedToken; + if (_available && cachedToken != null && cachedToken.isNotEmpty) { + return cachedToken; } - _cachedToken ??= await _instance.getToken(); - return _cachedToken; + final ready = await init(); + return ready ? _cachedToken : null; } String? cacheToken(String token) { diff --git a/apps/mobile/lib/services/notification_service.dart b/apps/mobile/lib/services/notification_service.dart index 9582f7b5..ccd9eb66 100644 --- a/apps/mobile/lib/services/notification_service.dart +++ b/apps/mobile/lib/services/notification_service.dart @@ -1,14 +1,104 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart' show ChangeNotifier, kIsWeb; import 'package:flutter/scheduler.dart' show SchedulerBinding, SchedulerPhase; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import '../core/logger.dart'; import '../l10n/app_localizations.dart'; import '../models/messages.dart'; bool shouldUseLocalNotificationFallback({ required bool isBackground, + required bool localNotificationsAllowed, required bool remoteNotificationsReady, -}) => isBackground && !remoteNotificationsReady; +}) => isBackground && localNotificationsAllowed && !remoteNotificationsReady; + +class SessionNotificationEvent { + static const approval = 'approval_required'; + static const question = 'ask_user_question'; + static const complete = 'session_completed'; +} + +class NotificationSessionTarget { + const NotificationSessionTarget({ + required this.sessionId, + required this.provider, + }); + + final String sessionId; + final String provider; +} + +String sessionNotificationPayload({ + required String sessionId, + required String provider, +}) => jsonEncode({ + 'sessionId': sessionId, + 'provider': provider == 'codex' ? 'codex' : 'claude', +}); + +NotificationSessionTarget? parseSessionNotificationPayload(String? payload) { + if (payload == null || payload.isEmpty) return null; + try { + final decoded = jsonDecode(payload); + if (decoded is Map) { + final sessionId = decoded['sessionId']?.toString(); + if (sessionId == null || sessionId.isEmpty) return null; + return NotificationSessionTarget( + sessionId: sessionId, + provider: decoded['provider'] == 'codex' ? 'codex' : 'claude', + ); + } + } catch (_) { + // Legacy notifications stored the Claude session ID as plain text. + } + return NotificationSessionTarget(sessionId: payload, provider: 'claude'); +} + +int sessionNotificationId({ + required String sessionId, + required String provider, + required String eventType, +}) { + final raw = '$provider:$sessionId:$eventType'; + var hash = 0; + for (final code in raw.codeUnits) { + hash = ((hash * 31) + code) & 0x7fffffff; + } + return hash; +} + +String localNotificationBody({ + required String standardBody, + required bool privacyMode, + required AppLocalizations l, +}) => privacyMode ? l.notificationPrivateBody : standardBody; + +class NotificationTapDispatcher { + final List _pendingPayloads = []; + void Function(String? payload)? _handler; + + set handler(void Function(String? payload)? value) { + _handler = value; + if (value == null || _pendingPayloads.isEmpty) return; + final pending = List.from(_pendingPayloads); + _pendingPayloads.clear(); + for (final payload in pending) { + value(payload); + } + } + + void dispatch(String? payload) { + if (payload == null || payload.isEmpty) return; + final handler = _handler; + if (handler == null) { + _pendingPayloads.add(payload); + return; + } + handler(payload); + } +} class NotificationService extends ChangeNotifier { NotificationService._(); @@ -21,21 +111,22 @@ class NotificationService extends ChangeNotifier { String? _activeSessionId; String? _activeProvider; bool _notifyScheduled = false; + final NotificationTapDispatcher _tapDispatcher = NotificationTapDispatcher(); String? get activeSessionId => _activeSessionId; String? get activeProvider => _activeProvider; - /// Called when the user taps a notification. The [payload] string - /// (typically a sessionId) is forwarded. - void Function(String? payload)? onNotificationTap; + /// Called when the user taps a notification. A cold-launch tap is retained + /// until the app router installs this callback. + set onNotificationTap(void Function(String? payload)? callback) { + _tapDispatcher.handler = callback; + } Future init() async { if (kIsWeb) return; if (_initialized) return; - const androidSettings = AndroidInitializationSettings( - '@mipmap/launcher_icon', - ); + const androidSettings = AndroidInitializationSettings('ic_notification'); const iosSettings = DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, @@ -56,10 +147,24 @@ class NotificationService extends ChangeNotifier { linux: linuxSettings, ); - await _plugin.initialize( + final initialized = await _plugin.initialize( settings: settings, onDidReceiveNotificationResponse: _onNotificationResponse, ); + if (initialized == false) return; + + try { + final launchDetails = await _plugin.getNotificationAppLaunchDetails(); + if (launchDetails?.didNotificationLaunchApp == true) { + _tapDispatcher.dispatch(launchDetails?.notificationResponse?.payload); + } + } catch (error, stackTrace) { + logger.warning( + '[notifications] failed to read launch notification', + error, + stackTrace, + ); + } // Create the notification channel eagerly so FCM uses it instead of // the low-priority fcm_fallback_notification_channel. @@ -71,8 +176,8 @@ class NotificationService extends ChangeNotifier { await androidPlugin.createNotificationChannel( const AndroidNotificationChannel( 'ccpocket_channel', - 'ccpocket', - description: 'Claude Code session notifications', + 'CC Pocket sessions', + description: 'Session updates from CC Pocket', importance: Importance.high, ), ); @@ -82,7 +187,7 @@ class NotificationService extends ChangeNotifier { } void _onNotificationResponse(NotificationResponse response) { - onNotificationTap?.call(response.payload); + _tapDispatcher.dispatch(response.payload); } void setActiveSession({required String sessionId, required String provider}) { @@ -138,8 +243,9 @@ class NotificationService extends ChangeNotifier { const androidDetails = AndroidNotificationDetails( 'ccpocket_channel', - 'ccpocket', - channelDescription: 'Claude Code session notifications', + 'CC Pocket sessions', + channelDescription: 'Session updates from CC Pocket', + icon: 'ic_notification', importance: Importance.high, priority: Priority.high, ); @@ -165,21 +271,36 @@ class NotificationService extends ChangeNotifier { Future showApprovalNotification( PermissionRequestMessage permission, { required AppLocalizations l, + required bool privacyMode, int id = 1, String? payload, }) { final copy = ApprovalNotificationCopy.from(permission, l: l); - return show(title: copy.title, body: copy.body, id: id, payload: payload); + return show( + title: copy.title, + body: localNotificationBody( + standardBody: copy.body, + privacyMode: privacyMode, + l: l, + ), + id: id, + payload: payload, + ); } Future showSessionCompleteNotification({ - required String body, + required AppLocalizations l, + required bool privacyMode, int id = 3, String? payload, }) { return show( - title: 'Session Complete', - body: body, + title: l.sessionCompleteNotificationTitle, + body: localNotificationBody( + standardBody: l.sessionCompleteNotificationBody, + privacyMode: privacyMode, + l: l, + ), id: id, payload: payload, ); diff --git a/apps/mobile/lib/services/platform_settings_service.dart b/apps/mobile/lib/services/platform_settings_service.dart new file mode 100644 index 00000000..547565d4 --- /dev/null +++ b/apps/mobile/lib/services/platform_settings_service.dart @@ -0,0 +1,13 @@ +import 'package:flutter/services.dart'; + +import '../utils/platform_helper.dart'; + +class PlatformSettingsService { + static const _channel = MethodChannel('ccpocket/app_settings'); + + static Future openNotificationSettings() async { + if (!isAndroidPlatform) return false; + return await _channel.invokeMethod('openNotificationSettings') ?? + false; + } +} diff --git a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift index aac7e835..9bc49ce4 100644 --- a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import app_links import bonsoir_darwin import device_info_plus import file_selector_macos +import firebase_app_installations import firebase_core import firebase_messaging import flutter_local_notifications @@ -31,6 +32,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FirebaseInstallationsPlugin.register(with: registry.registrar(forPlugin: "FirebaseInstallationsPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 44cc4526..6eb3d26a 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -497,6 +497,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + firebase_app_installations: + dependency: "direct main" + description: + name: firebase_app_installations + sha256: "746d59f33264547bac356b9394745d8551c5ec0393f58bebe15364d0627f90f1" + url: "https://pub.dev" + source: hosted + version: "0.4.2+7" + firebase_app_installations_platform_interface: + dependency: transitive + description: + name: firebase_app_installations_platform_interface + sha256: "4c272dd5b0b76a91a7812c78caa07993344efb700d65723239241997bbaece88" + url: "https://pub.dev" + source: hosted + version: "0.1.4+75" + firebase_app_installations_web: + dependency: transitive + description: + name: firebase_app_installations_web + sha256: e330fa07e1fefa453d5d7510f19d4044cf26827f2e123225a412ad8c9c644a60 + url: "https://pub.dev" + source: hosted + version: "0.1.7+12" firebase_core: dependency: "direct main" description: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 0cb6b2de..d27968f8 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: uuid: ^4.5.1 super_clipboard: ^0.9.1 firebase_core: ^4.4.0 + firebase_app_installations: ^0.4.2+7 firebase_messaging: ^16.1.1 shorebird_code_push: ^2.0.5 purchases_flutter: ^9.13.1 diff --git a/apps/mobile/test/fcm_service_test.dart b/apps/mobile/test/fcm_service_test.dart new file mode 100644 index 00000000..f9177589 --- /dev/null +++ b/apps/mobile/test/fcm_service_test.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:ccpocket/services/fcm_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _ScriptedFcmService extends FcmService { + _ScriptedFcmService(this.outcomes); + + final List outcomes; + var attempts = 0; + var repairAttempts = 0; + var preparationAttempts = 0; + + @override + bool get isSupportedPlatform => true; + + @override + String get platform => 'android'; + + @override + Future prepareMessaging() async { + preparationAttempts++; + return true; + } + + @override + Future requestToken() async { + final outcome = outcomes[attempts++]; + if (outcome is Error) throw outcome; + if (outcome is Exception) throw outcome; + return outcome as String?; + } + + @override + Future waitBeforeTokenRetry(Duration delay) async {} + + @override + Future repairInvalidInstallation() async { + repairAttempts++; + } +} + +void main() { + test('retries a transient registration exception in one init', () async { + final service = _ScriptedFcmService([ + StateError('temporary FCM registration failure'), + 'recovered-token', + ]); + + expect(await service.init(), isTrue); + expect(service.isAvailable, isTrue); + expect(await service.getToken(), 'recovered-token'); + expect(service.attempts, 2); + expect(service.preparationAttempts, 1); + expect(service.repairAttempts, 0); + }); + + test( + 'retries a null token and only marks FCM available with a token', + () async { + final service = _ScriptedFcmService([null, 'token-after-null']); + + expect(await service.init(), isTrue); + expect(service.isAvailable, isTrue); + expect(await service.getToken(), 'token-after-null'); + expect(service.attempts, 2); + }, + ); + + test('remains retryable after all registration attempts fail', () async { + final service = _ScriptedFcmService([ + StateError('failure 1'), + StateError('failure 2'), + StateError('failure 3'), + 'later-token', + ]); + + expect(await service.init(), isFalse); + expect(service.isAvailable, isFalse); + expect(service.attempts, 3); + + expect(await service.init(), isTrue); + expect(await service.getToken(), 'later-token'); + expect(service.attempts, 4); + expect(service.preparationAttempts, 2); + }); + + test('does not report availability after repeated null tokens', () async { + final service = _ScriptedFcmService([null, null, null, 'later-token']); + + expect(await service.init(), isFalse); + expect(service.isAvailable, isFalse); + expect(await service.init(), isTrue); + expect(await service.getToken(), 'later-token'); + }); + + test('repairs an explicitly invalid installation before retrying', () async { + final service = _ScriptedFcmService([ + StateError('Invalid argument for the given fid'), + 'repaired-token', + ]); + + expect(await service.init(), isTrue); + expect(await service.getToken(), 'repaired-token'); + expect(service.repairAttempts, 1); + expect(service.attempts, 2); + }); + + test('does not rotate installation identity for a generic failure', () async { + final service = _ScriptedFcmService([ + StateError('FCM Registration failed!'), + 'recovered-token', + ]); + + expect(await service.init(), isTrue); + expect(service.repairAttempts, 0); + }); + + test('coalesces concurrent initialization attempts', () async { + final token = Completer(); + final service = _BlockingFcmService(token.future); + + final first = service.init(); + final second = service.init(); + await Future.delayed(Duration.zero); + expect(service.attempts, 1); + + token.complete('shared-token'); + expect(await first, isTrue); + expect(await second, isTrue); + expect(service.attempts, 1); + }); + + test( + 'does not request a token when notification permission is denied', + () async { + final service = _PermissionDeniedFcmService(); + + expect(await service.init(), isFalse); + expect(service.isAvailable, isFalse); + expect(service.permissionDenied, isTrue); + expect(service.tokenRequests, 0); + }, + ); + + test('recovers after notification permission is granted later', () async { + final service = _PermissionSequenceFcmService(); + + expect(await service.init(), isFalse); + expect(service.permissionDenied, isTrue); + + expect(await service.init(), isTrue); + expect(service.permissionDenied, isFalse); + expect(await service.getToken(), 'granted-token'); + }); +} + +class _BlockingFcmService extends FcmService { + _BlockingFcmService(this.token); + + final Future token; + var attempts = 0; + + @override + bool get isSupportedPlatform => true; + + @override + Future prepareMessaging() async => true; + + @override + Future requestToken() { + attempts++; + return token; + } +} + +class _PermissionDeniedFcmService extends FcmService { + var tokenRequests = 0; + + @override + bool get isSupportedPlatform => true; + + @override + Future prepareMessaging() async => false; + + @override + Future requestToken() async { + tokenRequests++; + return 'should-not-be-requested'; + } +} + +class _PermissionSequenceFcmService extends FcmService { + var permissionAttempts = 0; + + @override + bool get isSupportedPlatform => true; + + @override + Future prepareMessaging() async => permissionAttempts++ > 0; + + @override + Future requestToken() async => 'granted-token'; +} diff --git a/apps/mobile/test/notification_service_test.dart b/apps/mobile/test/notification_service_test.dart index 7f50f513..4afa6bd2 100644 --- a/apps/mobile/test/notification_service_test.dart +++ b/apps/mobile/test/notification_service_test.dart @@ -1,4 +1,5 @@ import 'package:ccpocket/services/notification_service.dart'; +import 'package:ccpocket/l10n/app_localizations_en.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -7,6 +8,7 @@ void main() { expect( shouldUseLocalNotificationFallback( isBackground: false, + localNotificationsAllowed: true, remoteNotificationsReady: false, ), isFalse, @@ -17,6 +19,7 @@ void main() { expect( shouldUseLocalNotificationFallback( isBackground: true, + localNotificationsAllowed: true, remoteNotificationsReady: false, ), isTrue, @@ -27,10 +30,118 @@ void main() { expect( shouldUseLocalNotificationFallback( isBackground: true, + localNotificationsAllowed: true, remoteNotificationsReady: true, ), isFalse, ); }); + + test('stays silent when the user disabled notifications', () { + expect( + shouldUseLocalNotificationFallback( + isBackground: true, + localNotificationsAllowed: false, + remoteNotificationsReady: false, + ), + isFalse, + ); + }); + }); + + group('session notification identity', () { + test('round-trips a Codex target without exposing it as Claude', () { + final payload = sessionNotificationPayload( + sessionId: 'session-1', + provider: 'codex', + ); + + final target = parseSessionNotificationPayload(payload); + expect(target?.sessionId, 'session-1'); + expect(target?.provider, 'codex'); + }); + + test('keeps legacy plain payloads compatible with Claude', () { + final target = parseSessionNotificationPayload('legacy-session'); + + expect(target?.sessionId, 'legacy-session'); + expect(target?.provider, 'claude'); + }); + + test('uses stable IDs without cross-session or cross-provider overlap', () { + final first = sessionNotificationId( + sessionId: 'session-1', + provider: 'claude', + eventType: SessionNotificationEvent.approval, + ); + + expect( + sessionNotificationId( + sessionId: 'session-1', + provider: 'claude', + eventType: SessionNotificationEvent.approval, + ), + first, + ); + expect( + sessionNotificationId( + sessionId: 'session-2', + provider: 'claude', + eventType: SessionNotificationEvent.approval, + ), + isNot(first), + ); + expect( + sessionNotificationId( + sessionId: 'session-1', + provider: 'codex', + eventType: SessionNotificationEvent.approval, + ), + isNot(first), + ); + expect( + sessionNotificationId( + sessionId: 'session-1', + provider: 'claude', + eventType: SessionNotificationEvent.complete, + ), + isNot(first), + ); + }); + }); + + test('queues a cold-launch tap until routing is ready, exactly once', () { + final dispatcher = NotificationTapDispatcher(); + final received = []; + + dispatcher.dispatch('cold-session'); + expect(received, isEmpty); + + dispatcher.handler = received.add; + expect(received, ['cold-session']); + + dispatcher.handler = received.add; + expect(received, ['cold-session']); + }); + + test('privacy copy hides the standard notification body', () { + final l = AppLocalizationsEn(); + + expect( + localNotificationBody( + standardBody: 'sensitive tool summary', + privacyMode: true, + l: l, + ), + l.notificationPrivateBody, + ); + expect( + localNotificationBody( + standardBody: 'safe summary', + privacyMode: false, + l: l, + ), + 'safe summary', + ); }); } diff --git a/apps/mobile/test/settings_cubit_push_test.dart b/apps/mobile/test/settings_cubit_push_test.dart index c91f5cda..dd556b96 100644 --- a/apps/mobile/test/settings_cubit_push_test.dart +++ b/apps/mobile/test/settings_cubit_push_test.dart @@ -86,16 +86,21 @@ class FakeFcmService extends FcmService { required this.available, this.token, this.platformName = 'ios', + this.permissionDeniedValue = false, }); - final bool available; + bool available; String? token; final String platformName; + bool permissionDeniedValue; final _tokenRefreshController = StreamController.broadcast(); @override bool get isAvailable => available; + @override + bool get permissionDenied => permissionDeniedValue; + @override Stream get onTokenRefresh => _tokenRefreshController.stream; @@ -171,6 +176,47 @@ class FakeSecureStorage extends Fake implements FlutterSecureStorage { void main() { group('SettingsCubit push sync', () { + test('reports notification permission denial instead of enabled', () async { + SharedPreferences.setMockInitialValues({ + 'machines_v2': + '[{"id":"$_testMachineId","host":"$_testHost","port":$_testPort}]', + }); + final prefs = await SharedPreferences.getInstance(); + final manager = await _createMachineManager(prefs); + await manager.init(); + final bridge = FakeBridgeService() + ..emitConnection(BridgeConnectionState.connected, url: _testUrl); + final fcm = FakeFcmService(available: false, permissionDeniedValue: true); + final cubit = SettingsCubit( + prefs, + bridgeService: bridge, + machineManager: manager, + fcmService: fcm, + ); + + await _flushAsync(); + await cubit.toggleFcm(true); + + expect(cubit.state.fcmEnabled, isTrue); + expect(cubit.state.fcmAvailable, isFalse); + expect(cubit.state.fcmReady, isFalse); + expect(cubit.state.fcmStatusKey, FcmStatusKey.permissionDenied); + expect(bridge.registerCalls, isEmpty); + + fcm.available = true; + fcm.permissionDeniedValue = false; + fcm.token = 'recovered-token'; + await cubit.retryFcmPermission(); + + expect(cubit.state.fcmAvailable, isTrue); + expect(cubit.state.fcmStatusKey, FcmStatusKey.enabledPending); + expect(bridge.registerCalls.single.token, 'recovered-token'); + + await cubit.close(); + await fcm.disposeFake(); + bridge.dispose(); + }); + test('auto registers token on init when machine is enabled', () async { SharedPreferences.setMockInitialValues({ 'settings_fcm_machines': '["$_testMachineId"]', @@ -445,6 +491,47 @@ void main() { bridge.dispose(); }); + test('subscribes to token refresh after registration recovers', () async { + SharedPreferences.setMockInitialValues({ + 'machines_v2': + '[{"id":"$_testMachineId","host":"$_testHost","port":$_testPort}]', + }); + final prefs = await SharedPreferences.getInstance(); + final manager = await _createMachineManager(prefs); + await manager.init(); + final bridge = FakeBridgeService() + ..emitConnection(BridgeConnectionState.connected, url: _testUrl); + final fcm = FakeFcmService(available: false, token: 'token-1'); + final cubit = SettingsCubit( + prefs, + bridgeService: bridge, + machineManager: manager, + fcmService: fcm, + ); + + await _flushAsync(); + await cubit.toggleFcm(true); + expect(bridge.registerCalls, isEmpty); + + await cubit.toggleFcm(false); + bridge.unregisterCalls.clear(); + fcm.available = true; + await cubit.toggleFcm(true); + expect(bridge.registerCalls.map((call) => call.token), ['token-1']); + + fcm.emitTokenRefresh('token-2'); + await _flushAsync(); + expect(bridge.unregisterCalls, ['token-1']); + expect(bridge.registerCalls.map((call) => call.token), [ + 'token-1', + 'token-2', + ]); + + await cubit.close(); + await fcm.disposeFake(); + bridge.dispose(); + }); + test('toggle is no-op when not connected (no activeMachineId)', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance();