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: 0 additions & 2 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 19 additions & 2 deletions android/app/src/main/kotlin/club/devkor/ontime/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions lib/core/services/notification_routing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ bool isScheduleAlarmPayload(Map<dynamic, dynamic>? payload) {
final type = payload['type']?.toString();
final promptVariant = payload['promptVariant']?.toString();
return type == 'schedule_alarm' ||
payload['alarmLaunchPayloadVersion'] != null ||
(promptVariant == 'alarm' && payload['scheduleId'] != null);
}

Expand Down Expand Up @@ -56,7 +55,8 @@ NotificationRouteTarget? notificationRouteForData(Map<dynamic, dynamic> 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<String, dynamic>.from(data),
Expand Down
15 changes: 7 additions & 8 deletions lib/core/services/notification_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand Down Expand Up @@ -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,
Expand Down
144 changes: 72 additions & 72 deletions lib/domain/entities/alarm_entities.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -222,10 +226,10 @@ class AlarmSettings extends Equatable {

@override
List<Object?> get props => [
alarmsEnabled,
defaultAlarmOffsetMinutes,
updatedAt,
];
alarmsEnabled,
defaultAlarmOffsetMinutes,
updatedAt,
];
}

class AlarmDeviceInfo extends Equatable {
Expand All @@ -249,14 +253,14 @@ class AlarmDeviceInfo extends Equatable {

@override
List<Object?> get props => [
deviceId,
platform,
appVersion,
osVersion,
supportsNativeAlarm,
nativeAlarmProvider,
fallbackProvider,
];
deviceId,
platform,
appVersion,
osVersion,
supportsNativeAlarm,
nativeAlarmProvider,
fallbackProvider,
];
}

class AlarmSchedulerCapabilities extends Equatable {
Expand All @@ -278,10 +282,10 @@ class AlarmSchedulerCapabilities extends Equatable {

@override
List<Object?> get props => [
supportsNativeAlarm,
nativeAlarmProvider,
fallbackProvider,
];
supportsNativeAlarm,
nativeAlarmProvider,
fallbackProvider,
];
}

class ScheduledAlarmRecord extends Equatable {
Expand Down Expand Up @@ -333,28 +337,24 @@ class ScheduledAlarmRecord extends Equatable {

@override
List<Object?> get props => [
scheduleId,
alarmTime,
preparationStartTime,
scheduleFingerprint,
nativeAlarmId,
fallbackNotificationId,
provider,
scheduleTitle,
payload,
];
scheduleId,
alarmTime,
preparationStartTime,
scheduleFingerprint,
nativeAlarmId,
fallbackNotificationId,
provider,
scheduleTitle,
payload,
];
}

class AlarmFailure extends Equatable {
final String? scheduleId;
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<Object?> get props => [scheduleId, reason, message];
Expand Down Expand Up @@ -391,18 +391,18 @@ class AlarmReconciliationResult extends Equatable {

@override
List<Object?> 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 {
Expand Down Expand Up @@ -440,21 +440,21 @@ class AlarmStatusReport extends Equatable {

@override
List<Object?> 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) {
Expand Down Expand Up @@ -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',
},
);
}
13 changes: 10 additions & 3 deletions lib/domain/use-cases/reconcile_alarms_use_case.dart
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,7 @@ class ReconcileAlarmsUseCase {
Future<AlarmPermissionState> _checkNativePermission(
AlarmSchedulerCapabilities capabilities,
) async {
if (!capabilities.supportsNativeAlarm ||
capabilities.nativeAlarmProvider == AlarmProvider.none) {
if (!_canUseNativeProvider(capabilities)) {
return AlarmPermissionState.unsupported;
}
return _schedulerService.checkPermission().catchError(
Expand Down Expand Up @@ -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<void> _cancelRecords(List<ScheduledAlarmRecord> records) async {
for (final record in records) {
try {
Expand Down
Loading
Loading