From efaa84e683fff90b9c1cf42e8b05b7ec54b8c6ec Mon Sep 17 00:00:00 2001 From: Pauljd Date: Sat, 7 Nov 2026 15:08:51 +0000 Subject: [PATCH] Remember who's watching and the Adult unlock through restarts; optional nightly lock Ported from release-0.108 (0.108.92). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/tv/orbit/classic/AfterDarkLock.kt | 51 +++++++++++++++---- .../java/tv/orbit/classic/MainActivity.kt | 4 +- .../java/tv/orbit/classic/OrbitCompanion.kt | 3 +- .../main/java/tv/orbit/classic/Profiles.kt | 16 ++++-- .../src/main/java/tv/orbit/classic/Rooms.kt | 2 +- .../java/tv/orbit/classic/NightlyLockTest.kt | 24 +++++++++ 6 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 android/app/src/test/java/tv/orbit/classic/NightlyLockTest.kt diff --git a/android/app/src/main/java/tv/orbit/classic/AfterDarkLock.kt b/android/app/src/main/java/tv/orbit/classic/AfterDarkLock.kt index 8a18d2f..03cc6f8 100644 --- a/android/app/src/main/java/tv/orbit/classic/AfterDarkLock.kt +++ b/android/app/src/main/java/tv/orbit/classic/AfterDarkLock.kt @@ -35,13 +35,28 @@ internal object AfterDarkLock { /** A PIN has been set on this box. */ var pinSet by mutableStateOf(false) private set - /** Unlocked with the PIN, until Orbit closes. */ - var unlocked by mutableStateOf(false) + /** + * Unlocked with the PIN. It stays unlocked through Orbit closing, restarts and updates (Paul, 7 November 2026: "it keeps + * forgetting who I am"), until Lock now, Kids, or the nightly lock if that's turned on. + */ + private var unlockedState by mutableStateOf(false) + private var unlockedAt = 0L + /** Locks again overnight, at [NIGHTLY_LOCK_HOUR], after an unlock. Off unless chosen. */ + var nightly by mutableStateOf(false) private set + val unlocked get() = unlockedState && !(nightly && crossedNightlyLock(unlockedAt, System.currentTimeMillis())) /** Hidden: a PIN is set and it hasn't been entered, or Kids is watching. */ val locked get() = (pinSet && !unlocked) || Profiles.kids + private var store: android.content.SharedPreferences? = null - fun load(context: Context) { pinSet = prefs(context).getString("after_dark_pin", null) != null } + fun load(context: Context) { + val p = prefs(context).also { store = it } + pinSet = p.getString("after_dark_pin", null) != null + nightly = p.getBoolean("nightly_lock", false) + unlockedAt = p.getLong("unlocked_at", 0L) + unlockedState = pinSet && unlockedAt > 0 + } + fun setNightly(context: Context, on: Boolean) { nightly = on; prefs(context).edit().putBoolean("nightly_lock", on).apply() } private fun prefs(context: Context) = context.getSharedPreferences("orbit_lock", Context.MODE_PRIVATE) fun check(context: Context, pin: String): Boolean { @@ -52,11 +67,22 @@ internal object AfterDarkLock { fun set(context: Context, pin: String) { val salt = java.util.UUID.randomUUID().toString() prefs(context).edit().putString("after_dark_salt", salt).putString("after_dark_pin", pinHash(salt, pin)).apply() - pinSet = true; unlocked = false + pinSet = true; lock() } - fun clear(context: Context) { prefs(context).edit().clear().apply(); pinSet = false; unlocked = false } - fun unlock(context: Context, pin: String) = check(context, pin).also { if (it) unlocked = true } - fun lock() { unlocked = false } + fun clear(context: Context) { prefs(context).edit().clear().apply(); pinSet = false; unlockedState = false; unlockedAt = 0L; nightly = false } + fun unlock(context: Context, pin: String) = check(context, pin).also { if (it) { + unlockedState = true; unlockedAt = System.currentTimeMillis(); prefs(context).edit().putLong("unlocked_at", unlockedAt).apply() } } + fun lock() { unlockedState = false; unlockedAt = 0L; store?.edit()?.remove("unlocked_at")?.apply() } +} + +/** The hour the optional nightly lock falls, 3am. */ +internal const val NIGHTLY_LOCK_HOUR = 3 + +/** Whether a 3am has passed between an unlock at [unlockedMs] and [nowMs]. */ +internal fun crossedNightlyLock(unlockedMs: Long, nowMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): Boolean { + val at = java.time.Instant.ofEpochMilli(unlockedMs).atZone(zone) + val next = at.toLocalDate().atTime(NIGHTLY_LOCK_HOUR, 0).atZone(zone).let { if (it.isAfter(at)) it else it.plusDays(1) } + return nowMs >= next.toInstant().toEpochMilli() } internal fun pinHash(salt: String, pin: String): String = @@ -124,24 +150,27 @@ internal fun LockDetail(firstSwitch: FocusRequester, modifier: Modifier) { Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(when { !AfterDarkLock.pinSet -> "Off: After Dark and The Late Show are showing" - AfterDarkLock.unlocked -> "Unlocked until Orbit closes" + AfterDarkLock.unlocked -> if (AfterDarkLock.nightly) "Unlocked until 3am" else "Unlocked until you lock it" else -> "Locked: After Dark and The Late Show are hidden" }, color = Ink, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) Text("With a PIN set, Orbit After Dark and The Late Show at the Drive-In are hidden from the guide, Home, What's On and Catch Up, and from channel up and down. " + - "Enter the PIN here to watch them; they lock again when Orbit closes.", color = Muted, fontSize = 12.sp) + "Enter the PIN here to watch them. They stay unlocked through restarts until you press Lock now or Kids starts watching" + + (if (AfterDarkLock.nightly) ", and lock again at 3am." else "."), color = Muted, fontSize = 12.sp) note?.let { Text(it, color = Yellow, fontSize = 13.sp) } when (step) { LockStep.None -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (!AfterDarkLock.pinSet) OrbitButton("Set a PIN", Modifier.focusRequester(firstSwitch), onClick = { begin(LockStep.NewPin) }) else { if (AfterDarkLock.unlocked) OrbitButton("Lock now", Modifier.focusRequester(firstSwitch), onClick = { AfterDarkLock.lock(); note = "Locked." }) - else OrbitButton("Unlock until Orbit closes", Modifier.focusRequester(firstSwitch), onClick = { begin(LockStep.Unlock) }) + else OrbitButton("Unlock", Modifier.focusRequester(firstSwitch), onClick = { begin(LockStep.Unlock) }) + OrbitButton(if (AfterDarkLock.nightly) "Nightly lock: on" else "Nightly lock: off", selected = AfterDarkLock.nightly, + onClick = { AfterDarkLock.setNightly(context, !AfterDarkLock.nightly); note = if (AfterDarkLock.nightly) "Locks again every night at 3am." else "Stays unlocked until you lock it." }) OrbitButton("Change PIN", onClick = { begin(LockStep.CheckForChange) }) OrbitButton("Turn the lock off", onClick = { begin(LockStep.CheckForOff) }) } } LockStep.Unlock -> PinEntry("Enter the PIN", problem, onDone = { pin -> - if (AfterDarkLock.unlock(context, pin)) { step = LockStep.None; note = "Unlocked until Orbit closes." } else problem = "That's not the PIN" + if (AfterDarkLock.unlock(context, pin)) { step = LockStep.None; note = if (AfterDarkLock.nightly) "Unlocked until 3am." else "Unlocked until you lock it." } else problem = "That's not the PIN" }, onCancel = { step = LockStep.None }) LockStep.CheckForChange, LockStep.CheckForOff -> PinEntry("Enter the current PIN", problem, onDone = { pin -> if (!AfterDarkLock.check(context, pin)) problem = "That's not the PIN" diff --git a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt index ab89c59..d78d9ae 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -129,8 +129,8 @@ private fun OrbitApp() { // Opening the app always lands on the main menu, wherever it was left. if (event == Lifecycle.Event.ON_STOP && realChannelAvailable && section != "Connect Emby") section = "Home" // The sleep timer is for this evening's viewing only. - // Orbit asks who's watching again each time it opens. - if (event == Lifecycle.Event.ON_STOP) { SleepTimer.clear(); AfterDarkLock.lock(); Profiles.current = null } + // Who's watching and the lock are remembered through closing (Paul, 7 November 2026); Home's profile button changes them. + if (event == Lifecycle.Event.ON_STOP) SleepTimer.clear() } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt b/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt index 7c47d40..8fb4eee 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt @@ -225,7 +225,8 @@ internal object OrbitCompanion { /** Switches who's watching on the TV. Kids goes straight in; Adult needs the PIN when one's set, as on the TV. Null when done, else why not. */ fun switchProfile(context: Context?, id: String, pin: String?, nowMs: Long): String? { val p = Profiles.all.firstOrNull { it.id == id } ?: return "That profile isn't on this TV." - if (p.kind == ProfileKind.Grown && AfterDarkLock.pinSet) { + // Already unlocked on the TV: Adult goes straight in, as the TV itself would. + if (p.kind == ProfileKind.Grown && AfterDarkLock.pinSet && !AfterDarkLock.unlocked) { if (nowMs < pinWaitUntil) return "Too many wrong PINs. Try again in a few minutes." if (pin.isNullOrEmpty()) return "PIN" if (context == null || !AfterDarkLock.unlock(context, pin)) { diff --git a/android/app/src/main/java/tv/orbit/classic/Profiles.kt b/android/app/src/main/java/tv/orbit/classic/Profiles.kt index 9d2b1cf..04ca4b8 100644 --- a/android/app/src/main/java/tv/orbit/classic/Profiles.kt +++ b/android/app/src/main/java/tv/orbit/classic/Profiles.kt @@ -61,8 +61,15 @@ internal fun pictureName(id: String) = when (id) { internal object Profiles { var all by mutableStateOf(DefaultProfiles) private set - /** Who's watching, or null before anyone's chosen. */ - var current by mutableStateOf(null) + /** + * Who's watching, or null before anyone's chosen. Remembered through Orbit closing and restarting (Paul, 7 November + * 2026); Adult comes back only while the lock is still open. + */ + private var chosen by mutableStateOf(null) + var current: Profile? + get() = chosen + set(value) { chosen = value; store?.edit()?.putString("current", value?.id)?.apply() } + private var store: android.content.SharedPreferences? = null val kids get() = current?.kind == ProfileKind.Kids /** A Kids profile is in use, so Orbit asks who's watching; without one, Adult goes straight in. */ var kidsOn by mutableStateOf(true) @@ -73,6 +80,9 @@ internal object Profiles { fun load(context: Context) { all = runCatching { decode(prefs(context).getString("profiles", null)!!) }.getOrDefault(DefaultProfiles) kidsOn = prefs(context).getBoolean("kids_on", true) + store = prefs(context) + chosen = prefs(context).getString("current", null)?.let { id -> all.firstOrNull { it.id == id } } + ?.takeIf { it.kind == ProfileKind.Kids || !AfterDarkLock.pinSet || AfterDarkLock.unlocked } } fun update(context: Context, changed: Profile) { all = all.map { if (it.id == changed.id) changed else it } @@ -162,7 +172,7 @@ internal fun WhosWatching(onClose: () -> Unit) { LaunchedEffect(asking) { if (asking == null) runCatching { first.requestFocus() } } BackHandler(asking == null, onBack = onClose) fun choose(p: Profile) { - if (p.kind == ProfileKind.Grown && AfterDarkLock.pinSet) { asking = p; problem = null } + if (p.kind == ProfileKind.Grown && AfterDarkLock.pinSet && !AfterDarkLock.unlocked) { asking = p; problem = null } else { if (p.kind == ProfileKind.Kids) AfterDarkLock.lock(); Profiles.current = p } } Box(Modifier.fillMaxSize()) { diff --git a/android/app/src/main/java/tv/orbit/classic/Rooms.kt b/android/app/src/main/java/tv/orbit/classic/Rooms.kt index 951d950..dd36c4e 100644 --- a/android/app/src/main/java/tv/orbit/classic/Rooms.kt +++ b/android/app/src/main/java/tv/orbit/classic/Rooms.kt @@ -132,7 +132,7 @@ internal object RoomBundle { if (RoomPart.Family in parts) bundle.optJSONObject(RoomPart.Family.name)?.let { f -> putPrefs(context, "orbit_profiles", f.optJSONObject("profiles"), listOf("profiles", "kids_on")) putPrefs(context, "orbit_lock", f.optJSONObject("lock"), listOf("after_dark_salt", "after_dark_pin")) - Profiles.load(context); AfterDarkLock.load(context) + AfterDarkLock.load(context); Profiles.load(context) } } diff --git a/android/app/src/test/java/tv/orbit/classic/NightlyLockTest.kt b/android/app/src/test/java/tv/orbit/classic/NightlyLockTest.kt new file mode 100644 index 0000000..19dc363 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/NightlyLockTest.kt @@ -0,0 +1,24 @@ +package tv.orbit.classic + +import java.time.ZoneId +import java.time.ZonedDateTime +import org.junit.Assert.* +import org.junit.Test + +// The lock stays open through restarts; the optional nightly lock closes it at 3am (Paul, 7 November 2026). +class NightlyLockTest { + private val zone = ZoneId.of("Europe/London") + private fun at(day: Int, hour: Int, minute: Int = 0) = ZonedDateTime.of(2026, 11, day, hour, minute, 0, 0, zone).toInstant().toEpochMilli() + + @Test fun anEveningUnlockLastsUntilThreeTheNextMorning() { + assertFalse(crossedNightlyLock(at(7, 20), at(7, 23, 59), zone)) + assertFalse(crossedNightlyLock(at(7, 20), at(8, 2, 59), zone)) + assertTrue(crossedNightlyLock(at(7, 20), at(8, 3), zone)) + } + + @Test fun anUnlockAfterThreeLastsUntilTheNextNight() { + assertFalse(crossedNightlyLock(at(8, 3, 30), at(8, 23), zone)) + assertTrue(crossedNightlyLock(at(8, 1), at(8, 3, 1), zone)) + assertTrue(crossedNightlyLock(at(7, 10), at(9, 12), zone)) + } +}