diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2149134..823a9e1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,14 +3,44 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= 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))
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/ServiceRingCoordinator.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/ServiceRingCoordinator.kt
new file mode 100644
index 0000000..0b83ada
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/ServiceRingCoordinator.kt
@@ -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(null)
+
+ override val ringing: StateFlow = 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)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt
new file mode 100644
index 0000000..f64e014
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt
@@ -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 = 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
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt
new file mode 100644
index 0000000..3e311ca
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt
@@ -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),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmVibrator.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmVibrator.kt
new file mode 100644
index 0000000..7554f15
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmVibrator.kt
@@ -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() }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt
new file mode 100644
index 0000000..7a67b79
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt
@@ -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
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt
new file mode 100644
index 0000000..030532f
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt
@@ -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))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index bbab5db..5281849 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2,6 +2,16 @@
Clockula
A modern Material 3 Expressive clock app.\nComing into bloom.
+
+ Ringing alarms
+ The full-screen alarm while it is ringing. Turning this off does not silence an alarm.
+ Snoozed alarms
+ A quiet reminder that an alarm is snoozed, and when it comes back.
+ Alarm
+ Snooze
+ Dismiss
+ Snoozed until %1$s
+
Crash report
https://codeberg.org/jlmakiola/clockula/issues/new