Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/Release-Checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
118 changes: 43 additions & 75 deletions lib/domain/use-cases/reconcile_alarms_use_case.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -42,8 +40,8 @@ class ReconcileAlarmsUseCase {
this._fallbackNotificationService, {
required AlarmNowProvider nowProvider,
UserRepository? userRepository,
}) : _userRepository = userRepository,
_nowProvider = nowProvider;
}) : _userRepository = userRepository,
_nowProvider = nowProvider;

Future<AlarmReconciliationResult> call() {
final running = _inFlight;
Expand Down Expand Up @@ -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(
Expand All @@ -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} '
Expand Down Expand Up @@ -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,
Expand All @@ -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,
),
Expand All @@ -341,55 +340,21 @@ 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<AlarmPermissionState> _checkNativePermission(
AlarmSchedulerCapabilities capabilities,
) async {
if (!capabilities.supportsNativeAlarm ||
capabilities.nativeAlarmProvider == AlarmProvider.none) {
return AlarmPermissionState.unsupported;
}
return _schedulerService
.checkPermission()
.catchError((_) => AlarmPermissionState.unsupported);
return _schedulerService.checkPermission().catchError(
(_) => AlarmPermissionState.unsupported,
);
}

Future<AlarmPermissionState> _checkFallbackPermission(
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
);
}

Expand All @@ -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(
Expand All @@ -583,9 +551,7 @@ class ReconcileAlarmsUseCase {
record.provider == capabilities.nativeAlarmProvider;
}

Future<void> _cancelRecords(
List<ScheduledAlarmRecord> records,
) async {
Future<void> _cancelRecords(List<ScheduledAlarmRecord> records) async {
for (final record in records) {
try {
if (record.provider == AlarmProvider.localNotification) {
Expand Down Expand Up @@ -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();
Expand All @@ -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()}}',
Expand Down
41 changes: 29 additions & 12 deletions test/domain/use-cases/reconcile_alarms_use_case_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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(
Expand Down
Loading