feat(stopwatch): the foreground service, its silent notification and the receiver

The service is alive exactly while the stopwatch is not idle, and it is
`specialUse` with the subtype spelled out — reusing `systemExempted` would
have the app claim it is continuing alarm functionality, which it is not.
No wake lock, ever: the notification carries the platform chronometer
counting up from a base derived when it is posted and never stored, so the
system draws the ticking and the process can sleep through it.

Its channel is `IMPORTANCE_LOW` because nothing here ever alerts. The Lap,
Pause, Resume and Reset buttons are broadcasts carrying no extras — the
receiver reads the stored run rather than trusting an intent — and the body
opens the Stopwatch tab. Boot, time change and package replacement all reach
the third engine now, alongside the other two.
This commit is contained in:
2026-09-22 10:23:44 +02:00
parent 7f8b2e79fc
commit b667e46877
6 changed files with 435 additions and 0 deletions
+28
View File
@@ -34,6 +34,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<!-- The stopwatch's foreground service. specialUse, not systemExempted:
that type's justification is an app with an exact-alarm permission
continuing alarm functionality in the background, and a stopwatch
schedules nothing, wakes nothing and rings nothing. Claiming it would
have the app tell the platform something untrue (M7 D3). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- Keep the CPU awake for the length of a ring; the screen is the window's
business, not this one's. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -126,6 +133,27 @@
android:name=".timer.receiver.TimerActionReceiver"
android:exported="false" />
<!-- The stopwatch: one foreground service, up while the run is not
idle. specialUse rather than systemExempted (M7 D3), and not
shortService, which caps at ~3 minutes against a stopwatch that may
run for hours. It holds no wake lock: there is no deadline to wake
for, and the elapsed value is derived from a persisted monotonic
anchor at read time. The <property> is API 31+ and ignored below. -->
<service
android:name=".stopwatch.service.StopwatchService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="stopwatch" />
</service>
<!-- The stopwatch notification's buttons. Broadcasts rather than service
starts, and they carry nothing: there is one stopwatch (M7 D16). -->
<receiver
android:name=".stopwatch.receiver.StopwatchActionReceiver"
android:exported="false" />
<!-- Protected system broadcasts are delivered to unexported receivers —
this is how androidx.work declares its own RescheduleReceiver.
LOCKED_BOOT_COMPLETED is deliberately absent: the database and the
@@ -4,6 +4,8 @@ import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.timer.service.TimerNotifications
import de.jeanlucmakiola.floret.crash.CrashConfig
@@ -32,6 +34,9 @@ class ClockulaApp : Application() {
@Inject
lateinit var timerEngine: TimerEngine
@Inject
lateinit var stopwatchEngine: StopwatchEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
@@ -52,6 +57,7 @@ class ClockulaApp : Application() {
RingNotifications.createChannels(this)
TimerNotifications.createChannel(this)
StopwatchNotifications.createChannel(this)
// The same three steps as a BOOT_COMPLETED, and taken through the engine
// rather than one by one so the whole pass runs under the engine's lock:
@@ -65,6 +71,12 @@ class ClockulaApp : Application() {
// timers nothing — the anchors and the AlarmManager slot survive it —
// but the service may not have, so this brings it back.
timerEngine.onBootCompleted()
// And the third, after the other two. Process death costs the
// stopwatch nothing — the run is in DataStore and the laps in Room —
// but the service may not have survived it, so this brings it back;
// and if this is a new boot the gate above has already repaired the
// run, leaving it paused at what it banked (M7 D17).
stopwatchEngine.onBootCompleted()
}
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
@@ -32,6 +33,9 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var timerEngine: TimerEngine
@Inject
lateinit var stopwatchEngine: StopwatchEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
@@ -47,12 +51,15 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
Intent.ACTION_BOOT_COMPLETED -> {
engine.onBootCompleted()
timerEngine.onBootCompleted()
stopwatchEngine.onBootCompleted()
}
Intent.ACTION_MY_PACKAGE_REPLACED -> {
engine.onPackageReplaced()
// An app update clears AlarmManager for the timers too.
timerEngine.resync()
// And it took the stopwatch's service with it.
stopwatchEngine.resync()
}
Intent.ACTION_TIME_CHANGED,
@@ -67,6 +74,11 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
// path — so it is re-anchored from the monotonic one
// before the resync (M6 D4, D19).
timerEngine.onSystemTimeOrZoneChanged()
// The stopwatch writes nothing at all here: its reading
// is monotonic, so it cannot move. The call exists so
// the notification re-posts with a freshly derived
// chronometer base (M7 D4, D17).
stopwatchEngine.onSystemTimeChanged()
}
}
} finally {
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.clockula.stopwatch.receiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The stopwatch notification's buttons. A broadcast and not a service start,
* because a background foreground-service start can be refused and a broadcast
* cannot — and not an activity, because a control should not have to open the
* app (D16).
*
* Nothing is carried at all: there is exactly one stopwatch, so a button has
* nothing to identify. Every verb is guarded by the state it makes sense for,
* so a button pressed against a stale notification is a silent no-op and the
* notification is re-posted from the truth afterwards.
*/
@AndroidEntryPoint
class StopwatchActionReceiver : HiltBroadcastReceiver() {
@Inject
lateinit var engine: StopwatchEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
val action = intent.action ?: return
val pendingResult = goAsync()
scope.launch {
try {
when (action) {
StopwatchIntents.ACTION_LAP -> engine.lap()
StopwatchIntents.ACTION_PAUSE -> engine.pause()
StopwatchIntents.ACTION_RESUME -> engine.start()
StopwatchIntents.ACTION_RESET -> engine.reset()
}
} finally {
pendingResult.finish()
}
}
}
}
@@ -0,0 +1,169 @@
package de.jeanlucmakiola.clockula.stopwatch.service
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.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.StopwatchFormat
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchNotificationAction
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchNotificationState
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.stopwatch.receiver.StopwatchActionReceiver
/**
* One channel and one id for the one stopwatch. The running readout is the
* **platform chronometer**, counting *up*, so the system draws the ticking and
* the service posts once per state change rather than sixty times a second (D4).
*
* The channel is `IMPORTANCE_LOW` and silent: nothing here ever finishes, so
* nothing here ever needs to heads-up. That is the load-bearing difference from
* the timers' HIGH channel, and it is why there is no alert rule in sight.
*/
object StopwatchNotifications {
const val STOPWATCH_CHANNEL_ID: String = "stopwatch"
/** Beside the alarm's 1_001 and the timers' 1_002. */
const val STOPWATCH_NOTIFICATION_ID: Int = 1_003
/** Idempotent; called from the app class on every start. */
fun createChannel(context: Context) {
val channel = NotificationChannel(
STOPWATCH_CHANNEL_ID,
context.getString(R.string.channel_stopwatch_name),
// LOW, not HIGH: a stopwatch never expires, so it never has anything
// to interrupt the user with (D4).
NotificationManager.IMPORTANCE_LOW,
).apply {
description = context.getString(R.string.channel_stopwatch_description)
setSound(null, null)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
/**
* The notification the service goes foreground with the instant it starts,
* before it has read anything: `startForeground` must not wait on a
* DataStore read. [stopwatch] replaces it under the same id a moment later.
*/
fun preparing(context: Context): Notification =
NotificationCompat.Builder(context, STOPWATCH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.stopwatch_notification_title))
.setCategory(NotificationCompat.CATEGORY_STOPWATCH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setSilent(true)
.setOngoing(true)
.setAutoCancel(false)
.setShowWhen(false)
.setContentIntent(showPendingIntent(context))
.build()
/** Two actions at most, and they are the pair the tab's controls offer for that mode (D4). */
fun stopwatch(context: Context, state: StopwatchNotificationState): Notification {
val builder = NotificationCompat.Builder(context, STOPWATCH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.stopwatch_notification_title))
.setCategory(NotificationCompat.CATEGORY_STOPWATCH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
// Nothing here ever alerts: the channel is LOW and every post is
// silent, so there is no session window and no one heads-up to spend.
.setSilent(true)
.setOnlyAlertOnce(true)
.setContentIntent(showPendingIntent(context))
if (state.lapCount > 0) {
builder.setSubText(
context.resources.getQuantityString(
R.plurals.stopwatch_laps_recorded,
state.lapCount,
state.lapCount,
),
)
}
val base = state.chronometerBase
if (base != null) {
// Derived at post time and never stored, which is how the record
// keeps its "no wall-clock field whatsoever" rule. Whole seconds,
// because a shade entry redrawing at 100 Hz is absurd — and the
// platform chronometer does not offer hundredths anyway (D4).
builder.setWhen(base.toEpochMilliseconds())
.setShowWhen(true)
.setUsesChronometer(true)
.setChronometerCountDown(false)
} else {
// Frozen, so the full hundredths are stable — and the precision is
// the point of having stopped.
builder.setShowWhen(false).setContentText(
context.getString(
R.string.stopwatch_paused_at,
StopwatchFormat.precise(state.elapsed),
),
)
}
state.actions.forEach { action ->
builder.addAction(
R.drawable.ic_notification,
context.getString(labelFor(action)),
actionPendingIntent(context, action),
)
}
return builder.build()
}
private fun labelFor(action: StopwatchNotificationAction): Int = when (action) {
StopwatchNotificationAction.LAP -> R.string.stopwatch_lap
StopwatchNotificationAction.PAUSE -> R.string.stopwatch_pause
StopwatchNotificationAction.RESUME -> R.string.stopwatch_resume
StopwatchNotificationAction.RESET -> R.string.stopwatch_reset
}
/** Opens the Stopwatch tab, through the hook M6 built for the timers (D16). */
private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
context,
StopwatchIntents.REQUEST_SHOW,
Intent(context, MainActivity::class.java)
.setAction(StopwatchIntents.ACTION_SHOW_STOPWATCH)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PENDING_INTENT_FLAGS,
)
/** No extras and no per-id stride: there is exactly one stopwatch (D16). */
private fun actionPendingIntent(
context: Context,
action: StopwatchNotificationAction,
): PendingIntent = PendingIntent.getBroadcast(
context,
requestCode(action),
Intent(context, StopwatchActionReceiver::class.java).setAction(actionString(action)),
PENDING_INTENT_FLAGS,
)
private fun actionString(action: StopwatchNotificationAction): String = when (action) {
StopwatchNotificationAction.LAP -> StopwatchIntents.ACTION_LAP
StopwatchNotificationAction.PAUSE -> StopwatchIntents.ACTION_PAUSE
StopwatchNotificationAction.RESUME -> StopwatchIntents.ACTION_RESUME
StopwatchNotificationAction.RESET -> StopwatchIntents.ACTION_RESET
}
private fun requestCode(action: StopwatchNotificationAction): Int = when (action) {
StopwatchNotificationAction.LAP -> StopwatchIntents.REQUEST_LAP
StopwatchNotificationAction.PAUSE -> StopwatchIntents.REQUEST_PAUSE
StopwatchNotificationAction.RESUME -> StopwatchIntents.REQUEST_RESUME
StopwatchNotificationAction.RESET -> StopwatchIntents.REQUEST_RESET
}
private const val PENDING_INTENT_FLAGS: Int =
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
}
@@ -0,0 +1,161 @@
package de.jeanlucmakiola.clockula.stopwatch.service
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 androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchNotificationState
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The stopwatch's foreground service, alive exactly while the run is **not
* IDLE** — the live pill's predicate and [StopwatchReadings.isActive][de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings.isActive],
* so the shade, the pill and the tab cannot disagree about whether there is a
* stopwatch (D15). PAUSED is in it because dropping the notification on pause
* would leave a user who paused from the shade with no way to resume.
*
* It holds **no policy and no state at all**: every decision arrives from
* [StopwatchEngine.notificationState], and there is nothing here to alert once,
* because nothing here ever finishes. It also holds **no wake lock, ever** — a
* stopwatch has no deadline to wake for, and its elapsed value is derived from
* a persisted monotonic anchor at read time, so a CPU asleep for an hour loses
* nothing (D3).
*
* It is not the timekeeper. The anchors are, and they survive its absence.
*/
@AndroidEntryPoint
class StopwatchService : Service() {
@Inject
lateinit var engine: StopwatchEngine
@Inject
lateinit var stopwatch: StopwatchRepository
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var collector: Job? = null
/**
* `startForeground` is for going foreground, not for posting: every engine
* verb ends in a sync, so re-posting the placeholder on each
* `onStartCommand` would replace the live notification until the
* collector's next pass.
*/
private var wentForeground: Boolean = false
/** The last start command's id, so [stop] can decline to die on a newer one. */
private var lastStartId: Int = 0
/** A verb's way of asking for another pass when nothing observable changed. */
private val refresh = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
lastStartId = startId
if (intent?.action == StopwatchIntents.ACTION_SERVICE_STOP) {
stop()
return START_NOT_STICKY
}
// Before any suspension point, and on the first path only: the platform
// gives a foreground service a few seconds to post its notification.
if (!wentForeground) startForeground(StopwatchNotifications.preparing(this))
if (collector == null) {
collector = scope.launch {
triggers().collect { apply(engine.notificationState()) }
}
} else {
refresh.tryEmit(Unit)
}
// Sticky rather than re-delivered: the intent carries nothing, because a
// service the system recreated must know exactly as much as one the
// engine started — and it reads all of it from storage.
return START_STICKY
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
/** The run and its laps: the subtext counts the laps, so the table is a trigger too. */
private fun triggers(): Flow<Unit> = merge(
combine(stopwatch.run(), stopwatch.laps()) { _, _ -> Unit },
refresh,
)
private fun apply(state: StopwatchNotificationState?) {
if (state == null) {
// Nothing active: the service stops itself rather than waiting to be
// told to, so "down when the stopwatch is idle" needs no second caller.
stop()
return
}
startForeground(StopwatchNotifications.stopwatch(this, state))
}
private fun startForeground(notification: Notification) {
wentForeground = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// specialUse, and deliberately **not** systemExempted: that type's
// justification is alarm functionality continuing in the background,
// and a stopwatch schedules nothing, wakes nothing and rings nothing.
// Claiming it would be the app telling the platform something untrue (D3).
startForeground(
StopwatchNotifications.STOPWATCH_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(StopwatchNotifications.STOPWATCH_NOTIFICATION_ID, notification)
}
}
private fun stop() {
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(StopwatchNotifications.STOPWATCH_NOTIFICATION_ID)
wentForeground = false
// `stopSelfResult`, not `stopSelf`: resetting the stopwatch and starting
// a new run immediately delivers a start command to this still-live
// instance, and dying anyway would leave a running stopwatch with no
// notification until the next resync.
stopSelfResult(lastStartId)
}
companion object {
fun start(context: Context) {
context.startForegroundService(Intent(context, StopwatchService::class.java))
}
fun stop(context: Context) {
context.startService(
Intent(context, StopwatchService::class.java)
.setAction(StopwatchIntents.ACTION_SERVICE_STOP),
)
}
}
}