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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user