diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmCapabilities.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmCapabilities.kt new file mode 100644 index 0000000..bf4b1a0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmCapabilities.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.clockula.alarm.android + +import android.app.AlarmManager +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities +import de.jeanlucmakiola.clockula.domain.alarm.CapabilitySnapshot +import javax.inject.Inject +import javax.inject.Singleton + +/** + * What the platform will currently let the app do, read afresh every time: all + * three of these can be revoked while the app is not looking. `minSdk` is 29, so + * each newer API is guarded and degrades to the answer that was true before it + * existed — on those releases the capability was unconditional. + */ +@Singleton +class AndroidAlarmCapabilities @Inject constructor( + @param:ApplicationContext private val context: Context, +) : AlarmCapabilities { + + override fun snapshot(): CapabilitySnapshot = CapabilitySnapshot( + canScheduleExactAlarms = canScheduleExactAlarms(), + canUseFullScreenIntent = canUseFullScreenIntent(), + notificationsEnabled = notifications().areNotificationsEnabled(), + ) + + private fun canScheduleExactAlarms(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() + } else { + true + } + + private fun canUseFullScreenIntent(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + notifications().canUseFullScreenIntent() + } else { + true + } + + private fun notifications(): NotificationManager = + context.getSystemService(NotificationManager::class.java) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt new file mode 100644 index 0000000..6cb3d11 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt @@ -0,0 +1,109 @@ +package de.jeanlucmakiola.clockula.alarm.android + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities +import de.jeanlucmakiola.clockula.alarm.AlarmIntents +import de.jeanlucmakiola.clockula.alarm.AlarmScheduler +import de.jeanlucmakiola.clockula.alarm.receiver.AlarmFireReceiver +import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode +import de.jeanlucmakiola.clockula.domain.alarm.SchedulingPolicy +import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity +import kotlin.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Two AlarmManager slots, deliberately separate. The user's next alarm goes + * through `setAlarmClock`, which is the only variant that populates + * `getNextAlarmClock()` — the status-bar icon and the lockscreen line. The + * auto-silence backstop goes through its own PendingIntent, so an internal + * housekeeping time can never overwrite what the system shows the user. + * + * Translation only: which mode to use is [SchedulingPolicy]'s decision. + */ +@Singleton +class AndroidAlarmScheduler @Inject constructor( + @param:ApplicationContext private val context: Context, + private val capabilities: AlarmCapabilities, +) : AlarmScheduler { + + private val manager: AlarmManager = context.getSystemService(AlarmManager::class.java) + + override fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode { + val at = fireAt.toEpochMilliseconds() + val fire = firePendingIntent(alarmId, fireAt) + val mode = SchedulingPolicy.modeFor(capabilities.snapshot().canScheduleExactAlarms) + + if (mode == ScheduleMode.EXACT_ALARM_CLOCK) { + val scheduled = runCatching { + manager.setAlarmClock(AlarmManager.AlarmClockInfo(at, showPendingIntent()), fire) + }.isSuccess + // The permission can go away between the snapshot and the call. Late + // is survivable; silent is not. + if (scheduled) return ScheduleMode.EXACT_ALARM_CLOCK + } + + manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, fire) + return ScheduleMode.INEXACT_FALLBACK + } + + override fun cancelNext() = manager.cancel(firePendingIntent(alarmId = 0L, fireAt = null)) + + override fun scheduleAutoSilence(at: Instant, alarmId: Long) { + val pendingIntent = autoSilencePendingIntent(alarmId) + val millis = at.toEpochMilliseconds() + val exact = runCatching { + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) + }.isSuccess + if (!exact) manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent) + } + + override fun cancelAutoSilence() = manager.cancel(autoSilencePendingIntent(alarmId = 0L)) + + override fun systemNextAlarm(): Instant? = + manager.nextAlarmClock?.let { Instant.fromEpochMilliseconds(it.triggerTime) } + + /** + * One request code per slot, so the cancel below finds the same PendingIntent + * the schedule above created — the extras are updated, never matched on. + */ + private fun firePendingIntent(alarmId: Long, fireAt: Instant?): PendingIntent = PendingIntent.getBroadcast( + context, + AlarmIntents.REQUEST_FIRE, + Intent(context, AlarmFireReceiver::class.java).apply { + action = AlarmIntents.ACTION_FIRE + putExtra(AlarmIntents.EXTRA_ALARM_ID, alarmId) + putExtra(AlarmIntents.EXTRA_FIRE_AT, fireAt?.toEpochMilliseconds() ?: 0L) + }, + PENDING_INTENT_FLAGS, + ) + + private fun autoSilencePendingIntent(alarmId: Long): PendingIntent = PendingIntent.getBroadcast( + context, + AlarmIntents.REQUEST_AUTO_SILENCE, + Intent(context, AlarmFireReceiver::class.java).apply { + action = AlarmIntents.ACTION_AUTO_SILENCE + putExtra(AlarmIntents.EXTRA_ALARM_ID, alarmId) + }, + PENDING_INTENT_FLAGS, + ) + + /** What the system opens when the user taps the status-bar alarm icon. */ + private fun showPendingIntent(): PendingIntent = PendingIntent.getActivity( + context, + AlarmIntents.REQUEST_SHOW, + Intent(context, AlarmRingActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + }, + PENDING_INTENT_FLAGS, + ) + + private companion object { + const val PENDING_INTENT_FLAGS: Int = + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } +}