diff --git a/docs/Release-Checklist.md b/docs/Release-Checklist.md index 9f58e17f..e8cd6f13 100644 --- a/docs/Release-Checklist.md +++ b/docs/Release-Checklist.md @@ -139,7 +139,7 @@ flutter build appbundle --release ## iOS -- Confirm the bundle identifier is `club.devkor.ontime`. +- Confirm the bundle identifier is `club.devkor.ontime.ios`. - Confirm the display name and bundle name are `OnTime`. - Review the app icon set in `ios/Runner/Assets.xcassets/AppIcon.appiconset`. - Confirm the launch screen, supported orientations, background modes, diff --git a/lib/domain/use-cases/reconcile_alarms_use_case.dart b/lib/domain/use-cases/reconcile_alarms_use_case.dart index f3c8b0a0..ed282095 100644 --- a/lib/domain/use-cases/reconcile_alarms_use_case.dart +++ b/lib/domain/use-cases/reconcile_alarms_use_case.dart @@ -14,8 +14,6 @@ typedef AlarmNowProvider = DateTime Function(); @Singleton() class ReconcileAlarmsUseCase { static const _logTag = '[ReconcileAlarms]'; - static const _recentlyMissedAlarmGracePeriod = Duration(seconds: 30); - static const _recentlyMissedAlarmDeliveryDelay = Duration(seconds: 5); final AlarmRepository _alarmRepository; final AlarmRegistryRepository _registryRepository; @@ -31,8 +29,8 @@ class ReconcileAlarmsUseCase { this._schedulerService, this._fallbackNotificationService, UserRepository userRepository, - ) : _userRepository = userRepository, - _nowProvider = DateTime.now; + ) : _userRepository = userRepository, + _nowProvider = DateTime.now; @visibleForTesting ReconcileAlarmsUseCase.test( @@ -42,8 +40,8 @@ class ReconcileAlarmsUseCase { this._fallbackNotificationService, { required AlarmNowProvider nowProvider, UserRepository? userRepository, - }) : _userRepository = userRepository, - _nowProvider = nowProvider; + }) : _userRepository = userRepository, + _nowProvider = nowProvider; Future call() { final running = _inFlight; @@ -146,7 +144,8 @@ class ReconcileAlarmsUseCase { scheduleWindowEnd, ); AppLogger.debug( - '$_logTag getAlarmWindow success count=${schedules.length}'); + '$_logTag getAlarmWindow success count=${schedules.length}', + ); } catch (error) { AppLogger.debug('$_logTag getAlarmWindow failed: $error'); final result = _result( @@ -173,12 +172,14 @@ class ReconcileAlarmsUseCase { alarmOffset: settings.alarmOffset, ); final skippedScheduleCount = schedules - .where((schedule) => !_isDesired( - schedule, - now, - alarmCoverageEnd, - settings.alarmOffset, - )) + .where( + (schedule) => !_isDesired( + schedule, + now, + alarmCoverageEnd, + settings.alarmOffset, + ), + ) .length; AppLogger.debug( '$_logTag desiredRecords=${desiredRecords.length} ' @@ -289,8 +290,9 @@ class ReconcileAlarmsUseCase { status: status, permissionIssue: permissionIssue, capabilities: _effectiveCapabilities(capabilities, finalRecords), - armedScheduleIds: - finalRecords.map((record) => record.scheduleId).toList(), + armedScheduleIds: finalRecords + .map((record) => record.scheduleId) + .toList(), skippedScheduleCount: skippedScheduleCount, failures: failures, scheduleWindowStart: scheduleWindowStart, @@ -315,16 +317,13 @@ class ReconcileAlarmsUseCase { required Duration alarmOffset, }) { return schedules - .where((schedule) => _isDesired( - schedule, - now, - alarmCoverageEnd, - alarmOffset, - )) + .where( + (schedule) => + _isDesired(schedule, now, alarmCoverageEnd, alarmOffset), + ) .map( - (schedule) => _scheduledAlarmRecordFor( - schedule: schedule, - now: now, + (schedule) => buildScheduledAlarmRecord( + schedule, alarmOffset: alarmOffset, provider: AlarmProvider.none, ), @@ -341,45 +340,11 @@ class ReconcileAlarmsUseCase { if (!isAlarmEligibleSchedule(schedule)) return false; if (schedule.id.isEmpty) return false; final alarmTime = computeAlarmTime(schedule, offset: alarmOffset); - final isFutureAlarm = alarmTime.isAfter(now); - final isRecentlyMissedAlarm = !isFutureAlarm && - alarmTime.isAfter(now.subtract(_recentlyMissedAlarmGracePeriod)) && - schedule.preparationStartTime.isAfter(now); - return (isFutureAlarm || isRecentlyMissedAlarm) && + return alarmTime.isAfter(now) && (alarmTime.isBefore(alarmCoverageEnd) || alarmTime.isAtSameMomentAs(alarmCoverageEnd)); } - ScheduledAlarmRecord _scheduledAlarmRecordFor({ - required ScheduleWithPreparationEntity schedule, - required DateTime now, - required Duration alarmOffset, - required AlarmProvider provider, - }) { - final record = buildScheduledAlarmRecord( - schedule, - alarmOffset: alarmOffset, - provider: provider, - ); - if (record.alarmTime.isAfter(now)) return record; - - final adjustedAlarmTime = now.add(_recentlyMissedAlarmDeliveryDelay); - AppLogger.debug( - '$_logTag recently missed alarm catch-up ' - 'scheduleId=${record.scheduleId} ' - 'originalAlarmTime=${record.alarmTime.toIso8601String()} ' - 'adjustedAlarmTime=${adjustedAlarmTime.toIso8601String()}', - ); - return record.copyWith( - alarmTime: adjustedAlarmTime, - payload: { - ...record.payload, - 'scheduledAlarmTime': adjustedAlarmTime.toIso8601String(), - 'missedAlarmCatchUp': 'true', - }, - ); - } - Future _checkNativePermission( AlarmSchedulerCapabilities capabilities, ) async { @@ -387,9 +352,9 @@ class ReconcileAlarmsUseCase { capabilities.nativeAlarmProvider == AlarmProvider.none) { return AlarmPermissionState.unsupported; } - return _schedulerService - .checkPermission() - .catchError((_) => AlarmPermissionState.unsupported); + return _schedulerService.checkPermission().catchError( + (_) => AlarmPermissionState.unsupported, + ); } Future _checkFallbackPermission( @@ -398,9 +363,9 @@ class ReconcileAlarmsUseCase { if (capabilities.fallbackProvider != AlarmProvider.localNotification) { return AlarmPermissionState.unsupported; } - return _fallbackNotificationService - .checkPermission() - .catchError((_) => AlarmPermissionState.denied); + return _fallbackNotificationService.checkPermission().catchError( + (_) => AlarmPermissionState.denied, + ); } Future<_ScheduleAttempt> _scheduleRecord( @@ -517,7 +482,8 @@ class ReconcileAlarmsUseCase { required AlarmSchedulerCapabilities capabilities, required AlarmPermissionState fallbackPermission, }) { - final hasAnyProvider = capabilities.supportsNativeAlarm || + final hasAnyProvider = + capabilities.supportsNativeAlarm || fallbackPermission == AlarmPermissionState.granted; if (!hasAnyProvider) { if (permissionIssue != null) { @@ -554,8 +520,9 @@ class ReconcileAlarmsUseCase { return AlarmSchedulerCapabilities( supportsNativeAlarm: capabilities.supportsNativeAlarm, nativeAlarmProvider: nativeProvider, - fallbackProvider: - usesFallback ? AlarmProvider.localNotification : AlarmProvider.none, + fallbackProvider: usesFallback + ? AlarmProvider.localNotification + : AlarmProvider.none, ); } @@ -567,8 +534,9 @@ class ReconcileAlarmsUseCase { existing.payload['alarmLaunchPayloadVersion'] == desired.payload['alarmLaunchPayloadVersion'] && existing.alarmTime.isAtSameMomentAs(desired.alarmTime) && - existing.preparationStartTime - .isAtSameMomentAs(desired.preparationStartTime); + existing.preparationStartTime.isAtSameMomentAs( + desired.preparationStartTime, + ); } bool _recordProviderMatchesCapabilities( @@ -583,9 +551,7 @@ class ReconcileAlarmsUseCase { record.provider == capabilities.nativeAlarmProvider; } - Future _cancelRecords( - List records, - ) async { + Future _cancelRecords(List records) async { for (final record in records) { try { if (record.provider == AlarmProvider.localNotification) { @@ -668,7 +634,8 @@ class ReconcileAlarmsUseCase { AppLogger.debug('$_logTag postAlarmStatus success'); } on DeviceSessionNotActiveException { AppLogger.debug( - '$_logTag postAlarmStatus device session inactive; signing out'); + '$_logTag postAlarmStatus device session inactive; signing out', + ); final records = await _registryRepository.loadAll(); await _cancelRecords(records); await _registryRepository.deleteAll(); @@ -683,7 +650,8 @@ class ReconcileAlarmsUseCase { if (records.isEmpty) return '[]'; return records .map( - (record) => '{id=${record.scheduleId}, ' + (record) => + '{id=${record.scheduleId}, ' 'provider=${record.provider}, ' 'nativeId=${record.nativeAlarmId}, ' 'alarm=${record.alarmTime.toIso8601String()}}', 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 140edaba..64c7ec68 100644 --- a/test/domain/use-cases/reconcile_alarms_use_case_test.dart +++ b/test/domain/use-cases/reconcile_alarms_use_case_test.dart @@ -397,7 +397,7 @@ void main() { }, ); - test('arms a just-missed alarm immediately within grace period', () async { + test('skips a just-missed alarm instead of scheduling catch-up', () async { alarmRepository.schedules = [ scheduleWithAlarmAt( id: 'just-missed', @@ -407,19 +407,36 @@ void main() { final result = await useCase(); - expect(result.armedScheduleIds, ['just-missed']); - expect(result.skippedScheduleCount, 0); - expect(schedulerService.scheduledNative, hasLength(1)); - final scheduled = schedulerService.scheduledNative.single; - expect(scheduled.scheduleId, 'just-missed'); - expect(scheduled.alarmTime, now.add(const Duration(seconds: 5))); - expect(scheduled.payload['missedAlarmCatchUp'], 'true'); - expect( - scheduled.payload['scheduledAlarmTime'], - now.add(const Duration(seconds: 5)).toIso8601String(), - ); + expect(result.armedScheduleIds, isEmpty); + expect(result.skippedScheduleCount, 1); + expect(schedulerService.scheduledNative, isEmpty); }); + test( + 'clears an already armed just-missed alarm instead of scheduling catch-up', + () async { + final schedule = scheduleWithAlarmAt( + id: 'already-fired', + alarmTime: now.subtract(const Duration(seconds: 5)), + ); + final existing = buildScheduledAlarmRecord( + schedule, + alarmOffset: const Duration(minutes: 5), + provider: AlarmProvider.androidAlarmManager, + ); + alarmRepository.schedules = [schedule]; + registryRepository.records = [existing]; + + final result = await useCase(); + + expect(schedulerService.scheduledNative, isEmpty); + expect(schedulerService.canceledNative, [existing]); + expect(registryRepository.records, isEmpty); + expect(result.armedScheduleIds, isEmpty); + expect(result.skippedScheduleCount, 1); + }, + ); + test('android alarm manager arms exact alarms beyond 24 hours', () async { alarmRepository.schedules = [ scheduleWithAlarmAt(