feat(alarm): the receivers — fire, boot, time set, zone and replace

All five are thin: inject, goAsync, make one call into the engine, finish in a
finally. Anything resembling a decision belongs in the engine where it can be
tested without a device.

TIME_SET and TIMEZONE_CHANGED both mean the same thing to the engine — every
resolved instant is now suspect — so both re-resolve from scratch rather than
trying to patch the registrations they already made.

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 d256fc115f
commit dd9a920c28
4 changed files with 163 additions and 0 deletions
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.clockula.alarm.receiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/** The notification's Snooze and Dismiss buttons. */
@AndroidEntryPoint
class AlarmActionReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var engine: AlarmEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
val action = intent.action ?: return
val pendingResult = goAsync()
scope.launch {
try {
when (action) {
AlarmIntents.ACTION_SNOOZE -> engine.snooze(alarmId)
AlarmIntents.ACTION_DISMISS -> engine.dismiss(alarmId)
}
} finally {
pendingResult.finish()
}
}
}
}
@@ -0,0 +1,55 @@
package de.jeanlucmakiola.clockula.alarm.receiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.time.Instant
import javax.inject.Inject
/**
* The two AlarmManager slots arriving. Thin by design: every decision the engine
* could make is the engine's, so the receiver only unpacks and dispatches.
*/
@AndroidEntryPoint
class AlarmFireReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var engine: AlarmEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
// Hilt's injection happens in super.onReceive(); nothing above may touch
// an injected field.
super.onReceive(context, intent)
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
val action = intent.action ?: return
val pendingResult = goAsync()
scope.launch {
try {
when (action) {
AlarmIntents.ACTION_FIRE -> engine.onFire(
alarmId = alarmId,
// The instant the PendingIntent carried, not `now`: a fire
// delivered late must still name the occurrence it is for.
scheduledFor = Instant.fromEpochMilliseconds(
intent.getLongExtra(AlarmIntents.EXTRA_FIRE_AT, 0L),
),
)
AlarmIntents.ACTION_AUTO_SILENCE -> engine.onAutoSilence(alarmId)
}
} finally {
pendingResult.finish()
}
}
}
}
@@ -0,0 +1,16 @@
package de.jeanlucmakiola.clockula.alarm.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* `BroadcastReceiver.onReceive` is abstract, so a Kotlin subclass cannot call
* `super.onReceive(...)` — and that call is exactly where Hilt injects. One
* concrete no-op base fixes it: the Hilt plugin rewrites each receiver's
* superclass to its generated `Hilt_…` class, whose `onReceive` does the
* injection, and the `super` call above resolves there at runtime.
*/
abstract class HiltBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) = Unit
}
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.clockula.alarm.receiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Everything that invalidates a registration: a reboot clears AlarmManager, an
* app update clears it too, and a clock or zone change moves what "07:00" means.
* All four are answered the same way — re-resolve from now.
*
* `LOCKED_BOOT_COMPLETED` is deliberately not handled: the database and
* DataStore live in credential-encrypted storage, unreadable before first unlock.
*/
@AndroidEntryPoint
class SystemEventReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var engine: AlarmEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
val action = intent.action ?: return
val pendingResult = goAsync()
scope.launch {
try {
when (action) {
Intent.ACTION_BOOT_COMPLETED -> engine.onBootCompleted()
Intent.ACTION_MY_PACKAGE_REPLACED -> engine.onPackageReplaced()
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
-> engine.onSystemTimeOrZoneChanged()
}
} finally {
pendingResult.finish()
}
}
}
}