feat(stopwatch): the engine, its one seam and the verbs the shade can press
A third engine beside the alarms' and the timers', and a small one: four verbs behind a single mutex, no scheduler and nothing that rings. Start, pause, lap and reset are here rather than on the repository because three of them arrive as notification buttons and must mean the same thing whichever surface pressed them. The verbs guard on the *reading's* mode, not the stored row, so a run whose anchor a reboot invalidated resumes when the tab says Resume instead of silently doing nothing; resuming banks the last lap's total first, so the readout never stands still and never walks backwards. `StopwatchIntents` namespaces the actions and hands out request codes that cannot collide with the timers'. The service is reached through one seam, which keeps the engine free of Android and lets the tests watch the transitions in order.
This commit is contained in:
@@ -11,6 +11,9 @@ abstract class LapDao {
|
||||
@Query("SELECT * FROM stopwatch_laps ORDER BY lap_index")
|
||||
abstract fun observeAll(): Flow<List<LapEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM stopwatch_laps")
|
||||
abstract suspend fun count(): Int
|
||||
|
||||
@Query("SELECT * FROM stopwatch_laps ORDER BY lap_index DESC LIMIT 1")
|
||||
abstract suspend fun latest(): LapEntity?
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ interface StopwatchRepository {
|
||||
fun laps(): Flow<List<Lap>>
|
||||
suspend fun currentRun(): StopwatchRun
|
||||
|
||||
/** How many laps are recorded right now. Never materialises the rows. */
|
||||
suspend fun lapCount(): Int
|
||||
|
||||
/** From IDLE: a fresh run, laps cleared. From PAUSED: resume, laps kept. No-op when RUNNING. */
|
||||
suspend fun start()
|
||||
|
||||
|
||||
+25
-4
@@ -3,7 +3,7 @@ package de.jeanlucmakiola.clockula.data.stopwatch
|
||||
import de.jeanlucmakiola.clockula.domain.Lap
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchRun
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchState
|
||||
import de.jeanlucmakiola.clockula.domain.snapshotAt
|
||||
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings
|
||||
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -29,13 +29,28 @@ class StopwatchRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun currentRun(): StopwatchRun = stateStore.current()
|
||||
|
||||
override suspend fun lapCount(): Int = lapDao.count()
|
||||
|
||||
override suspend fun start() {
|
||||
val run = stateStore.current()
|
||||
val now = elapsedRealtimeClock.elapsedRealtime()
|
||||
when (run.state) {
|
||||
StopwatchState.RUNNING -> return
|
||||
StopwatchState.PAUSED ->
|
||||
stateStore.set(run.copy(state = StopwatchState.RUNNING, startedAtElapsedRealtime = now))
|
||||
stateStore.set(
|
||||
run.copy(
|
||||
state = StopwatchState.RUNNING,
|
||||
// Resuming banks the floor a readout already applies: a lap
|
||||
// recorded inside a segment the reboot repair discarded is a
|
||||
// real measurement, so the run is at least that far along
|
||||
// (D11). Without this the record ticks from below its own
|
||||
// last lap, and the shade's free-running chronometer — posted
|
||||
// from the floored reading — would count while the tab sat
|
||||
// frozen.
|
||||
accumulated = maxOf(run.accumulated, run.lastLapCumulative),
|
||||
startedAtElapsedRealtime = now,
|
||||
),
|
||||
)
|
||||
StopwatchState.IDLE -> {
|
||||
// A fresh run, not a resume: last run's laps would be nonsense here.
|
||||
lapDao.deleteAll()
|
||||
@@ -55,7 +70,9 @@ class StopwatchRepositoryImpl @Inject constructor(
|
||||
val run = stateStore.current()
|
||||
if (run.state != StopwatchState.RUNNING) return
|
||||
|
||||
val elapsed = run.snapshotAt(elapsedRealtimeClock.elapsedRealtime()).elapsed
|
||||
// The value every surface is showing, not the raw snapshot: banking less
|
||||
// than the user was just reading would make the readout jump backwards.
|
||||
val elapsed = StopwatchReadings.of(run, elapsedRealtimeClock.elapsedRealtime()).elapsed
|
||||
stateStore.set(
|
||||
run.copy(
|
||||
state = StopwatchState.PAUSED,
|
||||
@@ -74,7 +91,11 @@ class StopwatchRepositoryImpl @Inject constructor(
|
||||
val run = stateStore.current()
|
||||
if (run.state != StopwatchState.RUNNING) return null
|
||||
|
||||
val cumulative = run.snapshotAt(elapsedRealtimeClock.elapsedRealtime()).elapsed
|
||||
// The same floored reading the hero figure, the lap table and the shade
|
||||
// are all showing: a lap that recorded a cumulative *below* the previous
|
||||
// lap's would drag `lastLapCumulative` — and with it every readout —
|
||||
// backwards (D11).
|
||||
val cumulative = StopwatchReadings.of(run, elapsedRealtimeClock.elapsedRealtime()).elapsed
|
||||
val entity = lapDao.appendLap(cumulative.inWholeMilliseconds)
|
||||
stateStore.set(run.copy(lastLapCumulative = cumulative))
|
||||
return LapMapper.toDomain(entity)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package de.jeanlucmakiola.clockula.stopwatch
|
||||
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
|
||||
import de.jeanlucmakiola.clockula.domain.Lap
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchRun
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchState
|
||||
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps
|
||||
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchNotificationPolicy
|
||||
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchNotificationState
|
||||
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings
|
||||
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.system.RebootRepair
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The stopwatch's analogue of `AlarmEngine` and `TimerEngine`: plain Kotlin, no
|
||||
* `android.*` import ever, reaching the platform through one seam it does not
|
||||
* implement (D1, D2).
|
||||
*
|
||||
* It is deliberately much smaller than [de.jeanlucmakiola.clockula.timer.TimerEngine],
|
||||
* because the stopwatch has less to own: nothing has a deadline, so there is no
|
||||
* `AlarmManager` registration and no wake lock; nothing expires, so there is no
|
||||
* ring and no arbitration; and there is one record rather than N rows. What it
|
||||
* does have is a foreground service and four verbs reachable from the shade with
|
||||
* no ViewModel in sight — which is why the verbs live here and not on a screen.
|
||||
*
|
||||
* Like the other two it is not driven by a Flow: [resync] is called explicitly,
|
||||
* from the app class, the receivers, the service and the ViewModels — threads
|
||||
* that know nothing about each other, which is what the one `Mutex` is for.
|
||||
*/
|
||||
@Singleton
|
||||
class StopwatchEngine @Inject constructor(
|
||||
private val stopwatch: StopwatchRepository,
|
||||
private val service: StopwatchServiceHandle,
|
||||
private val rebootRepair: RebootRepair,
|
||||
private val elapsed: ElapsedRealtimeClock,
|
||||
private val wall: WallClock,
|
||||
) {
|
||||
/**
|
||||
* A `Mutex` is not reentrant, so exactly one layer takes it: the public
|
||||
* entry points below. Every private `…Locked` body assumes it is held.
|
||||
*/
|
||||
private val lock = Mutex()
|
||||
|
||||
/** From IDLE a fresh run with its laps cleared, from PAUSED a resume. No-op when RUNNING. */
|
||||
suspend fun start(): Unit = lock.withLock {
|
||||
val run = stopwatch.currentRun()
|
||||
// A no-op is a no-op all the way down: a button pressed against a stale
|
||||
// notification must not re-post one, let alone restart the service.
|
||||
if (modeOf(run) == StopwatchState.RUNNING) return@withLock
|
||||
if (run.state == StopwatchState.RUNNING) {
|
||||
// Stored RUNNING but reading PAUSED: an anchor that did not survive a
|
||||
// reboot, which every surface already draws as paused with a Resume
|
||||
// button (D6). Resume must therefore resume, not hit the repository's
|
||||
// "already running" no-op — so bank the reading first, exactly as the
|
||||
// repair would have.
|
||||
stopwatch.pause()
|
||||
}
|
||||
stopwatch.start()
|
||||
syncLocked()
|
||||
}
|
||||
|
||||
/** No-op unless the stopwatch *reads* RUNNING. */
|
||||
suspend fun pause(): Unit = lock.withLock {
|
||||
if (modeOf(stopwatch.currentRun()) != StopwatchState.RUNNING) return@withLock
|
||||
stopwatch.pause()
|
||||
// Still up, not down: dropping the notification on pause would leave a
|
||||
// user who paused from the shade with no way to resume (D15).
|
||||
syncLocked()
|
||||
}
|
||||
|
||||
/** Null when not RUNNING, or when [StopwatchLaps.MAX] laps already exist (D14). */
|
||||
suspend fun lap(): Lap? = lock.withLock {
|
||||
if (modeOf(stopwatch.currentRun()) != StopwatchState.RUNNING) return@withLock null
|
||||
// Counted rather than listed: an unbounded table behind a button that can
|
||||
// be held down is a storage leak, and appending a lap must not have to
|
||||
// materialise 999 rows to find that out.
|
||||
if (stopwatch.lapCount() >= StopwatchLaps.MAX) return@withLock null
|
||||
|
||||
val lap = stopwatch.lap()
|
||||
syncLocked()
|
||||
lap
|
||||
}
|
||||
|
||||
/** Back to IDLE at zero; deletes every lap; takes the service down. */
|
||||
suspend fun reset(): Unit = lock.withLock {
|
||||
stopwatch.reset()
|
||||
syncLocked()
|
||||
}
|
||||
|
||||
/** Match the service to "the stopwatch is active". Writes nothing. */
|
||||
suspend fun resync(): Unit = lock.withLock { syncLocked() }
|
||||
|
||||
/** The reboot repair (idempotent within a boot), then a resync (D17). */
|
||||
suspend fun onBootCompleted(): Unit = lock.withLock {
|
||||
// The boot-id gate makes the second and third call of a boot a no-op, so
|
||||
// no engine depends on another's ordering.
|
||||
rebootRepair.repairIfRebooted()
|
||||
syncLocked()
|
||||
}
|
||||
|
||||
/**
|
||||
* A clock change. **Writes nothing** — the reading is monotonic, so it cannot
|
||||
* move. The resync exists only so the service re-posts with a freshly derived
|
||||
* chronometer base (D4, D17).
|
||||
*/
|
||||
suspend fun onSystemTimeChanged(): Unit = lock.withLock { syncLocked() }
|
||||
|
||||
/** Everything the service must show. Null ⇒ nothing active ⇒ stop (D4). */
|
||||
suspend fun notificationState(): StopwatchNotificationState? = lock.withLock {
|
||||
val run = stopwatch.currentRun()
|
||||
StopwatchNotificationPolicy.stateFor(
|
||||
run = run,
|
||||
// Asked only when there is something to show it on.
|
||||
lapCount = if (StopwatchReadings.isActive(run)) stopwatch.lapCount() else 0,
|
||||
elapsedRealtime = elapsed.elapsedRealtime(),
|
||||
now = wall.now(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode every surface is showing — the snapshot's, never the stored row's
|
||||
* (D6). A verb that guarded on the row would offer the user a Resume button
|
||||
* whose handler did nothing while a stale record existed.
|
||||
*/
|
||||
private fun modeOf(run: StopwatchRun): StopwatchState =
|
||||
StopwatchReadings.of(run, elapsed.elapsedRealtime()).mode
|
||||
|
||||
private suspend fun syncLocked() {
|
||||
service.sync(StopwatchReadings.isActive(stopwatch.currentRun()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.jeanlucmakiola.clockula.stopwatch
|
||||
|
||||
/**
|
||||
* The stopwatch path's actions and `PendingIntent` request codes. No extras and
|
||||
* no per-id stride: there is exactly one stopwatch, so a button has nothing to
|
||||
* identify (D16).
|
||||
*/
|
||||
object StopwatchIntents {
|
||||
private const val PREFIX = "de.jeanlucmakiola.clockula."
|
||||
|
||||
const val ACTION_LAP: String = PREFIX + "action.STOPWATCH_LAP"
|
||||
const val ACTION_PAUSE: String = PREFIX + "action.STOPWATCH_PAUSE"
|
||||
const val ACTION_RESUME: String = PREFIX + "action.STOPWATCH_RESUME"
|
||||
const val ACTION_RESET: String = PREFIX + "action.STOPWATCH_RESET"
|
||||
const val ACTION_SHOW_STOPWATCH: String = PREFIX + "action.SHOW_STOPWATCH"
|
||||
const val ACTION_SERVICE_STOP: String = PREFIX + "action.STOPWATCH_SERVICE_STOP"
|
||||
|
||||
const val REQUEST_SHOW: Int = 21
|
||||
const val REQUEST_LAP: Int = 22
|
||||
const val REQUEST_PAUSE: Int = 23
|
||||
const val REQUEST_RESUME: Int = 24
|
||||
const val REQUEST_RESET: Int = 25
|
||||
|
||||
val ALL_REQUEST_CODES: List<Int> =
|
||||
listOf(REQUEST_SHOW, REQUEST_LAP, REQUEST_PAUSE, REQUEST_RESUME, REQUEST_RESET)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.jeanlucmakiola.clockula.stopwatch
|
||||
|
||||
/** Brings the foreground service up while the stopwatch is active and down when it is not (D15). */
|
||||
interface StopwatchServiceHandle {
|
||||
fun sync(active: Boolean)
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.clockula.stopwatch.android
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchServiceHandle
|
||||
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchService
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Starts and stops the one stopwatch foreground service. The service is a
|
||||
* readout and a control surface, never the timekeeper — the persisted monotonic
|
||||
* anchor is (D3).
|
||||
*/
|
||||
@Singleton
|
||||
class ServiceStopwatchHandle @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : StopwatchServiceHandle {
|
||||
|
||||
override fun sync(active: Boolean) {
|
||||
// Both ways in `runCatching`: a refused start costs the user a shade
|
||||
// entry, never a measurement — the persisted anchor is the timekeeper.
|
||||
runCatching {
|
||||
if (active) StopwatchService.start(context) else StopwatchService.stop(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.jeanlucmakiola.clockula.stopwatch.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchServiceHandle
|
||||
import de.jeanlucmakiola.clockula.stopwatch.android.ServiceStopwatchHandle
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class StopwatchModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindStopwatchServiceHandle(impl: ServiceStopwatchHandle): StopwatchServiceHandle
|
||||
}
|
||||
Reference in New Issue
Block a user