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
This commit is contained in:
1 parent
2f39b00398
commit
badd7aaa54
12 files changed
+519
-19
No files matched your search
+4
-2
@@ -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
|
||||
|
||||
@@ -183,6 +183,18 @@
|
||||
android:taskAffinity=""
|
||||
android:turnScreenOn="true" />
|
||||
|
||||
<!-- The timer's ring screen: the alarm's window settings, opened by the
|
||||
expired-timer notification's full-screen intent (issue #15). -->
|
||||
<activity
|
||||
android:name=".ui.ring.TimerRingActivity"
|
||||
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. -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Int> = 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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<TimerRingUiState> =
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user