Merge branch 'feat/26-quick-settings-tiles' into release/v1.0.0

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt
#	app/src/main/res/values/strings.xml
#	app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
#	docs/ROADMAP.md
This commit is contained in:
makiolaj committed 2026-10-26 21:00:57 +01:00
commit df8be5e158
31 files changed
+998 -27

No files matched your search

+4
View File
@@ -109,3 +109,7 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
nothing burns into the screen. Pick it in Android's screen saver settings,
or open it any time from the menu on each tab. Its settings are in Settings
→ Clock and are included in backups.
- Quick Settings tiles: "Next alarm" shows the day and time of your next alarm
and opens the Alarms tab, and "Quick timer" starts a timer with one tap,
shows when it ends, and resets it on a second tap. Long-press the quick
timer tile to choose its length.
+52
View File
@@ -496,6 +496,58 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_quick_timer_info" />
</receiver>
<!-- #26: the two Quick Settings tiles. A TileService must be exported
for System UI to bind it, and BIND_QUICK_SETTINGS_TILE — a
signature permission only System UI holds — keeps anyone else
from binding it. Both are active tiles: they are redrawn only
when the app asks (`widget/WidgetSync.kt`), never on every panel
open. -->
<service
android:name=".tile.NextAlarmTileService"
android:exported="true"
android:icon="@drawable/widget_ic_alarm"
android:label="@string/tile_next_alarm_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
</service>
<service
android:name=".tile.QuickTimerTileService"
android:exported="true"
android:icon="@drawable/tile_ic_timer"
android:label="@string/tile_quick_timer_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
<!-- What long-pressing either tile opens: the quick-timer tile's
duration, or the Alarms tab for the next-alarm one. Exported
because System UI starts it; it answers that one action and
closes on any other. A task of its own, so finishing it returns
to wherever the user was, not into an open Clockula. -->
<activity
android:name=".ui.tile.TilePreferencesActivity"
android:exported="true"
android:excludeFromRecents="true"
android:taskAffinity=""
android:theme="@style/Theme.Clockula">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -91,8 +91,9 @@ class ClockulaApp : Application() {
}
}
// Never completes on its own — it is the widgets' one refresh trigger
// for as long as the process lives (M10, docs/PLAN.md §9).
// Never completes on its own — it is the widgets' and the Quick
// Settings tiles' one refresh trigger for as long as the process
// lives (M10, docs/PLAN.md §9; #26).
scope.launch { widgetSync.run() }
scope.launch { settleTimerVibrate() }
@@ -7,6 +7,7 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.tile.requestAllTileUpdates
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
import de.jeanlucmakiola.clockula.widget.MidnightRefresh
@@ -99,6 +100,10 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
AnalogClockWidget().updateAll(context)
NextAlarmWidget().updateAll(context)
MidnightRefresh.schedule(context)
// Both tiles show a wall-clock time in the zone they
// were drawn in, and the instant behind it need not
// have changed, so no write may reach `WidgetSync`.
requestAllTileUpdates(context)
}
}
} finally {
@@ -117,6 +117,7 @@ class BackupEngine @Inject constructor(
timerPresets = settings.timerPresets.first(),
worldClockSeconds = settings.worldClockSeconds.first(),
screenSaver = settings.screenSaver.first(),
quickTileDuration = settings.quickTileDuration.first(),
bedtime = settings.currentBedtime(),
)
}
@@ -20,6 +20,7 @@ import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlinx.serialization.SerializationException
@@ -151,6 +152,7 @@ object BackupCodec {
bedtime = bedtime?.toDto(),
screenSaverStyle = screenSaver.style.name,
screenSaverNightMode = screenSaver.nightMode,
quickTileDurationMillis = quickTileDuration.inWholeMilliseconds,
)
private fun BedtimeSettings.toDto() = BedtimeDto(
@@ -313,6 +315,7 @@ object BackupCodec {
style = settingsDto.screenSaverStyle.toEnum(ScreenSaverStyle.DIGITAL),
nightMode = settingsDto.screenSaverNightMode,
),
quickTileDuration = parseQuickTimerDuration(settingsDto.quickTileDurationMillis),
bedtime = settingsDto.bedtime?.toBedtime(),
)
@@ -108,6 +108,8 @@ data class SettingsDto(
/** Absent in files from before the screen saver; they restore its defaults. */
@SerialName("screen_saver_style") val screenSaverStyle: String = "DIGITAL",
@SerialName("screen_saver_night_mode") val screenSaverNightMode: Boolean = false,
/** Absent (a file from before #26) or out of range keeps the tile's default. */
@SerialName("quick_tile_duration_millis") val quickTileDurationMillis: Long? = null,
)
/**
@@ -133,6 +133,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
backup.bedtime?.let { prefs.putBedtime(it) }
prefs[ScreenSaverPrefs.style] = backup.screenSaver.style
prefs[ScreenSaverPrefs.nightMode] = backup.screenSaver.nightMode
prefs[TimerPrefs.quickTileDuration] = backup.quickTileDuration
}
}
@@ -288,6 +289,13 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
suspend fun setScreenSaverNightMode(enabled: Boolean) = store.set(ScreenSaverPrefs.nightMode, enabled)
// --- quick-timer tile (#26) ---
/** What the quick-timer Quick Settings tile starts; bounded as the quick-timer widget's duration is. */
val quickTileDuration: Flow<Duration> = store.flow(TimerPrefs.quickTileDuration)
suspend fun setQuickTileDuration(value: Duration) = store.set(TimerPrefs.quickTileDuration, value)
// --- first-launch bookkeeping (M4) ---
/**
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.nullableLongPref
import de.jeanlucmakiola.floret.prefs.nullableStringPref
@@ -41,4 +42,22 @@ object TimerPrefs {
decode = { millis -> millis?.let(Instant::fromEpochMilliseconds) },
encode = { instant -> instant?.toEpochMilliseconds() },
)
/**
* The duration the quick-timer Quick Settings tile starts (#26). The
* widget's own bounds and default (`QuickTimerWidgetSettings`), sanitised
* on write as well as on read, so a stored value is always one the tile
* can start. A setting, so it is in the backup.
*/
val quickTileDuration: Pref<Duration> = nullableLongPref("quick_tile_duration_millis").map(
decode = ::parseQuickTimerDuration,
encode = { duration -> parseQuickTimerDuration(duration.inWholeMilliseconds).inWholeMilliseconds },
)
/**
* The transient timer the quick-timer tile last started, so the tile can
* show and control that one. Volatile state, like [ringSoundingSince]: it
* names a row that is deleted on reset, and stays out of the backup.
*/
val quickTileTimerId: Pref<Long?> = nullableLongPref("quick_tile_timer_id")
}
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.clockula.data.timers
import de.jeanlucmakiola.clockula.data.prefs.TimerPrefs
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which transient timer the quick-timer Quick Settings tile last started
* (#26) — the tile's counterpart of the quick-timer widget's per-instance
* Glance state. One volatile record, so DataStore rather than Room, and out
* of the backup for the reason [TimerRingStateStore] gives.
*/
@Singleton
class QuickTileStateStore @Inject constructor(private val store: PrefStore) {
/** Built once, distinct-until-changed (ARCHITECTURE.md §6). */
val timerId: Flow<Long?> = store.flow(TimerPrefs.quickTileTimerId)
suspend fun currentTimerId(): Long? = store.get(TimerPrefs.quickTileTimerId)
suspend fun setTimerId(id: Long?) = store.set(TimerPrefs.quickTileTimerId, id)
}
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings
import de.jeanlucmakiola.floret.prefs.ThemeMode
import java.time.LocalDate
import kotlin.time.Duration
@@ -65,6 +66,8 @@ data class BackupSettings(
/** The world clock's "Display time with seconds" (#23). */
val worldClockSeconds: Boolean,
val screenSaver: ScreenSaverSettings,
/** What the quick-timer Quick Settings tile starts (#26). */
val quickTileDuration: Duration = QuickTimerWidgetSettings.DEFAULT,
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
val bedtime: BedtimeSettings? = null,
)
@@ -0,0 +1,69 @@
package de.jeanlucmakiola.clockula.domain.tile
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetPresenter
import kotlin.time.Duration
import kotlin.time.Instant
/**
* What the quick-timer Quick Settings tile shows. The tile is the quick-timer
* widget's twin (`domain/widget/QuickTimerWidgetSettings.kt`): one duration,
* and the transient timer it last started while that one is still active.
*
* Nothing here ticks. A tile is redrawn only when the app asks it to be, so a
* running timer shows when it **ends** — an absolute wall-clock time that
* cannot go stale between the writes that refresh it — never a countdown.
*/
sealed interface QuickTimerTileModel {
/** Offering to start a timer of [duration]; a tap starts one. */
data class AtRest(val duration: Duration) : QuickTimerTileModel
/**
* Its timer [timerId] active. A tap resets it — which, for a transient
* timer, stops it (or its ring) and deletes it, as the widget's second
* tap does.
*/
sealed interface Active : QuickTimerTileModel {
val timerId: Long
}
/** Its timer counting down, due at [endsAt]. */
data class Running(override val timerId: Long, val endsAt: Instant) : Active
/** Its timer paused with [remaining] left. */
data class Paused(override val timerId: Long, val remaining: Duration) : Active
/** Its timer ringing. */
data class Expired(override val timerId: Long) : Active
}
object QuickTimerTilePresenter {
/**
* [timerId] is the timer the tile last started; once it is deleted or back
* to idle, the tile is at rest again. The same selection as the widget's,
* so the two can never disagree about whether a quick timer is "theirs".
*/
fun present(
duration: Duration,
timerId: Long?,
timers: List<Timer>,
elapsedRealtime: Duration,
wallClock: Instant,
): QuickTimerTileModel {
val active = QuickTimerWidgetPresenter.present(duration, timerId, timers, elapsedRealtime, wallClock).active
?: return QuickTimerTileModel.AtRest(duration)
return when (active.mode) {
// The stored end, which the engine re-anchors on a clock change,
// rather than one re-derived per read: two clocks read a moment
// apart would make every emission a "change".
TimerMode.RUNNING -> QuickTimerTileModel.Running(active.id, active.endsAtWallClock ?: (wallClock + active.remaining))
TimerMode.PAUSED -> QuickTimerTileModel.Paused(active.id, active.remaining)
TimerMode.EXPIRED -> QuickTimerTileModel.Expired(active.id)
// Never active, so never reached; at rest is the honest reading.
TimerMode.IDLE -> QuickTimerTileModel.AtRest(duration)
}
}
}
@@ -0,0 +1,95 @@
package de.jeanlucmakiola.clockula.tile
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.time.LocalTime
import java.time.format.TextStyle
import javax.inject.Inject
/**
* The next-alarm Quick Settings tile (#26): "Next alarm" over the day and
* time of the next one, or "No alarms"; a tap opens the Alarms tab.
*
* It reads the next-alarm widget's own model ([NextAlarmWidgetPresenter]),
* so the tile, the widget and the Alarms tab cannot disagree about which
* alarm is next. The day is always the weekday's name, never "Today": an
* active tile is redrawn only when an alarm changes (`widget/WidgetSync.kt`)
* or the clock or zone does (`SystemEventReceiver`), and
* "Today" would go stale at midnight with nothing to redraw it.
*/
@AndroidEntryPoint
class NextAlarmTileService : TileService() {
@Inject lateinit var alarmEngine: AlarmEngine
@Inject lateinit var wallClock: WallClock
@Inject lateinit var zones: ZoneProvider
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var listening: Job? = null
override fun onTileAdded() {
super.onTileAdded()
requestTileUpdate(this, NextAlarmTileService::class.java)
}
override fun onStartListening() {
super.onStartListening()
listening?.cancel()
listening = scope.launch {
alarmEngine.upcoming()
.map { NextAlarmWidgetPresenter.present(it, wallClock.now(), zones.current()) }
.collect(::show)
}
}
override fun onStopListening() {
listening?.cancel()
listening = null
super.onStopListening()
}
override fun onClick() {
super.onClick()
openApp(AlarmIntents.ACTION_SHOW_ALARMS)
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun show(model: NextAlarmWidgetModel) {
val tile = qsTile ?: return
val label = getString(R.string.tile_next_alarm_label)
val day = model.dayOfWeek
if (!model.hasAlarm || day == null) {
render(tile, Tile.STATE_INACTIVE, label, getString(R.string.tile_next_alarm_empty), R.drawable.widget_ic_alarm_off)
return
}
val locale = resources.configuration.locales[0]
val subtitle = getString(
R.string.tile_next_alarm_subtitle,
day.getDisplayName(TextStyle.SHORT, locale),
tileTime(LocalTime.of(model.hour, model.minute)),
)
render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.widget_ic_alarm)
}
}
@@ -0,0 +1,112 @@
package de.jeanlucmakiola.clockula.tile
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTileModel
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.timer.QuickTileTimer
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.time.ZonedDateTime
import javax.inject.Inject
import kotlin.time.toJavaInstant
/**
* The quick-timer Quick Settings tile (#26): the quick-timer widget's twin.
* At rest it shows the duration it starts; a tap starts a transient timer of
* that length. While that timer is active the tile is on and shows when it
* ends (or that it is paused, or done), and a tap resets it — all decided by
* [QuickTileTimer], which this only renders and forwards to.
*
* Works on a locked device, deliberately: starting or stopping a kitchen
* timer is something the timer notification already lets anyone do from the
* lock screen, and asking for an unlock first would make the tile slower
* than the notification it sits above. Long-pressing it opens
* `ui/tile/TilePreferencesActivity.kt`, where its duration is chosen.
*
* Starting the timer brings up its foreground service through the engine, as
* every other start does. Should the platform refuse that start, the timer
* still runs and still rings — the AlarmManager slot is the timekeeper, the
* service only its readout (`timer/android/ServiceTimerHandle.kt`).
*/
@AndroidEntryPoint
class QuickTimerTileService : TileService() {
@Inject lateinit var quickTile: QuickTileTimer
@Inject lateinit var zones: ZoneProvider
/** The tap outlives the binding: the panel may unbind the moment it collapses. */
@Inject
@ApplicationScope
lateinit var appScope: CoroutineScope
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var listening: Job? = null
override fun onTileAdded() {
super.onTileAdded()
requestTileUpdate(this, QuickTimerTileService::class.java)
}
override fun onStartListening() {
super.onStartListening()
listening?.cancel()
listening = scope.launch { quickTile.model.collect(::show) }
}
override fun onStopListening() {
listening?.cancel()
listening = null
super.onStopListening()
}
override fun onClick() {
super.onClick()
appScope.launch { quickTile.onTap() }
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun show(model: QuickTimerTileModel) {
val tile = qsTile ?: return
val label = getString(R.string.tile_quick_timer_label)
when (model) {
is QuickTimerTileModel.AtRest -> {
val duration = ClockFormat.countdown(model.duration)
render(
tile,
Tile.STATE_INACTIVE,
label,
duration,
R.drawable.tile_ic_timer,
)
}
is QuickTimerTileModel.Running -> {
val endsAt = ZonedDateTime.ofInstant(model.endsAt.toJavaInstant(), zones.current()).toLocalTime()
val subtitle = getString(R.string.tile_quick_timer_until, tileTime(endsAt))
render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.tile_ic_timer)
}
is QuickTimerTileModel.Paused -> {
val subtitle = getString(R.string.timer_paused_at, ClockFormat.countdown(model.remaining))
render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.tile_ic_timer)
}
is QuickTimerTileModel.Expired -> {
val subtitle = getString(R.string.widget_timer_expired)
render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.tile_ic_timer)
}
}
}
}
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.clockula.tile
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Icon
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import de.jeanlucmakiola.clockula.ui.common.localTimeFormatter
import de.jeanlucmakiola.clockula.ui.widget.openAppIntent
import java.time.LocalTime
/**
* Opens the app on the shell request [action] names (`ui/shell/ShellNavigation`)
* and collapses the panel. On a locked device the user unlocks first: the app
* is not something to open over the keyguard, and the system would only ask
* for the unlock itself, after the panel had already gone.
*
* API 34 refuses the `Intent` overload of `startActivityAndCollapse` and takes
* a `PendingIntent` instead; everything below it only has the `Intent` one.
*/
internal fun TileService.openApp(action: String) {
// The widgets' own launch intent, so a tile and a widget land the same way.
val intent = openAppIntent(this, action)
val launch = Runnable {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pending = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
startActivityAndCollapse(pending)
} else {
startIntentAndCollapse(intent)
}
}
if (isLocked) unlockAndRun(launch) else launch.run()
}
@Suppress("DEPRECATION")
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun TileService.startIntentAndCollapse(intent: Intent) = startActivityAndCollapse(intent)
/**
* Writes one rendering onto [tile]. The subtitle is API 29, which is
* `minSdk`. TalkBack reads the label, the subtitle and the on/off state the
* panel derives from [state], so there is no separate description to keep in
* step with them.
*/
internal fun TileService.render(
tile: Tile,
state: Int,
label: String,
subtitle: String,
icon: Int,
) {
tile.state = state
tile.label = label
tile.subtitle = subtitle
tile.icon = Icon.createWithResource(this, icon)
tile.updateTile()
}
/** The app's one 12/24-hour time format, as the next-alarm widget shows it. */
internal fun Context.tileTime(time: LocalTime): String = localTimeFormatter(this).format(time)
/**
* Asks the system to bind [service] and let it redraw. Both tiles are
* *active* tiles (`META_DATA_ACTIVE_TILE` in the manifest), so this is the
* only way they are ever refreshed outside a click — which is exactly the
* point: the panel opening costs the app nothing. `widget/WidgetSync.kt`
* calls it on every change the tiles mirror. A tile that is not in the
* panel makes this a no-op; a refusal is harmless for the same reason, so it
* never throws.
*/
internal fun requestTileUpdate(context: Context, service: Class<out TileService>) {
runCatching { TileService.requestListeningState(context, ComponentName(context, service)) }
}
/** Both tiles, for a change that moves what their times mean rather than the state behind them. */
internal fun requestAllTileUpdates(context: Context) {
requestTileUpdate(context, NextAlarmTileService::class.java)
requestTileUpdate(context, QuickTimerTileService::class.java)
}
@@ -0,0 +1,90 @@
package de.jeanlucmakiola.clockula.timer
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.QuickTileStateStore
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTileModel
import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTilePresenter
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
/**
* The quick-timer Quick Settings tile's state and its one tap (#26), with no
* Android type in sight: `tile/QuickTimerTileService.kt` only renders [model]
* and forwards the click here.
*
* At rest a tap creates a transient timer at the tile's duration, remembers
* its id and starts it — the same create-then-start as `QuickTimerReceiver`
* and the Timers screen's quick start. On an active timer a tap resets it,
* which for a transient timer means stop (or stop ringing) and delete, so the
* tile is at rest again: the widget's second tap. Everything goes through the
* [TimerEngine], so the AlarmManager slot and the foreground service follow
* exactly as they do for every other path.
*/
@Singleton
class QuickTileTimer @Inject constructor(
private val timers: TimerRepository,
private val engine: TimerEngine,
private val settings: SettingsPrefs,
private val state: QuickTileStateStore,
private val elapsed: ElapsedRealtimeClock,
private val wall: WallClock,
) {
/** Re-derived on every write that could change it — never on a tick. */
val model: Flow<QuickTimerTileModel> = combine(
settings.quickTileDuration,
state.timerId,
timers.timers(),
) { duration, timerId, all ->
QuickTimerTilePresenter.present(duration, timerId, all, elapsed.elapsedRealtime(), wall.now())
}
/**
* Serialised, so a double tap's second half sees the first half's timer
* instead of starting another one the tile could not show.
*/
suspend fun onTap(): Unit = mutex.withLock {
when (val current = model.first()) {
is QuickTimerTileModel.AtRest -> {
dropStrandedTimer()
val id = timers.create(TimerDraft(duration = current.duration, deleteAfterUse = true))
// Remembered before it starts, so a start that throws, or a
// process that dies in between, never leaves a running timer
// the tile can neither see nor stop. It also makes the start
// the last write, the one `WidgetSync` redraws the tile on.
state.setTimerId(id)
engine.start(id)
}
is QuickTimerTileModel.Active -> reset(current.timerId)
}
}
/**
* A transient timer the tile created but never got running — a start that
* threw, or a process that died in between — would sit idle in the Timers
* list for good, since only a reset deletes one and a reset of an idle
* timer does nothing. The next tap clears it before making another.
*/
private suspend fun dropStrandedTimer() {
val id = state.currentTimerId() ?: return
val timer = timers.find(id) ?: return
if (timer.deleteAfterUse && timer.state == TimerState.IDLE) engine.delete(id)
}
private suspend fun reset(timerId: Long) {
engine.reset(timerId)
state.setTimerId(null)
}
private val mutex = Mutex()
}
@@ -1,11 +1,13 @@
package de.jeanlucmakiola.clockula.ui.common
import android.content.Context
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.ui.widget.currentLocale as contextLocale
import de.jeanlucmakiola.floret.locale.currentLocale
import java.time.LocalTime
import java.time.chrono.IsoChronology
@@ -54,6 +56,16 @@ internal fun localTimePattern(is24Hour: Boolean, locale: Locale, withSeconds: Bo
DateTimeFormatterBuilder.getLocalizedDateTimePattern(null, style, IsoChronology.INSTANCE, locale)
}
/**
* The same rule as a formatter, the non-Composable half of
* [rememberLocalTimeFormatter], for the surfaces outside a composition: the
* next-alarm widget and the Quick Settings tiles.
*/
internal fun localTimeFormatter(context: Context, withSeconds: Boolean = false): DateTimeFormatter {
val locale = contextLocale(context)
return DateTimeFormatter.ofPattern(localTimePattern(DateFormat.is24HourFormat(context), locale, withSeconds), locale)
}
/** Delegates to [rememberLocalTimeFormatter]. Unchanged for every existing caller. */
@Composable
internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String {
@@ -0,0 +1,102 @@
package de.jeanlucmakiola.clockula.ui.tile
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import android.service.quicksettings.TileService
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.tile.NextAlarmTileService
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import de.jeanlucmakiola.clockula.ui.widget.config.QuickTimerPicker
import de.jeanlucmakiola.clockula.ui.widget.openAppIntent
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.isDark
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import javax.inject.Inject
/**
* What long-pressing one of the Quick Settings tiles opens (#26) — the
* platform's `QS_TILE_PREFERENCES` action, which names the tile in
* `Intent.EXTRA_COMPONENT_NAME`.
*
* The quick-timer tile's one setting is its duration, so for it this is the
* quick-timer widget's own duration picker, saved the moment a row is
* picked, like every other setting in the app. The next-alarm tile has
* nothing to set, so its long-press opens the Alarms tab — what its tap does —
* rather than an empty screen.
*/
@AndroidEntryPoint
class TilePreferencesActivity : ComponentActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs
/** Set once a pick is being written, so the picker's own dismiss can't finish underneath it. */
private var saving = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Answers only the action System UI sends. Not access control —
// nothing behind it needs any: it is one setting, changed by a tap.
if (intent?.action != TileService.ACTION_QS_TILE_PREFERENCES) {
finish()
return
}
// Guarded like `MainActivity.requestFrom`: this activity is exported,
// and reading an extra unparcels the whole bundle.
val tile = runCatching {
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_COMPONENT_NAME, ComponentName::class.java)
}.getOrNull()
if (tile?.className == NextAlarmTileService::class.java.name) {
startActivity(openAppIntent(this, AlarmIntents.ACTION_SHOW_ALARMS))
finish()
return
}
enableEdgeToEdge()
setContent {
val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance())
// `null` until read, so the picker never flashes the default
// before the stored duration arrives.
val duration by settingsPrefs.quickTileDuration.collectAsState(initial = null)
val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
val current = duration ?: return@ClockulaTheme
QuickTimerPicker(
selected = current,
onSelect = ::save,
onDismiss = { if (!saving) finish() },
title = stringResource(R.string.tile_quick_timer_config_title),
)
}
}
}
private fun save(duration: Duration) {
if (saving) return
saving = true
lifecycleScope.launch {
withContext(NonCancellable) { settingsPrefs.setQuickTileDuration(duration) }
finish()
}
}
}
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
@@ -22,10 +21,9 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.ui.alarms.AlarmDateText
import de.jeanlucmakiola.clockula.ui.common.localTimeFormatter
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
@@ -128,15 +126,8 @@ fun NextAlarmWidgetContent(model: NextAlarmWidgetModel) {
private fun showAlarmsIntent(context: Context): Intent = openAppIntent(context, AlarmIntents.ACTION_SHOW_ALARMS)
private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String {
val time = LocalTime.of(model.hour, model.minute)
val formatter = if (DateFormat.is24HourFormat(context)) {
DateTimeFormatter.ofPattern("HH:mm")
} else {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
}
return formatter.format(time)
}
private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String =
localTimeFormatter(context).format(LocalTime.of(model.hour, model.minute))
private fun dayLabel(context: Context, model: NextAlarmWidgetModel): String = when {
model.isToday -> context.getString(R.string.widget_next_alarm_today)
@@ -88,15 +88,22 @@ class QuickTimerConfigActivity : WidgetConfigActivity() {
* The listed durations, then "Custom…", which opens the timer keypad. A
* stored duration that isn't listed marks the Custom row instead, with the
* value as its summary, so the picker always shows what the widget starts.
* Picking either closes it.
* Picking either closes it. Also the quick-timer Quick Settings tile's whole
* settings screen (`ui/tile/TilePreferencesActivity.kt`), which shares the
* widget's durations.
*/
@Composable
private fun QuickTimerPicker(selected: Duration, onSelect: (Duration) -> Unit, onDismiss: () -> Unit) {
internal fun QuickTimerPicker(
selected: Duration,
onSelect: (Duration) -> Unit,
onDismiss: () -> Unit,
title: String = stringResource(R.string.timer_editor_duration),
) {
var typing by rememberSaveable { mutableStateOf(false) }
val options = QuickTimerWidgetSettings.OPTIONS
val count = options.size + 1
FullScreenPicker(title = stringResource(R.string.timer_editor_duration), onDismiss = onDismiss) {
FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
@@ -1,11 +1,16 @@
package de.jeanlucmakiola.clockula.widget
import android.content.Context
import android.service.quicksettings.TileService
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import de.jeanlucmakiola.clockula.tile.NextAlarmTileService
import de.jeanlucmakiola.clockula.tile.QuickTimerTileService
import de.jeanlucmakiola.clockula.tile.requestTileUpdate
import de.jeanlucmakiola.clockula.timer.QuickTileTimer
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.updateAll
import kotlinx.coroutines.CoroutineScope
@@ -35,9 +40,16 @@ import kotlin.time.Duration.Companion.milliseconds
* (`widget/QuickTimerWidget.kt`). The analog clock reads none of them; its
* date is kept current by [MidnightRefresh].
*
* The two Quick Settings tiles (#26) are kept here too: both are *active*
* tiles, so nothing redraws them outside a click unless the app asks the
* system to bind them, which is what a change here does. The next-alarm tile
* rides the next-alarm widget's flow; the quick-timer tile follows its own
* model, which covers its timer, its remembered id and its duration.
*
* Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the
* app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home
* screen costs nothing between writes.
* screen costs nothing between writes. Every process start emits once, which
* is also the tiles' redraw after a reboot or a process death.
*/
@Singleton
class WidgetSync @Inject constructor(
@@ -46,24 +58,35 @@ class WidgetSync @Inject constructor(
private val timers: TimerRepository,
private val stopwatch: StopwatchRepository,
private val worldClocks: WorldClockRepository,
private val quickTile: QuickTileTimer,
) {
suspend fun run(): Unit = coroutineScope {
sync(alarmEngine.upcoming(), NextAlarmWidget())
sync(alarmEngine.upcoming(), NextAlarmWidget(), tiles = listOf(NextAlarmTileService::class.java))
sync(timers.timers(), TimerWidget(), QuickTimerWidget())
// The tile's own model rather than the timers alone: its duration
// (a pick, or a restored backup) and its timer id are part of it too.
sync(quickTile.model, tiles = listOf(QuickTimerTileService::class.java))
// A lap leaves the run untouched, so the lap count is watched too.
sync(combine(stopwatch.run(), stopwatch.laps()) { run, laps -> run to laps.size }, StopwatchWidget())
sync(worldClocks.worldClocks(), ClockWidget())
}
@OptIn(FlowPreview::class)
private fun CoroutineScope.sync(source: Flow<*>, vararg widgets: GlanceAppWidget) = launch {
private fun CoroutineScope.sync(
source: Flow<*>,
vararg widgets: GlanceAppWidget,
tiles: List<Class<out TileService>> = emptyList(),
) = launch {
source
.distinctUntilChanged()
// A burst of writes (an import's replace-all, a reorder) collapses
// to one redraw rather than one per row.
.debounce(DEBOUNCE)
.collect { widgets.forEach { it.updateAll(context) } }
.collect {
tiles.forEach { requestTileUpdate(context, it) }
widgets.forEach { it.updateAll(context) }
}
}
private companion object {
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "timer", white: the Quick Settings panel tints it by the tile's state. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M15,1L9,1v2h6L15,1zM11,14h2L13,8h-2v6zM19.03,7.39l1.42,-1.42c-0.43,-0.51 -0.9,-0.99 -1.41,-1.41l-1.42,1.42C16.07,4.74 14.12,4 12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9 9,-4.03 9,-9c0,-2.12 -0.74,-4.07 -1.97,-5.61zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"
android:fillColor="#FFFFFFFF" />
</vector>
+10
View File
@@ -552,4 +552,14 @@
<item quantity="other">Bedtime in %1$d minutes</item>
</plurals>
<string name="bedtime_reminder_text">Bedtime mode starts at %1$s.</string>
<!-- #26: Quick Settings tiles -->
<string name="tile_next_alarm_label">Next alarm</string>
<string name="tile_next_alarm_empty">No alarms</string>
<!-- The next alarm's day and time, e.g. "Tue 07:00". %1$s: the abbreviated weekday, %2$s: the time. -->
<string name="tile_next_alarm_subtitle">%1$s %2$s</string>
<string name="tile_quick_timer_label">Quick timer</string>
<!-- A running quick timer's end time, e.g. "Until 14:32". -->
<string name="tile_quick_timer_until">Until %1$s</string>
<string name="tile_quick_timer_config_title">Quick timer tile</string>
</resources>
@@ -31,7 +31,7 @@ class ManifestRulesTest {
}
@Test
fun `exactly the launcher and its two icon aliases, the door, the six widget configurators, the six widget receivers and the screen saver are exported`() {
fun `exactly the launcher and its two icon aliases, the door, the six widget configurators, the six widget receivers, the screen saver and the tile surfaces are exported`() {
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|</\\1>)")
.findAll(text)
.map { it.value }
@@ -51,6 +51,8 @@ class ManifestRulesTest {
// screen saver's dream is bound by the system's dream manager and its
// settings screen started by the Settings app, both from another
// process; `the screen saver is bound only by the system` guards them.
// The two Quick Settings tiles and their long-press screen (#26) are
// exported for System UI, and pinned to that below the same way.
assertThat(exported).containsExactly(
".MainActivity",
".BloomIconAlias",
@@ -70,6 +72,9 @@ class ManifestRulesTest {
".widget.QuickTimerWidgetReceiver",
".ui.screensaver.ClockulaDreamService",
".ui.screensaver.ScreenSaverSettingsActivity",
".tile.NextAlarmTileService",
".tile.QuickTimerTileService",
".ui.tile.TilePreferencesActivity",
)
}
@@ -93,6 +98,40 @@ class ManifestRulesTest {
assertThat(activityBlock(".ui.screensaver.ScreenSaverActivity")).contains("android:exported=\"false\"")
}
/**
* #26: a tile is exported so System UI can bind it, and only System UI
* can: `BIND_QUICK_SETTINGS_TILE` is a signature permission. Without it,
* any app could bind the quick-timer tile and click it.
*/
@Test
fun `the two tiles demand the system's bind permission and answer only QS_TILE`() {
for (name in listOf(".tile.NextAlarmTileService", ".tile.QuickTimerTileService")) {
val block = Regex("<service\\b[\\s\\S]*?(?:/>|</service>)")
.findAll(text)
.map { it.value }
.first { it.contains("android:name=\"$name\"") }
val actions = Regex("<action\\s+android:name=\"([^\"]+)\"").findAll(block)
.map { it.groupValues[1] }
.toList()
assertThat(actions).containsExactly("android.service.quicksettings.action.QS_TILE")
assertThat(block).contains("android:permission=\"android.permission.BIND_QUICK_SETTINGS_TILE\"")
}
}
@Test
fun `the tile preferences screen answers only QS_TILE_PREFERENCES`() {
val block = activityBlock(".ui.tile.TilePreferencesActivity")
val actions = Regex("<action\\s+android:name=\"([^\"]+)\"").findAll(block)
.map { it.groupValues[1] }
.toList()
assertThat(actions).containsExactly("android.service.quicksettings.action.QS_TILE_PREFERENCES")
assertThat(block).doesNotContain("android:permission")
}
/**
* M11: the mirror of `the six widget receivers answer only
* APPWIDGET_UPDATE` for the six widget config activities — being
@@ -18,6 +18,7 @@ import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings
import de.jeanlucmakiola.floret.prefs.ThemeMode
import org.junit.jupiter.api.Test
import java.time.LocalDate
@@ -78,6 +79,7 @@ class BackupCodecTest {
),
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
worldClockSeconds = true,
quickTileDuration = 3.minutes,
screenSaver = ScreenSaverSettings(style = ScreenSaverStyle.ANALOG, nightMode = true),
bedtime = BedtimeSettings(
enabled = true,
@@ -508,6 +510,19 @@ class BackupCodecTest {
assertThat(settings.screenSaver).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.DIGITAL, nightMode = true))
}
@Test
fun `a file without the quick tile's duration keeps its default`() {
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
assertThat(settings.quickTileDuration).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
}
@Test
fun `a quick tile duration the tile cannot start falls back to its default`() {
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"quick_tile_duration_millis\": 7200000}")
val settings = (BackupCodec.decode(json) as BackupResult.Success).content.settings
assertThat(settings.quickTileDuration).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
}
@Test
fun `an empty preset list is honoured as all removed`() {
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}")
@@ -97,6 +97,7 @@ class SettingsPrefsTest {
),
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
worldClockSeconds = false,
quickTileDuration = 2.minutes,
screenSaver = ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true),
),
)
@@ -113,6 +114,7 @@ class SettingsPrefsTest {
assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(60)
assertThat(prefs.currentDefaults().automaticLocalClock).isFalse()
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
assertThat(prefs.quickTileDuration.first()).isEqualTo(2.minutes)
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
}
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.clockula.domain.tile
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.idleTimer
import de.jeanlucmakiola.clockula.testing.pausedTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
class QuickTimerTileModelTest {
@Test
fun `nothing started is at rest on its duration`() {
val model = QuickTimerTilePresenter.present(5.minutes, null, listOf(runningTimerWith(id = 1, left = 1.minutes)), Duration.ZERO, T0)
assertThat(model).isEqualTo(QuickTimerTileModel.AtRest(5.minutes))
}
@Test
fun `its own running timer shows when it ends, on the wall clock`() {
val timers = listOf(runningTimerWith(id = 1, left = 1.minutes), runningTimerWith(id = 2, left = 90.seconds))
val model = QuickTimerTilePresenter.present(5.minutes, 2L, timers, Duration.ZERO, T0)
assertThat(model).isEqualTo(QuickTimerTileModel.Running(2L, T0 + 90.seconds))
}
@Test
fun `paused shows what is left`() {
val model = QuickTimerTilePresenter.present(5.minutes, 1L, listOf(pausedTimer(id = 1, remaining = 2.minutes)), Duration.ZERO, T0)
assertThat(model).isEqualTo(QuickTimerTileModel.Paused(1L, 2.minutes))
}
@Test
fun `expired is done`() {
val model = QuickTimerTilePresenter.present(5.minutes, 1L, listOf(expiredTimer(id = 1)), Duration.ZERO, T0)
assertThat(model).isEqualTo(QuickTimerTileModel.Expired(1L))
}
@Test
fun `a deleted or idle timer is back at rest`() {
assertThat(QuickTimerTilePresenter.present(3.minutes, 1L, emptyList(), Duration.ZERO, T0))
.isEqualTo(QuickTimerTileModel.AtRest(3.minutes))
assertThat(QuickTimerTilePresenter.present(3.minutes, 1L, listOf(idleTimer(id = 1)), Duration.ZERO, T0))
.isEqualTo(QuickTimerTileModel.AtRest(3.minutes))
}
}
@@ -0,0 +1,115 @@
package de.jeanlucmakiola.clockula.timer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.timers.QuickTileStateStore
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTileModel
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
/**
* The quick-timer tile's tap (#26) over the **real** [TimerEngine] and a real
* DataStore: what it starts, what it resets, and that the engine — so the
* slot and the service — is what does both.
*/
class QuickTileTimerTest {
private fun TimerEngineHarness.quickTile(): Pair<QuickTileTimer, QuickTileStateStore> {
val state = QuickTileStateStore(store)
return QuickTileTimer(timers, engine, settings, state, elapsed, wallClock) to state
}
@Test
fun `a tap at rest starts a transient timer at the tile's duration`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
val (tile, state) = harness.quickTile()
harness.settings.setQuickTileDuration(3.minutes)
tile.onTap()
val started = harness.timers.timers().first().single()
assertThat(started.duration).isEqualTo(3.minutes)
assertThat(started.deleteAfterUse).isTrue()
assertThat(started.state).isEqualTo(TimerState.RUNNING)
assertThat(state.currentTimerId()).isEqualTo(started.id)
assertThat(harness.service.active).isTrue()
assertThat(tile.model.first()).isInstanceOf(QuickTimerTileModel.Running::class.java)
}
@Test
fun `a second tap resets its timer, which deletes it, and the tile is at rest`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
val (tile, state) = harness.quickTile()
tile.onTap()
tile.onTap()
assertThat(harness.timers.timers().first()).isEmpty()
assertThat(state.currentTimerId()).isNull()
assertThat(harness.scheduler.expiryAt).isNull()
assertThat(harness.service.active).isFalse()
assertThat(tile.model.first()).isEqualTo(QuickTimerTileModel.AtRest(5.minutes))
}
@Test
fun `a timer deleted elsewhere leaves the tile at rest, and the next tap starts afresh`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
val (tile, state) = harness.quickTile()
tile.onTap()
val first = state.currentTimerId()!!
harness.engine.delete(first)
tile.onTap()
val second = harness.timers.timers().first().single()
assertThat(second.id).isNotEqualTo(first)
assertThat(second.state).isEqualTo(TimerState.RUNNING)
}
@Test
fun `a transient timer it created but never started is cleared by the next tap`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
val (tile, state) = harness.quickTile()
val stranded = harness.timers.create(TimerDraft(duration = 5.minutes, deleteAfterUse = true))
state.setTimerId(stranded)
tile.onTap()
val left = harness.timers.timers().first().single()
assertThat(left.id).isNotEqualTo(stranded)
assertThat(left.state).isEqualTo(TimerState.RUNNING)
}
@Test
fun `two taps at once start one timer and reset it, never two`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
val (tile, _) = harness.quickTile()
launch { tile.onTap() }
launch { tile.onTap() }
testScheduler.advanceUntilIdle()
assertThat(harness.timers.timers().first()).isEmpty()
}
@Test
fun `it never touches a timer it did not start`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
val (tile, _) = harness.quickTile()
val other = harness.timers.create(TimerDraft(duration = 10.minutes))
harness.engine.start(other)
tile.onTap()
tile.onTap()
assertThat(harness.timers.timers().first().map { it.id to it.state }).containsExactly(other to TimerState.RUNNING)
}
}
+7 -3
View File
@@ -101,9 +101,10 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `domain/bedtime/` | the bedtime schedule's pure half (issue #1): `BedtimeSettings`/`BedtimeEffects`/`BedtimeLimits` and `BedtimeSchedule` (the window a night resolves to, the active window, the due reminder, the next event). §18 |
| `domain/screensaver/` | the screen saver's pure half (issue #25): `ScreenSaverSettings` (style and night mode), `ScreenSaverDrift`, the once-a-minute burn-in position as fractions of the free space, derived from the minute alone, and `ScreenSaverAlarmDay`, which dates a next alarm a week or more away rather than naming its weekday alone |
| `domain/widget/` | the three widget presenters (M10), `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance |
| `domain/tile/` | `QuickTimerTileModel`/`QuickTimerTilePresenter` (#26): what the quick-timer Quick Settings tile shows and what a tap does, over the quick-timer widget's own selection. The next-alarm tile needs no presenter of its own; it reads `NextAlarmWidgetModel` |
| `data/db/` | `ClockulaDatabase` (`@Database` v4, `exportSchema = true`) and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record), `QuickTileStateStore` (which timer the quick-timer tile started, #26) |
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)`, unchanged since M2; M8 is the milestone that finally calls all of it |
| `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory`, the locale-keyed catalog cache and the DST-keyed display-name memo |
| `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
@@ -118,7 +119,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `alarm/ring/` | the alarm's ringing foreground service and its notifications. Its audio player and vibrator moved to `ring/` in M6, shared with the timer's ring |
| `alarm/di/` | `AlarmModule`: `@Binds` for the four seams |
| `ring/` | `RingAudioPlayer` and `RingVibrator`: one `MediaPlayer` wrapper and one vibrator for both ring paths. A build rule fails on a second one |
| `timer/` | `TimerEngine` and the three seams it talks to (`TimerScheduler`, `TimerServiceHandle`, `AlarmRingStatus` + `AlarmStateRingStatus`), plus `TimerIntents` |
| `timer/` | `TimerEngine` and the three seams it talks to (`TimerScheduler`, `TimerServiceHandle`, `AlarmRingStatus` + `AlarmStateRingStatus`), plus `TimerIntents`, and `QuickTileTimer` (#26), the quick-timer tile's model and tap, through the engine and with no Android type |
| `timer/android/` | the seams' Android implementations: the one elapsed-realtime AlarmManager slot, the service handle |
| `timer/receiver/` | `TimerExpiryReceiver` (the slot arriving), `TimerActionReceiver` (the notification's buttons) |
| `timer/service/` | `TimerService` (one foreground service for the countdown and the ring) and `TimerNotifications` |
@@ -132,7 +133,9 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `bedtime/` | `BedtimeEngine` (issue #1) and its three seams, `BedtimeScheduler`, `BedtimeMode` and `BedtimeNotifier`, plus `BedtimeIntents`. `bedtime/android/` holds their implementations (the one wall-clock slot, the Do Not Disturb rule, the reminder), `bedtime/receiver/` the `BedtimeReceiver`, `bedtime/di/` the `BedtimeModule`. §18 |
| `system/` | `RebootRepair`, the boot-id gate |
| `backup/` | `BackupEngine` (M10), the orchestrator (a ringing alarm dismissed and running timers deleted before a replace, `AlarmEngine.reschedule()`/`TimerEngine.resync()` after it), plus `BackupFiles`, the SAF seam, with its Android implementation in `backup/android/` |
| `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync`, the event-driven collector (started from `ClockulaApp.onCreate`) that redraws all three on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed |
| `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync`, the event-driven collector (started from `ClockulaApp.onCreate`) that redraws all three, and since #26 the two Quick Settings tiles, on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed |
| `tile/` | the two Quick Settings tiles (#26): `NextAlarmTileService` and `QuickTimerTileService`, which only render and forward a click, and `TileKit` (opening the app, behind an unlock on a locked device, through the `PendingIntent` overload on API 34+; asking for a redraw). Both are *active* tiles, redrawn only when `WidgetSync` (on a change they mirror) or `SystemEventReceiver` (on a clock or zone change) asks the system to, never on every panel open |
| `ui/tile/` | `TilePreferencesActivity` (#26), what long-pressing a tile opens: the quick-timer tile's duration, through the quick-timer widget's own picker |
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is gone: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller |
| `ui/common/` | `rememberLocalTimeFormatter`, the app's one 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17); and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need |
@@ -621,6 +624,7 @@ Six slices:
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active`, `bedtime_rule_stale`, `bedtime_state_owed` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow<BedtimeSettings>` and written as one record by `BedtimeEngine` only. Times clamp into a day, the reminder into 0–120 minutes, the days through `RepeatDays.fromMask`. The system rule's id is **not** stored (§18) |
| Screen saver (issue #25) | `screen_saver_style`, `screen_saver_night_mode` | `ScreenSaverPrefs`, surfaced as `SettingsPrefs.screenSaver: Flow<ScreenSaverSettings>` and carried in the backup |
| Quick-timer tile (#26) | `quick_tile_duration_millis`, `quick_tile_timer_id` | `TimerPrefs`: the duration (a backed-up setting) surfaced as `SettingsPrefs.quickTileDuration`, the transient timer the tile started (volatile, not backed up) behind `QuickTileStateStore` |
The stopwatch's *run* is a single record, so it lives in DataStore rather than
as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room.
+6 -1
View File
@@ -54,6 +54,10 @@ written here.
`default_timer_volume_ramp_seconds` restores 0, the ring timers had then.
- A missing `timer_presets` keeps the built-in presets. An empty list is a
user who removed them all, and is imported as such.
- A missing `quick_tile_duration_millis` (a file from before the Quick
Settings tiles), or one outside the quick-timer range of 1 second to 59:59,
keeps the quick-timer tile's default of five minutes — the same reading the
quick-timer widget gives its own stored duration.
- An enum field with a name this version does not recognise (a `dismiss_
challenge` or `theme_mode` from a future version, say) degrades to that
field's default, the same forgiving-read posture `AlarmMapper` already uses
@@ -174,7 +178,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"hide_always_on_display": false
},
"screen_saver_style": "DIGITAL",
"screen_saver_night_mode": false
"screen_saver_night_mode": false,
"quick_tile_duration_millis": 300000
}
```
+7 -1
View File
@@ -616,7 +616,13 @@ Deferred deliberately, in rough priority order:
`ClockulaDreamService` plus `ScreenSaverActivity` for the overflow menu's
"Screen saver", digital or analog, with a dim night mode and a once-a-minute
drift against burn-in (`ARCHITECTURE.md` §3, §6).
- **Quick Settings tile**: timer control and next alarm from the shade.
- ✅ **Quick Settings tiles** (#26): next alarm and a quick timer from the
shade. "Next alarm" reads the next-alarm widget's model and opens the Alarms
tab (after an unlock, on a locked device); "Quick timer" is the quick-timer
widget's twin, with its own duration (long-press to choose; in the backup),
and works on a locked device as the timer notification does. Both are active
tiles, redrawn by `WidgetSync` on a write rather than bound on every panel
open. No new permission.
- 🚧 **Bedtime / sleep schedule** (issue #1): a first version exists as an
opt-in screen under Settings, not a fifth tab. Clockula's own Do Not Disturb
mode (a real mode in Settings › Modes on Android 15+, with grayscale, dimmed