diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index beacc085..1fdfbc7a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,8 +38,6 @@ android:launchMode="singleTask" android:taskAffinity="club.devkor.ontime.alarm" android:theme="@style/NormalTheme" - android:showWhenLocked="true" - android:turnScreenOn="true" android:excludeFromRecents="true" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" /> diff --git a/android/app/src/main/kotlin/club/devkor/ontime/AlarmRingingActivity.kt b/android/app/src/main/kotlin/club/devkor/ontime/AlarmRingingActivity.kt index 0987d5fb..bb6635a6 100644 --- a/android/app/src/main/kotlin/club/devkor/ontime/AlarmRingingActivity.kt +++ b/android/app/src/main/kotlin/club/devkor/ontime/AlarmRingingActivity.kt @@ -65,6 +65,11 @@ class AlarmRingingActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NativeLog.d(TAG, "AlarmRingingActivity onCreate ${NativeLog.summarizeIntent(intent)}") + if (!NativeAlarmPolicy.isAndroidFullScreenAlarmApproved()) { + NativeLog.d(TAG, "AlarmRingingActivity finishing: full-screen alarm approval is disabled") + finish() + return + } configureWindow() capturePayload(intent) buildContent() diff --git a/android/app/src/main/kotlin/club/devkor/ontime/MainActivity.kt b/android/app/src/main/kotlin/club/devkor/ontime/MainActivity.kt index 4dacf73c..a9c14778 100644 --- a/android/app/src/main/kotlin/club/devkor/ontime/MainActivity.kt +++ b/android/app/src/main/kotlin/club/devkor/ontime/MainActivity.kt @@ -45,9 +45,15 @@ open class MainActivity : FlutterActivity() { methodChannel?.setMethodCallHandler { call, result -> when (call.method) { "getCapabilities" -> { + val nativeAlarmApproved = + NativeAlarmPolicy.isAndroidFullScreenAlarmApproved() val capabilities = mapOf( - "supportsNativeAlarm" to true, - "nativeAlarmProvider" to "androidAlarmManager", + "supportsNativeAlarm" to nativeAlarmApproved, + "nativeAlarmProvider" to if (nativeAlarmApproved) { + "androidAlarmManager" + } else { + "none" + }, "fallbackProvider" to "localNotification", ) NativeLog.d(TAG, "getCapabilities -> $capabilities") @@ -84,6 +90,16 @@ open class MainActivity : FlutterActivity() { } private fun scheduleNativeAlarm(call: MethodCall, result: MethodChannel.Result) { + if (!NativeAlarmPolicy.isAndroidFullScreenAlarmApproved()) { + NativeLog.w(TAG, "scheduleNativeAlarm blocked: full-screen alarm approval is disabled") + result.error( + "unsupported", + "Android native alarms require full-screen alarm policy approval", + null, + ) + return + } + val args = call.arguments as? Map<*, *> if (args == null) { NativeLog.w(TAG, "scheduleNativeAlarm invalid: missing args") @@ -267,6 +283,7 @@ open class MainActivity : FlutterActivity() { } private fun configureAlarmLaunchWindow(intent: Intent?) { + if (!NativeAlarmPolicy.isAndroidFullScreenAlarmApproved()) return if (payloadFromIntent(intent) == null) return NativeLog.d(TAG, "configureAlarmLaunchWindow for alarm launch") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { diff --git a/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmBootReceiver.kt b/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmBootReceiver.kt index 75eb0c68..64d9e967 100644 --- a/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmBootReceiver.kt +++ b/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmBootReceiver.kt @@ -40,6 +40,10 @@ class NativeAlarmBootReceiver : BroadcastReceiver() { } private fun restorePersistedNativeAlarms(context: Context) { + if (!NativeAlarmPolicy.isAndroidFullScreenAlarmApproved()) { + NativeLog.d(TAG, "restorePersistedNativeAlarms skipped: full-screen alarm approval is disabled") + return + } val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager if (alarmManager == null) { NativeLog.w(TAG, "restorePersistedNativeAlarms skipped: AlarmManager unavailable") diff --git a/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmPolicy.kt b/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmPolicy.kt new file mode 100644 index 00000000..852ce492 --- /dev/null +++ b/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmPolicy.kt @@ -0,0 +1,9 @@ +package club.devkor.ontime + +object NativeAlarmPolicy { + const val ANDROID_FULL_SCREEN_ALARM_APPROVED = false + + fun isAndroidFullScreenAlarmApproved(): Boolean { + return ANDROID_FULL_SCREEN_ALARM_APPROVED + } +} diff --git a/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmReceiver.kt b/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmReceiver.kt index 77f74e20..abfc8b13 100644 --- a/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmReceiver.kt +++ b/android/app/src/main/kotlin/club/devkor/ontime/NativeAlarmReceiver.kt @@ -211,6 +211,10 @@ class NativeAlarmReceiver : BroadcastReceiver() { } private fun handleFireAlarm(context: Context, intent: Intent) { + if (!NativeAlarmPolicy.isAndroidFullScreenAlarmApproved()) { + NativeLog.d(TAG, "Native alarm broadcast ignored: full-screen alarm approval is disabled") + return + } val extras = payloadFromIntent(intent) val requestCode = intent.getIntExtra( "nativeAlarmId", diff --git a/lib/core/services/notification_routing.dart b/lib/core/services/notification_routing.dart index 9ef957f8..093e702b 100644 --- a/lib/core/services/notification_routing.dart +++ b/lib/core/services/notification_routing.dart @@ -25,7 +25,6 @@ bool isScheduleAlarmPayload(Map? payload) { final type = payload['type']?.toString(); final promptVariant = payload['promptVariant']?.toString(); return type == 'schedule_alarm' || - payload['alarmLaunchPayloadVersion'] != null || (promptVariant == 'alarm' && payload['scheduleId'] != null); } @@ -56,7 +55,8 @@ NotificationRouteTarget? notificationRouteForData(Map data) { final type = data['type']?.toString(); final scheduleId = data['scheduleId']?.toString(); - if (type == 'schedule_alarm' && scheduleId != null) { + if ((type == 'schedule_alarm' || type == 'schedule_notification') && + scheduleId != null) { return NotificationRouteTarget( '/scheduleStart', extra: Map.from(data), diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index 82ca7289..1c9d79af 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -308,9 +308,9 @@ class NotificationService { ?.createNotificationChannel(channel); const alarmChannel = AndroidNotificationChannel( - 'scheduled_alarm_channel', - 'Scheduled alarms', - description: 'Schedule preparation alarm fallback notifications.', + 'scheduled_notification_channel', + 'Schedule notifications', + description: 'Schedule preparation notifications.', importance: Importance.max, ); @@ -532,13 +532,12 @@ class NotificationService { scheduledDate: scheduledAt, notificationDetails: const NotificationDetails( android: AndroidNotificationDetails( - 'scheduled_alarm_channel', - 'Scheduled alarms', - channelDescription: - 'Schedule preparation alarm fallback notifications.', + 'scheduled_notification_channel', + 'Schedule notifications', + channelDescription: 'Schedule preparation notifications.', importance: Importance.max, priority: Priority.max, - category: AndroidNotificationCategory.alarm, + category: AndroidNotificationCategory.reminder, icon: '@mipmap/ic_launcher', playSound: true, enableVibration: true, diff --git a/lib/domain/entities/alarm_entities.dart b/lib/domain/entities/alarm_entities.dart index aa01b91a..7d225622 100644 --- a/lib/domain/entities/alarm_entities.dart +++ b/lib/domain/entities/alarm_entities.dart @@ -3,22 +3,26 @@ import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; const alarmDefaultOffset = Duration(minutes: 5); -const alarmLaunchPayloadVersion = '7'; +const alarmLaunchPayloadVersion = '8'; -enum AlarmProvider { - androidAlarmManager, - iosAlarmKit, - localNotification, - none, -} +enum AlarmProvider { androidAlarmManager, iosAlarmKit, localNotification, none } -enum AlarmPermissionState { - granted, - denied, - notDetermined, - unsupported, +const androidFullScreenAlarmPolicyApproved = false; + +bool nativeAlarmProviderAllowedByReleasePolicy(AlarmProvider provider) { + switch (provider) { + case AlarmProvider.iosAlarmKit: + return true; + case AlarmProvider.androidAlarmManager: + return androidFullScreenAlarmPolicyApproved; + case AlarmProvider.localNotification: + case AlarmProvider.none: + return false; + } } +enum AlarmPermissionState { granted, denied, notDetermined, unsupported } + enum AlarmPermissionIssue { nativePermissionDenied, notificationPermissionDenied, @@ -222,10 +226,10 @@ class AlarmSettings extends Equatable { @override List get props => [ - alarmsEnabled, - defaultAlarmOffsetMinutes, - updatedAt, - ]; + alarmsEnabled, + defaultAlarmOffsetMinutes, + updatedAt, + ]; } class AlarmDeviceInfo extends Equatable { @@ -249,14 +253,14 @@ class AlarmDeviceInfo extends Equatable { @override List get props => [ - deviceId, - platform, - appVersion, - osVersion, - supportsNativeAlarm, - nativeAlarmProvider, - fallbackProvider, - ]; + deviceId, + platform, + appVersion, + osVersion, + supportsNativeAlarm, + nativeAlarmProvider, + fallbackProvider, + ]; } class AlarmSchedulerCapabilities extends Equatable { @@ -278,10 +282,10 @@ class AlarmSchedulerCapabilities extends Equatable { @override List get props => [ - supportsNativeAlarm, - nativeAlarmProvider, - fallbackProvider, - ]; + supportsNativeAlarm, + nativeAlarmProvider, + fallbackProvider, + ]; } class ScheduledAlarmRecord extends Equatable { @@ -333,16 +337,16 @@ class ScheduledAlarmRecord extends Equatable { @override List get props => [ - scheduleId, - alarmTime, - preparationStartTime, - scheduleFingerprint, - nativeAlarmId, - fallbackNotificationId, - provider, - scheduleTitle, - payload, - ]; + scheduleId, + alarmTime, + preparationStartTime, + scheduleFingerprint, + nativeAlarmId, + fallbackNotificationId, + provider, + scheduleTitle, + payload, + ]; } class AlarmFailure extends Equatable { @@ -350,11 +354,7 @@ class AlarmFailure extends Equatable { final AlarmFailureReason reason; final String? message; - const AlarmFailure({ - required this.reason, - this.scheduleId, - this.message, - }); + const AlarmFailure({required this.reason, this.scheduleId, this.message}); @override List get props => [scheduleId, reason, message]; @@ -391,18 +391,18 @@ class AlarmReconciliationResult extends Equatable { @override List get props => [ - status, - permissionIssue, - nativeAlarmProvider, - fallbackProvider, - armedScheduleIds, - skippedScheduleCount, - failures, - scheduleWindowStart, - scheduleWindowEnd, - alarmCoverageStart, - alarmCoverageEnd, - ]; + status, + permissionIssue, + nativeAlarmProvider, + fallbackProvider, + armedScheduleIds, + skippedScheduleCount, + failures, + scheduleWindowStart, + scheduleWindowEnd, + alarmCoverageStart, + alarmCoverageEnd, + ]; } class AlarmStatusReport extends Equatable { @@ -440,21 +440,21 @@ class AlarmStatusReport extends Equatable { @override List get props => [ - deviceId, - reconciledAt, - scheduleWindowStart, - scheduleWindowEnd, - alarmCoverageStart, - alarmCoverageEnd, - status, - permissionIssue, - nativeAlarmProvider, - fallbackProvider, - armedScheduleCount, - armedScheduleIds, - skippedScheduleCount, - failures, - ]; + deviceId, + reconciledAt, + scheduleWindowStart, + scheduleWindowEnd, + alarmCoverageStart, + alarmCoverageEnd, + status, + permissionIssue, + nativeAlarmProvider, + fallbackProvider, + armedScheduleCount, + armedScheduleIds, + skippedScheduleCount, + failures, + ]; } bool isAlarmEligibleSchedule(ScheduleWithPreparationEntity schedule) { @@ -501,14 +501,14 @@ ScheduledAlarmRecord buildScheduledAlarmRecord( provider: provider, scheduleTitle: schedule.scheduleName, payload: { - 'type': 'schedule_alarm', + 'type': 'schedule_notification', 'alarmLaunchPayloadVersion': alarmLaunchPayloadVersion, 'scheduleId': schedule.id, 'alarmTime': alarmTime.toIso8601String(), 'preparationStartTime': preparationStartTime.toIso8601String(), 'scheduleFingerprint': buildAlarmScheduleFingerprint(schedule), 'placeName': schedule.place.placeName, - 'promptVariant': 'alarm', + 'promptVariant': 'notification', }, ); } diff --git a/lib/domain/use-cases/reconcile_alarms_use_case.dart b/lib/domain/use-cases/reconcile_alarms_use_case.dart index f83409c1..53b4336e 100644 --- a/lib/domain/use-cases/reconcile_alarms_use_case.dart +++ b/lib/domain/use-cases/reconcile_alarms_use_case.dart @@ -353,8 +353,7 @@ class ReconcileAlarmsUseCase { Future _checkNativePermission( AlarmSchedulerCapabilities capabilities, ) async { - if (!capabilities.supportsNativeAlarm || - capabilities.nativeAlarmProvider == AlarmProvider.none) { + if (!_canUseNativeProvider(capabilities)) { return AlarmPermissionState.unsupported; } return _schedulerService.checkPermission().catchError( @@ -541,10 +540,18 @@ class ReconcileAlarmsUseCase { return capabilities.fallbackProvider == AlarmProvider.localNotification; } if (record.provider == AlarmProvider.none) return false; - return capabilities.supportsNativeAlarm && + return _canUseNativeProvider(capabilities) && record.provider == capabilities.nativeAlarmProvider; } + bool _canUseNativeProvider(AlarmSchedulerCapabilities capabilities) { + return capabilities.supportsNativeAlarm && + capabilities.nativeAlarmProvider != AlarmProvider.none && + nativeAlarmProviderAllowedByReleasePolicy( + capabilities.nativeAlarmProvider, + ); + } + Future _cancelRecords(List records) async { for (final record in records) { try { diff --git a/lib/presentation/app/cubit/alarm_gate_cubit.dart b/lib/presentation/app/cubit/alarm_gate_cubit.dart index 01cc067f..41df55c8 100644 --- a/lib/presentation/app/cubit/alarm_gate_cubit.dart +++ b/lib/presentation/app/cubit/alarm_gate_cubit.dart @@ -135,7 +135,10 @@ class AlarmGateCubit extends Cubit { AlarmSchedulerCapabilities capabilities, ) async { if (!capabilities.supportsNativeAlarm || - capabilities.nativeAlarmProvider == AlarmProvider.none) { + capabilities.nativeAlarmProvider == AlarmProvider.none || + !nativeAlarmProviderAllowedByReleasePolicy( + capabilities.nativeAlarmProvider, + )) { return AlarmPermissionState.unsupported; } return _alarmSchedulerService.checkPermission(); @@ -145,7 +148,10 @@ class AlarmGateCubit extends Cubit { AlarmSchedulerCapabilities capabilities, ) async { if (!capabilities.supportsNativeAlarm || - capabilities.nativeAlarmProvider == AlarmProvider.none) { + capabilities.nativeAlarmProvider == AlarmProvider.none || + !nativeAlarmProviderAllowedByReleasePolicy( + capabilities.nativeAlarmProvider, + )) { return AlarmPermissionState.unsupported; } return _alarmSchedulerService.requestPermission(); diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index 496e87bf..145f8d99 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -448,8 +448,7 @@ Future _checkNativePermission( AlarmSchedulerService schedulerService, AlarmSchedulerCapabilities capabilities, ) async { - if (!capabilities.supportsNativeAlarm || - capabilities.nativeAlarmProvider == AlarmProvider.none) { + if (!_shouldRecoverNativeAlarmPermission(capabilities)) { return AlarmPermissionState.unsupported; } return schedulerService.checkPermission(); @@ -465,6 +464,16 @@ Future _checkFallbackPermission( return fallbackService.checkPermission(); } +bool _shouldRecoverNativeAlarmPermission( + AlarmSchedulerCapabilities capabilities, +) { + return capabilities.supportsNativeAlarm && + capabilities.nativeAlarmProvider != AlarmProvider.none && + nativeAlarmProviderAllowedByReleasePolicy( + capabilities.nativeAlarmProvider, + ); +} + class _MyAccountView extends StatelessWidget { const _MyAccountView(); diff --git a/test/core/services/notification_routing_test.dart b/test/core/services/notification_routing_test.dart index 99ae86ee..abd5400e 100644 --- a/test/core/services/notification_routing_test.dart +++ b/test/core/services/notification_routing_test.dart @@ -19,7 +19,7 @@ void main() { group('isScheduleAlarmPayload', () { test( - 'detects native alarm payloads by type, version, and prompt variant', + 'detects native alarm payloads without suppressing schedule notifications', () { expect(isScheduleAlarmPayload(null), isFalse); expect( @@ -27,8 +27,11 @@ void main() { isTrue, ); expect( - isScheduleAlarmPayload(const {'alarmLaunchPayloadVersion': 1}), - isTrue, + isScheduleAlarmPayload(const { + 'type': 'schedule_notification', + 'alarmLaunchPayloadVersion': 1, + }), + isFalse, ); expect( isScheduleAlarmPayload(const { @@ -77,23 +80,26 @@ void main() { }); group('notificationRouteForPayloadString', () { - test('routes full schedule alarm payload to the schedule start screen', () { - final payload = jsonEncode({ - 'type': 'schedule_alarm', - 'scheduleId': 'schedule-1', - 'title': 'Morning meeting', - }); + test( + 'routes schedule notification payload to the schedule start screen', + () { + final payload = jsonEncode({ + 'type': 'schedule_notification', + 'scheduleId': 'schedule-1', + 'title': 'Morning meeting', + }); - final target = notificationRouteForPayloadString(payload); + final target = notificationRouteForPayloadString(payload); - expect(target, isNotNull); - expect(target!.path, '/scheduleStart'); - expect(target.extra, { - 'type': 'schedule_alarm', - 'scheduleId': 'schedule-1', - 'title': 'Morning meeting', - }); - }); + expect(target, isNotNull); + expect(target!.path, '/scheduleStart'); + expect(target.extra, { + 'type': 'schedule_notification', + 'scheduleId': 'schedule-1', + 'title': 'Morning meeting', + }); + }, + ); test('routes five-minute prompts as early-start schedule starts', () { final target = notificationRouteForPayloadString( @@ -149,12 +155,12 @@ void main() { test('routes background message data with the same notification rules', () { expect( notificationRouteForData(const { - 'type': 'schedule_alarm', + 'type': 'schedule_notification', 'scheduleId': 'schedule-2', }), const NotificationRouteTarget( '/scheduleStart', - extra: {'type': 'schedule_alarm', 'scheduleId': 'schedule-2'}, + extra: {'type': 'schedule_notification', 'scheduleId': 'schedule-2'}, ), ); expect( diff --git a/test/core/services/notification_service_test.dart b/test/core/services/notification_service_test.dart index 336fe157..c71db33d 100644 --- a/test/core/services/notification_service_test.dart +++ b/test/core/services/notification_service_test.dart @@ -542,28 +542,31 @@ void main() { expect(displayFailureNotifications.shown, isEmpty); }); - test('fallback alarm scheduling requires notification permission', () async { - final messaging = _FakeFirebaseMessaging(AuthorizationStatus.denied); - final service = NotificationService.test( - messaging: messaging, - localNotifications: _RecordingLocalNotifications(), - isFlutterLocalNotificationsInitialized: true, - ); + test( + 'schedule notification scheduling requires notification permission', + () async { + final messaging = _FakeFirebaseMessaging(AuthorizationStatus.denied); + final service = NotificationService.test( + messaging: messaging, + localNotifications: _RecordingLocalNotifications(), + isFlutterLocalNotificationsInitialized: true, + ); - await expectLater( - service.scheduleFallbackAlarm(_record()), - throwsA( - isA().having( - (error) => error.permissionIssue, - 'permissionIssue', - AlarmPermissionIssue.notificationPermissionDenied, + await expectLater( + service.scheduleFallbackAlarm(_record()), + throwsA( + isA().having( + (error) => error.permissionIssue, + 'permissionIssue', + AlarmPermissionIssue.notificationPermissionDenied, + ), ), - ), - ); - }); + ); + }, + ); test( - 'fallback alarms schedule and cancel by stable notification id', + 'schedule notifications schedule and cancel by stable notification id', () async { final messaging = _FakeFirebaseMessaging(AuthorizationStatus.authorized); final localNotifications = _RecordingLocalNotifications(); @@ -590,6 +593,24 @@ void main() { localNotifications.scheduled.single.body, contains('time to get ready'), ); + expect( + localNotifications + .scheduled + .single + .notificationDetails + .android + ?.channelName, + 'Schedule notifications', + ); + expect( + localNotifications + .scheduled + .single + .notificationDetails + .android + ?.category, + AndroidNotificationCategory.reminder, + ); expect( localNotifications .scheduled @@ -629,7 +650,11 @@ ScheduledAlarmRecord _record({int? fallbackNotificationId = 42}) { scheduleFingerprint: 'fingerprint', provider: AlarmProvider.localNotification, scheduleTitle: 'Morning meeting', - payload: const {'type': 'schedule_alarm', 'scheduleId': 'schedule-1'}, + payload: const { + 'type': 'schedule_notification', + 'scheduleId': 'schedule-1', + 'promptVariant': 'notification', + }, fallbackNotificationId: fallbackNotificationId, ); } diff --git a/test/data/models/alarm_models_test.dart b/test/data/models/alarm_models_test.dart index 6b5b5e93..f86a38e7 100644 --- a/test/data/models/alarm_models_test.dart +++ b/test/data/models/alarm_models_test.dart @@ -365,7 +365,7 @@ void main() { }); test( - 'alarm scheduling helpers derive stable alarm records from schedules', + 'schedule notification helpers derive stable notification records from schedules', () { final schedule = _scheduleWithPreparation( doneStatus: ScheduleDoneStatus.notEnded, @@ -391,7 +391,8 @@ void main() { record.payload['alarmLaunchPayloadVersion'], alarmLaunchPayloadVersion, ); - expect(record.payload['promptVariant'], 'alarm'); + expect(record.payload['type'], 'schedule_notification'); + expect(record.payload['promptVariant'], 'notification'); expect(record.payload['placeName'], 'Office'); }, ); diff --git a/test/domain/use-cases/reconcile_alarms_use_case_test.dart b/test/domain/use-cases/reconcile_alarms_use_case_test.dart index 64c7ec68..36a06353 100644 --- a/test/domain/use-cases/reconcile_alarms_use_case_test.dart +++ b/test/domain/use-cases/reconcile_alarms_use_case_test.dart @@ -336,6 +336,7 @@ void main() { registryRepository = FakeAlarmRegistryRepository(); schedulerService = FakeAlarmSchedulerService(); fallbackService = FakeFallbackAlarmNotificationService(); + fallbackService.permission = AlarmPermissionState.granted; userRepository = FakeUserRepository(); useCase = ReconcileAlarmsUseCase.test( alarmRepository, @@ -348,7 +349,7 @@ void main() { }); test( - 'requests padded window and schedules only eligible 7-day alarms', + 'requests padded window and schedules eligible Android records as notifications', () async { final eligible = scheduleWithAlarmAt( id: 'eligible', @@ -376,20 +377,21 @@ void main() { alarmRepository.requestedWindowEnd, now.add(const Duration(days: 8)), ); + expect(schedulerService.scheduledNative, isEmpty); expect( - schedulerService.scheduledNative.map((record) => record.scheduleId), + fallbackService.scheduledFallback.map((record) => record.scheduleId), ['eligible'], ); expect(result.armedScheduleIds, ['eligible']); - expect(result.nativeAlarmProvider, AlarmProvider.androidAlarmManager); - expect(result.fallbackProvider, AlarmProvider.none); + expect(result.nativeAlarmProvider, AlarmProvider.none); + expect(result.fallbackProvider, AlarmProvider.localNotification); expect( alarmRepository.statusReports.single.nativeAlarmProvider, - AlarmProvider.androidAlarmManager, + AlarmProvider.none, ); expect( alarmRepository.statusReports.single.fallbackProvider, - AlarmProvider.none, + AlarmProvider.localNotification, ); expect(result.skippedScheduleCount, 3); expect(result.alarmCoverageEnd, now.add(const Duration(days: 7))); @@ -437,7 +439,7 @@ void main() { }, ); - test('android alarm manager arms exact alarms beyond 24 hours', () async { + test('android schedule notifications arm beyond 24 hours', () async { alarmRepository.schedules = [ scheduleWithAlarmAt( id: 'two-days', @@ -447,15 +449,17 @@ void main() { final result = await useCase(); + expect(schedulerService.scheduledNative, isEmpty); expect( - schedulerService.scheduledNative.map((record) => record.scheduleId), + fallbackService.scheduledFallback.map((record) => record.scheduleId), ['two-days'], ); expect( - schedulerService.scheduledNative.single.provider, - AlarmProvider.androidAlarmManager, + fallbackService.scheduledFallback.single.provider, + AlarmProvider.localNotification, ); - expect(result.nativeAlarmProvider, AlarmProvider.androidAlarmManager); + expect(result.nativeAlarmProvider, AlarmProvider.none); + expect(result.fallbackProvider, AlarmProvider.localNotification); expect(result.alarmCoverageEnd, now.add(const Duration(days: 7))); }); @@ -473,7 +477,8 @@ void main() { expect(alarmRepository.alarmWindowRequestCount, 1); expect(alarmRepository.registeredDevices.length, 1); expect(alarmRepository.statusReports.length, 1); - expect(schedulerService.scheduledNative.length, 1); + expect(schedulerService.scheduledNative, isEmpty); + expect(fallbackService.scheduledFallback.length, 1); }); test( @@ -506,7 +511,12 @@ void main() { await useCase(); expect(schedulerService.canceledNative.single.scheduleId, 'changed'); - expect(schedulerService.scheduledNative.single.scheduleId, 'changed'); + expect(schedulerService.scheduledNative, isEmpty); + expect(fallbackService.scheduledFallback.single.scheduleId, 'changed'); + expect( + registryRepository.records.single.provider, + AlarmProvider.localNotification, + ); expect( registryRepository.records.single.scheduleFingerprint, desiredRecord.scheduleFingerprint, @@ -514,28 +524,39 @@ void main() { }, ); - test('keeps matching native registry record without rescheduling', () async { - final schedule = scheduleWithAlarmAt( - id: 'already-armed', - alarmTime: now.add(const Duration(hours: 1)), - ); - final existing = buildScheduledAlarmRecord( - schedule, - alarmOffset: const Duration(minutes: 5), - provider: AlarmProvider.androidAlarmManager, - ); - registryRepository.records = [existing]; - alarmRepository.schedules = [schedule]; + test( + 'replaces matching Android native registry record with notification', + () async { + final schedule = scheduleWithAlarmAt( + id: 'already-armed', + alarmTime: now.add(const Duration(hours: 1)), + ); + final existing = buildScheduledAlarmRecord( + schedule, + alarmOffset: const Duration(minutes: 5), + provider: AlarmProvider.androidAlarmManager, + ); + registryRepository.records = [existing]; + alarmRepository.schedules = [schedule]; - final result = await useCase(); + final result = await useCase(); - expect(schedulerService.canceledNative, isEmpty); - expect(schedulerService.scheduledNative, isEmpty); - expect(registryRepository.records, [existing]); - expect(result.status, AlarmReconciliationStatus.armed); - expect(result.armedScheduleIds, ['already-armed']); - expect(result.nativeAlarmProvider, AlarmProvider.androidAlarmManager); - }); + expect(schedulerService.canceledNative, [existing]); + expect(schedulerService.scheduledNative, isEmpty); + expect( + fallbackService.scheduledFallback.single.scheduleId, + 'already-armed', + ); + expect( + registryRepository.records.single.provider, + AlarmProvider.localNotification, + ); + expect(result.status, AlarmReconciliationStatus.armed); + expect(result.armedScheduleIds, ['already-armed']); + expect(result.nativeAlarmProvider, AlarmProvider.none); + expect(result.fallbackProvider, AlarmProvider.localNotification); + }, + ); test( 'keeps matching fallback registry record when fallback provider is available', @@ -600,7 +621,15 @@ void main() { await useCase(); expect(schedulerService.canceledNative.single.scheduleId, 'old-payload'); - expect(schedulerService.scheduledNative.single.scheduleId, 'old-payload'); + expect(schedulerService.scheduledNative, isEmpty); + expect( + fallbackService.scheduledFallback.single.scheduleId, + 'old-payload', + ); + expect( + registryRepository.records.single.provider, + AlarmProvider.localNotification, + ); expect( registryRepository.records.single.payload['alarmLaunchPayloadVersion'], alarmLaunchPayloadVersion, @@ -741,11 +770,11 @@ void main() { expect(result.status, AlarmReconciliationStatus.permissionNeeded); expect( result.permissionIssue, - AlarmPermissionIssue.nativePermissionDenied, + AlarmPermissionIssue.notificationPermissionDenied, ); expect( alarmRepository.statusReports.single.permissionIssue, - AlarmPermissionIssue.nativePermissionDenied, + AlarmPermissionIssue.notificationPermissionDenied, ); }, ); @@ -771,11 +800,8 @@ void main() { expect(schedulerService.scheduledNative, isEmpty); expect(fallbackService.scheduledFallback, isEmpty); - expect(result.status, AlarmReconciliationStatus.permissionNeeded); - expect( - result.permissionIssue, - AlarmPermissionIssue.nativePermissionDenied, - ); + expect(result.status, AlarmReconciliationStatus.unsupported); + expect(result.permissionIssue, isNull); expect(result.fallbackProvider, AlarmProvider.none); }, ); @@ -858,40 +884,43 @@ void main() { }, ); - test('reports partial when scheduling a desired alarm fails', () async { - final failing = scheduleWithAlarmAt( - id: 'failing', - alarmTime: now.add(const Duration(hours: 1)), - ); - alarmRepository.schedules = [failing]; - schedulerService.throwOnScheduleIds.add('failing'); - fallbackService.permission = AlarmPermissionState.denied; + test( + 'reports partial when scheduling a desired notification fails', + () async { + final failing = scheduleWithAlarmAt( + id: 'failing', + alarmTime: now.add(const Duration(hours: 1)), + ); + alarmRepository.schedules = [failing]; + fallbackService.throwOnScheduleIds.add('failing'); - final result = await useCase(); + final result = await useCase(); - expect(result.status, AlarmReconciliationStatus.partial); - expect(result.failures.single.scheduleId, 'failing'); - expect(result.failures.single.reason, AlarmFailureReason.platformError); - expect(registryRepository.records, isEmpty); - }); + expect(result.status, AlarmReconciliationStatus.partial); + expect(result.failures.single.scheduleId, 'failing'); + expect(result.failures.single.reason, AlarmFailureReason.platformError); + expect(registryRepository.records, isEmpty); + }, + ); test( - 'generic platform failures are reported when no fallback is available', + 'notification permission is reported when fallback notification is unavailable', () async { final failing = scheduleWithAlarmAt( - id: 'generic-native-failure', + id: 'notification-unavailable', alarmTime: now.add(const Duration(hours: 1)), ); alarmRepository.schedules = [failing]; - schedulerService.throwGenericOnScheduleIds.add('generic-native-failure'); fallbackService.permission = AlarmPermissionState.denied; final result = await useCase(); - expect(result.status, AlarmReconciliationStatus.partial); - expect(result.failures.single.scheduleId, 'generic-native-failure'); - expect(result.failures.single.reason, AlarmFailureReason.platformError); - expect(result.failures.single.message, contains('native channel failed')); + expect(result.status, AlarmReconciliationStatus.permissionNeeded); + expect( + result.permissionIssue, + AlarmPermissionIssue.notificationPermissionDenied, + ); + expect(result.failures, isEmpty); }, ); diff --git a/test/presentation/alarm_allow/alarm_allow_screen_test.dart b/test/presentation/alarm_allow/alarm_allow_screen_test.dart index 728fa004..a630a5c6 100644 --- a/test/presentation/alarm_allow/alarm_allow_screen_test.dart +++ b/test/presentation/alarm_allow/alarm_allow_screen_test.dart @@ -41,7 +41,7 @@ void main() { ); }); - testWidgets('granted precise notification permission enables notifications', ( + testWidgets('granted Android notification permission enables notifications', ( tester, ) async { final harness = await _pumpAlarmAllowScreen( @@ -53,7 +53,8 @@ void main() { await tester.tap(find.text('Allow precise notifications')); await tester.pumpAndSettle(); - expect(harness.scheduler.requestCount, 1); + expect(harness.scheduler.requestCount, 0); + expect(harness.fallback.requestCount, 1); expect(harness.repository.updatedSettings, [true]); expect(harness.reconcileUseCase.callCount, 1); expect(find.text('home'), findsOneWidget); @@ -79,23 +80,24 @@ void main() { expect(find.text('Allow alarms'), findsOneWidget); }); - testWidgets('dismiss keeps notification delivery when fallback is available', ( - tester, - ) async { - final harness = await _pumpAlarmAllowScreen( - tester, - permissionAfterRequest: AlarmPermissionState.denied, - ); - addTearDown(harness.dispose); + testWidgets( + 'dismiss keeps notification delivery when fallback is available', + (tester) async { + final harness = await _pumpAlarmAllowScreen( + tester, + permissionAfterRequest: AlarmPermissionState.denied, + ); + addTearDown(harness.dispose); - await tester.tap(find.text("I'll do it later.")); - await tester.pumpAndSettle(); + await tester.tap(find.text("I'll do it later.")); + await tester.pumpAndSettle(); - expect(harness.repository.updatedSettings, isEmpty); - expect(harness.cancelAllUseCase.callCount, 0); - expect(find.text('home'), findsOneWidget); - expect(harness.gateCubit.state.status, AlarmGateStatus.dismissed); - }); + expect(harness.repository.updatedSettings, isEmpty); + expect(harness.cancelAllUseCase.callCount, 0); + expect(find.text('home'), findsOneWidget); + expect(harness.gateCubit.state.status, AlarmGateStatus.dismissed); + }, + ); } Future<_AlarmAllowHarness> _pumpAlarmAllowScreen( @@ -167,6 +169,7 @@ Future<_AlarmAllowHarness> _pumpAlarmAllowScreen( router: router, repository: repository, scheduler: scheduler, + fallback: fallback, reconcileUseCase: reconcileUseCase, cancelAllUseCase: cancelAllUseCase, ); @@ -178,6 +181,7 @@ class _AlarmAllowHarness { required this.router, required this.repository, required this.scheduler, + required this.fallback, required this.reconcileUseCase, required this.cancelAllUseCase, }); @@ -186,6 +190,7 @@ class _AlarmAllowHarness { final GoRouter router; final _FakeAlarmRepository repository; final _FakeAlarmSchedulerService scheduler; + final _FakeFallbackAlarmNotificationService fallback; final _FakeReconcileAlarmsUseCase reconcileUseCase; final _FakeCancelAllAlarmsUseCase cancelAllUseCase; @@ -348,6 +353,8 @@ class _FakeAlarmRegistry implements AlarmRegistryRepository { class _FakeFallbackAlarmNotificationService implements FallbackAlarmNotificationService { + int requestCount = 0; + @override Future checkPermission() async { return AlarmPermissionState.granted; @@ -355,6 +362,7 @@ class _FakeFallbackAlarmNotificationService @override Future requestPermission() async { + requestCount += 1; return AlarmPermissionState.granted; } diff --git a/test/presentation/app/cubit/alarm_gate_cubit_test.dart b/test/presentation/app/cubit/alarm_gate_cubit_test.dart index ae3e0c49..949f5c9f 100644 --- a/test/presentation/app/cubit/alarm_gate_cubit_test.dart +++ b/test/presentation/app/cubit/alarm_gate_cubit_test.dart @@ -33,7 +33,7 @@ void main() { ); test( - 'refreshPermission clears dismissal and enables alarms when granted', + 'refreshPermission clears dismissal and enables alarms when approved native permission is granted', () async { SharedPreferences.setMockInitialValues({'alarm_prompt_dismissed': true}); final repository = _FakeAlarmRepository(); @@ -87,7 +87,7 @@ void main() { ); test( - 'refreshPermission prompts and disables alarms when permission is denied', + 'refreshPermission prompts and disables alarms when approved native permission is denied', () async { final repository = _FakeAlarmRepository(); final cancelAll = _FakeCancelAllAlarmsUseCase(); @@ -125,7 +125,7 @@ void main() { }); test( - 'requestPermission grants access, clears dismissal, and enables alarms', + 'requestPermission grants approved native access, clears dismissal, and enables alarms', () async { SharedPreferences.setMockInitialValues({'alarm_prompt_dismissed': true}); final repository = _FakeAlarmRepository(); @@ -249,7 +249,7 @@ void main() { ); test( - 'requestPermission denial disables alarms and keeps prompt required', + 'requestPermission denial for approved native delivery disables alarms and keeps prompt required', () async { final repository = _FakeAlarmRepository(); final cancelAll = _FakeCancelAllAlarmsUseCase(); @@ -287,7 +287,7 @@ void main() { }); test( - 'best-effort alarm enable still allows permission state to resolve', + 'best-effort alarm enable still allows approved native permission state to resolve', () async { final cubit = _buildCubit( scheduler: _FakeAlarmSchedulerService( @@ -343,7 +343,7 @@ class _FakeAlarmSchedulerService extends AlarmSchedulerService { this.requestPermissionState = AlarmPermissionState.denied, this.capabilities = const AlarmSchedulerCapabilities( supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, + nativeAlarmProvider: AlarmProvider.iosAlarmKit, fallbackProvider: AlarmProvider.none, ), }); @@ -456,7 +456,7 @@ class _FakeReconcileAlarmsUseCase implements ReconcileAlarmsUseCase { final now = DateTime(2026, 5, 15); return AlarmReconciliationResult( status: AlarmReconciliationStatus.armed, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, + nativeAlarmProvider: AlarmProvider.none, fallbackProvider: AlarmProvider.localNotification, armedScheduleIds: const [], skippedScheduleCount: 0, diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart index 42240479..3805641c 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -275,44 +275,48 @@ void main() { expect(notificationService.openSettingsCount, 1); }); - testWidgets('enables alarms through fallback when exact timing is denied', ( - tester, - ) async { - final alarmRepository = - getIt.get() as _FakeAlarmRepository; - final alarmScheduler = - getIt.get() as _FakeAlarmSchedulerService; - final fallbackService = - getIt.get() - as _FakeFallbackAlarmNotificationService; - final reconcileUseCase = - getIt.get() as _FakeReconcileAlarmsUseCase; - alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); - alarmScheduler - ..capabilities = const AlarmSchedulerCapabilities( - supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, - ) - ..permission = AlarmPermissionState.denied; - fallbackService.permission = AlarmPermissionState.granted; + testWidgets( + 'enables schedule notifications when Android exact timing is denied', + (tester) async { + final alarmRepository = + getIt.get() as _FakeAlarmRepository; + final alarmScheduler = + getIt.get() as _FakeAlarmSchedulerService; + final fallbackService = + getIt.get() + as _FakeFallbackAlarmNotificationService; + final reconcileUseCase = + getIt.get() as _FakeReconcileAlarmsUseCase; + alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); + alarmScheduler + ..capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + ) + ..permission = AlarmPermissionState.denied; + fallbackService.permission = AlarmPermissionState.granted; - await _pumpMyPage(tester, locale: const Locale('en')); + await _pumpMyPage(tester, locale: const Locale('en')); - await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); - await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); + await tester.pumpAndSettle(); - expect(find.text('Precise notification permission needed'), findsNothing); - expect(alarmRepository.updatedSettings, [true]); - expect(fallbackService.requestCount, 1); - expect(reconcileUseCase.callCount, 1); - expect( - tester.widget(find.byKey(const Key('alarmSettingsSwitch'))).value, - isTrue, - ); - }); + expect(find.text('Precise notification permission needed'), findsNothing); + expect(alarmScheduler.requestCount, 0); + expect(alarmRepository.updatedSettings, [true]); + expect(fallbackService.requestCount, 1); + expect(reconcileUseCase.callCount, 1); + expect( + tester + .widget(find.byKey(const Key('alarmSettingsSwitch'))) + .value, + isTrue, + ); + }, + ); testWidgets( - 'enabling alarms can recover exact alarm permission through settings', + 'enabling alarms can recover approved native alarm permission through settings', (tester) async { final alarmRepository = getIt.get() as _FakeAlarmRepository; @@ -327,7 +331,7 @@ void main() { alarmScheduler ..capabilities = const AlarmSchedulerCapabilities( supportsNativeAlarm: true, - nativeAlarmProvider: AlarmProvider.androidAlarmManager, + nativeAlarmProvider: AlarmProvider.iosAlarmKit, ) ..permission = AlarmPermissionState.denied ..permissionAfterRequest = AlarmPermissionState.granted;