feat(alarm): setAlarmClock scheduling with the exact-alarm ladder

Only the single next alarm is ever registered, via setAlarmClock, so the
platform draws the status-bar icon and the alarm is exempt from doze. A second
slot holds the auto-silence backstop, kept deliberately separate so cancelling
one cannot cancel the other.

USE_EXACT_ALARM is declared for the versions that grant it outright, with a
SCHEDULE_EXACT_ALARM fallback path behind canScheduleExactAlarms() above that
boundary. Capabilities are exposed as a snapshot; asking the user for the
permission is M4's job and explaining it is M10's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:05:12 +02:00
co-authored by Claude Opus 5
parent edbbf0070d
commit d256fc115f
2 changed files with 155 additions and 0 deletions
@@ -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)
}
@@ -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
}
}