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) <noreply@anthropic.com>
This commit is contained in:
2026-11-07 15:08:51 +00:00
co-authored by Claude Opus 5.5
parent a5564f6972
commit efaa84e683
6 changed files with 82 additions and 18 deletions
@@ -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"
@@ -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) }
@@ -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)) {
@@ -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<Profile?>(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<Profile?>(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()) {
@@ -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)
}
}
@@ -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))
}
}