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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Keep Delivery Enabled With Fallback Notifications

When exact alarm or AlarmKit permission is denied but notification fallback is available, OnTime will keep schedule notifications enabled and arm them through Fallback Notification delivery. Exact timing or AlarmKit recovery remains a non-blocking improvement path, while disabling schedule notifications is reserved for states where no native or fallback delivery path can currently notify the user.
114 changes: 114 additions & 0 deletions lib/domain/entities/alarm_delivery_policy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import 'package:equatable/equatable.dart';
import 'package:on_time_front/domain/entities/alarm_entities.dart';

enum AlarmDeliveryMode { nativeAlarm, fallbackNotification, unavailable }

class AlarmDeliveryPolicy extends Equatable {
const AlarmDeliveryPolicy._({
required this.mode,
required this.activeProvider,
required this.blockingPermissionIssue,
required this.recoveryPermissionIssue,
});

factory AlarmDeliveryPolicy.evaluate({
required AlarmSchedulerCapabilities capabilities,
required AlarmPermissionState nativePermission,
required AlarmPermissionState fallbackPermission,
}) {
if (capabilities.supportsNativeAlarm &&
capabilities.nativeAlarmProvider != AlarmProvider.none &&
nativePermission == AlarmPermissionState.granted) {
return AlarmDeliveryPolicy._(
mode: AlarmDeliveryMode.nativeAlarm,
activeProvider: capabilities.nativeAlarmProvider,
blockingPermissionIssue: null,
recoveryPermissionIssue: null,
);
}

if (capabilities.fallbackProvider == AlarmProvider.localNotification &&
fallbackPermission == AlarmPermissionState.granted) {
return AlarmDeliveryPolicy._(
mode: AlarmDeliveryMode.fallbackNotification,
activeProvider: AlarmProvider.localNotification,
blockingPermissionIssue: null,
recoveryPermissionIssue: _nativeRecoveryIssue(
capabilities,
nativePermission,
),
);
}

final blockingIssue = _blockingPermissionIssue(
capabilities,
nativePermission,
fallbackPermission,
);
return AlarmDeliveryPolicy._(
mode: AlarmDeliveryMode.unavailable,
activeProvider: AlarmProvider.none,
blockingPermissionIssue: blockingIssue,
recoveryPermissionIssue: null,
);
}

final AlarmDeliveryMode mode;
final AlarmProvider activeProvider;
final AlarmPermissionIssue? blockingPermissionIssue;
final AlarmPermissionIssue? recoveryPermissionIssue;

bool get canDeliver => activeProvider != AlarmProvider.none;

bool get isUnsupported {
return mode == AlarmDeliveryMode.unavailable &&
blockingPermissionIssue == null;
}

bool get shouldDisableAlarms {
return mode == AlarmDeliveryMode.unavailable &&
blockingPermissionIssue != null;
}

@override
List<Object?> get props => [
mode,
activeProvider,
blockingPermissionIssue,
recoveryPermissionIssue,
];

static AlarmPermissionIssue? _nativeRecoveryIssue(
AlarmSchedulerCapabilities capabilities,
AlarmPermissionState nativePermission,
) {
if (!capabilities.supportsNativeAlarm ||
capabilities.nativeAlarmProvider == AlarmProvider.none) {
return null;
}
if (nativePermission == AlarmPermissionState.denied ||
nativePermission == AlarmPermissionState.notDetermined) {
return AlarmPermissionIssue.nativePermissionDenied;
}
return null;
}

static AlarmPermissionIssue? _blockingPermissionIssue(
AlarmSchedulerCapabilities capabilities,
AlarmPermissionState nativePermission,
AlarmPermissionState fallbackPermission,
) {
if (capabilities.supportsNativeAlarm &&
capabilities.nativeAlarmProvider != AlarmProvider.none &&
(nativePermission == AlarmPermissionState.denied ||
nativePermission == AlarmPermissionState.notDetermined)) {
return AlarmPermissionIssue.nativePermissionDenied;
}
if (capabilities.fallbackProvider == AlarmProvider.localNotification &&
(fallbackPermission == AlarmPermissionState.denied ||
fallbackPermission == AlarmPermissionState.notDetermined)) {
return AlarmPermissionIssue.notificationPermissionDenied;
}
return null;
}
}
46 changes: 20 additions & 26 deletions lib/domain/use-cases/reconcile_alarms_use_case.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import 'package:on_time_front/core/services/alarm_scheduler_service.dart';
import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart';
import 'package:on_time_front/domain/entities/alarm_delivery_policy.dart';
import 'package:on_time_front/domain/entities/alarm_entities.dart';
import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart';
import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart';
Expand Down Expand Up @@ -217,6 +218,11 @@ class ReconcileAlarmsUseCase {
'$_logTag permissions native=$nativePermission '
'fallback=$fallbackPermission',
);
final deliveryPolicy = AlarmDeliveryPolicy.evaluate(
capabilities: capabilities,
nativePermission: nativePermission,
fallbackPermission: fallbackPermission,
);

final finalRecords = <ScheduledAlarmRecord>[];
final failures = <AlarmFailure>[];
Expand All @@ -238,7 +244,7 @@ class ReconcileAlarmsUseCase {
final scheduled = await _scheduleRecord(
desired,
capabilities: capabilities,
nativePermission: nativePermission,
deliveryPolicy: deliveryPolicy,
fallbackPermission: fallbackPermission,
);

Expand Down Expand Up @@ -282,8 +288,7 @@ class ReconcileAlarmsUseCase {
armedCount: finalRecords.length,
failures: failures,
permissionIssue: permissionIssue,
capabilities: capabilities,
fallbackPermission: fallbackPermission,
deliveryPolicy: deliveryPolicy,
);

final result = _result(
Expand Down Expand Up @@ -371,12 +376,10 @@ class ReconcileAlarmsUseCase {
Future<_ScheduleAttempt> _scheduleRecord(
ScheduledAlarmRecord desired, {
required AlarmSchedulerCapabilities capabilities,
required AlarmPermissionState nativePermission,
required AlarmDeliveryPolicy deliveryPolicy,
required AlarmPermissionState fallbackPermission,
}) async {
if (capabilities.supportsNativeAlarm &&
capabilities.nativeAlarmProvider != AlarmProvider.none &&
nativePermission == AlarmPermissionState.granted) {
if (deliveryPolicy.mode == AlarmDeliveryMode.nativeAlarm) {
final nativeRecord = desired.copyWith(
provider: capabilities.nativeAlarmProvider,
);
Expand Down Expand Up @@ -415,7 +418,8 @@ class ReconcileAlarmsUseCase {
}
}

if (capabilities.fallbackProvider == AlarmProvider.localNotification &&
if ((deliveryPolicy.mode == AlarmDeliveryMode.fallbackNotification ||
capabilities.fallbackProvider == AlarmProvider.localNotification) &&
fallbackPermission == AlarmPermissionState.granted) {
final fallbackRecord = desired.copyWith(
provider: AlarmProvider.localNotification,
Expand Down Expand Up @@ -455,17 +459,9 @@ class ReconcileAlarmsUseCase {
}
}

if (nativePermission == AlarmPermissionState.denied ||
nativePermission == AlarmPermissionState.notDetermined) {
return const _ScheduleAttempt(
permissionIssue: AlarmPermissionIssue.nativePermissionDenied,
);
}
if (fallbackPermission == AlarmPermissionState.denied ||
fallbackPermission == AlarmPermissionState.notDetermined) {
return const _ScheduleAttempt(
permissionIssue: AlarmPermissionIssue.notificationPermissionDenied,
);
final blockingIssue = deliveryPolicy.blockingPermissionIssue;
if (blockingIssue != null) {
return _ScheduleAttempt(permissionIssue: blockingIssue);
}

return const _ScheduleAttempt(
Expand All @@ -479,17 +475,15 @@ class ReconcileAlarmsUseCase {
required int armedCount,
required List<AlarmFailure> failures,
required AlarmPermissionIssue? permissionIssue,
required AlarmSchedulerCapabilities capabilities,
required AlarmPermissionState fallbackPermission,
required AlarmDeliveryPolicy deliveryPolicy,
}) {
final hasAnyProvider =
capabilities.supportsNativeAlarm ||
fallbackPermission == AlarmPermissionState.granted;
if (!hasAnyProvider) {
if (!deliveryPolicy.canDeliver) {
if (permissionIssue != null) {
return AlarmReconciliationStatus.permissionNeeded;
}
return AlarmReconciliationStatus.unsupported;
return deliveryPolicy.isUnsupported
? AlarmReconciliationStatus.unsupported
: AlarmReconciliationStatus.permissionNeeded;
}
if (desiredCount > armedCount || failures.isNotEmpty) {
return armedCount == 0 && permissionIssue != null
Expand Down
92 changes: 55 additions & 37 deletions lib/presentation/app/cubit/alarm_gate_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:on_time_front/core/di/di_setup.dart';
import 'package:on_time_front/core/logging/app_logger.dart';
import 'package:on_time_front/core/services/alarm_scheduler_service.dart';
import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart';
import 'package:on_time_front/domain/entities/alarm_delivery_policy.dart';
import 'package:on_time_front/domain/entities/alarm_entities.dart';
import 'package:on_time_front/domain/repositories/alarm_repository.dart';
import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart';
Expand Down Expand Up @@ -45,14 +46,14 @@ class AlarmGateCubit extends Cubit<AlarmGateState> {
bool enableAlarmsOnGrant = false,
}) async {
final capabilities = await _alarmSchedulerService.getCapabilities();
final permission = await _checkEffectivePermission(capabilities);
if (permission == AlarmPermissionState.unsupported) {
final delivery = await _checkDeliveryPolicy(capabilities);
if (delivery.policy.isUnsupported) {
emit(const AlarmGateState.unsupported());
return;
}

final prefs = await SharedPreferences.getInstance();
if (permission == AlarmPermissionState.granted) {
if (delivery.policy.canDeliver) {
await prefs.remove(_dismissedKey);
if (enableAlarmsOnGrant) {
await _enableAlarmsBestEffort();
Expand All @@ -62,7 +63,7 @@ class AlarmGateCubit extends Cubit<AlarmGateState> {
}

if (disableAlarmsWhenPermissionMissing &&
!await _hasGrantedFallbackPermission(capabilities)) {
delivery.policy.shouldDisableAlarms) {
await _disableAlarmsBestEffort();
}

Expand All @@ -76,57 +77,58 @@ class AlarmGateCubit extends Cubit<AlarmGateState> {

Future<AlarmPermissionState> requestPermission() async {
final capabilities = await _alarmSchedulerService.getCapabilities();
final permission = await _requestEffectivePermission(capabilities);
if (permission == AlarmPermissionState.unsupported) {
final delivery = await _requestDeliveryPolicy(capabilities);
final permission = delivery.permissionState;
if (delivery.policy.isUnsupported) {
emit(const AlarmGateState.unsupported());
return permission;
}

if (permission == AlarmPermissionState.granted) {
if (delivery.policy.canDeliver) {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_dismissedKey);
await _enableAlarmsBestEffort();
emit(const AlarmGateState.allowed());
return permission;
}

if (!await _hasGrantedFallbackPermission(capabilities)) {
if (delivery.policy.shouldDisableAlarms) {
await _disableAlarmsBestEffort();
}
emit(const AlarmGateState.required());
return permission;
}

Future<AlarmPermissionState> _checkEffectivePermission(
Future<_EvaluatedAlarmDelivery> _checkDeliveryPolicy(
AlarmSchedulerCapabilities capabilities,
) async {
final nativePermission = await _checkNativePermission(capabilities);
if (nativePermission == AlarmPermissionState.granted) {
return nativePermission;
}

if (nativePermission == AlarmPermissionState.unsupported) {
return _checkFallbackPermission(capabilities);
}
return nativePermission;
final fallbackPermission = await _checkFallbackPermission(capabilities);
return _EvaluatedAlarmDelivery(
policy: AlarmDeliveryPolicy.evaluate(
capabilities: capabilities,
nativePermission: nativePermission,
fallbackPermission: fallbackPermission,
),
nativePermission: nativePermission,
fallbackPermission: fallbackPermission,
);
}

Future<AlarmPermissionState> _requestEffectivePermission(
Future<_EvaluatedAlarmDelivery> _requestDeliveryPolicy(
AlarmSchedulerCapabilities capabilities,
) async {
final nativePermission = await _requestNativePermission(capabilities);
if (nativePermission == AlarmPermissionState.granted) {
return nativePermission;
}

if (nativePermission == AlarmPermissionState.unsupported) {
return _requestFallbackPermission(capabilities);
}
final fallbackPermission = await _requestFallbackPermission(capabilities);
if (fallbackPermission == AlarmPermissionState.granted) {
return fallbackPermission;
}
return nativePermission;
return _EvaluatedAlarmDelivery(
policy: AlarmDeliveryPolicy.evaluate(
capabilities: capabilities,
nativePermission: nativePermission,
fallbackPermission: fallbackPermission,
),
nativePermission: nativePermission,
fallbackPermission: fallbackPermission,
);
}

Future<AlarmPermissionState> _checkNativePermission(
Expand Down Expand Up @@ -171,7 +173,8 @@ class AlarmGateCubit extends Cubit<AlarmGateState> {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_dismissedKey, true);
final capabilities = await _alarmSchedulerService.getCapabilities();
if (!await _hasGrantedFallbackPermission(capabilities)) {
final delivery = await _checkDeliveryPolicy(capabilities);
if (delivery.policy.shouldDisableAlarms) {
await _disableAlarmsBestEffort();
}
emit(const AlarmGateState.dismissed());
Expand All @@ -183,13 +186,6 @@ class AlarmGateCubit extends Cubit<AlarmGateState> {
capabilities.nativeAlarmProvider == AlarmProvider.iosAlarmKit;
}

Future<bool> _hasGrantedFallbackPermission(
AlarmSchedulerCapabilities capabilities,
) async {
return await _checkFallbackPermission(capabilities) ==
AlarmPermissionState.granted;
}

Future<void> _enableAlarmsBestEffort() async {
try {
await _alarmRepository.updateAlarmSettings(alarmsEnabled: true);
Expand All @@ -212,3 +208,25 @@ class AlarmGateCubit extends Cubit<AlarmGateState> {
}
}
}

class _EvaluatedAlarmDelivery {
const _EvaluatedAlarmDelivery({
required this.policy,
required this.nativePermission,
required this.fallbackPermission,
});

final AlarmDeliveryPolicy policy;
final AlarmPermissionState nativePermission;
final AlarmPermissionState fallbackPermission;

AlarmPermissionState get permissionState {
if (policy.canDeliver) return AlarmPermissionState.granted;
if (policy.isUnsupported) return AlarmPermissionState.unsupported;
if (policy.blockingPermissionIssue ==
AlarmPermissionIssue.notificationPermissionDenied) {
return fallbackPermission;
}
return nativePermission;
}
}
Loading
Loading