From bf511b47d7eacf4b4dc96d95a26b1244cfabdb1f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 11 Sep 2026 16:05:25 +0200 Subject: [PATCH] feat(alarm): the ringing service, and never degrading to silence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/src/main/AndroidManifest.xml | 89 ++++++++- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 32 ++- .../alarm/android/AndroidAlarmNotifier.kt | 50 +++++ .../alarm/android/ServiceRingCoordinator.kt | 36 ++++ .../clockula/alarm/ring/AlarmAudioPlayer.kt | 139 +++++++++++++ .../clockula/alarm/ring/AlarmRingService.kt | 172 ++++++++++++++++ .../clockula/alarm/ring/AlarmVibrator.kt | 46 +++++ .../clockula/alarm/ring/RingNotifications.kt | 188 ++++++++++++++++++ .../clockula/ui/ring/AlarmRingActivity.kt | 136 +++++++++++++ app/src/main/res/values/strings.xml | 10 + 10 files changed, 887 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmNotifier.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/ServiceRingCoordinator.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmAudioPlayer.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmVibrator.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt 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