feat(alarm): the ringing service, and never degrading to silence

A systemExempted foreground service holds the ring: audio focus, USAGE_ALARM,
the volume ramp, vibration, and the ringtone URI. It goes foreground
synchronously before it reads anything from the database, because the platform
kills a service that reaches startForeground late — including on the path where
there turns out to be no session to ring.

The chain that matters is the one that never ends in silence. A full-screen
intent when canUseFullScreenIntent() allows it; a high-priority heads-up
notification that still rings when it does not; the device's default alarm
ringtone when the configured URI will not open; and forced vibration when no
audio source will play at all.

Preparing a MediaPlayer is done off the main thread — a synchronous prepare at
the moment the alarm fires is an ANR — and a player is released rather than
orphaned on every failure path, so a dismissal cannot be outlived by the ring
it cancelled.

The ring screen here is plumbing and a placeholder: window flags to show over
the lockscreen and turn the screen on. Its UI is M4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:05:25 +02:00
co-authored by Claude Opus 5
parent dd9a920c28
commit bf511b47d7
10 changed files with 887 additions and 11 deletions
+83 -6
View File
@@ -3,14 +3,44 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- <!--
No permissions yet, deliberately. Clockula's permission set is the alarm Clockula's permission set is the alarm engine's, and nothing more. Each
engine's (USE_EXACT_ALARM, POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED, one is here because a specific path would otherwise fail silently — see
USE_FULL_SCREEN_INTENT, FOREGROUND_SERVICE*) and it is declared in M3 docs/ARCHITECTURE.md §9 and docs/PLAN.md §4.
alongside the receivers and the ringing service that need it — so the
manifest never claims a capability the code cannot yet honour. See
docs/PLAN.md §4.
--> -->
<!-- The exact-alarm ladder. USE_EXACT_ALARM is install-granted on 33+ for an
app whose core function is alarms; SCHEDULE_EXACT_ALARM covers 31–32,
where it is pre-granted, and is bounded there so the two never overlap.
Below 31 an exact alarm needs no permission at all. -->
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission
android:name="android.permission.SCHEDULE_EXACT_ALARM"
android:maxSdkVersion="32" />
<!-- Re-register the next alarm after a reboot: AlarmManager keeps nothing. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- The ring screen over the lock screen. Denied, the ring degrades to a
heads-up notification that still sounds — never to silence. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- The ring notification. Denied, the alarm still sounds: the audio belongs
to the foreground service, not to the notification. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- The ringing service. systemExempted is Android's own answer for an app
holding an exact-alarm permission that continues an alarm in the
background, including a haptics-only one. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<!-- Keep the CPU awake for the length of a ring; the screen is the window's
business, not this one's. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- The alarm vibrates, and vibrates on its own when no audio source opens. -->
<uses-permission android:name="android.permission.VIBRATE" />
<application <application
android:name=".ClockulaApp" android:name=".ClockulaApp"
android:allowBackup="true" android:allowBackup="true"
@@ -42,6 +72,53 @@
android:excludeFromRecents="true" android:excludeFromRecents="true"
android:launchMode="singleTask" /> android:launchMode="singleTask" />
<!-- The ring screen. Shown over the lock screen with the display turned
on; excluded from recents so a dismissed alarm leaves nothing
behind. M4 replaces its contents, not its declaration. -->
<activity
android:name=".ui.ring.AlarmRingActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:showOnLockScreen="true"
android:showWhenLocked="true"
android:taskAffinity=""
android:turnScreenOn="true" />
<!-- The ring itself. systemExempted per D17: not mediaPlayback (an alarm
is not the user's media session) and emphatically not shortService,
which caps at ~3 minutes against a 10-minute ring window. -->
<service
android:name=".alarm.ring.AlarmRingService"
android:exported="false"
android:foregroundServiceType="systemExempted" />
<!-- The two AlarmManager slots arriving. Unexported: a PendingIntent the
app created is delivered regardless, and nothing else may fake a fire. -->
<receiver
android:name=".alarm.receiver.AlarmFireReceiver"
android:exported="false" />
<!-- The notification's Snooze and Dismiss buttons. -->
<receiver
android:name=".alarm.receiver.AlarmActionReceiver"
android:exported="false" />
<!-- Protected system broadcasts are delivered to unexported receivers —
this is how androidx.work declares its own RescheduleReceiver.
LOCKED_BOOT_COMPLETED is deliberately absent: the database and the
preferences live in credential-encrypted storage. -->
<receiver
android:name=".alarm.receiver.SystemEventReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- Persists the per-app language on API < 33, where the platform <!-- Persists the per-app language on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. --> per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service <service
@@ -2,21 +2,35 @@ package de.jeanlucmakiola.clockula
import android.app.Application import android.app.Application
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/** /**
* Application entry point. Registered as android:name=".ClockulaApp". * Application entry point. Registered as android:name=".ClockulaApp".
* *
* For now this is Hilt init plus crash capture. From M3 it also owns the launch- * Hilt init, crash capture, and the launch-time alarm re-sync: an alarm's next
* time alarm re-sync: an alarm's next fire time is resolved against the current * fire time is resolved against the current zone, never stored absolutely
* zone, never stored absolutely (docs/PLAN.md §4), so anything that could have * (docs/PLAN.md §4), so anything that could have moved while the app was closed
* moved while the app was closed has to be re-resolved before the user can see * is re-resolved before the user can see a stale one. The same pass closes the
* a stale one. * reboot's blind spot — a `BOOT_COMPLETED` the system never delivered is still
* repaired here, because the boot gate is persisted rather than in memory.
*/ */
@HiltAndroidApp @HiltAndroidApp
class ClockulaApp : Application() { class ClockulaApp : Application() {
@Inject
lateinit var engine: AlarmEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Install first thing so startup crashes are captured too (privacy- // Install first thing so startup crashes are captured too (privacy-
@@ -30,5 +44,13 @@ class ClockulaApp : Application() {
issueTitle = getString(R.string.crash_report_issue_title), issueTitle = getString(R.string.crash_report_issue_title),
), ),
) )
RingNotifications.createChannels(this)
// The same three steps as a BOOT_COMPLETED, and taken through the engine
// rather than one by one so the whole pass runs under the engine's lock:
// a cold start caused by the fire broadcast must not interleave its
// read-modify-write over `alarm_states` with the fire's own.
scope.launch { engine.onBootCompleted() }
} }
} }
@@ -0,0 +1,50 @@
package de.jeanlucmakiola.clockula.alarm.android
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.alarm.AlarmNotifier
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.clockula.domain.Alarm
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* The snoozed notification — the one surface the engine posts directly. A denied
* notification permission costs the user this line and nothing else: the snooze
* itself is a row in `alarm_states` and an AlarmManager registration.
*/
@Singleton
class AndroidAlarmNotifier @Inject constructor(
@param:ApplicationContext private val context: Context,
) : AlarmNotifier {
private val notifications = NotificationManagerCompat.from(context)
override fun showSnoozed(alarm: Alarm, until: Instant) {
// POST_NOTIFICATIONS is a runtime permission from API 33; below it, it is
// not one. Denied, the user loses this line and keeps the alarm.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
runCatching {
notifications.notify(
RingNotifications.snoozedNotificationId(alarm.id),
RingNotifications.snoozed(context, alarm, until),
)
}
}
override fun clearSnoozed(alarmId: Long) {
notifications.cancel(RingNotifications.snoozedNotificationId(alarmId))
}
}
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.clockula.alarm.android
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
import de.jeanlucmakiola.clockula.alarm.ring.AlarmRingService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Starts and stops [AlarmRingService], and holds which alarm is ringing for the
* ring screen to collect. The StateFlow is seeded null in a fresh process: the
* persisted `alarm_states` row is the truth a restarted process reads, not this.
*/
@Singleton
class ServiceRingCoordinator @Inject constructor(
@param:ApplicationContext private val context: Context,
) : RingCoordinator {
private val state = MutableStateFlow<Long?>(null)
override val ringing: StateFlow<Long?> = state.asStateFlow()
override fun startRinging(alarmId: Long) {
state.value = alarmId
AlarmRingService.start(context)
}
override fun stopRinging(alarmId: Long) {
if (state.value == alarmId) state.value = null
AlarmRingService.stop(context)
}
}
@@ -0,0 +1,139 @@
package de.jeanlucmakiola.clockula.alarm.ring
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.net.Uri
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
import de.jeanlucmakiola.clockula.domain.alarm.VolumeRamp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* `MediaPlayer` over `USAGE_ALARM`, plus the ramp loop. The ramp is stepped into
* [MediaPlayer.setVolume] and **never** into `AudioManager.setStreamVolume`,
* which would edit the user's own alarm volume and leave it edited.
*
* [start] returns false when no audio source could be opened at all — which is
* what turns forced vibration on (`RingFallbackPolicy`), so the alarm is never
* silent.
*/
class AlarmAudioPlayer(private val context: Context) {
private val audioManager: AudioManager = context.getSystemService(AudioManager::class.java)
private val attributes: AudioAttributes = AudioAttributes.Builder()
// USAGE_ALARM is what makes Do Not Disturb's alarm exemption apply.
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
private var player: MediaPlayer? = null
private var focusRequest: AudioFocusRequest? = null
private var ramp: Job? = null
/** Set by [stop], so a player that finished preparing after it is released too. */
private var stopped: Boolean = false
/**
* Tries the alarm's ringtone, then the device default, then the built-in
* default. Suspending, because [MediaPlayer.prepare] is blocking I/O and the
* moment the alarm rings is the worst possible moment to block the main
* thread with it.
*/
suspend fun start(scope: CoroutineScope, ringtoneUri: String?, rampSeconds: Int): Boolean {
stopped = false
val prepared = withContext(Dispatchers.IO) {
sources(ringtoneUri).firstNotNullOfOrNull { prepare(it) }
} ?: return false
// Stopped while we were preparing: release rather than start, or the ring
// would outlive the dismissal that asked for it.
if (stopped) {
release(prepared)
return false
}
player = prepared
requestFocus()
val rampFor = rampSeconds.coerceAtLeast(0).seconds
val initial = VolumeRamp.levelAt(Duration.ZERO, rampFor)
// The level is set before the first sample plays, so a ramp from silence
// does not begin with a burst at full volume.
runCatching { prepared.setVolume(initial, initial) }
runCatching { prepared.start() }
ramp = scope.launch {
var elapsed = Duration.ZERO
while (isActive && elapsed < rampFor) {
delay(AlarmRing.RAMP_TICK)
elapsed += AlarmRing.RAMP_TICK
val level = VolumeRamp.levelAt(elapsed, rampFor)
runCatching { player?.setVolume(level, level) }
}
}
return true
}
fun stop() {
stopped = true
ramp?.cancel()
ramp = null
player?.let(::release)
player = null
abandonFocus()
}
private fun sources(ringtoneUri: String?): List<Uri> = listOfNotNull(
ringtoneUri?.takeIf { it.isNotBlank() }?.let(Uri::parse),
RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM),
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM),
)
/**
* A prepared, not-yet-started player, or null. A source that throws has its
* player released here: one leaked [MediaPlayer] per unusable fallback is
* still a leak, and there are three fallbacks.
*/
private fun prepare(uri: Uri): MediaPlayer? {
val candidate = MediaPlayer()
return runCatching {
candidate.setAudioAttributes(attributes)
candidate.setDataSource(context, uri)
candidate.isLooping = true
candidate.prepare()
candidate
}.getOrElse {
release(candidate)
null
}
}
private fun release(player: MediaPlayer) {
runCatching { player.stop() }
runCatching { player.release() }
}
/** GAIN_TRANSIENT: an alarm interrupts what is playing; it does not duck it. */
private fun requestFocus() {
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(attributes)
.build()
focusRequest = request
runCatching { audioManager.requestAudioFocus(request) }
}
private fun abandonFocus() {
focusRequest?.let { runCatching { audioManager.abandonAudioFocusRequest(it) } }
focusRequest = null
}
}
@@ -0,0 +1,172 @@
package de.jeanlucmakiola.clockula.alarm.ring
import android.app.Notification
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.domain.alarm.RingFallbackPolicy
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The ring itself: a foreground service so the alarm keeps sounding whatever the
* app is doing, and so a process kill mid-ring is something the system restores
* rather than something that silences the user's alarm.
*
* The session is read back from storage on every start rather than carried in
* the intent, so a service the system recreated knows exactly as much as one the
* engine started.
*/
@AndroidEntryPoint
class AlarmRingService : Service() {
@Inject
lateinit var engine: AlarmEngine
@Inject
lateinit var capabilities: AlarmCapabilities
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val audio by lazy { AlarmAudioPlayer(this) }
private val vibrator by lazy { AlarmVibrator(this) }
private var wakeLock: PowerManager.WakeLock? = null
private var ringing: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stop()
return START_NOT_STICKY
}
// The whole ring is released, not just the coroutine: cancelling the job
// alone would orphan a MediaPlayer the previous start had already
// prepared, and an orphaned looping alarm cannot be stopped again.
releaseRing()
// Before any suspension point, and on every path: the platform gives a
// foreground service a few seconds to post its notification, and a
// database read is not something to spend them on.
startForegroundPreparing()
ringing = scope.launch {
val session = engine.ringSession()
if (session == null) {
// Dismissed between the start and here: nothing to ring.
stop()
return@launch
}
ring(session)
}
// Re-delivered rather than dropped: a killed ring is resumed with the
// same intent, and the session is re-read from storage anyway.
return START_REDELIVER_INTENT
}
override fun onDestroy() {
releaseRing()
scope.cancel()
super.onDestroy()
}
private suspend fun ring(session: RingSession) {
startForeground(session)
acquireWakeLock()
val audible = audio.start(scope, session.settings.ringtoneUri, session.settings.volumeRampSeconds)
// Silence is not one of the outcomes: with no audio source at all the
// alarm vibrates even when the user switched vibration off.
if (RingFallbackPolicy.vibrationRequired(audible, session.settings.vibrate)) vibrator.start()
}
/**
* The placeholder the service goes foreground with immediately; [ring]
* replaces it with the alarm's own notification as soon as the session is
* read. Same channel and same id, so the user sees one notification.
*/
private fun startForegroundPreparing() {
startForeground(RingNotifications.preparing(this))
}
private fun startForeground(session: RingSession) {
startForeground(
RingNotifications.ringing(
context = this,
alarm = session.alarm,
presentation = RingPresentationPolicy.forCapabilities(capabilities.snapshot()),
),
)
}
private fun startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// systemExempted: Android's own guidance names an app holding an
// exact-alarm permission and continuing an alarm in the background.
startForeground(
RingNotifications.RING_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED,
)
} else {
startForeground(RingNotifications.RING_NOTIFICATION_ID, notification)
}
}
private fun acquireWakeLock() {
if (wakeLock != null) return
val power = getSystemService(PowerManager::class.java)
wakeLock = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
setReferenceCounted(false)
runCatching { acquire(WAKE_LOCK_TIMEOUT_MILLIS) }
}
}
private fun releaseRing() {
ringing?.cancel()
ringing = null
audio.stop()
vibrator.stop()
wakeLock?.let { if (it.isHeld) runCatching { it.release() } }
wakeLock = null
}
private fun stop() {
releaseRing()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
companion object {
private const val ACTION_STOP = AlarmIntents.ACTION_DISMISS + ".SERVICE_STOP"
private const val WAKE_LOCK_TAG = "clockula:alarm-ring"
/** A backstop on the backstop: the ring window is ten minutes. */
private const val WAKE_LOCK_TIMEOUT_MILLIS = 15L * 60L * 1_000L
fun start(context: Context) {
context.startForegroundService(Intent(context, AlarmRingService::class.java))
}
fun stop(context: Context) {
context.startService(
Intent(context, AlarmRingService::class.java).setAction(ACTION_STOP),
)
}
}
}
@@ -0,0 +1,46 @@
package de.jeanlucmakiola.clockula.alarm.ring
import android.content.Context
import android.media.AudioAttributes
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import de.jeanlucmakiola.clockula.domain.alarm.AlarmVibrationPattern
/**
* The waveform, looped, with `USAGE_ALARM` attributes so Do Not Disturb treats
* it the way it treats the sound. `minSdk` is 29, so `VibratorManager` (API 31+)
* is guarded and the older accessor stands in below it.
*/
class AlarmVibrator(private val context: Context) {
private val attributes: AudioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
private val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Vibrator::class.java)
}
fun start() {
val device = vibrator?.takeIf { it.hasVibrator() } ?: return
val effect = VibrationEffect.createWaveform(
AlarmVibrationPattern.TIMINGS,
AlarmVibrationPattern.AMPLITUDES,
AlarmVibrationPattern.REPEAT_INDEX,
)
runCatching {
@Suppress("DEPRECATION")
device.vibrate(effect, attributes)
}
}
fun stop() {
runCatching { vibrator?.cancel() }
}
}
@@ -0,0 +1,188 @@
package de.jeanlucmakiola.clockula.alarm.ring
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.alarm.receiver.AlarmActionReceiver
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentation
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import java.time.Instant as JavaInstant
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.time.Instant
/**
* The two notification surfaces of a ring cycle: the one the foreground service
* posts while the alarm is sounding, and the one that says an alarm is snoozed.
*
* The ringing notification is never how the alarm makes a sound — the audio
* belongs to the service — so a denied notification permission costs the user
* the *screen*, never the alarm.
*/
object RingNotifications {
const val RING_CHANNEL_ID: String = "alarm_ring"
const val SNOOZED_CHANNEL_ID: String = "alarm_snoozed"
/** One ringing alarm at a time (D7), so one id. */
const val RING_NOTIFICATION_ID: Int = 1_001
/** Creates both channels. Idempotent; called from the app class on every start. */
fun createChannels(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java)
// IMPORTANCE_HIGH is what makes a heads-up notification possible when the
// full-screen intent is denied — the degradation must still be loud.
val ring = NotificationChannel(
RING_CHANNEL_ID,
context.getString(R.string.channel_alarm_ring_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = context.getString(R.string.channel_alarm_ring_description)
// The service owns the sound and the vibration, so the channel adds
// neither — two ringtones at once is not a feature.
setSound(null, null)
enableVibration(false)
setBypassDnd(true)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
val snoozed = NotificationChannel(
SNOOZED_CHANNEL_ID,
context.getString(R.string.channel_alarm_snoozed_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = context.getString(R.string.channel_alarm_snoozed_description)
}
manager.createNotificationChannel(ring)
manager.createNotificationChannel(snoozed)
}
/**
* The notification the service goes foreground with the instant it starts,
* before it has read anything: `startForeground` must not wait on a database
* read. [ringing] replaces it under the same id a moment later.
*/
fun preparing(context: Context): Notification =
NotificationCompat.Builder(context, RING_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.alarm_notification_title))
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(showPendingIntent(context))
.build()
/** The ringing notification, presented as [presentation] says the platform allows. */
fun ringing(
context: Context,
alarm: Alarm,
presentation: RingPresentation,
): Notification {
val show = showPendingIntent(context)
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(alarm.label.ifBlank { context.getString(R.string.alarm_notification_title) })
.setContentText(formatTime(context, alarm.time.hour, alarm.time.minute))
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(show)
.addAction(
R.drawable.ic_notification,
context.getString(R.string.alarm_action_snooze),
actionPendingIntent(context, AlarmIntents.ACTION_SNOOZE, AlarmIntents.REQUEST_SNOOZE, alarm.id),
)
.addAction(
R.drawable.ic_notification,
context.getString(R.string.alarm_action_dismiss),
actionPendingIntent(context, AlarmIntents.ACTION_DISMISS, AlarmIntents.REQUEST_DISMISS, alarm.id),
)
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
return builder.build()
}
/** "Snoozed until 07:10", with a Dismiss action so the cycle can end from here. */
fun snoozed(context: Context, alarm: Alarm, until: Instant): Notification =
NotificationCompat.Builder(context, SNOOZED_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(alarm.label.ifBlank { context.getString(R.string.alarm_notification_title) })
.setContentText(context.getString(R.string.alarm_snoozed_until, formatInstant(context, until)))
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setContentIntent(showPendingIntent(context))
.addAction(
R.drawable.ic_notification,
context.getString(R.string.alarm_action_dismiss),
actionPendingIntent(context, AlarmIntents.ACTION_DISMISS, AlarmIntents.REQUEST_DISMISS, alarm.id),
)
.build()
/** Distinct per alarm, so two snoozed alarms are two lines rather than one. */
fun snoozedNotificationId(alarmId: Long): Int = (SNOOZED_ID_BASE + alarmId).toInt()
private fun showPendingIntent(context: Context): 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
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun actionPendingIntent(
context: Context,
action: String,
requestCode: Int,
alarmId: Long,
): PendingIntent = PendingIntent.getBroadcast(
context,
// Per alarm, so two snoozed alarms' Dismiss buttons do not overwrite each
// other's extras the way one shared request code would.
requestCode * REQUEST_CODE_STRIDE + (alarmId % REQUEST_CODE_STRIDE).toInt(),
Intent(context, AlarmActionReceiver::class.java).apply {
this.action = action
putExtra(AlarmIntents.EXTRA_ALARM_ID, alarmId)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun formatTime(context: Context, hour: Int, minute: Int): String =
format(context, LocalTime.of(hour, minute))
private fun formatInstant(context: Context, instant: Instant): String = format(
context,
JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(ZoneId.systemDefault()).toLocalTime(),
)
/** The device's 12/24-hour choice, then the locale's own short form. */
private fun format(context: Context, time: LocalTime): String {
val pattern = if (android.text.format.DateFormat.is24HourFormat(context)) {
DateTimeFormatter.ofPattern("HH:mm")
} else {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
}
return pattern.format(time)
}
private const val SNOOZED_ID_BASE = 2_000L
/** Keeps a derived request code clear of the scheduler's own small ones. */
private const val REQUEST_CODE_STRIDE = 100_000
}
@@ -0,0 +1,136 @@
package de.jeanlucmakiola.clockula.ui.ring
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The ring screen's **plumbing only** — the window flags that cannot wait, and
* finishing when the ring ends. M4 owns what it looks like; what is here is a
* deliberately plain placeholder so the milestone ships a working path from a
* fired alarm to a dismissed one without pre-empting M4's design.
*/
@AndroidEntryPoint
class AlarmRingActivity : ComponentActivity() {
@Inject
lateinit var engine: AlarmEngine
@Inject
lateinit var ring: RingCoordinator
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
showOverLockScreen()
lifecycleScope.launch {
val session = engine.ringSession()
// Nothing ringing: dismissed from the notification while the screen
// was coming up, or a stale showIntent from the status bar.
if (session == null) {
finish()
return@launch
}
setContent {
ClockulaTheme { RingPlaceholder(session, ::snooze, ::dismiss) }
}
// The ring ending is what closes this screen, whoever ended it. The
// coordinator is seeded null in a fresh process, so storage — not
// memory — is what settles whether the ring is really over.
ring.ringing.collectLatest { alarmId ->
if (alarmId == null && engine.ringSession() == null) finish()
}
}
}
private fun snooze() {
lifecycleScope.launch {
engine.snooze(sessionAlarmId())
finish()
}
}
private fun dismiss() {
lifecycleScope.launch {
engine.dismiss(sessionAlarmId())
finish()
}
}
private suspend fun sessionAlarmId(): Long = engine.ringSession()?.alarm?.id ?: 0L
/**
* Shown over the lock screen with the display turned on: an alarm the user
* has to unlock to silence is not an alarm. `minSdk` is 29, so the
* pre-API-27 window flags are not needed.
*/
private fun showOverLockScreen() {
setShowWhenLocked(true)
setTurnScreenOn(true)
}
}
/** Stands in for M4's ring screen. No challenge, no motion, no ViewModel — by design. */
@Composable
private fun RingPlaceholder(
session: RingSession,
onSnooze: () -> Unit,
onDismiss: () -> Unit,
) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "%02d:%02d".format(session.alarm.time.hour, session.alarm.time.minute),
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = session.alarm.label.ifBlank { stringResource(R.string.alarm_notification_title) },
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.padding(top = 32.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
if (session.canSnooze) {
OutlinedButton(onClick = onSnooze) {
Text(stringResource(R.string.alarm_action_snooze))
}
}
Button(onClick = onDismiss) {
Text(stringResource(R.string.alarm_action_dismiss))
}
}
}
}
}
+10
View File
@@ -2,6 +2,16 @@
<string name="app_name">Clockula</string> <string name="app_name">Clockula</string>
<string name="app_tagline">A modern Material 3 Expressive clock app.\nComing into bloom.</string> <string name="app_tagline">A modern Material 3 Expressive clock app.\nComing into bloom.</string>
<!-- Alarms (M3): the notification channels and the ring actions -->
<string name="channel_alarm_ring_name">Ringing alarms</string>
<string name="channel_alarm_ring_description">The full-screen alarm while it is ringing. Turning this off does not silence an alarm.</string>
<string name="channel_alarm_snoozed_name">Snoozed alarms</string>
<string name="channel_alarm_snoozed_description">A quiet reminder that an alarm is snoozed, and when it comes back.</string>
<string name="alarm_notification_title">Alarm</string>
<string name="alarm_action_snooze">Snooze</string>
<string name="alarm_action_dismiss">Dismiss</string>
<string name="alarm_snoozed_until">Snoozed until %1$s</string>
<!-- Crash reporting (floret-kit core-crash) --> <!-- Crash reporting (floret-kit core-crash) -->
<string name="crash_report_issue_title">Crash report</string> <string name="crash_report_issue_title">Crash report</string>
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/clockula/issues/new</string> <string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/clockula/issues/new</string>