feat(bedtime): add an opt-in bedtime mode under Settings
Clockula can now turn on a Do Not Disturb mode of its own at bedtime and off at the wake-up time, on the nights the user picks, with an optional reminder before it starts. Digital Wellbeing's Bedtime mode can't be driven by a third-party app, so this is a separate rule: a typed mode with grayscale, dimmed wallpaper, dark theme and hidden always-on display on Android 15+, and a plain DND schedule rule on Android 10-14. The rule's policy always allows alarms (and media). BedtimeEngine owns one AlarmManager slot of its own, on its own receiver and request code, and every bedtime call on the boot, time-change and launch paths runs after the alarm and timer passes, guarded. The rule id is never stored: the platform lists an app's own rules, so restores and reinstalls find or recreate it. Turning bedtime off removes the rule. The screen walks the user through granting DND access and explains the Wellbeing overlap. Settings are part of the JSON backup; older files restore bedtime as off. Refs #1
This commit is contained in:
@@ -58,6 +58,14 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||
state: permissions, battery optimisation, Do Not Disturb, alarm volume, and
|
||||
whether the system has Clockula's next alarm. It links straight to the
|
||||
setting that fixes each problem.
|
||||
- **Bedtime**, under Settings: Clockula can turn on a Do Not Disturb mode of
|
||||
its own at bedtime and off at your wake-up time, on the nights you choose,
|
||||
with an optional reminder before it starts. On Android 15 and later it is a
|
||||
real mode in Settings › Modes and can also turn the screen grayscale, dim the
|
||||
wallpaper, switch to the dark theme and hide the always-on display. It needs
|
||||
Do Not Disturb access, which the screen helps you grant, and it always lets
|
||||
alarms through. It is separate from Digital Wellbeing's own Bedtime mode,
|
||||
which apps can't turn on.
|
||||
- Home-screen widgets: next alarm, timer (with controls), a quick timer, and
|
||||
analog and digital clocks that show your world clocks at larger sizes. Each
|
||||
widget can follow the system theme or stay light or dark, and choose what a
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
<!-- The alarm vibrates, and vibrates on its own when no audio source opens. -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- The opt-in bedtime mode (issue #1): lists Clockula under Settings ›
|
||||
Special app access › Do Not Disturb, where the user grants it by hand.
|
||||
Without that grant nothing changes; with it, Clockula adds one Do Not
|
||||
Disturb rule of its own, which always lets alarms through. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||
|
||||
<!-- "Choose from a music app…": package visibility for the apps that share
|
||||
a library through Android's standard MediaBrowserService, and for
|
||||
nothing else. A visibility declaration, not a permission — and no
|
||||
@@ -273,6 +279,17 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- The bedtime slot arriving, and Do Not Disturb access changing. The
|
||||
second is a system broadcast addressed to this package, so the
|
||||
receiver stays unexported (issue #1). -->
|
||||
<receiver
|
||||
android:name=".bedtime.receiver.BedtimeReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.app.action.NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Persists the per-app language on API < 33, where the platform
|
||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||
<service
|
||||
|
||||
@@ -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.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
@@ -38,6 +40,9 @@ class ClockulaApp : Application() {
|
||||
@Inject
|
||||
lateinit var stopwatchEngine: StopwatchEngine
|
||||
|
||||
@Inject
|
||||
lateinit var bedtimeEngine: BedtimeEngine
|
||||
|
||||
@Inject
|
||||
lateinit var widgetSync: WidgetSync
|
||||
|
||||
@@ -61,6 +66,7 @@ class ClockulaApp : Application() {
|
||||
RingNotifications.createChannels(this)
|
||||
TimerNotifications.createChannel(this)
|
||||
StopwatchNotifications.createChannel(this)
|
||||
AndroidBedtimeNotifier.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:
|
||||
@@ -80,6 +86,10 @@ class ClockulaApp : Application() {
|
||||
// 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()
|
||||
// Last and guarded, like every bedtime call on a shared path: a
|
||||
// force stop clears its AlarmManager slot too, so the slot is
|
||||
// re-armed here. The mode's own state is left as it is.
|
||||
runCatching { bedtimeEngine.rearm() }
|
||||
}
|
||||
|
||||
// Never completes on its own — it is the widgets' one refresh trigger
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
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.timer.TimerEngine
|
||||
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
|
||||
@@ -22,7 +23,7 @@ import javax.inject.Inject
|
||||
* granting "Alarms & reminders" again, which turns an inexact registration
|
||||
* back into an exact one.
|
||||
*
|
||||
* Both engines are told, in that order. For the timers a clock change
|
||||
* Both engines are told, in that order, and the bedtime schedule after them. For the timers a clock change
|
||||
* reschedules nothing (their slot is elapsed-realtime anchored); it re-anchors
|
||||
* the wall-clock fallback, which is what the notification's chronometer counts
|
||||
* to and what a reboot would fall back on.
|
||||
@@ -42,6 +43,9 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
@Inject
|
||||
lateinit var stopwatchEngine: StopwatchEngine
|
||||
|
||||
@Inject
|
||||
lateinit var bedtimeEngine: BedtimeEngine
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var scope: CoroutineScope
|
||||
@@ -97,6 +101,10 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
MidnightRefresh.schedule(context)
|
||||
}
|
||||
}
|
||||
// Last, and on its own: every one of these events can move or
|
||||
// clear the bedtime slot too, but nothing bedtime does may
|
||||
// delay or break the alarm and timer passes above.
|
||||
runCatching { bedtimeEngine.resync() }
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.backup
|
||||
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.data.backup.BackupRepository
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
@@ -26,6 +27,7 @@ class BackupEngine @Inject constructor(
|
||||
private val settings: SettingsPrefs,
|
||||
private val alarmEngine: AlarmEngine,
|
||||
private val timerEngine: TimerEngine,
|
||||
private val bedtimeEngine: BedtimeEngine,
|
||||
private val wallClock: WallClock,
|
||||
) {
|
||||
|
||||
@@ -93,6 +95,11 @@ class BackupEngine @Inject constructor(
|
||||
}
|
||||
if (failure == null) failure = reported else failure?.addSuppressed(reported)
|
||||
}
|
||||
// The bedtime settings came with the rest (or were rolled back
|
||||
// with them), so the slot and the Do Not Disturb rule follow.
|
||||
// Its platform calls are guarded already; a failure here is
|
||||
// not a failed import.
|
||||
runCatching { bedtimeEngine.resync(refreshRule = true) }
|
||||
}
|
||||
}
|
||||
failure?.let { throw it }
|
||||
@@ -103,5 +110,6 @@ class BackupEngine @Inject constructor(
|
||||
dynamicColor = settings.dynamicColor.first(),
|
||||
defaults = settings.currentDefaults(),
|
||||
timerPresets = settings.timerPresets.first(),
|
||||
bedtime = settings.currentBedtime(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSchedule
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The bedtime schedule (issue #1), orchestrated the way the other engines
|
||||
* orchestrate theirs: plain Kotlin, the platform behind three seams.
|
||||
*
|
||||
* It owns one AlarmManager slot, armed for the schedule's next event (the
|
||||
* reminder, bedtime, or the wake time), and one Do Not Disturb rule, which it
|
||||
* creates while the schedule is on and Clockula has Do Not Disturb access,
|
||||
* and removes when the schedule is turned off. Every entry point is an
|
||||
* idempotent pass over "where does the schedule stand right now", so a late,
|
||||
* missed or repeated fire settles into the right state.
|
||||
*
|
||||
* It never touches an alarm: the rule lets alarms through, and its slot is
|
||||
* its own.
|
||||
*/
|
||||
@Singleton
|
||||
class BedtimeEngine @Inject constructor(
|
||||
private val settings: SettingsPrefs,
|
||||
private val scheduler: BedtimeScheduler,
|
||||
private val mode: BedtimeMode,
|
||||
private val notifier: BedtimeNotifier,
|
||||
private val wallClock: WallClock,
|
||||
private val zones: ZoneProvider,
|
||||
) {
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
val bedtime: Flow<BedtimeSettings> = settings.bedtime
|
||||
|
||||
/**
|
||||
* A change made on the bedtime screen: stored, then applied at once. The
|
||||
* rule is rewritten from the new settings, and the mode is switched to
|
||||
* what the new schedule says now, so turning bedtime on at 23:30 for a
|
||||
* 23:00 bedtime starts it straight away.
|
||||
*/
|
||||
suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock {
|
||||
val updated = transform(settings.currentBedtime())
|
||||
settings.setBedtime(updated)
|
||||
apply(updated, refreshRule = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that can invalidate the slot or the rule: a reboot, an app
|
||||
* update, a clock or zone change, a backup restore, and Do Not Disturb
|
||||
* access being granted or revoked.
|
||||
*/
|
||||
suspend fun resync(refreshRule: Boolean = false) = lock.withLock {
|
||||
apply(settings.currentBedtime(), refreshRule = refreshRule)
|
||||
}
|
||||
|
||||
/**
|
||||
* App launch: re-arm the slot only. AlarmManager forgets it on a force
|
||||
* stop, but the mode's state is left alone here — a launch is not a
|
||||
* bedtime event, and a mode the user switched on or off by hand should
|
||||
* stay that way until the next one.
|
||||
*/
|
||||
suspend fun rearm() = lock.withLock {
|
||||
schedule(settings.currentBedtime())
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot arriving. A reminder fire posts the reminder and leaves the
|
||||
* mode alone; any other fire switches the mode to what the schedule says
|
||||
* now. Either way the next event is armed.
|
||||
*/
|
||||
suspend fun onSlotFired() = lock.withLock {
|
||||
val current = settings.currentBedtime()
|
||||
val now = wallClock.now()
|
||||
val zone = zones.current()
|
||||
val reminderFor = BedtimeSchedule.dueReminder(current, now, zone)
|
||||
if (reminderFor != null) {
|
||||
notifier.showReminder(reminderFor.start, current.reminderMinutes)
|
||||
schedule(current)
|
||||
} else {
|
||||
apply(current, refreshRule = false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether Clockula may change Do Not Disturb, for the screen's onboarding row. */
|
||||
fun hasAccess(): Boolean = mode.hasAccess()
|
||||
|
||||
val supportsEffects: Boolean get() = mode.supportsEffects
|
||||
|
||||
fun ruleId(): String? = mode.ruleId()
|
||||
|
||||
private fun apply(current: BedtimeSettings, refreshRule: Boolean) {
|
||||
if (!current.enabled) {
|
||||
scheduler.cancel()
|
||||
notifier.cancelReminder()
|
||||
// Removing the rule ends the mode if it is on. Without access there
|
||||
// is nothing Clockula may remove; revoking access in Settings
|
||||
// already removed the rule.
|
||||
if (mode.hasAccess()) mode.uninstall()
|
||||
return
|
||||
}
|
||||
val now = wallClock.now()
|
||||
val active = BedtimeSchedule.activeWindow(current, now, zones.current()) != null
|
||||
if (active) notifier.cancelReminder()
|
||||
if (mode.hasAccess()) {
|
||||
mode.install(current, refresh = refreshRule)
|
||||
mode.setActive(active)
|
||||
}
|
||||
schedule(current)
|
||||
}
|
||||
|
||||
private fun schedule(current: BedtimeSettings) {
|
||||
val next = BedtimeSchedule.nextEvent(current, wallClock.now(), zones.current())
|
||||
if (next == null) scheduler.cancel() else scheduler.schedule(next)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime
|
||||
|
||||
/**
|
||||
* The bedtime slot's broadcast. Internal and unexported, like the alarm's and
|
||||
* the timer's: only the PendingIntent this app created can deliver it.
|
||||
*/
|
||||
object BedtimeIntents {
|
||||
const val ACTION_SLOT: String = "de.jeanlucmakiola.clockula.bedtime.action.SLOT"
|
||||
|
||||
/** Opens the bedtime screen, from the reminder notification. */
|
||||
const val ACTION_SHOW_BEDTIME: String = "de.jeanlucmakiola.clockula.action.SHOW_BEDTIME"
|
||||
|
||||
/**
|
||||
* `NotificationManager.ACTION_AUTOMATIC_ZEN_RULE`: what Settings sends the
|
||||
* rule's configuration activity (`MainActivity`) when the user taps
|
||||
* "Clockula" on the mode's page. Spelled out so the shell's pure routing
|
||||
* table needs no Android import.
|
||||
*/
|
||||
const val ACTION_AUTOMATIC_ZEN_RULE: String = "android.app.action.AUTOMATIC_ZEN_RULE"
|
||||
|
||||
/**
|
||||
* Apart from every alarm and timer request code, although the explicit
|
||||
* receiver and the action already keep these PendingIntents distinct.
|
||||
*/
|
||||
const val REQUEST_SLOT: Int = 40
|
||||
|
||||
const val REQUEST_SHOW: Int = 41
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The AlarmManager seam: one slot of its own, on its own receiver and request
|
||||
* code, so nothing bedtime does can replace or cancel an alarm's or a timer's
|
||||
* registration.
|
||||
*/
|
||||
interface BedtimeScheduler {
|
||||
fun schedule(at: Instant)
|
||||
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Do Not Disturb seam: Clockula's one automatic zen rule (a "mode" on
|
||||
* Android 15 and later). Every call is a no-op without Do Not Disturb access,
|
||||
* and none of them throws: a failure here must never stop the slot from being
|
||||
* rescheduled.
|
||||
*/
|
||||
interface BedtimeMode {
|
||||
/** Whether the user granted Do Not Disturb access ("Special app access"). */
|
||||
fun hasAccess(): Boolean
|
||||
|
||||
/** Whether the mode can carry screen effects (Android 15 and later). */
|
||||
val supportsEffects: Boolean
|
||||
|
||||
/**
|
||||
* Creates the rule when it is missing. [refresh] also rewrites an existing
|
||||
* one from [settings]; otherwise an existing rule is left exactly as it is.
|
||||
*/
|
||||
fun install(settings: BedtimeSettings, refresh: Boolean)
|
||||
|
||||
/** Turns the mode on or off. Does nothing when there is no rule. */
|
||||
fun setActive(active: Boolean)
|
||||
|
||||
/** Removes the rule, which also ends it if it is on. */
|
||||
fun uninstall()
|
||||
|
||||
/** The rule's id, for a deep link into the system's settings; null when there is none. */
|
||||
fun ruleId(): String?
|
||||
}
|
||||
|
||||
/** The reminder notification. */
|
||||
interface BedtimeNotifier {
|
||||
fun showReminder(start: Instant, minutesBefore: Int)
|
||||
|
||||
fun cancelReminder()
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.app.AutomaticZenRule
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.service.notification.Condition
|
||||
import android.service.notification.ZenDeviceEffects
|
||||
import android.service.notification.ZenPolicy
|
||||
import androidx.annotation.RequiresApi
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import java.time.LocalTime
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Clockula's one automatic zen rule. On Android 15 and later it is a mode in
|
||||
* Settings › Modes, typed as a time schedule, under Clockula's name and icon,
|
||||
* carrying the screen effects the user picked. On Android 10 to 14 it is a
|
||||
* plain Do Not Disturb schedule rule.
|
||||
*
|
||||
* Digital Wellbeing's own Bedtime mode is off limits: the platform refuses
|
||||
* `TYPE_BEDTIME` from any package but the system's wellbeing app, and no app
|
||||
* can read its schedule. This is a separate mode next to it.
|
||||
*
|
||||
* **Alarms always get through.** The rule's [ZenPolicy] allows alarms (and
|
||||
* media, for a sleep sound) and leaves everything else to the device's own Do
|
||||
* Not Disturb defaults. A rule needs an owner service or a configuration
|
||||
* activity; [MainActivity] is the latter, and it opens the bedtime screen when
|
||||
* Settings sends `ACTION_AUTOMATIC_ZEN_RULE`. It is already exported, so no new
|
||||
* component is exposed for this.
|
||||
*
|
||||
* Nothing is stored about the rule. [NotificationManager.getAutomaticZenRules]
|
||||
* lists only this app's own rules, and the condition id tells Clockula's apart,
|
||||
* so a reinstall, a cleared app or a restore onto another device finds or
|
||||
* recreates it without a stale id. Every call is guarded: a revoked access or
|
||||
* an OEM quirk costs the mode, never the caller.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidBedtimeMode @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : BedtimeMode {
|
||||
|
||||
private val manager: NotificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
private val conditionId: Uri = Uri.Builder()
|
||||
.scheme(Condition.SCHEME)
|
||||
.authority(context.packageName)
|
||||
.appendPath("bedtime")
|
||||
.build()
|
||||
|
||||
override val supportsEffects: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
|
||||
|
||||
override fun hasAccess(): Boolean = guarded(false) { manager.isNotificationPolicyAccessGranted }
|
||||
|
||||
override fun install(settings: BedtimeSettings, refresh: Boolean) {
|
||||
guarded(Unit) {
|
||||
val ours = ownRules()
|
||||
// Only ever one. A second (two passes racing on another process,
|
||||
// say) is removed rather than left to fight the first.
|
||||
ours.keys.drop(1).forEach(manager::removeAutomaticZenRule)
|
||||
val existing = ours.entries.firstOrNull()
|
||||
if (existing == null) {
|
||||
manager.addAutomaticZenRule(rule(settings, enabled = true))
|
||||
} else if (refresh) {
|
||||
// Keeps the user's own on/off switch for the rule in Settings: a
|
||||
// change on Clockula's screen must not re-enable a rule they disabled.
|
||||
manager.updateAutomaticZenRule(existing.key, rule(settings, enabled = existing.value.isEnabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setActive(active: Boolean) {
|
||||
guarded(Unit) {
|
||||
val id = ownRules().keys.firstOrNull() ?: return@guarded
|
||||
val state = if (active) Condition.STATE_TRUE else Condition.STATE_FALSE
|
||||
val summary = context.getString(R.string.bedtime_mode_name)
|
||||
val condition = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// A schedule's own transition, so Android 15 keeps a user's manual
|
||||
// override of the mode until the schedule's next one.
|
||||
Condition(conditionId, summary, state, Condition.SOURCE_SCHEDULE)
|
||||
} else {
|
||||
Condition(conditionId, summary, state)
|
||||
}
|
||||
manager.setAutomaticZenRuleState(id, condition)
|
||||
}
|
||||
}
|
||||
|
||||
override fun uninstall() {
|
||||
guarded(Unit) {
|
||||
ownRules().keys.forEach(manager::removeAutomaticZenRule)
|
||||
}
|
||||
}
|
||||
|
||||
override fun ruleId(): String? = guarded(null) { ownRules().keys.firstOrNull() }
|
||||
|
||||
private fun ownRules(): Map<String, AutomaticZenRule> =
|
||||
manager.automaticZenRules.filterValues { it.conditionId == conditionId }
|
||||
|
||||
private fun rule(settings: BedtimeSettings, enabled: Boolean): AutomaticZenRule {
|
||||
val name = context.getString(R.string.bedtime_mode_name)
|
||||
val configuration = ComponentName(context, MainActivity::class.java)
|
||||
val policy = ZenPolicy.Builder()
|
||||
.allowAlarms(true)
|
||||
.allowMedia(true)
|
||||
.build()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
modeRule(name, configuration, policy, settings, enabled)
|
||||
} else {
|
||||
AutomaticZenRule(
|
||||
name,
|
||||
null,
|
||||
configuration,
|
||||
conditionId,
|
||||
policy,
|
||||
NotificationManager.INTERRUPTION_FILTER_PRIORITY,
|
||||
enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
private fun modeRule(
|
||||
name: String,
|
||||
configuration: ComponentName,
|
||||
policy: ZenPolicy,
|
||||
settings: BedtimeSettings,
|
||||
enabled: Boolean,
|
||||
): AutomaticZenRule = AutomaticZenRule.Builder(name, conditionId)
|
||||
.setType(AutomaticZenRule.TYPE_SCHEDULE_TIME)
|
||||
.setConfigurationActivity(configuration)
|
||||
.setZenPolicy(policy)
|
||||
.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY)
|
||||
.setDeviceEffects(
|
||||
ZenDeviceEffects.Builder()
|
||||
.setShouldDisplayGrayscale(settings.effects.grayscale)
|
||||
.setShouldDimWallpaper(settings.effects.dimWallpaper)
|
||||
.setShouldUseNightMode(settings.effects.darkTheme)
|
||||
.setShouldSuppressAmbientDisplay(settings.effects.hideAlwaysOnDisplay)
|
||||
.build(),
|
||||
)
|
||||
.setTriggerDescription(
|
||||
context.getString(
|
||||
R.string.bedtime_mode_trigger,
|
||||
formatTime(settings.bedtime),
|
||||
formatTime(settings.wakeTime),
|
||||
),
|
||||
)
|
||||
.setIconResId(R.drawable.ic_bedtime)
|
||||
.setManualInvocationAllowed(true)
|
||||
.setEnabled(enabled)
|
||||
.build()
|
||||
|
||||
private fun formatTime(time: TimeOfDay): String = formatBedtimeTime(context, LocalTime.of(time.hour, time.minute))
|
||||
|
||||
private inline fun <T> guarded(fallback: T, block: () -> T): T = try {
|
||||
block()
|
||||
} catch (_: RuntimeException) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The "bedtime soon" reminder, on a channel of its own so it can be silenced
|
||||
* without touching anything an alarm or timer posts. Denied the notification
|
||||
* permission, the user loses the reminder and nothing else.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidBedtimeNotifier @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val zones: ZoneProvider,
|
||||
) : BedtimeNotifier {
|
||||
|
||||
private val notifications = NotificationManagerCompat.from(context)
|
||||
|
||||
override fun showReminder(start: Instant, minutesBefore: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
val time = formatBedtimeTime(context, start.toJavaInstant().atZone(zones.current()).toLocalTime())
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_bedtime)
|
||||
.setContentTitle(
|
||||
context.resources.getQuantityString(R.plurals.bedtime_reminder_title, minutesBefore, minutesBefore),
|
||||
)
|
||||
.setContentText(context.getString(R.string.bedtime_reminder_text, time))
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(showPendingIntent())
|
||||
.build()
|
||||
runCatching { notifications.notify(NOTIFICATION_ID, notification) }
|
||||
}
|
||||
|
||||
override fun cancelReminder() = notifications.cancel(NOTIFICATION_ID)
|
||||
|
||||
private fun showPendingIntent(): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
BedtimeIntents.REQUEST_SHOW,
|
||||
Intent(context, MainActivity::class.java)
|
||||
.setAction(BedtimeIntents.ACTION_SHOW_BEDTIME)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID: String = "bedtime_reminder"
|
||||
|
||||
/** Next to the other fixed ids (1_001..1_004), below the snoozed alarms' 2_000 + id. */
|
||||
private const val NOTIFICATION_ID: Int = 1_005
|
||||
|
||||
/** Idempotent; called from the app class on every start, like the other channels. */
|
||||
fun createChannel(context: Context) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.channel_bedtime_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = context.getString(R.string.channel_bedtime_description)
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
|
||||
import de.jeanlucmakiola.clockula.bedtime.receiver.BedtimeReceiver
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One wall-clock slot for the schedule's next event. Never `setAlarmClock`:
|
||||
* that slot is the status bar's "next alarm", and bedtime is not an alarm.
|
||||
* Exact while idle where the platform allows it, the same two branches the
|
||||
* timer's slot uses: a wake-up time that ends the mode an hour late, after a
|
||||
* doze maintenance window, is the failure this avoids. It is its own
|
||||
* PendingIntent (its own receiver, action and request code), so arming or
|
||||
* cancelling it can never touch an alarm's or a timer's registration.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidBedtimeScheduler @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : BedtimeScheduler {
|
||||
|
||||
private val manager: AlarmManager = context.getSystemService(AlarmManager::class.java)
|
||||
|
||||
override fun schedule(at: Instant) {
|
||||
val millis = at.toEpochMilliseconds()
|
||||
val pendingIntent = slotPendingIntent()
|
||||
val exact = runCatching {
|
||||
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
|
||||
}.isSuccess
|
||||
if (!exact) manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
|
||||
}
|
||||
|
||||
override fun cancel() = manager.cancel(slotPendingIntent())
|
||||
|
||||
private fun slotPendingIntent(): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
BedtimeIntents.REQUEST_SLOT,
|
||||
Intent(context, BedtimeReceiver::class.java).setAction(BedtimeIntents.ACTION_SLOT),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.android
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateFormat
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/**
|
||||
* The same two steps `ui/common/AlarmTimeFormatter.kt` takes — the device's
|
||||
* 12/24-hour choice, then the locale's short form — for the two bedtime
|
||||
* strings the system shows outside the app: the mode's trigger line and the
|
||||
* reminder notification.
|
||||
*/
|
||||
internal fun formatBedtimeTime(context: Context, time: LocalTime): String {
|
||||
val formatter = if (DateFormat.is24HourFormat(context)) {
|
||||
DateTimeFormatter.ofPattern("HH:mm")
|
||||
} else {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
}
|
||||
return formatter.format(time)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeMode
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeScheduler
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** The three seams `BedtimeEngine` sees, which keep it JVM-testable. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class BedtimeModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBedtimeScheduler(impl: AndroidBedtimeScheduler): BedtimeScheduler
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBedtimeMode(impl: AndroidBedtimeMode): BedtimeMode
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBedtimeNotifier(impl: AndroidBedtimeNotifier): BedtimeNotifier
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime.receiver
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The bedtime slot arriving, and Do Not Disturb access changing. The second
|
||||
* is sent by the system to this package only, so an unexported receiver gets
|
||||
* it: granting access creates the rule at once, and revoking it leaves the
|
||||
* slot running for the reminder (Settings removes the rule itself when the
|
||||
* user revokes access there).
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class BedtimeReceiver : HiltBroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var engine: BedtimeEngine
|
||||
|
||||
@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) {
|
||||
BedtimeIntents.ACTION_SLOT -> engine.onSlotFired()
|
||||
NotificationManager.ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED ->
|
||||
engine.resync(refreshRule = true)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
@@ -128,8 +131,44 @@ object BackupCodec {
|
||||
dismissChallenge = defaults.dismissChallenge.name,
|
||||
homeZoneId = defaults.homeZoneId,
|
||||
timerPresets = timerPresets.map { it.inWholeMilliseconds },
|
||||
bedtime = bedtime.toDto(),
|
||||
)
|
||||
|
||||
private fun BedtimeSettings.toDto() = BedtimeDto(
|
||||
enabled = enabled,
|
||||
bedtimeMinutes = bedtime.minutesOfDay,
|
||||
wakeMinutes = wakeTime.minutesOfDay,
|
||||
days = days.mask,
|
||||
reminderMinutes = reminderMinutes,
|
||||
grayscale = effects.grayscale,
|
||||
dimWallpaper = effects.dimWallpaper,
|
||||
darkTheme = effects.darkTheme,
|
||||
hideAlwaysOnDisplay = effects.hideAlwaysOnDisplay,
|
||||
)
|
||||
|
||||
/**
|
||||
* Forgiving, like the other settings: an out-of-range time or reminder is
|
||||
* clamped rather than failing the import.
|
||||
*/
|
||||
private fun BedtimeDto.toBedtime() = BedtimeSettings(
|
||||
enabled = enabled,
|
||||
bedtime = minutesToTime(bedtimeMinutes),
|
||||
wakeTime = minutesToTime(wakeMinutes),
|
||||
days = RepeatDays.fromMask(days),
|
||||
reminderMinutes = reminderMinutes.coerceIn(BedtimeLimits.REMINDER_MINUTES),
|
||||
effects = BedtimeEffects(
|
||||
grayscale = grayscale,
|
||||
dimWallpaper = dimWallpaper,
|
||||
darkTheme = darkTheme,
|
||||
hideAlwaysOnDisplay = hideAlwaysOnDisplay,
|
||||
),
|
||||
)
|
||||
|
||||
private fun minutesToTime(minutes: Int): TimeOfDay {
|
||||
val clamped = minutes.coerceIn(0, 24 * 60 - 1)
|
||||
return TimeOfDay(clamped / 60, clamped % 60)
|
||||
}
|
||||
|
||||
// --- decode + validate ---
|
||||
|
||||
private fun BackupDocument.toContent(): BackupResult {
|
||||
@@ -235,6 +274,7 @@ object BackupCodec {
|
||||
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
|
||||
),
|
||||
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
|
||||
bedtime = settingsDto.bedtime?.toBedtime() ?: BedtimeSettings(),
|
||||
)
|
||||
|
||||
return BackupResult.Success(
|
||||
|
||||
@@ -77,4 +77,23 @@ data class SettingsDto(
|
||||
* built-in ones.
|
||||
*/
|
||||
@SerialName("timer_presets") val timerPresets: List<Long>? = null,
|
||||
/** Absent from files written before issue #1; restores as "off" with the default schedule. */
|
||||
val bedtime: BedtimeDto? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The bedtime schedule (issue #1). Times are minutes since midnight, like the
|
||||
* `BedtimePrefs` keys; `days` is the same 7-bit mask an alarm's `repeat_days` is.
|
||||
*/
|
||||
@Serializable
|
||||
data class BedtimeDto(
|
||||
val enabled: Boolean = false,
|
||||
@SerialName("bedtime_minutes") val bedtimeMinutes: Int = 22 * 60 + 30,
|
||||
@SerialName("wake_minutes") val wakeMinutes: Int = 7 * 60,
|
||||
val days: Int = 0b111_1111,
|
||||
@SerialName("reminder_minutes") val reminderMinutes: Int = 0,
|
||||
val grayscale: Boolean = true,
|
||||
@SerialName("dim_wallpaper") val dimWallpaper: Boolean = true,
|
||||
@SerialName("dark_theme") val darkTheme: Boolean = true,
|
||||
@SerialName("hide_always_on_display") val hideAlwaysOnDisplay: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.intPref
|
||||
|
||||
/**
|
||||
* The bedtime schedule's on-disk keys (issue #1). Configuration only: the
|
||||
* id of the system mode Clockula created is not stored at all, because the
|
||||
* platform already lists an app's own rules (`AndroidBedtimeMode`), and a
|
||||
* stored id would go stale after a restore onto another device.
|
||||
*/
|
||||
object BedtimePrefs {
|
||||
|
||||
private val DEFAULT = BedtimeSettings()
|
||||
|
||||
val enabled: Pref<Boolean> = booleanPref("bedtime_enabled", DEFAULT.enabled)
|
||||
|
||||
val bedtime: Pref<TimeOfDay> = intPref("bedtime_start_minutes", DEFAULT.bedtime.minutesOfDay).asTimeOfDay()
|
||||
|
||||
val wakeTime: Pref<TimeOfDay> = intPref("bedtime_wake_minutes", DEFAULT.wakeTime.minutesOfDay).asTimeOfDay()
|
||||
|
||||
/** The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set. */
|
||||
val days: Pref<RepeatDays> = intPref("bedtime_days", DEFAULT.days.mask)
|
||||
.map(decode = RepeatDays::fromMask, encode = { it.mask })
|
||||
|
||||
val reminderMinutes: Pref<Int> = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes)
|
||||
.map(
|
||||
decode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) },
|
||||
encode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) },
|
||||
)
|
||||
|
||||
val grayscale: Pref<Boolean> = booleanPref("bedtime_effect_grayscale", DEFAULT.effects.grayscale)
|
||||
|
||||
val dimWallpaper: Pref<Boolean> = booleanPref("bedtime_effect_dim_wallpaper", DEFAULT.effects.dimWallpaper)
|
||||
|
||||
val darkTheme: Pref<Boolean> = booleanPref("bedtime_effect_dark_theme", DEFAULT.effects.darkTheme)
|
||||
|
||||
val hideAlwaysOnDisplay: Pref<Boolean> =
|
||||
booleanPref("bedtime_effect_hide_aod", DEFAULT.effects.hideAlwaysOnDisplay)
|
||||
|
||||
/** Minutes since midnight on disk, clamped into a day both ways. */
|
||||
private fun Pref<Int>.asTimeOfDay(): Pref<TimeOfDay> = map(
|
||||
decode = { minutes ->
|
||||
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||
TimeOfDay(clamped / 60, clamped % 60)
|
||||
},
|
||||
encode = { it.minutesOfDay },
|
||||
)
|
||||
|
||||
private const val MINUTES_PER_DAY = 24 * 60
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
|
||||
@@ -93,6 +96,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge
|
||||
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
|
||||
prefs[TimerPrefs.presets] = backup.timerPresets
|
||||
prefs.putBedtime(backup.bedtime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +137,59 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) }
|
||||
}
|
||||
|
||||
// --- bedtime (issue #1) ---
|
||||
|
||||
/** Distinct-until-changed, composed from the individual keys like [defaults]. */
|
||||
val bedtime: Flow<BedtimeSettings> = combine(
|
||||
combine(
|
||||
store.flow(BedtimePrefs.enabled),
|
||||
store.flow(BedtimePrefs.bedtime),
|
||||
store.flow(BedtimePrefs.wakeTime),
|
||||
store.flow(BedtimePrefs.days),
|
||||
store.flow(BedtimePrefs.reminderMinutes),
|
||||
) { enabled, bedtime, wakeTime, days, reminderMinutes ->
|
||||
BedtimeSettings(
|
||||
enabled = enabled,
|
||||
bedtime = bedtime,
|
||||
wakeTime = wakeTime,
|
||||
days = days,
|
||||
reminderMinutes = reminderMinutes,
|
||||
)
|
||||
},
|
||||
store.flow(BedtimePrefs.grayscale),
|
||||
store.flow(BedtimePrefs.dimWallpaper),
|
||||
store.flow(BedtimePrefs.darkTheme),
|
||||
store.flow(BedtimePrefs.hideAlwaysOnDisplay),
|
||||
) { partial, grayscale, dimWallpaper, darkTheme, hideAlwaysOnDisplay ->
|
||||
partial.copy(
|
||||
effects = BedtimeEffects(
|
||||
grayscale = grayscale,
|
||||
dimWallpaper = dimWallpaper,
|
||||
darkTheme = darkTheme,
|
||||
hideAlwaysOnDisplay = hideAlwaysOnDisplay,
|
||||
),
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
suspend fun currentBedtime(): BedtimeSettings = bedtime.first()
|
||||
|
||||
/** Every bedtime key in one transaction. `BedtimeEngine` is the only writer. */
|
||||
suspend fun setBedtime(settings: BedtimeSettings) {
|
||||
store.edit { prefs -> prefs.putBedtime(settings) }
|
||||
}
|
||||
|
||||
private fun MutablePreferences.putBedtime(settings: BedtimeSettings) {
|
||||
this[BedtimePrefs.enabled] = settings.enabled
|
||||
this[BedtimePrefs.bedtime] = settings.bedtime
|
||||
this[BedtimePrefs.wakeTime] = settings.wakeTime
|
||||
this[BedtimePrefs.days] = settings.days
|
||||
this[BedtimePrefs.reminderMinutes] = settings.reminderMinutes
|
||||
this[BedtimePrefs.grayscale] = settings.effects.grayscale
|
||||
this[BedtimePrefs.dimWallpaper] = settings.effects.dimWallpaper
|
||||
this[BedtimePrefs.darkTheme] = settings.effects.darkTheme
|
||||
this[BedtimePrefs.hideAlwaysOnDisplay] = settings.effects.hideAlwaysOnDisplay
|
||||
}
|
||||
|
||||
// --- first-launch bookkeeping (M4) ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
@@ -56,4 +57,6 @@ data class BackupSettings(
|
||||
val dynamicColor: Boolean,
|
||||
val defaults: ClockDefaults,
|
||||
val timerPresets: List<Duration>,
|
||||
/** Absent from older backups, which restore it as [BedtimeSettings]' defaults (off). */
|
||||
val bedtime: BedtimeSettings = BedtimeSettings(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.jeanlucmakiola.clockula.domain.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmOccurrences
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
/** One night of bedtime: on from [start] (inclusive) to [end] (exclusive). */
|
||||
data class BedtimeWindow(val start: Instant, val end: Instant) {
|
||||
operator fun contains(instant: Instant): Boolean = instant >= start && instant < end
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a bedtime schedule stands at a given instant. Like the alarm path,
|
||||
* every window is re-resolved from local times and the zone, never by adding
|
||||
* a fixed length to the last one, so a DST night is as long as the clock
|
||||
* says it is.
|
||||
*/
|
||||
object BedtimeSchedule {
|
||||
|
||||
/**
|
||||
* The window that starts on [date], if [date] is one of the schedule's
|
||||
* nights. It ends at the first wake time after it starts, so a wake time
|
||||
* at or before the bedtime means the next morning, and an equal one a
|
||||
* full day.
|
||||
*/
|
||||
fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? {
|
||||
if (date.dayOfWeek !in settings.days) return null
|
||||
val start = AlarmOccurrences.at(date, settings.bedtime, zone)
|
||||
val sameDayEnd = AlarmOccurrences.at(date, settings.wakeTime, zone)
|
||||
val end = if (sameDayEnd > start) sameDayEnd else AlarmOccurrences.at(date.plusDays(1), settings.wakeTime, zone)
|
||||
return BedtimeWindow(start, end)
|
||||
}
|
||||
|
||||
/** The window [now] falls in, or null when it is daytime or the schedule is off. */
|
||||
fun activeWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
|
||||
if (!settings.enabled) return null
|
||||
val today = today(now, zone)
|
||||
// A window is at most a day long, so one that contains now started
|
||||
// today or the day before.
|
||||
return listOf(today, today.minusDays(1))
|
||||
.firstNotNullOfOrNull { date -> windowOn(settings, date, zone)?.takeIf { now in it } }
|
||||
}
|
||||
|
||||
/** The first window that starts strictly after [now], within the coming week. */
|
||||
fun nextWindow(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
|
||||
if (!settings.enabled) return null
|
||||
val today = today(now, zone)
|
||||
return (0L..7L)
|
||||
.firstNotNullOfOrNull { offset ->
|
||||
windowOn(settings, today.plusDays(offset), zone)?.takeIf { it.start > now }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The upcoming window whose reminder is due at [now]: [now] lies between
|
||||
* its reminder time and its start. Null when reminders are off, or when
|
||||
* [now] is already inside a window (the reminder has nothing left to say).
|
||||
*/
|
||||
fun dueReminder(settings: BedtimeSettings, now: Instant, zone: ZoneId): BedtimeWindow? {
|
||||
if (!settings.enabled || settings.reminderMinutes <= 0) return null
|
||||
if (activeWindow(settings, now, zone) != null) return null
|
||||
val next = nextWindow(settings, now, zone) ?: return null
|
||||
return next.takeIf { now >= reminderAt(settings, next) }
|
||||
}
|
||||
|
||||
/**
|
||||
* When the schedule next needs attention: the end of the current window,
|
||||
* the next window's reminder or the next window's start, whichever comes
|
||||
* first. Null when there is nothing to schedule.
|
||||
*/
|
||||
fun nextEvent(settings: BedtimeSettings, now: Instant, zone: ZoneId): Instant? {
|
||||
if (!settings.enabled) return null
|
||||
val active = activeWindow(settings, now, zone)
|
||||
val next = nextWindow(settings, now, zone)
|
||||
val reminder = next
|
||||
?.takeIf { settings.reminderMinutes > 0 }
|
||||
?.let { reminderAt(settings, it) }
|
||||
?.takeIf { it > now }
|
||||
return listOfNotNull(active?.end, reminder, next?.start).minOrNull()
|
||||
}
|
||||
|
||||
private fun reminderAt(settings: BedtimeSettings, window: BedtimeWindow): Instant =
|
||||
window.start - settings.reminderMinutes.minutes
|
||||
|
||||
private fun today(now: Instant, zone: ZoneId): LocalDate = now.toJavaInstant().atZone(zone).toLocalDate()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.clockula.domain.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
|
||||
/**
|
||||
* The bedtime schedule (issue #1): an opt-in Do Not Disturb mode Clockula
|
||||
* turns on at [bedtime] and off at [wakeTime]. [days] are the nights the
|
||||
* mode *starts* on, so a Friday-only schedule runs from Friday evening into
|
||||
* Saturday morning.
|
||||
*/
|
||||
data class BedtimeSettings(
|
||||
val enabled: Boolean = false,
|
||||
val bedtime: TimeOfDay = TimeOfDay(22, 30),
|
||||
val wakeTime: TimeOfDay = TimeOfDay(7, 0),
|
||||
val days: RepeatDays = RepeatDays.EVERY_DAY,
|
||||
/** How long before [bedtime] to post a reminder; 0 is off. */
|
||||
val reminderMinutes: Int = 0,
|
||||
val effects: BedtimeEffects = BedtimeEffects(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The screen effects a mode can carry on Android 15 and later
|
||||
* (`ZenDeviceEffects`). Earlier versions keep the setting and ignore it.
|
||||
*/
|
||||
data class BedtimeEffects(
|
||||
val grayscale: Boolean = true,
|
||||
val dimWallpaper: Boolean = true,
|
||||
val darkTheme: Boolean = true,
|
||||
val hideAlwaysOnDisplay: Boolean = false,
|
||||
)
|
||||
|
||||
object BedtimeLimits {
|
||||
/** The reminder lead times the picker offers, in minutes; 0 is off. */
|
||||
val REMINDER_CHOICES: List<Int> = listOf(0, 15, 30, 45, 60)
|
||||
|
||||
/** Anything read from disk or a backup is clamped into this. */
|
||||
val REMINDER_MINUTES: IntRange = 0..120
|
||||
}
|
||||
@@ -155,9 +155,9 @@ object SelfCheck {
|
||||
/**
|
||||
* Only total silence (`INTERRUPTION_FILTER_NONE`) is treated as blocking
|
||||
* alarms: Priority and Alarms-only modes leave the system's own alarm
|
||||
* exemption in effect. Clockula never requests `ACCESS_NOTIFICATION_POLICY`
|
||||
* and never changes DND itself — it only reports what the user already has
|
||||
* (`docs/PLAN.md` §4).
|
||||
* exemption in effect. The check never changes DND itself — it only
|
||||
* reports what the user already has (`docs/PLAN.md` §4). The opt-in
|
||||
* bedtime mode does change it, with a rule that always allows alarms.
|
||||
*/
|
||||
private fun dnd(state: DeviceState): CheckResult {
|
||||
val blocked = state.interruptionFilter == INTERRUPTION_FILTER_NONE
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package de.jeanlucmakiola.clockula.ui.bedtime
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.DoNotDisturbOn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.AlarmTimePickerDialog
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.RepeatDaySelector
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
||||
import de.jeanlucmakiola.clockula.ui.settings.SettingsHint
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** Which of the screen's dialogs is open. */
|
||||
private enum class BedtimePicker { BEDTIME, WAKE_TIME, REMINDER }
|
||||
|
||||
/**
|
||||
* Bedtime (issue #1): an opt-in Do Not Disturb mode that Clockula turns on at
|
||||
* bedtime and off at the wake-up time, with an optional reminder before it
|
||||
* starts and, on Android 15 and later, screen effects. Reached from Settings
|
||||
* rather than a fifth tab, from the reminder, and from the mode's own page in
|
||||
* the system settings.
|
||||
*/
|
||||
@Composable
|
||||
fun BedtimeScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: BedtimeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val formatTime = rememberAlarmTimeFormatter()
|
||||
var picker by rememberSaveable { mutableStateOf<BedtimePicker?>(null) }
|
||||
|
||||
// Access is granted in the system's settings, so it is re-read whenever
|
||||
// the user comes back from there.
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh()
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val loaded = state as? BedtimeUiState.Loaded
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.bedtime_title),
|
||||
onBack = onBack,
|
||||
modifier = modifier,
|
||||
) {
|
||||
if (loaded == null) return@CollapsingScaffold
|
||||
val settings = loaded.settings
|
||||
|
||||
SettingsHint(stringResource(R.string.bedtime_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_enabled),
|
||||
summary = if (settings.enabled) {
|
||||
stringResource(R.string.bedtime_enabled_on, formatTime(settings.bedtime), formatTime(settings.wakeTime))
|
||||
} else {
|
||||
stringResource(R.string.bedtime_enabled_off)
|
||||
},
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Switch(checked = settings.enabled, onCheckedChange = viewModel::onEnabledChanged)
|
||||
},
|
||||
onClick = { viewModel.onEnabledChanged(!settings.enabled) },
|
||||
)
|
||||
|
||||
// The onboarding step: access is a special app access the user grants
|
||||
// by hand, so the row says what it is for and opens the right page.
|
||||
if (!loaded.hasAccess) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_access_title),
|
||||
summary = stringResource(R.string.bedtime_access_summary),
|
||||
position = Position.Alone,
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Rounded.DoNotDisturbOn,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
TextButton(onClick = { openAccessSettings(context) }) {
|
||||
Text(stringResource(R.string.bedtime_access_allow))
|
||||
}
|
||||
},
|
||||
onClick = { openAccessSettings(context) },
|
||||
)
|
||||
}
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.bedtime_section_schedule))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_start),
|
||||
summary = formatTime(settings.bedtime),
|
||||
position = Position.Top,
|
||||
onClick = { picker = BedtimePicker.BEDTIME },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_wake),
|
||||
summary = formatTime(settings.wakeTime),
|
||||
position = Position.Middle,
|
||||
onClick = { picker = BedtimePicker.WAKE_TIME },
|
||||
)
|
||||
RepeatDaySelector(
|
||||
repeatDays = settings.days,
|
||||
onDayToggled = viewModel::onDayToggled,
|
||||
position = Position.Bottom,
|
||||
)
|
||||
SettingsHint(stringResource(R.string.bedtime_days_hint))
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.bedtime_section_reminder))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_reminder),
|
||||
summary = reminderText(settings.reminderMinutes),
|
||||
position = Position.Alone,
|
||||
onClick = { picker = BedtimePicker.REMINDER },
|
||||
)
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.bedtime_section_effects))
|
||||
if (loaded.supportsEffects) {
|
||||
EffectRow(
|
||||
title = stringResource(R.string.bedtime_effect_grayscale),
|
||||
checked = settings.effects.grayscale,
|
||||
position = Position.Top,
|
||||
onChange = viewModel::onGrayscaleChanged,
|
||||
)
|
||||
EffectRow(
|
||||
title = stringResource(R.string.bedtime_effect_dim_wallpaper),
|
||||
checked = settings.effects.dimWallpaper,
|
||||
position = Position.Middle,
|
||||
onChange = viewModel::onDimWallpaperChanged,
|
||||
)
|
||||
EffectRow(
|
||||
title = stringResource(R.string.bedtime_effect_dark_theme),
|
||||
checked = settings.effects.darkTheme,
|
||||
position = Position.Middle,
|
||||
onChange = viewModel::onDarkThemeChanged,
|
||||
)
|
||||
EffectRow(
|
||||
title = stringResource(R.string.bedtime_effect_hide_aod),
|
||||
checked = settings.effects.hideAlwaysOnDisplay,
|
||||
position = Position.Bottom,
|
||||
onChange = viewModel::onHideAlwaysOnDisplayChanged,
|
||||
)
|
||||
val ruleId = loaded.ruleId
|
||||
if (ruleId != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_open_mode),
|
||||
summary = stringResource(R.string.bedtime_open_mode_summary),
|
||||
position = Position.Alone,
|
||||
onClick = { openModeSettings(context, ruleId) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SettingsHint(stringResource(R.string.bedtime_effects_unavailable))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SettingsHint(stringResource(R.string.bedtime_alarms_note))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SettingsHint(stringResource(R.string.bedtime_wellbeing_note))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
val settings = loaded?.settings ?: return
|
||||
when (picker) {
|
||||
null -> Unit
|
||||
BedtimePicker.BEDTIME -> AlarmTimePickerDialog(
|
||||
initial = settings.bedtime,
|
||||
onConfirm = { hour, minute ->
|
||||
viewModel.onBedtimeChanged(hour, minute)
|
||||
picker = null
|
||||
},
|
||||
onDismiss = { picker = null },
|
||||
)
|
||||
BedtimePicker.WAKE_TIME -> AlarmTimePickerDialog(
|
||||
initial = settings.wakeTime,
|
||||
onConfirm = { hour, minute ->
|
||||
viewModel.onWakeTimeChanged(hour, minute)
|
||||
picker = null
|
||||
},
|
||||
onDismiss = { picker = null },
|
||||
)
|
||||
BedtimePicker.REMINDER -> OptionPicker(
|
||||
title = stringResource(R.string.bedtime_reminder),
|
||||
options = BedtimeLimits.REMINDER_CHOICES,
|
||||
selected = settings.reminderMinutes,
|
||||
label = { reminderText(it) },
|
||||
onSelect = {
|
||||
viewModel.onReminderChanged(it)
|
||||
picker = null
|
||||
},
|
||||
onDismiss = { picker = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EffectRow(title: String, checked: Boolean, position: Position, onChange: (Boolean) -> Unit) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
position = position,
|
||||
trailing = { Switch(checked = checked, onCheckedChange = onChange) },
|
||||
onClick = { onChange(!checked) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun reminderText(minutes: Int): String = if (minutes <= 0) {
|
||||
stringResource(R.string.bedtime_reminder_off)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.bedtime_reminder_before, minutes, minutes)
|
||||
}
|
||||
|
||||
/** Guarded like the self-check's fixes: a settings page a device cannot open must not crash the row. */
|
||||
private fun openAccessSettings(context: Context) {
|
||||
runCatching { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)) }
|
||||
}
|
||||
|
||||
/** Android 15's page for one mode; the link is only offered there. */
|
||||
private fun openModeSettings(context: Context, ruleId: String) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_AUTOMATIC_ZEN_RULE_SETTINGS).putExtra(Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID, ruleId),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.jeanlucmakiola.clockula.ui.bedtime
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
|
||||
/** The bedtime screen's state (issue #1). */
|
||||
internal sealed interface BedtimeUiState {
|
||||
data object Loading : BedtimeUiState
|
||||
|
||||
data class Loaded(
|
||||
val settings: BedtimeSettings,
|
||||
/** Whether the user granted Do Not Disturb access; without it only the reminder works. */
|
||||
val hasAccess: Boolean,
|
||||
/** Android 15 and later: the mode can carry screen effects. */
|
||||
val supportsEffects: Boolean,
|
||||
/** The system rule's id while it exists, for the "open in Settings" link. */
|
||||
val ruleId: String?,
|
||||
) : BedtimeUiState
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.jeanlucmakiola.clockula.ui.bedtime
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.DayOfWeek
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The bedtime screen. Every control writes at once through [BedtimeEngine],
|
||||
* which stores the change and applies it to the slot and the Do Not Disturb
|
||||
* rule — no Save button, as everywhere else in Settings (M5 D22).
|
||||
*
|
||||
* Do Not Disturb access is granted outside the app, so [refresh] re-reads it
|
||||
* on every `ON_RESUME`, and a change found there is applied straight away:
|
||||
* coming back from granting access creates the mode without waiting for the
|
||||
* system's broadcast.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class BedtimeViewModel @Inject internal constructor(
|
||||
private val engine: BedtimeEngine,
|
||||
) : ViewModel() {
|
||||
|
||||
private val access = MutableStateFlow(engine.hasAccess())
|
||||
private val ruleId = MutableStateFlow(engine.ruleId())
|
||||
|
||||
internal val state: StateFlow<BedtimeUiState> = combine(engine.bedtime, access, ruleId) { settings, access, ruleId ->
|
||||
BedtimeUiState.Loaded(
|
||||
settings = settings,
|
||||
hasAccess = access,
|
||||
supportsEffects = engine.supportsEffects,
|
||||
ruleId = ruleId,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), BedtimeUiState.Loading)
|
||||
|
||||
fun refresh() {
|
||||
val granted = engine.hasAccess()
|
||||
val changed = granted != access.value
|
||||
access.value = granted
|
||||
viewModelScope.launch {
|
||||
if (changed) engine.resync(refreshRule = true)
|
||||
ruleId.value = engine.ruleId()
|
||||
}
|
||||
}
|
||||
|
||||
fun onEnabledChanged(enabled: Boolean) = update { it.copy(enabled = enabled) }
|
||||
|
||||
fun onBedtimeChanged(hour: Int, minute: Int) = update { it.copy(bedtime = TimeOfDay(hour, minute)) }
|
||||
|
||||
fun onWakeTimeChanged(hour: Int, minute: Int) = update { it.copy(wakeTime = TimeOfDay(hour, minute)) }
|
||||
|
||||
/** The last night cannot be switched off: a schedule with no nights would never start. */
|
||||
fun onDayToggled(day: DayOfWeek) = update { current ->
|
||||
val days = if (day in current.days) current.days - day else current.days + day
|
||||
if (days.isRepeating) current.copy(days = days) else current
|
||||
}
|
||||
|
||||
fun onReminderChanged(minutes: Int) = update { it.copy(reminderMinutes = minutes) }
|
||||
|
||||
fun onGrayscaleChanged(on: Boolean) = updateEffects { it.copy(grayscale = on) }
|
||||
|
||||
fun onDimWallpaperChanged(on: Boolean) = updateEffects { it.copy(dimWallpaper = on) }
|
||||
|
||||
fun onDarkThemeChanged(on: Boolean) = updateEffects { it.copy(darkTheme = on) }
|
||||
|
||||
fun onHideAlwaysOnDisplayChanged(on: Boolean) = updateEffects { it.copy(hideAlwaysOnDisplay = on) }
|
||||
|
||||
private fun updateEffects(transform: (BedtimeEffects) -> BedtimeEffects) =
|
||||
update { it.copy(effects = transform(it.effects)) }
|
||||
|
||||
private fun update(transform: (BedtimeSettings) -> BedtimeSettings) {
|
||||
viewModelScope.launch {
|
||||
engine.update(transform)
|
||||
ruleId.value = engine.ruleId()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package de.jeanlucmakiola.clockula.ui.settings
|
||||
|
||||
/** Settings hub and its two sub-screens. No arguments: each is a singleton page (M10). */
|
||||
/** Settings hub and its three sub-screens. No arguments: each is a singleton page (M10). */
|
||||
internal object SettingsRoutes {
|
||||
const val MAIN: String = "settings"
|
||||
const val SELF_CHECK: String = "settings/self_check"
|
||||
const val BACKUP: String = "settings/backup"
|
||||
const val BEDTIME: String = "settings/bedtime"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Alarm
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
@@ -83,6 +84,7 @@ fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenSelfCheck: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
onOpenBedtime: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -127,6 +129,7 @@ fun SettingsScreen(
|
||||
onOpenSection = { section = it },
|
||||
onOpenSelfCheck = onOpenSelfCheck,
|
||||
onOpenBackup = onOpenBackup,
|
||||
onOpenBedtime = onOpenBedtime,
|
||||
)
|
||||
|
||||
if (loaded != null) {
|
||||
@@ -183,8 +186,8 @@ private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Four named groups of category rows, after Calendula's hub: look & behaviour,
|
||||
* data, app, and a foot of reference links. Source, licence and privacy sit in that
|
||||
* Five named groups of category rows, after Calendula's hub: look & behaviour,
|
||||
* sleep, data, app, and a foot of reference links. Source, licence and privacy sit in that
|
||||
* last group rather than as buttons inside the About card — they are reference
|
||||
* material, and the card is the app's identity.
|
||||
*/
|
||||
@@ -195,6 +198,7 @@ private fun SettingsHub(
|
||||
onOpenSection: (SettingsSection) -> Unit,
|
||||
onOpenSelfCheck: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
onOpenBedtime: () -> Unit,
|
||||
) {
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
||||
// Card and support row are one grouped block, so the call to action
|
||||
@@ -235,6 +239,18 @@ private fun SettingsHub(
|
||||
onClick = { onOpenSection(SettingsSection.Clock) },
|
||||
)
|
||||
|
||||
// Opt-in and its own destination (issue #1): a Do Not Disturb mode,
|
||||
// not a default every alarm inherits, so not one of the rows above.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_sleep))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.bedtime_title),
|
||||
summary = stringResource(R.string.settings_bedtime_summary),
|
||||
position = Position.Alone,
|
||||
leading = { CategoryIcon(Icons.Default.Bedtime, ChipAccent.Tertiary) },
|
||||
onClick = onOpenBedtime,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.settings_group_data))
|
||||
GroupedRow(
|
||||
|
||||
@@ -42,6 +42,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.AlarmEditorScreen
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRoutes
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.AlarmsScreen
|
||||
import de.jeanlucmakiola.clockula.ui.bedtime.BedtimeScreen
|
||||
import de.jeanlucmakiola.clockula.ui.settings.BackupScreen
|
||||
import de.jeanlucmakiola.clockula.ui.settings.SelfCheckScreen
|
||||
import de.jeanlucmakiola.clockula.ui.settings.SettingsRoutes
|
||||
@@ -158,6 +159,12 @@ fun ClockulaShell(
|
||||
select(ShellNavigation.onTabRequested(route, ClockulaDestination.ALARMS))
|
||||
dismissCandidates = request.alarmIds.toLongArray()
|
||||
}
|
||||
ShellRequest.OpenBedtime -> {
|
||||
// Over whichever tab is showing, after popping anything nested,
|
||||
// so back from it returns to the tabs.
|
||||
select(ShellNavigation.onTabRequested(route, selected))
|
||||
navController.navigate(SettingsRoutes.BEDTIME)
|
||||
}
|
||||
}
|
||||
onRequestConsumed()
|
||||
}
|
||||
@@ -255,6 +262,7 @@ fun ClockulaShell(
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenSelfCheck = { navController.navigate(SettingsRoutes.SELF_CHECK) },
|
||||
onOpenBackup = { navController.navigate(SettingsRoutes.BACKUP) },
|
||||
onOpenBedtime = { navController.navigate(SettingsRoutes.BEDTIME) },
|
||||
)
|
||||
}
|
||||
composable(SettingsRoutes.SELF_CHECK) {
|
||||
@@ -263,6 +271,9 @@ fun ClockulaShell(
|
||||
composable(SettingsRoutes.BACKUP) {
|
||||
BackupScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(SettingsRoutes.BEDTIME) {
|
||||
BedtimeScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
// In the pill's own band above the navigation component, not in the
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.ui.shell
|
||||
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.interop.InteropIntents
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
||||
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
||||
@@ -31,6 +32,9 @@ sealed interface ShellRequest {
|
||||
data class OpenAlarmEditor(val alarmId: Long) : ShellRequest
|
||||
data object ComposeTimer : ShellRequest
|
||||
data class ChooseAlarmToDismiss(val alarmIds: List<Long>) : ShellRequest
|
||||
|
||||
/** The bedtime screen, from its reminder or from the mode's page in Settings (issue #1). */
|
||||
data object OpenBedtime : ShellRequest
|
||||
}
|
||||
|
||||
sealed interface ShellBackAction {
|
||||
@@ -156,6 +160,9 @@ object ShellNavigation {
|
||||
ShellRequest.OpenTab(ClockulaDestination.ALARMS)
|
||||
}
|
||||
InteropIntents.ACTION_COMPOSE_TIMER -> ShellRequest.ComposeTimer
|
||||
BedtimeIntents.ACTION_SHOW_BEDTIME,
|
||||
BedtimeIntents.ACTION_AUTOMATIC_ZEN_RULE,
|
||||
-> ShellRequest.OpenBedtime
|
||||
InteropIntents.ACTION_CHOOSE_ALARM_TO_DISMISS ->
|
||||
if (alarmIds.isEmpty()) {
|
||||
ShellRequest.OpenTab(ClockulaDestination.ALARMS)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The bedtime mode's icon in Settings › Modes and the reminder's status-bar
|
||||
icon: a crescent moon, tinted by the system. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12.34,2.02C6.59,1.82 2,6.42 2,12c0,5.52 4.48,10 10,10c3.71,0 6.93,-2.02 8.66,-5.02C13.15,16.73 8.57,8.55 12.34,2.02z" />
|
||||
</vector>
|
||||
@@ -439,4 +439,46 @@
|
||||
<string name="widget_quick_timer_custom">Custom…</string>
|
||||
<string name="widget_quick_timer_custom_title">Custom duration</string>
|
||||
<string name="widget_quick_timer_range_hint">Between 1 second and 59:59</string>
|
||||
|
||||
<!-- Issue #1: bedtime -->
|
||||
<string name="settings_group_sleep">Sleep</string>
|
||||
<string name="settings_bedtime_summary">A Do Not Disturb mode for the night</string>
|
||||
<string name="bedtime_title">Bedtime</string>
|
||||
<string name="bedtime_hint">Clockula turns on a Do Not Disturb mode of its own at bedtime and turns it off when you wake up. Alarms always ring.</string>
|
||||
<string name="bedtime_enabled">Bedtime mode</string>
|
||||
<string name="bedtime_enabled_on">On, %1$s to %2$s</string>
|
||||
<string name="bedtime_enabled_off">Off</string>
|
||||
<string name="bedtime_access_title">Allow Do Not Disturb access</string>
|
||||
<string name="bedtime_access_summary">Clockula needs it to turn its bedtime mode on and off. Find Clockula in the list and allow it. Without it, only the reminder works.</string>
|
||||
<string name="bedtime_access_allow">Allow</string>
|
||||
<string name="bedtime_section_schedule">Schedule</string>
|
||||
<string name="bedtime_start">Bedtime</string>
|
||||
<string name="bedtime_wake">Wake up</string>
|
||||
<string name="bedtime_days_hint">The nights bedtime starts on. A wake-up time before the bedtime is the next morning.</string>
|
||||
<string name="bedtime_section_reminder">Reminder</string>
|
||||
<string name="bedtime_reminder">Remind me before bedtime</string>
|
||||
<string name="bedtime_reminder_off">Off</string>
|
||||
<plurals name="bedtime_reminder_before">
|
||||
<item quantity="one">%1$d minute before</item>
|
||||
<item quantity="other">%1$d minutes before</item>
|
||||
</plurals>
|
||||
<string name="bedtime_section_effects">Screen</string>
|
||||
<string name="bedtime_effect_grayscale">Grayscale</string>
|
||||
<string name="bedtime_effect_dim_wallpaper">Dim the wallpaper</string>
|
||||
<string name="bedtime_effect_dark_theme">Dark theme</string>
|
||||
<string name="bedtime_effect_hide_aod">Hide the always-on display</string>
|
||||
<string name="bedtime_effects_unavailable">Screen effects need Android 15 or later. On this phone, bedtime silences notifications and calls the way Do Not Disturb does.</string>
|
||||
<string name="bedtime_open_mode">Open in system settings</string>
|
||||
<string name="bedtime_open_mode_summary">Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept.</string>
|
||||
<string name="bedtime_alarms_note">Bedtime never silences an alarm or a timer: its mode always lets alarms through.</string>
|
||||
<string name="bedtime_wellbeing_note">This is not Digital Wellbeing\'s Bedtime mode. Android lets only the system turn that one on, so if you use it too you will see two modes in Settings › Modes. Use one of them.</string>
|
||||
<string name="bedtime_mode_name">Clockula bedtime</string>
|
||||
<string name="bedtime_mode_trigger">%1$s to %2$s</string>
|
||||
<string name="channel_bedtime_name">Bedtime reminders</string>
|
||||
<string name="channel_bedtime_description">A reminder shortly before bedtime starts.</string>
|
||||
<plurals name="bedtime_reminder_title">
|
||||
<item quantity="one">Bedtime in %1$d minute</item>
|
||||
<item quantity="other">Bedtime in %1$d minutes</item>
|
||||
</plurals>
|
||||
<string name="bedtime_reminder_text">Bedtime mode starts at %1$s.</string>
|
||||
</resources>
|
||||
|
||||
@@ -140,6 +140,7 @@ class ArchitectureRulesTest {
|
||||
path.endsWith("de/jeanlucmakiola/clockula/interop/AlarmClockHandler.kt") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/timer/TimerEngine.kt") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/stopwatch/StopwatchEngine.kt") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/bedtime/BedtimeEngine.kt") ||
|
||||
path.endsWith("de/jeanlucmakiola/clockula/alarm/AlarmResolver.kt") ||
|
||||
path.contains("de/jeanlucmakiola/clockula/system/")
|
||||
}
|
||||
|
||||
@@ -231,6 +231,10 @@ class ManifestRulesTest {
|
||||
"android.permission.FOREGROUND_SERVICE_SPECIAL_USE",
|
||||
"android.permission.WAKE_LOCK",
|
||||
"android.permission.VIBRATE",
|
||||
// Issue #1's opt-in bedtime mode. Install-time and harmless on its
|
||||
// own: it only lists Clockula under Do Not Disturb access, which
|
||||
// the user still has to grant by hand.
|
||||
"android.permission.ACCESS_NOTIFICATION_POLICY",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,29 @@ class BackupEngineTest {
|
||||
assertThat(target.settings.currentDefaults().homeZoneId).isEqualTo("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bedtime schedule round-trips and is applied on import`(@TempDir tempDir: Path) = runTest {
|
||||
val source = backupHarness(tempDir.resolve("bedtimeSource").also { it.toFile().mkdirs() })
|
||||
source.bedtimeEngine.update {
|
||||
it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0), reminderMinutes = 15)
|
||||
}
|
||||
|
||||
val exported = source.backupEngine.export()
|
||||
|
||||
val target = backupHarness(tempDir.resolve("bedtimeTarget").also { it.toFile().mkdirs() })
|
||||
target.backupEngine.import(exported)
|
||||
|
||||
val restored = target.settings.currentBedtime()
|
||||
assertThat(restored.enabled).isTrue()
|
||||
assertThat(restored.bedtime).isEqualTo(TimeOfDay(23, 0))
|
||||
assertThat(restored.reminderMinutes).isEqualTo(15)
|
||||
// The device's own rule and slot follow the restored settings: 05:00
|
||||
// is inside last night's window, so the mode is on until 07:00.
|
||||
assertThat(target.bedtimeMode.rule).isEqualTo(restored)
|
||||
assertThat(target.bedtimeMode.on).isTrue()
|
||||
assertThat(target.bedtimeScheduler.armedAt).isEqualTo(instantAt(BERLIN, "2026-06-10T07:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import never carries ring state`(@TempDir tempDir: Path) = runTest {
|
||||
val source = backupHarness(tempDir.resolve("source2").also { it.toFile().mkdirs() })
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package de.jeanlucmakiola.clockula.bedtime
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.FakeBedtimeMode
|
||||
import de.jeanlucmakiola.clockula.testing.FakeBedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.testing.FakeBedtimeScheduler
|
||||
import de.jeanlucmakiola.clockula.testing.FakeWallClock
|
||||
import de.jeanlucmakiola.clockula.testing.FakeZoneProvider
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
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.Instant
|
||||
|
||||
/**
|
||||
* [BedtimeEngine] over a real DataStore and fake seams: the slot is always
|
||||
* armed for the next event, the rule follows the switch and the access, and a
|
||||
* reminder fire never touches the mode.
|
||||
*/
|
||||
class BedtimeEngineTest {
|
||||
|
||||
private class Harness(scope: TestScope, tempDir: Path, now: Instant, access: Boolean) {
|
||||
val wallClock = FakeWallClock(now)
|
||||
val scheduler = FakeBedtimeScheduler()
|
||||
val mode = FakeBedtimeMode(access = access)
|
||||
val notifier = FakeBedtimeNotifier()
|
||||
val settings = SettingsPrefs(
|
||||
PrefStore(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(UnconfinedTestDispatcher(scope.testScheduler) + Job()),
|
||||
produceFile = { tempDir.resolve("bedtime_test.preferences_pb").toFile() },
|
||||
),
|
||||
),
|
||||
)
|
||||
val engine = BedtimeEngine(settings, scheduler, mode, notifier, wallClock, FakeZoneProvider(BERLIN))
|
||||
}
|
||||
|
||||
private fun TestScope.harness(
|
||||
tempDir: Path,
|
||||
now: Instant = instantAt(BERLIN, "2026-06-10T12:00"),
|
||||
access: Boolean = true,
|
||||
) = Harness(this, tempDir, now, access)
|
||||
|
||||
private val nightly: (BedtimeSettings) -> BedtimeSettings = {
|
||||
it.copy(enabled = true, bedtime = TimeOfDay(23, 0), wakeTime = TimeOfDay(7, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning bedtime on creates the rule and arms the slot for bedtime`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
|
||||
h.engine.update(nightly)
|
||||
|
||||
assertThat(h.mode.rule?.enabled).isTrue()
|
||||
assertThat(h.mode.on).isFalse()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning it on during the night starts the mode at once`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
|
||||
|
||||
h.engine.update(nightly)
|
||||
|
||||
assertThat(h.mode.on).isTrue()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bedtime and wake-up fires switch the mode`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update(nightly)
|
||||
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00")
|
||||
h.engine.onSlotFired()
|
||||
assertThat(h.mode.on).isTrue()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-11T07:00")
|
||||
h.engine.onSlotFired()
|
||||
assertThat(h.mode.on).isFalse()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder fire posts the reminder and leaves the mode alone`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update { nightly(it).copy(reminderMinutes = 30) }
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T22:30+02:00"))
|
||||
// Switched on by hand from the system's Modes page.
|
||||
h.mode.on = true
|
||||
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T22:30")
|
||||
h.engine.onSlotFired()
|
||||
|
||||
assertThat(h.notifier.reminders).containsExactly(offsetInstant("2026-06-10T23:00+02:00") to 30)
|
||||
assertThat(h.mode.on).isTrue()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-10T23:00")
|
||||
h.engine.onSlotFired()
|
||||
assertThat(h.notifier.showing).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning bedtime off removes the rule and disarms the slot`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
|
||||
h.engine.update(nightly)
|
||||
assertThat(h.mode.on).isTrue()
|
||||
|
||||
h.engine.update { it.copy(enabled = false) }
|
||||
|
||||
assertThat(h.mode.rule).isNull()
|
||||
assertThat(h.mode.on).isFalse()
|
||||
assertThat(h.scheduler.armedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without access the reminder still runs and no rule is made`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, access = false)
|
||||
|
||||
h.engine.update { nightly(it).copy(reminderMinutes = 15) }
|
||||
|
||||
assertThat(h.mode.rule).isNull()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T22:45+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `granting access later creates the rule in the current state`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-11T01:00"), access = false)
|
||||
h.engine.update(nightly)
|
||||
|
||||
h.mode.access = true
|
||||
h.engine.resync(refreshRule = true)
|
||||
|
||||
assertThat(h.mode.rule).isNotNull()
|
||||
assertThat(h.mode.on).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit rewrites the rule, a plain resync does not`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update(nightly)
|
||||
|
||||
h.engine.resync()
|
||||
assertThat(h.mode.refreshes).isEqualTo(0)
|
||||
|
||||
h.engine.update { it.copy(effects = it.effects.copy(grayscale = false)) }
|
||||
assertThat(h.mode.refreshes).isEqualTo(1)
|
||||
assertThat(h.mode.rule?.effects?.grayscale).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rearming touches the slot and not the mode`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir)
|
||||
h.engine.update(nightly)
|
||||
h.mode.on = true
|
||||
h.scheduler.cancel()
|
||||
|
||||
h.engine.rearm()
|
||||
|
||||
assertThat(h.mode.on).isTrue()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missed wake-up fire is caught up by the next resync`(@TempDir tempDir: Path) = runTest {
|
||||
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
|
||||
h.engine.update(nightly)
|
||||
|
||||
// A reboot through the wake-up time.
|
||||
h.wallClock.instant = instantAt(BERLIN, "2026-06-11T09:00")
|
||||
h.engine.resync()
|
||||
|
||||
assertThat(h.mode.on).isFalse()
|
||||
assertThat(h.scheduler.armedAt).isEqualTo(offsetInstant("2026-06-11T23:00+02:00"))
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -62,6 +64,19 @@ class BackupCodecTest {
|
||||
homeZoneId = "America/New_York",
|
||||
),
|
||||
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
|
||||
bedtime = BedtimeSettings(
|
||||
enabled = true,
|
||||
bedtime = TimeOfDay(23, 15),
|
||||
wakeTime = TimeOfDay(6, 45),
|
||||
days = RepeatDays.WEEKDAYS,
|
||||
reminderMinutes = 30,
|
||||
effects = BedtimeEffects(
|
||||
grayscale = false,
|
||||
dimWallpaper = true,
|
||||
darkTheme = false,
|
||||
hideAlwaysOnDisplay = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -263,6 +278,27 @@ class BackupCodecTest {
|
||||
assertThat(settings.timerPresets).isEqualTo(TimerPresets.BUILT_IN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file from before bedtime existed restores it off`() {
|
||||
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
|
||||
assertThat(settings.bedtime).isEqualTo(BedtimeSettings())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out-of-range bedtime values are clamped rather than rejected`() {
|
||||
val json = minimalDocument().replace(
|
||||
"\"settings\": {}",
|
||||
"\"settings\": {\"bedtime\": {\"enabled\": true, \"bedtime_minutes\": 99999, " +
|
||||
"\"wake_minutes\": -5, \"days\": 255, \"reminder_minutes\": 999}}",
|
||||
)
|
||||
val bedtime = (BackupCodec.decode(json) as BackupResult.Success).content.settings.bedtime
|
||||
assertThat(bedtime.enabled).isTrue()
|
||||
assertThat(bedtime.bedtime).isEqualTo(TimeOfDay(23, 59))
|
||||
assertThat(bedtime.wakeTime).isEqualTo(TimeOfDay(0, 0))
|
||||
assertThat(bedtime.days).isEqualTo(RepeatDays.EVERY_DAY)
|
||||
assertThat(bedtime.reminderMinutes).isEqualTo(120)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty preset list is honoured as all removed`() {
|
||||
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}")
|
||||
|
||||
@@ -3,11 +3,17 @@ package de.jeanlucmakiola.clockula.data.prefs
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
@@ -143,6 +149,48 @@ class SettingsPrefsTest {
|
||||
}
|
||||
}
|
||||
|
||||
// --- issue #1: bedtime ---
|
||||
|
||||
@Test
|
||||
fun `a fresh install has bedtime off`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
assertThat(prefs.currentBedtime()).isEqualTo(BedtimeSettings())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bedtime schedule survives process death`(@TempDir tempDir: Path) = runTest {
|
||||
val stored = BedtimeSettings(
|
||||
enabled = true,
|
||||
bedtime = TimeOfDay(0, 30),
|
||||
wakeTime = TimeOfDay(8, 15),
|
||||
days = RepeatDays.WEEKENDS,
|
||||
reminderMinutes = 45,
|
||||
effects = BedtimeEffects(grayscale = false, dimWallpaper = false, darkTheme = true, hideAlwaysOnDisplay = true),
|
||||
)
|
||||
val firstProcess = newScope()
|
||||
SettingsPrefs(PrefStore(newDataStore(tempDir, firstProcess))).setBedtime(stored)
|
||||
firstProcess.coroutineContext.job.cancelAndJoin()
|
||||
|
||||
val reopened = SettingsPrefs(PrefStore(newDataStore(tempDir, newScope())))
|
||||
|
||||
assertThat(reopened.currentBedtime()).isEqualTo(stored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a corrupt bedtime value reads back clamped`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
dataStore.edit {
|
||||
it[intPreferencesKey("bedtime_start_minutes")] = 5_000
|
||||
it[intPreferencesKey("bedtime_reminder_minutes")] = -3
|
||||
}
|
||||
|
||||
val bedtime = SettingsPrefs(PrefStore(dataStore)).currentBedtime()
|
||||
|
||||
assertThat(bedtime.bedtime).isEqualTo(TimeOfDay(23, 59))
|
||||
assertThat(bedtime.reminderMinutes).isEqualTo(0)
|
||||
}
|
||||
|
||||
// --- M4: the one-shot notification permission request ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package de.jeanlucmakiola.clockula.domain.bedtime
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.DayOfWeek
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
/** 2026-06-10 is a Wednesday. */
|
||||
class BedtimeScheduleTest {
|
||||
|
||||
private val everyNight = BedtimeSettings(
|
||||
enabled = true,
|
||||
bedtime = TimeOfDay(23, 0),
|
||||
wakeTime = TimeOfDay(7, 0),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a wake time before the bedtime is the next morning`() {
|
||||
val window = BedtimeSchedule.activeWindow(everyNight, instantAt(BERLIN, "2026-06-11T02:00"), BERLIN)
|
||||
|
||||
assertThat(window).isEqualTo(
|
||||
BedtimeWindow(offsetInstant("2026-06-10T23:00+02:00"), offsetInstant("2026-06-11T07:00+02:00")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wake time after the bedtime is the same day`() {
|
||||
val earlyNap = everyNight.copy(bedtime = TimeOfDay(1, 0), wakeTime = TimeOfDay(6, 0))
|
||||
|
||||
val window = BedtimeSchedule.activeWindow(earlyNap, instantAt(BERLIN, "2026-06-10T03:00"), BERLIN)
|
||||
|
||||
assertThat(window?.start).isEqualTo(offsetInstant("2026-06-10T01:00+02:00"))
|
||||
assertThat(window?.end).isEqualTo(offsetInstant("2026-06-10T06:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daytime is outside every window`() {
|
||||
assertThat(BedtimeSchedule.activeWindow(everyNight, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wake time itself is already outside the window`() {
|
||||
assertThat(BedtimeSchedule.activeWindow(everyNight, instantAt(BERLIN, "2026-06-11T07:00"), BERLIN)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a disabled schedule is never active and schedules nothing`() {
|
||||
val off = everyNight.copy(enabled = false)
|
||||
val night = instantAt(BERLIN, "2026-06-11T02:00")
|
||||
|
||||
assertThat(BedtimeSchedule.activeWindow(off, night, BERLIN)).isNull()
|
||||
assertThat(BedtimeSchedule.nextEvent(off, night, BERLIN)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the days are the nights a window starts on`() {
|
||||
val fridays = everyNight.copy(days = RepeatDays.of(DayOfWeek.FRIDAY))
|
||||
|
||||
// Saturday 02:00 belongs to Friday night.
|
||||
assertThat(BedtimeSchedule.activeWindow(fridays, instantAt(BERLIN, "2026-06-13T02:00"), BERLIN)).isNotNull()
|
||||
// Thursday 02:00 would belong to Wednesday night, which is not one.
|
||||
assertThat(BedtimeSchedule.activeWindow(fridays, instantAt(BERLIN, "2026-06-11T02:00"), BERLIN)).isNull()
|
||||
assertThat(BedtimeSchedule.nextWindow(fridays, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)?.start)
|
||||
.isEqualTo(offsetInstant("2026-06-12T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in the daytime the next event is bedtime`() {
|
||||
assertThat(BedtimeSchedule.nextEvent(everyNight, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN))
|
||||
.isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `at night the next event is the wake time`() {
|
||||
assertThat(BedtimeSchedule.nextEvent(everyNight, instantAt(BERLIN, "2026-06-11T02:00"), BERLIN))
|
||||
.isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder comes before bedtime, then bedtime itself`() {
|
||||
val reminded = everyNight.copy(reminderMinutes = 30)
|
||||
|
||||
assertThat(BedtimeSchedule.nextEvent(reminded, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN))
|
||||
.isEqualTo(offsetInstant("2026-06-10T22:30+02:00"))
|
||||
assertThat(BedtimeSchedule.nextEvent(reminded, instantAt(BERLIN, "2026-06-10T22:30"), BERLIN))
|
||||
.isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder is due between its time and bedtime only`() {
|
||||
val reminded = everyNight.copy(reminderMinutes = 30)
|
||||
|
||||
assertThat(BedtimeSchedule.dueReminder(reminded, instantAt(BERLIN, "2026-06-10T22:29"), BERLIN)).isNull()
|
||||
assertThat(BedtimeSchedule.dueReminder(reminded, instantAt(BERLIN, "2026-06-10T22:30"), BERLIN)?.start)
|
||||
.isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
assertThat(BedtimeSchedule.dueReminder(reminded, instantAt(BERLIN, "2026-06-10T23:05"), BERLIN)).isNull()
|
||||
assertThat(BedtimeSchedule.dueReminder(everyNight, instantAt(BERLIN, "2026-06-10T22:45"), BERLIN)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an equal bedtime and wake time is a whole day, back to back`() {
|
||||
val always = everyNight.copy(wakeTime = TimeOfDay(23, 0))
|
||||
|
||||
val window = BedtimeSchedule.activeWindow(always, instantAt(BERLIN, "2026-06-10T22:00"), BERLIN)
|
||||
|
||||
assertThat(window?.start).isEqualTo(offsetInstant("2026-06-09T23:00+02:00"))
|
||||
assertThat(window?.end).isEqualTo(offsetInstant("2026-06-10T23:00+02:00"))
|
||||
assertThat(BedtimeSchedule.activeWindow(always, instantAt(BERLIN, "2026-06-10T23:00"), BERLIN)).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fall-back night is an hour longer, by the wall clock`() {
|
||||
// Berlin falls back on 2026-10-25 at 03:00.
|
||||
val window = BedtimeSchedule.windowOn(everyNight, java.time.LocalDate.of(2026, 10, 24), BERLIN)!!
|
||||
|
||||
assertThat(window.end - window.start).isEqualTo(9.hours)
|
||||
assertThat(window.end).isEqualTo(offsetInstant("2026-10-25T07:00+01:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no nights means nothing to schedule`() {
|
||||
val none = everyNight.copy(days = RepeatDays.NONE)
|
||||
|
||||
assertThat(BedtimeSchedule.nextEvent(none, instantAt(BERLIN, "2026-06-10T12:00"), BERLIN)).isNull()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.testing
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.backup.BackupEngine
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
|
||||
@@ -99,11 +100,24 @@ class BackupHarness(
|
||||
wall = wallClock,
|
||||
)
|
||||
|
||||
val bedtimeScheduler: FakeBedtimeScheduler = FakeBedtimeScheduler()
|
||||
val bedtimeMode: FakeBedtimeMode = FakeBedtimeMode()
|
||||
|
||||
val bedtimeEngine: BedtimeEngine = BedtimeEngine(
|
||||
settings = settings,
|
||||
scheduler = bedtimeScheduler,
|
||||
mode = bedtimeMode,
|
||||
notifier = FakeBedtimeNotifier(),
|
||||
wallClock = wallClock,
|
||||
zones = zones,
|
||||
)
|
||||
|
||||
val backupEngine: BackupEngine = BackupEngine(
|
||||
repository = backupRepository,
|
||||
settings = settings,
|
||||
alarmEngine = alarmEngine,
|
||||
timerEngine = timerEngine,
|
||||
bedtimeEngine = bedtimeEngine,
|
||||
wallClock = wallClock,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.jeanlucmakiola.clockula.testing
|
||||
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeMode
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeNotifier
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeScheduler
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** The bedtime slot: the instant it is armed for, or null when it is not. */
|
||||
class FakeBedtimeScheduler : BedtimeScheduler {
|
||||
var armedAt: Instant? = null
|
||||
private set
|
||||
|
||||
override fun schedule(at: Instant) {
|
||||
armedAt = at
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
armedAt = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Do Not Disturb rule as the platform would hold it: at most one, with its
|
||||
* last-written settings and whether its condition is true. Without [access],
|
||||
* every write is a no-op, as `AndroidBedtimeMode`'s guarded calls are.
|
||||
*/
|
||||
class FakeBedtimeMode(
|
||||
var access: Boolean = true,
|
||||
override val supportsEffects: Boolean = true,
|
||||
) : BedtimeMode {
|
||||
/** The settings the rule was last written from; null when there is no rule. */
|
||||
var rule: BedtimeSettings? = null
|
||||
var on: Boolean = false
|
||||
var refreshes: Int = 0
|
||||
private set
|
||||
|
||||
override fun hasAccess(): Boolean = access
|
||||
|
||||
override fun install(settings: BedtimeSettings, refresh: Boolean) {
|
||||
if (!access) return
|
||||
if (rule == null) {
|
||||
rule = settings
|
||||
} else if (refresh) {
|
||||
rule = settings
|
||||
refreshes++
|
||||
}
|
||||
}
|
||||
|
||||
override fun setActive(active: Boolean) {
|
||||
if (!access || rule == null) return
|
||||
on = active
|
||||
}
|
||||
|
||||
override fun uninstall() {
|
||||
if (!access) return
|
||||
rule = null
|
||||
on = false
|
||||
}
|
||||
|
||||
override fun ruleId(): String? = if (access && rule != null) "rule" else null
|
||||
}
|
||||
|
||||
class FakeBedtimeNotifier : BedtimeNotifier {
|
||||
/** Each reminder posted, as (window start, minutes before). */
|
||||
val reminders: MutableList<Pair<Instant, Int>> = mutableListOf()
|
||||
var showing: Boolean = false
|
||||
private set
|
||||
|
||||
override fun showReminder(start: Instant, minutesBefore: Int) {
|
||||
reminders += start to minutesBefore
|
||||
showing = true
|
||||
}
|
||||
|
||||
override fun cancelReminder() {
|
||||
showing = false
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.ui.shell
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||
import de.jeanlucmakiola.clockula.domain.interop.AlarmClockContract
|
||||
import de.jeanlucmakiola.clockula.interop.InteropIntents
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
||||
@@ -122,4 +123,12 @@ class ShellNavigationInteropTest {
|
||||
assertThat(InteropIntents.ACTIONS.intersect(others.toSet())).isEmpty()
|
||||
assertThat(InteropIntents.ACTIONS).containsNoDuplicates()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bedtime reminder and the mode's settings page both open the bedtime screen`() {
|
||||
assertThat(ShellNavigation.requestFor(BedtimeIntents.ACTION_SHOW_BEDTIME, null, emptyList()))
|
||||
.isEqualTo(ShellRequest.OpenBedtime)
|
||||
assertThat(ShellNavigation.requestFor(BedtimeIntents.ACTION_AUTOMATIC_ZEN_RULE, null, emptyList()))
|
||||
.isEqualTo(ShellRequest.OpenBedtime)
|
||||
}
|
||||
}
|
||||
|
||||
+125
-1
@@ -98,6 +98,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `domain/format/` | `ClockFormat` (`M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated), `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat`, the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
|
||||
| `domain/backup/` | `BackupContent` (M10): the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
|
||||
| `domain/selfcheck/` | `SelfCheck` (M10): the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` |
|
||||
| `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/widget/` | the three widget presenters (M10), `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance |
|
||||
| `data/db/` | `ClockulaDatabase` (`@Database` v3, `exportSchema = true`) and `Migrations` |
|
||||
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
|
||||
@@ -105,7 +106,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `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)` |
|
||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` |
|
||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs`, `BedtimePrefs` (issue #1) |
|
||||
| `data/ringtones/` | the two ringtone seams, `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer`, plus their `System*` implementations, and `MediaLibrary`/`SystemMediaLibrary`, the picker's view of other apps' `MediaBrowserService` libraries |
|
||||
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
|
||||
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8; one `@Binds`, `ZoneNames → IcuZoneNames`), `BackupModule` (M10) |
|
||||
@@ -127,6 +128,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `stopwatch/service/` | `StopwatchService` (the `specialUse` foreground service) and `StopwatchNotifications` |
|
||||
| `stopwatch/di/` | `StopwatchModule`: `@Binds` for the one seam |
|
||||
| `interop/` | the `AlarmClock` boundary's Android half (M9): `AlarmClockActivity` (the one exported, permission-guarded, window-less door), `IntentExtrasReader` (`Bundle` → `IntentExtras`), `AlarmClockHandler` (the orchestrator; no `android.*` import, ever), `InteropIntents` (the internal transport to `MainActivity`) and `InteropLaunch` |
|
||||
| `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 |
|
||||
@@ -139,6 +141,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
|
||||
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
|
||||
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel`, all reachable from a gear icon on every tab, with no owning tab of their own |
|
||||
| `ui/bedtime/` | `BedtimeScreen`/`BedtimeViewModel` (issue #1), a nested destination reached from the settings hub, the bedtime reminder and the mode's page in system Settings, never a tab |
|
||||
| `ui/widget/` | the three widgets' `@Composable` Glance content (M10), the only Compose this milestone adds outside a tab; the `GlanceAppWidget`/`GlanceAppWidgetReceiver` classes themselves live in `widget/`, since they are manifest components, not UI. `ui/widget/config/` holds each widget's config activity; every widget has one, if only for its per-instance colours (`domain/widget/WidgetColors.kt`: System/Light/Dark and, on API 31+, wallpaper colours on/off). Glance content takes those through `WidgetTheme`; the plain-`RemoteViews` faces, readouts and chronometers through `WidgetPalette.kt`, which resolves the `widget_*` palette under a forced day/night (the app's own tones are `widget_app_*`) and sets concrete colours, or day/night pairs, on the views. The analog and digital clocks are re-coloured on API 31+ only, since below that `AnalogClock`'s hands and a shape's background tint can't be set through `RemoteViews` |
|
||||
|
||||
---
|
||||
@@ -599,6 +602,7 @@ Four slices:
|
||||
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
|
||||
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
|
||||
| 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` | `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) |
|
||||
|
||||
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.
|
||||
@@ -668,6 +672,7 @@ Hilt, `SingletonComponent` throughout. Nine app modules plus the kit's:
|
||||
| `ZoneModule` | `@Binds` for the one ICU seam: `ZoneNames → IcuZoneNames` (M8). `ZoneDirectory` needs no binding; it is a `@Singleton` `@Inject` class |
|
||||
| `StopwatchModule` | `@Binds` for the stopwatch engine's one seam: `StopwatchServiceHandle` (the foreground service). It has no scheduler, because nothing the stopwatch does has a deadline |
|
||||
| `TimerModule` | `@Binds` for the timer engine's three seams: `TimerScheduler` (the one elapsed-realtime slot), `TimerServiceHandle` (the foreground service), `AlarmRingStatus` (read-only: "is an alarm ringing") |
|
||||
| `BedtimeModule` | `@Binds` for the bedtime engine's three seams (issue #1): `BedtimeScheduler`, `BedtimeMode`, `BedtimeNotifier` |
|
||||
|
||||
From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the
|
||||
process-lifetime `@ApplicationScope` the receivers launch on, and `core-prefs`
|
||||
@@ -968,6 +973,7 @@ declared because a specific path would otherwise fail, silently, at 07:00.
|
||||
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | the two ringing services: the alarm's, and (since M6) the timers' |
|
||||
| `WAKE_LOCK` | keep the CPU up for the length of a ring |
|
||||
| `VIBRATE` | an alarm and a timer both vibrate, and vibrate alone when no audio source opens |
|
||||
| `ACCESS_NOTIFICATION_POLICY` | issue #1's opt-in bedtime mode. Install-time, and on its own it does nothing: it lists Clockula under Special app access › Do Not Disturb, which the user grants by hand. With that grant Clockula adds one Do Not Disturb rule of its own, which always lets alarms through (§18) |
|
||||
|
||||
**M6 adds no permission at all.** A timer never takes over the screen (no
|
||||
full-screen intent, no ring activity, no `showWhenLocked`), because its user set
|
||||
@@ -2686,3 +2692,121 @@ app has shipped nine milestones with no toast anywhere, and formatting "Alarm se
|
||||
for 7:00" would need a second, non-composable 12/24-hour formatter, the thing
|
||||
M8 spent a milestone eliminating. The platform's own receipt is better than a
|
||||
toast and costs nothing.
|
||||
|
||||
---
|
||||
|
||||
## 18. Bedtime
|
||||
|
||||
Issue #1: an opt-in Do Not Disturb mode, after Google Clock's Bedtime tab,
|
||||
without a fifth tab. v1's four surfaces stay as they are; bedtime is a nested
|
||||
destination (`settings/bedtime`) reached from a row on the settings hub, from
|
||||
its own reminder notification, and from the mode's page in the system's
|
||||
Settings.
|
||||
|
||||
### Why a mode of Clockula's own
|
||||
|
||||
Digital Wellbeing's Bedtime mode is off limits. `NotificationManagerService`
|
||||
refuses an `AutomaticZenRule` of `TYPE_BEDTIME` from any package but the one in
|
||||
`config_systemWellbeing`, and no third-party app can trigger Wellbeing's mode or
|
||||
read its schedule. What Android does offer an app with Do Not Disturb access
|
||||
(`ACCESS_NOTIFICATION_POLICY`, granted by hand under Special app access) is an
|
||||
automatic zen rule of its own:
|
||||
|
||||
- **Android 15 and later**: a *mode*, built with `AutomaticZenRule.Builder`,
|
||||
typed `TYPE_SCHEDULE_TIME`, shown in Settings › Modes under Clockula's name and
|
||||
the `ic_bedtime` icon, with a trigger line ("23:00 to 07:00") and the public
|
||||
`ZenDeviceEffects` the screen offers: grayscale, dimmed wallpaper, dark theme
|
||||
and hiding the always-on display. The hidden effects (auto-brightness,
|
||||
tap/tilt to wake, doze, radios) are dropped by `ZenModeHelper` when an app
|
||||
sets them, so the screen does not offer them. Manual invocation is allowed,
|
||||
so the user can also switch the mode on from Settings or Quick Settings.
|
||||
- **Android 10 to 14** (minSdk 29): a plain Do Not Disturb schedule rule from
|
||||
the seven-argument constructor that takes a `ZenPolicy`. No type, no effects:
|
||||
the screen says so and hides the effect switches.
|
||||
|
||||
A rule needs an owner `ConditionProviderService` or a configuration activity.
|
||||
Clockula uses `MainActivity` as the configuration activity: it is already
|
||||
exported, so no new component is exposed, and when Settings sends it
|
||||
`NotificationManager.ACTION_AUTOMATIC_ZEN_RULE` the shell opens the bedtime
|
||||
screen (`ShellRequest.OpenBedtime`). There is no condition provider service:
|
||||
Clockula reports the rule's state itself with `setAutomaticZenRuleState`.
|
||||
|
||||
The two modes coexist. A Pixel user who also uses Wellbeing's Bedtime sees two
|
||||
modes in Settings › Modes; the screen says so and suggests using one.
|
||||
|
||||
### Alarms are never suppressed
|
||||
|
||||
The rule's `ZenPolicy` sets exactly two things, `allowAlarms(true)` and
|
||||
`allowMedia(true)` (a sleep sound keeps playing), and leaves every other field
|
||||
unset, so calls, messages and the rest follow the device's own Do Not Disturb
|
||||
defaults. Clockula's alarms and timers play with `USAGE_ALARM`, so the
|
||||
exemption applies. A user who later edits the mode in Settings and blocks alarms
|
||||
there has made that choice in the system's own UI; Android 15 keeps a user's
|
||||
edits over an app's updates, and Clockula does not fight them. The self-check
|
||||
(M10's `SelfCheck`) still reports total silence as a problem whatever caused it.
|
||||
|
||||
### The engine
|
||||
|
||||
`BedtimeEngine` is plain Kotlin behind three seams, JVM-tested like the other
|
||||
engines (`ArchitectureRulesTest` holds it to no `android.*` import):
|
||||
|
||||
- `BedtimeScheduler`, **one** AlarmManager slot of its own. Its PendingIntent
|
||||
targets `BedtimeReceiver` with its own action and request code, so arming or
|
||||
cancelling it cannot replace or cancel an alarm's or a timer's registration.
|
||||
It is `RTC_WAKEUP`, exact while idle when the platform allows it and
|
||||
`setAndAllowWhileIdle` otherwise, the timer slot's two branches. It never
|
||||
uses `setAlarmClock`: that slot is the status bar's next *alarm*. Alarms
|
||||
themselves use `setAlarmClock`, which the while-idle quota does not touch,
|
||||
and bedtime adds at most three fires a night.
|
||||
- `BedtimeMode`, the rule. Every call is guarded and a no-op without access.
|
||||
- `BedtimeNotifier`, the reminder, on its own `bedtime_reminder` channel.
|
||||
|
||||
Every entry point is an idempotent pass over "where does the schedule stand
|
||||
now", computed by `BedtimeSchedule`: a night's window runs from the bedtime on
|
||||
one of the chosen days to the first wake time after it (an earlier wake time is
|
||||
the next morning, an equal one a full day), resolved from local times and the
|
||||
zone like an alarm, so a DST night is as long as the wall clock says. The slot
|
||||
is armed for the earliest of the current window's end, the next window's
|
||||
reminder and the next window's start.
|
||||
|
||||
| Entry point | Called from | What it does |
|
||||
|---|---|---|
|
||||
| `update(transform)` | the bedtime screen | stores the settings, rewrites the rule from them, sets the mode to what the schedule says now (on at 23:30 for a 23:00 bedtime starts it at once), arms the slot. Off removes the rule, which ends the mode, cancels the slot and clears the reminder |
|
||||
| `onSlotFired()` | `BedtimeReceiver` | a reminder fire posts the reminder and **leaves the mode alone**; any other fire sets the mode on or off. Then arms the next event |
|
||||
| `resync(refreshRule)` | `SystemEventReceiver` (boot, app update, clock or zone change, exact-alarm permission), Do Not Disturb access changing, a backup restore | creates the rule if it is missing, sets the mode, arms the slot |
|
||||
| `rearm()` | `ClockulaApp.onCreate` | arms the slot only. A force stop clears it, but a launch is not a bedtime event, so a mode the user switched by hand stays as it is |
|
||||
|
||||
Every bedtime call on a shared path runs **after** the alarm, timer and
|
||||
stopwatch passes and inside `runCatching`, so nothing bedtime does can delay
|
||||
or break them.
|
||||
|
||||
### No stored rule id
|
||||
|
||||
`NotificationManager.getAutomaticZenRules()` returns only the calling app's
|
||||
rules, and the rule's condition id (`condition://<package>/bedtime`) marks
|
||||
Clockula's. So nothing about the rule is stored: a reinstall, a cleared app or
|
||||
a restore onto another device finds the rule or creates it, and a stray second
|
||||
one is removed. An update keeps the rule's own enabled switch, so a change on
|
||||
Clockula's screen does not re-enable a rule the user disabled in Settings.
|
||||
|
||||
### Access, granted and revoked
|
||||
|
||||
Do Not Disturb access cannot be requested with a dialog. The screen shows an
|
||||
onboarding row while it is missing, deep-linking to
|
||||
`Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS`, and re-reads access on
|
||||
every `ON_RESUME`; a change found there runs `resync(refreshRule = true)` at
|
||||
once, so coming back from granting it creates the mode without waiting for the
|
||||
system's `ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED` (which
|
||||
`BedtimeReceiver` also answers). Without access the schedule still runs for the
|
||||
reminder. Revoking access in Settings removes the app's rules with it (that is
|
||||
Settings' own confirmation dialog), and Clockula cannot remove a rule without
|
||||
access, so a rule left by a revoke from outside Settings is the platform's.
|
||||
Turning bedtime off removes the rule cleanly.
|
||||
|
||||
### Backup
|
||||
|
||||
The settings travel in the backup's `settings.bedtime` object
|
||||
(`docs/BACKUP.md`); a file from before issue #1 restores bedtime as off.
|
||||
`BackupEngine.import` resyncs the engine after the replace, or after rolling
|
||||
it back.
|
||||
|
||||
|
||||
+21
-1
@@ -136,10 +136,30 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
|
||||
"default_timer_ringtone_uri": null,
|
||||
"default_dismiss_challenge": "NONE",
|
||||
"home_zone_id": null,
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000]
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
|
||||
"bedtime": {
|
||||
"enabled": false,
|
||||
"bedtime_minutes": 1350,
|
||||
"wake_minutes": 420,
|
||||
"days": 127,
|
||||
"reminder_minutes": 0,
|
||||
"grayscale": true,
|
||||
"dim_wallpaper": true,
|
||||
"dark_theme": true,
|
||||
"hide_always_on_display": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`bedtime` is the opt-in bedtime schedule (issue #1). Its times are minutes
|
||||
since local midnight, `days` is the same 7-bit mask as an alarm's
|
||||
`repeat_days` (the nights bedtime starts on), and `reminder_minutes` 0 means no
|
||||
reminder. A file without `bedtime` (any file written before it existed)
|
||||
restores bedtime as off with the default schedule. Like the other settings it
|
||||
is clamped rather than rejected: times into a day, the reminder into 0–120
|
||||
minutes. The id of the Do Not Disturb rule a device created is not part of it;
|
||||
the importing device creates its own (`docs/ARCHITECTURE.md` §18).
|
||||
|
||||
Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older
|
||||
files may also carry `default_timer_duration_millis`, from a default timer
|
||||
length setting that has since been removed; it is ignored on import like any
|
||||
|
||||
+5
-3
@@ -162,9 +162,11 @@ notification whose content intent is the ring activity.
|
||||
A "**Why might my alarm not ring?**" screen that reports actual device state, not
|
||||
generic advice: exact-alarm permission, notification permission, full-screen-intent
|
||||
permission, battery-optimisation exemption, **whether DND currently blocks alarms**
|
||||
(read via `NotificationManager.getCurrentInterruptionFilter()`; Clockula never
|
||||
requests `ACCESS_NOTIFICATION_POLICY` or changes DND itself, it only reports what the
|
||||
user already has), whether the OEM has Clockula under an aggressive app-killer, and
|
||||
(read via `NotificationManager.getCurrentInterruptionFilter()`; the self-check never
|
||||
changes DND itself, it only reports what the user already has. The one place Clockula
|
||||
touches DND is the opt-in bedtime mode added after v1, which needs the user to grant
|
||||
`ACCESS_NOTIFICATION_POLICY` by hand and always lets alarms through; see
|
||||
`ARCHITECTURE.md` §18), whether the OEM has Clockula under an aggressive app-killer, and
|
||||
the next scheduled fire time as the system sees it (`getNextAlarmClock()`), compared
|
||||
against the next fire Clockula itself computed. Where possible each row deep-links to
|
||||
the exact settings page. This screen is a v1 feature, not a support afterthought.
|
||||
|
||||
+6
-2
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Privacy Policy — Clockula
|
||||
description: What Clockula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
|
||||
updated: 2026-10-02
|
||||
updated: 2026-10-22
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -20,7 +20,7 @@ updated: 2026-10-02
|
||||
render on the published page.
|
||||
-->
|
||||
|
||||
**Last updated:** 2 October 2026
|
||||
**Last updated:** 22 October 2026
|
||||
Applies to the Android app **Clockula** (package `de.jeanlucmakiola.clockula`),
|
||||
all versions and all distribution channels.
|
||||
|
||||
@@ -167,6 +167,10 @@ on. Clockula only opens the address and sends no data of yours with it.
|
||||
stopwatch going while the app is in the background.
|
||||
- `WAKE_LOCK`: keep the phone awake while an alarm or timer rings.
|
||||
- `VIBRATE`: vibrate when an alarm or timer rings.
|
||||
- `ACCESS_NOTIFICATION_POLICY`: only used if you turn on Bedtime and allow
|
||||
Clockula Do Not Disturb access yourself. Clockula then adds one Do Not
|
||||
Disturb mode of its own and turns it on and off on your schedule. It does not
|
||||
read your notifications.
|
||||
- `ACCESS_NETWORK_STATE`: added by the Android library that updates the
|
||||
home-screen widgets (WorkManager). It lets an app see whether a network
|
||||
connection exists. It does not let it use one, and without `INTERNET` no
|
||||
|
||||
+9
-2
@@ -611,8 +611,15 @@ Deferred deliberately, in rough priority order:
|
||||
- **Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
|
||||
win, and the surface people look at most.
|
||||
- **Quick Settings tile**: timer control and next alarm from the shade.
|
||||
- **Bedtime / sleep schedule**: wind-down and wake, with DND handoff. A feature
|
||||
in its own right, realistically v2.
|
||||
- 🚧 **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
|
||||
wallpaper, dark theme and hidden always-on display; a plain DND rule on 10–14),
|
||||
bedtime and wake-up times, nights, an optional reminder, and backup. Digital
|
||||
Wellbeing's Bedtime mode stays out of reach (the platform refuses
|
||||
`TYPE_BEDTIME` to third parties). Still open: a wind-down phase before
|
||||
bedtime, and whether the wake-up should tie into an alarm. See
|
||||
`ARCHITECTURE.md` §18.
|
||||
- **`core-notification` in floret-kit**: revisit once Clockula's real
|
||||
notification surface is known (`PLAN.md` §3).
|
||||
- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`**: tracked in
|
||||
|
||||
Reference in New Issue
Block a user