diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..bf7eedf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cfe76b4..fc436c2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,6 +50,12 @@ + + + + + + + + + = 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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt new file mode 100644 index 0000000..4eb52a0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeIntents.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt new file mode 100644 index 0000000..8192ed5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/BedtimeSeams.kt @@ -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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt new file mode 100644 index 0000000..ce71253 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeMode.kt @@ -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 = + 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 guarded(fallback: T, block: () -> T): T = try { + block() + } catch (_: RuntimeException) { + fallback + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeNotifier.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeNotifier.kt new file mode 100644 index 0000000..00dbcf4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeNotifier.kt @@ -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) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt new file mode 100644 index 0000000..11ba260 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/AndroidBedtimeScheduler.kt @@ -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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/BedtimeTimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/BedtimeTimeFormat.kt new file mode 100644 index 0000000..4504780 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/android/BedtimeTimeFormat.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/di/BedtimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/di/BedtimeModule.kt new file mode 100644 index 0000000..7e0c0d6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/di/BedtimeModule.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt new file mode 100644 index 0000000..2d6b448 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/bedtime/receiver/BedtimeReceiver.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index c6daf96..3fd7069 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 118e99e..b225f4a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -77,4 +77,23 @@ data class SettingsDto( * built-in ones. */ @SerialName("timer_presets") val timerPresets: List? = 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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt new file mode 100644 index 0000000..ab1704c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BedtimePrefs.kt @@ -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 = booleanPref("bedtime_enabled", DEFAULT.enabled) + + val bedtime: Pref = intPref("bedtime_start_minutes", DEFAULT.bedtime.minutesOfDay).asTimeOfDay() + + val wakeTime: Pref = intPref("bedtime_wake_minutes", DEFAULT.wakeTime.minutesOfDay).asTimeOfDay() + + /** The 7-bit mask `RepeatDays` uses; a corrupt value cannot widen the set. */ + val days: Pref = intPref("bedtime_days", DEFAULT.days.mask) + .map(decode = RepeatDays::fromMask, encode = { it.mask }) + + val reminderMinutes: Pref = intPref("bedtime_reminder_minutes", DEFAULT.reminderMinutes) + .map( + decode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) }, + encode = { it.coerceIn(BedtimeLimits.REMINDER_MINUTES) }, + ) + + val grayscale: Pref = booleanPref("bedtime_effect_grayscale", DEFAULT.effects.grayscale) + + val dimWallpaper: Pref = booleanPref("bedtime_effect_dim_wallpaper", DEFAULT.effects.dimWallpaper) + + val darkTheme: Pref = booleanPref("bedtime_effect_dark_theme", DEFAULT.effects.darkTheme) + + val hideAlwaysOnDisplay: Pref = + booleanPref("bedtime_effect_hide_aod", DEFAULT.effects.hideAlwaysOnDisplay) + + /** Minutes since midnight on disk, clamped into a day both ways. */ + private fun Pref.asTimeOfDay(): Pref = 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index b76785b..b802eca 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -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 = 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) --- /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index eac440d..e20b821 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -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, + /** Absent from older backups, which restore it as [BedtimeSettings]' defaults (off). */ + val bedtime: BedtimeSettings = BedtimeSettings(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt new file mode 100644 index 0000000..09e5b67 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSchedule.kt @@ -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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt new file mode 100644 index 0000000..f8b0e82 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeSettings.kt @@ -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 = listOf(0, 15, 30, 45, 60) + + /** Anything read from disk or a backup is clamped into this. */ + val REMINDER_MINUTES: IntRange = 0..120 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt index 0450a24..792eb50 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt new file mode 100644 index 0000000..9102513 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeScreen.kt @@ -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(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), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeUiState.kt new file mode 100644 index 0000000..147afe4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeUiState.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt new file mode 100644 index 0000000..d05c397 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/bedtime/BedtimeViewModel.kt @@ -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 = 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt index ce364a0..1aad5f4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt @@ -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" } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt index eaeda0d..8ea54a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt index 71a4595..713674a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index aac52fc..b0bb5bd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -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) : 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) diff --git a/app/src/main/res/drawable/ic_bedtime.xml b/app/src/main/res/drawable/ic_bedtime.xml new file mode 100644 index 0000000..091d0fd --- /dev/null +++ b/app/src/main/res/drawable/ic_bedtime.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..06d57cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -439,4 +439,46 @@ Custom… Custom duration Between 1 second and 59:59 + + + Sleep + A Do Not Disturb mode for the night + Bedtime + Clockula turns on a Do Not Disturb mode of its own at bedtime and turns it off when you wake up. Alarms always ring. + Bedtime mode + On, %1$s to %2$s + Off + Allow Do Not Disturb access + 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. + Allow + Schedule + Bedtime + Wake up + The nights bedtime starts on. A wake-up time before the bedtime is the next morning. + Reminder + Remind me before bedtime + Off + + %1$d minute before + %1$d minutes before + + Screen + Grayscale + Dim the wallpaper + Dark theme + Hide the always-on display + Screen effects need Android 15 or later. On this phone, bedtime silences notifications and calls the way Do Not Disturb does. + Open in system settings + Who can reach you, and more effects, under Settings › Modes. Changes you make there are kept. + Bedtime never silences an alarm or a timer: its mode always lets alarms through. + 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. + Clockula bedtime + %1$s to %2$s + Bedtime reminders + A reminder shortly before bedtime starts. + + Bedtime in %1$d minute + Bedtime in %1$d minutes + + Bedtime mode starts at %1$s. diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt index d22312d..1ae6a3e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt @@ -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/") } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt index fc5bea3..3acd004 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt @@ -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", ) } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt index f572b7d..43b7261 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt @@ -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() }) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt new file mode 100644 index 0000000..d961b20 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/bedtime/BedtimeEngineTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index db57734..f4bbe52 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -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\": []}") diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt index 71ead3e..8037794 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt new file mode 100644 index 0000000..9b48ad6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/bedtime/BedtimeScheduleTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt index 43e2b66..2a83db0 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/BackupHarness.kt @@ -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, ) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt new file mode 100644 index 0000000..6cef44e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeBedtimeSeams.kt @@ -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> = 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 + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt index 09190be..cbe63e3 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationInteropTest.kt @@ -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) + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..363dc39 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` 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:///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. + diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e8536de..e58eb1f 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -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 diff --git a/docs/PLAN.md b/docs/PLAN.md index 4899d1e..25015e9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 17c2562..385d5ec 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -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 --- -**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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb7731b..8b0f770 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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