From badd7aaa549397e2ca7595b17b06de67c8132124 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 14:16:47 +0200 Subject: [PATCH] fix: turn the screen on when an alarm or timer goes off SystemUI launches a full-screen intent only when a notification is first posted, never on an update. The alarm service went foreground with a placeholder that had none and added it in the update, so a locked phone could ring behind a black screen. The placeholder now carries it. Timers had no full-screen screen at all. A finished timer now opens TimerRingActivity over the lock screen with Stop and +1 min, posted under a second notification id so the expiry is a fresh post. It only does so while the timer is sounding, so it never covers a ringing alarm's screen or wakes the display for an already auto-silenced timer. Fixes #15 --- CHANGELOG.md | 6 +- app/src/main/AndroidManifest.xml | 12 ++ .../clockula/alarm/ring/AlarmRingService.kt | 10 +- .../clockula/alarm/ring/RingNotifications.kt | 18 ++- .../clockula/timer/TimerIntents.kt | 2 + .../timer/service/TimerNotifications.kt | 35 ++++- .../clockula/timer/service/TimerService.kt | 40 +++++- .../clockula/ui/ring/TimerRingActivity.kt | 63 ++++++++ .../clockula/ui/ring/TimerRingScreen.kt | 112 +++++++++++++++ .../clockula/ui/ring/TimerRingUiState.kt | 18 +++ .../clockula/ui/ring/TimerRingViewModel.kt | 88 ++++++++++++ .../ui/ring/TimerRingViewModelTest.kt | 134 ++++++++++++++++++ 12 files changed, 519 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingActivity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingUiState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7bc36..fdf438a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,8 +33,10 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. can't silence an alarm. - **Timers.** Run several at once, each with its own label, length and sound, set from a keypad with saved presets. Pause, resume, reset and +1 min work - from the timer, from the live pill and from the notification. Timers keep - counting through a reboot and aren't affected by changes to the clock. + from the timer, from the live pill and from the notification. A finished + timer turns the screen on and opens over the lock screen with Stop and + +1 min. Timers keep counting through a reboot and aren't affected by changes + to the clock. - **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause in the notification. It keeps counting while the app is closed. - **World clock.** An analog face for your own time, plus cities from the diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1160653..139dc39 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -183,6 +183,18 @@ android:taskAffinity="" android:turnScreenOn="true" /> + + + 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 index 1f659ce..f41714a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt @@ -109,10 +109,12 @@ class AlarmRingService : Service() { /** * 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. + * read. Same channel and same id, so the user sees one notification — and + * the placeholder is the first post, so it is the one that opens the ring + * screen. */ private fun startForegroundPreparing() { - startForeground(RingNotifications.preparing(this)) + startForeground(RingNotifications.preparing(this, presentation())) } private fun startForeground(session: RingSession) { @@ -120,11 +122,13 @@ class AlarmRingService : Service() { RingNotifications.ringing( context = this, alarm = session.alarm, - presentation = RingPresentationPolicy.forCapabilities(capabilities.snapshot()), + presentation = presentation(), ), ) } + private fun 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 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 index 7a67b79..980a2b2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt @@ -72,9 +72,17 @@ object RingNotifications { * 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. + * + * It already carries the full-screen intent. SystemUI launches one only + * when a notification is first *posted*, never on an update, so an intent + * that only [ringing] carried would leave a locked phone ringing behind a + * black screen (issue #15). The ring screen reads the session itself and + * closes when there is none, so a dismiss that lands before the service's + * read costs one brief screen wake, never a ring screen left up. */ - fun preparing(context: Context): Notification = - NotificationCompat.Builder(context, RING_CHANNEL_ID) + fun preparing(context: Context, presentation: RingPresentation): Notification { + val show = showPendingIntent(context) + val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(context.getString(R.string.alarm_notification_title)) .setCategory(NotificationCompat.CATEGORY_ALARM) @@ -82,8 +90,10 @@ object RingNotifications { .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setOngoing(true) .setAutoCancel(false) - .setContentIntent(showPendingIntent(context)) - .build() + .setContentIntent(show) + if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true) + return builder.build() + } /** The ringing notification, presented as [presentation] says the platform allows. */ fun ringing( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerIntents.kt index 723155a..04e3a23 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerIntents.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerIntents.kt @@ -24,6 +24,7 @@ object TimerIntents { const val REQUEST_RESUME: Int = 14 const val REQUEST_RESET: Int = 15 const val REQUEST_ADD_TIME: Int = 16 + const val REQUEST_RING: Int = 17 val ALL_REQUEST_CODES: List = listOf( REQUEST_EXPIRY, @@ -32,6 +33,7 @@ object TimerIntents { REQUEST_RESUME, REQUEST_RESET, REQUEST_ADD_TIME, + REQUEST_RING, ) /** base * 100_000 + (timerId % 100_000) — the stride `RingNotifications` already uses. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt index af07e3d..0c74381 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt @@ -16,6 +16,7 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState import de.jeanlucmakiola.clockula.timer.TimerIntents import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver +import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity /** * One channel and one id for however many timers: the foreground service's @@ -30,9 +31,20 @@ object TimerNotifications { const val TIMER_CHANNEL_ID: String = "timers" - /** One notification for every timer (D17), so one id. */ + /** One notification for every timer (D17), so one id at a time — of these two. */ const val TIMER_NOTIFICATION_ID: Int = 1_002 + /** + * The id an expiry's alerting post swaps to, and back from on the next one. + * SystemUI launches a full-screen intent only for a newly *posted* + * notification, and an expiry is otherwise an update of the countdown's. + */ + const val TIMER_ALERT_NOTIFICATION_ID: Int = 1_004 + + /** The id an alerting post goes up under when [current] is on screen. */ + fun alertingIdAfter(current: Int): Int = + if (current == TIMER_NOTIFICATION_ID) TIMER_ALERT_NOTIFICATION_ID else TIMER_NOTIFICATION_ID + /** Idempotent; called from the app class on every start. */ fun createChannel(context: Context) { val channel = NotificationChannel( @@ -75,8 +87,12 @@ object TimerNotifications { .setContentIntent(showPendingIntent(context)) .build() - /** The subject, and a count. Two quick actions; the app row also offers subtraction. */ - fun timer(context: Context, state: TimerNotificationState): Notification { + /** + * The subject, and a count. Two quick actions; the app row also offers + * subtraction. An expiry's one alerting post also opens [TimerRingActivity] + * over the lock screen when [fullScreen] allows it. + */ + fun timer(context: Context, state: TimerNotificationState, fullScreen: Boolean): Notification { val builder = NotificationCompat.Builder(context, TIMER_CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle( @@ -130,6 +146,11 @@ object TimerNotifications { builder.setShowWhen(false).setContentText(bodyFor(context, state)) } + if (fullScreen && state.mode == TimerMode.EXPIRED && !state.alertOnce) { + builder.setPriority(NotificationCompat.PRIORITY_MAX) + .setFullScreenIntent(ringPendingIntent(context), true) + } + state.actions.forEach { action -> builder.addAction( R.drawable.ic_notification, @@ -179,6 +200,14 @@ object TimerNotifications { PENDING_INTENT_FLAGS, ) + private fun ringPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity( + context, + TimerIntents.REQUEST_RING, + Intent(context, TimerRingActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK), + PENDING_INTENT_FLAGS, + ) + private fun actionPendingIntent( context: Context, action: TimerNotificationAction, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt index 8c215a9..f4ec018 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt @@ -10,6 +10,7 @@ import android.os.IBinder import android.os.PowerManager import androidx.core.app.NotificationManagerCompat import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.data.timers.TimerRepository import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy @@ -71,6 +72,9 @@ class TimerService : Service() { @Inject lateinit var settings: SettingsPrefs + @Inject + lateinit var capabilities: AlarmCapabilities + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val audio by lazy { RingAudioPlayer(this) } @@ -105,6 +109,13 @@ class TimerService : Service() { */ private var wentForeground: Boolean = false + /** + * The id the notification is up under. An alerting post moves it to the + * other of the two, so an expiry is a *new* notification and its + * full-screen intent fires — every other post keeps it where it is. + */ + private var notificationId: Int = TimerNotifications.TIMER_NOTIFICATION_ID + /** The last start command's id, so [stop] can decline to die on a newer one. */ private var lastStartId: Int = 0 @@ -170,7 +181,7 @@ class TimerService : Service() { } applyRing(state.ring) - post(notification) + post(notification, sounding = state.ring is TimerRingDecision.Sound) scheduleDelays(state) progressRefresh?.cancel() progressRefresh = if (notification.mode == TimerMode.RUNNING) { @@ -239,9 +250,20 @@ class TimerService : Service() { } } - private fun post(state: TimerNotificationState) { - if (!state.alertOnce) alreadyAlerted = true - startForeground(TimerNotifications.timer(this, state)) + private fun post(state: TimerNotificationState, sounding: Boolean) { + var fullScreen = false + if (!state.alertOnce) { + alreadyAlerted = true + // Moving the foreground notification to a new id removes the old one, + // so the user still sees one notification. + notificationId = TimerNotifications.alertingIdAfter(notificationId) + // Only a sounding timer opens its ring screen: one suppressed by a + // ringing alarm would cover the alarm's screen (D9), and one that + // auto-silenced before a process restart would wake the display for + // nothing (D11). + fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent + } + startForeground(TimerNotifications.timer(this, state, fullScreen)) } /** @@ -279,12 +301,12 @@ class TimerService : Service() { // ringing service: an app holding an exact-alarm permission, // continuing an alarm-shaped event in the background (D6). startForeground( - TimerNotifications.TIMER_NOTIFICATION_ID, + notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED, ) } else { - startForeground(TimerNotifications.TIMER_NOTIFICATION_ID, notification) + startForeground(notificationId, notification) } } @@ -314,7 +336,11 @@ class TimerService : Service() { stopForeground(STOP_FOREGROUND_REMOVE) // The placeholder is posted before anything is read, so a service that // starts with nothing active must take its own notification with it. - NotificationManagerCompat.from(this).cancel(TimerNotifications.TIMER_NOTIFICATION_ID) + NotificationManagerCompat.from(this).apply { + cancel(TimerNotifications.TIMER_NOTIFICATION_ID) + cancel(TimerNotifications.TIMER_ALERT_NOTIFICATION_ID) + } + notificationId = TimerNotifications.TIMER_NOTIFICATION_ID wentForeground = false // Not `stopSelf()`: resetting the last timer and immediately starting a // new one delivers a start command to this still-live instance, and diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingActivity.kt new file mode 100644 index 0000000..2369bae --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingActivity.kt @@ -0,0 +1,63 @@ +package de.jeanlucmakiola.clockula.ui.ring + +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme + +/** + * The window an expired timer gets, opened by the timer notification's + * full-screen intent: over the lock screen with the display turned on, for as + * long as any timer has expired. Without it, a timer finishing on a locked + * phone rang behind a black screen (issue #15). + * + * Deliberately not [de.jeanlucmakiola.clockula.MainActivity]: that would put + * the whole app over the lock screen. + */ +@AndroidEntryPoint +class TimerRingActivity : ComponentActivity() { + + private val viewModel: TimerRingViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // As AlarmRingActivity: shown over the lock screen, display on, and + // kept on for this window's life only. + setShowWhenLocked(true) + setTurnScreenOn(true) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + setContent { + ClockulaTheme { + BackHandler(enabled = true) { /* A timer is not stopped by leaving. */ } + + val state by viewModel.state.collectAsStateWithLifecycle() + when (val current = state) { + TimerRingUiState.Loading -> + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} + + is TimerRingUiState.Ringing -> TimerRingScreen( + state = current, + onStop = viewModel::onStop, + onAddMinute = viewModel::onAddMinute, + ) + + // Stopped here, from the shade or from the Timers tab, or + // auto-silenced: whoever ended the ring closes this window. + TimerRingUiState.Finished -> LaunchedEffect(Unit) { finish() } + } + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingScreen.kt new file mode 100644 index 0000000..0beb8e3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingScreen.kt @@ -0,0 +1,112 @@ +package de.jeanlucmakiola.clockula.ui.ring + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Timer +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +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.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.floret.identity.rememberReduceMotion + +/** + * The timer ring screen: the label, "Finished", and the notification's two + * buttons. Back is swallowed at [TimerRingActivity]'s composition root, for the + * same reason the alarm's is. + */ +@Composable +internal fun TimerRingScreen( + state: TimerRingUiState.Ringing, + onStop: () -> Unit, + onAddMinute: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Column( + modifier = Modifier.fillMaxSize().safeDrawingPadding().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TimerBadge() + Text( + text = stringResource(R.string.timer_finished), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = state.label.ifBlank { stringResource(R.string.timer_notification_title) }, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (state.otherExpired > 0) { + Text( + text = pluralStringResource(R.plurals.timer_more_active, state.otherExpired, state.otherExpired), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedButton(onClick = onAddMinute) { + Text(stringResource(R.string.timer_add_minute)) + } + Button(onClick = onStop) { + Text(stringResource(R.string.timer_stop)) + } + } + } + } +} + +/** The alarm screen's pulsing badge, with the timer's icon. */ +@Composable +private fun TimerBadge() { + val reduceMotion = rememberReduceMotion() + val pulse = rememberInfiniteTransition(label = "timer-ring-pulse") + val scale by pulse.animateFloat( + initialValue = 1f, + targetValue = if (reduceMotion) 1f else 1.08f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "timer-ring-pulse-scale", + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(96.dp) + .scale(scale) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), + ) { + Icon( + imageVector = Icons.Rounded.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(48.dp), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingUiState.kt new file mode 100644 index 0000000..91bbf70 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingUiState.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.clockula.ui.ring + +sealed interface TimerRingUiState { + /** The timers have not been read yet. Every action is ignored. */ + data object Loading : TimerRingUiState + + data class Ringing( + /** The timer the screen speaks for: the first expired one, in subject order. */ + val timerId: Long, + /** Verbatim; blank stays blank — the screen supplies the fallback string. */ + val label: String, + /** Expired timers besides [timerId]; each gets its own Stop once this one stops. */ + val otherExpired: Int, + ) : TimerRingUiState + + /** Nothing has expired any more. The activity finishes. */ + data object Finished : TimerRingUiState +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt new file mode 100644 index 0000000..a5fd38d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt @@ -0,0 +1,88 @@ +package de.jeanlucmakiola.clockula.ui.ring + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.clockula.data.timers.TimerRepository +import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore +import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock +import de.jeanlucmakiola.clockula.domain.time.Ticker +import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.domain.timer.TimerReadings +import de.jeanlucmakiola.clockula.domain.timer.TimerRing +import de.jeanlucmakiola.clockula.timer.TimerEngine +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds +import javax.inject.Inject + +/** + * The timer ring screen's behaviour: which expired timer it speaks for, and the + * same two verbs as the notification's buttons, through the engine — so the + * screen, the shade and the Timers tab can never disagree about what Stop does. + */ +@HiltViewModel +class TimerRingViewModel @Inject constructor( + timers: TimerRepository, + ringState: TimerRingStateStore, + private val engine: TimerEngine, + elapsed: ElapsedRealtimeClock, + wall: WallClock, + ticker: Ticker, +) : ViewModel() { + + /** + * Re-read on a tick as well as on every write: a row stored RUNNING *reads* + * EXPIRED once its time is up, before the engine's sweep has written it, and + * the auto-silence window ends without anything being written at all. + */ + val state: StateFlow = + combine(timers.timers(), ringState.soundingSince, ticker.ticks(TICK)) { rows, soundingSince, _ -> + val now = wall.now() + val expired = TimerReadings.expired( + TimerReadings.active(rows, elapsed.elapsedRealtime(), now), + ) + val subject = expired.firstOrNull() + // Auto-silence leaves the rows EXPIRED (D11) but ends the ring, and a + // screen held on over a silent timer is only a battery drain. + val silenced = soundingSince != null && now - soundingSince >= TimerRing.AUTO_SILENCE_AFTER + if (subject == null || silenced) { + TimerRingUiState.Finished + } else { + TimerRingUiState.Ringing( + timerId = subject.timer.id, + label = subject.timer.label, + otherExpired = expired.size - 1, + ) + } + } + .distinctUntilChanged() + // Stops a while after the screen goes to the background, so a ring + // screen left behind Home does not tick forever; the grace period + // covers a rotation without flashing back to Loading. + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), TimerRingUiState.Loading) + + /** + * "Stop": the notification's RESET on an expired timer. The guarded verb, + * because the id may be one emission old: a "+1 min" from here or the shade + * can have set the timer running again, and that countdown must survive. + */ + fun onStop() { + val ringing = state.value as? TimerRingUiState.Ringing ?: return + viewModelScope.launch { engine.dismissExpired(ringing.timerId) } + } + + fun onAddMinute() { + val ringing = state.value as? TimerRingUiState.Ringing ?: return + viewModelScope.launch { engine.addTime(ringing.timerId) } + } + + private companion object { + val TICK = 1.seconds + const val STOP_TIMEOUT_MS = 5_000L + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt new file mode 100644 index 0000000..93a42ed --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt @@ -0,0 +1,134 @@ +package de.jeanlucmakiola.clockula.ui.ring + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.TimerState +import de.jeanlucmakiola.clockula.domain.timer.TimerRing +import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension +import de.jeanlucmakiola.clockula.testing.T0 +import de.jeanlucmakiola.clockula.testing.TimerEngineHarness +import de.jeanlucmakiola.clockula.testing.expiredTimer +import de.jeanlucmakiola.clockula.testing.runningTimerWith +import de.jeanlucmakiola.clockula.testing.timerEngineHarness +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * The timer ring screen over the **real** timer engine: which timer it speaks + * for, that its buttons are the notification's verbs, and that it closes + * whenever the ring ends — including the auto-silence that leaves rows EXPIRED. + */ +class TimerRingViewModelTest { + + @JvmField + @RegisterExtension + val main = MainDispatcherExtension() + + /** Subscribed for the whole test, as the visible screen is: the state is shared while observed. */ + private fun TestScope.viewModel(harness: TimerEngineHarness) = TimerRingViewModel( + timers = harness.timers, + ringState = harness.ringState, + engine = harness.engine, + elapsed = harness.elapsed, + wall = harness.wallClock, + ticker = harness.ticker, + ).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } } + + @Test + fun `an expired timer rings, with the other expired ones counted`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = timerEngineHarness(tempDir) + harness.given(expiredTimer(id = 1L, label = "Pasta", sortOrder = 0)) + harness.given(expiredTimer(id = 2L, label = "Eggs", sortOrder = 1)) + + val viewModel = viewModel(harness) + advanceUntilIdle() + + assertThat(viewModel.state.value) + .isEqualTo(TimerRingUiState.Ringing(timerId = 1L, label = "Pasta", otherExpired = 1)) + } + + @Test + fun `nothing expired finishes the screen`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val harness = timerEngineHarness(tempDir) + harness.given(runningTimerWith(id = 1L, left = 3.minutes, elapsedNow = harness.elapsed.value)) + + val viewModel = viewModel(harness) + advanceUntilIdle() + + assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished) + } + + @Test + fun `stop resets the timer and the screen moves on to the next expired one`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = timerEngineHarness(tempDir) + harness.given(expiredTimer(id = 1L, label = "Pasta", sortOrder = 0)) + harness.given(expiredTimer(id = 2L, label = "Eggs", sortOrder = 1)) + val viewModel = viewModel(harness) + advanceUntilIdle() + + viewModel.onStop() + advanceUntilIdle() + + assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.IDLE) + assertThat(viewModel.state.value) + .isEqualTo(TimerRingUiState.Ringing(timerId = 2L, label = "Eggs", otherExpired = 0)) + } + + @Test + fun `stop leaves a timer that is running again alone`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = timerEngineHarness(tempDir) + harness.given(expiredTimer(id = 1L)) + val viewModel = viewModel(harness) + advanceUntilIdle() + + // "+1 min" from the shade lands before the screen has re-read the rows. + harness.engine.addTime(1L) + viewModel.onStop() + advanceUntilIdle() + + assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.RUNNING) + } + + @Test + fun `plus one minute runs the timer again and finishes the screen`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = timerEngineHarness(tempDir) + harness.given(expiredTimer(id = 1L)) + val viewModel = viewModel(harness) + advanceUntilIdle() + + viewModel.onAddMinute() + advanceUntilIdle() + + assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.RUNNING) + assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished) + } + + @Test + fun `an auto-silenced ring finishes the screen though the row stays expired`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = timerEngineHarness(tempDir) + harness.given(expiredTimer(id = 1L)) + harness.ringState.set(T0) + val viewModel = viewModel(harness) + advanceUntilIdle() + assertThat(viewModel.state.value).isInstanceOf(TimerRingUiState.Ringing::class.java) + + harness.wallClock.advance(TimerRing.AUTO_SILENCE_AFTER + 1.seconds) + harness.ticker.tick() + advanceUntilIdle() + + assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED) + assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished) + } +}