The After Dark lock (from 0.108.33)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m2s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m41s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 22:53:21 +00:00
co-authored by Claude Opus 5.5
parent e21ab601f0
commit 8455bd7ef0
9 changed files with 202 additions and 7 deletions
@@ -0,0 +1,158 @@
package tv.orbit.classic
import android.content.Context
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
/**
* The After Dark lock (Paul, 5 November 2026: the first of first-run setup, with children using the TV). Once a four-digit PIN
* is set, Orbit After Dark and The Late Show at the Drive-In are hidden everywhere: the guide, Home, What's On, Catch Up, channel
* up and down and the number box. Entering the PIN in Settings unlocks them until Orbit closes; it locks again by itself. The
* PIN is kept only as a salted hash on this box. Changing it or turning the lock off needs the PIN.
*/
internal const val PIN_LENGTH = 4
/** The channels the lock hides. */
internal val LockedChannels = setOf(AFTER_DARK_NUMBER)
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)
private set
val locked get() = pinSet && !unlocked
fun load(context: Context) { pinSet = prefs(context).getString("after_dark_pin", null) != null }
private fun prefs(context: Context) = context.getSharedPreferences("orbit_lock", Context.MODE_PRIVATE)
fun check(context: Context, pin: String): Boolean {
val p = prefs(context)
val salt = p.getString("after_dark_salt", null) ?: return false
return p.getString("after_dark_pin", null) == pinHash(salt, pin)
}
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
}
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 }
}
internal fun pinHash(salt: String, pin: String): String =
java.security.MessageDigest.getInstance("SHA-256").digest("$salt:$pin".toByteArray()).joinToString("") { "%02x".format(it) }
/** Hidden by the lock: the channel itself, or a Drive-In bill that's The Late Show. */
internal fun lockedOut(number: Int, locked: Boolean = AfterDarkLock.locked) = locked && number in LockedChannels
internal fun lockedOut(bill: DriveInBill, locked: Boolean = AfterDarkLock.locked) = locked && bill.kind == DriveInKind.LateShow
/**
* Four digits, dialled with up and down (the Xiaomi remote may have no number keys) or typed. Earlier digits show as dots.
* OK or right moves on; OK on the last submits. Back cancels.
*/
@Composable
internal fun PinEntry(title: String, problem: String?, onDone: (String) -> Unit, onCancel: () -> Unit) {
var entry by remember { mutableStateOf(ChannelEntry(List(PIN_LENGTH) { 0 })) }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
LaunchedEffect(problem) { if (problem != null) entry = ChannelEntry(List(PIN_LENGTH) { 0 }) }
BackHandler(onBack = onCancel)
fun submit(e: ChannelEntry) = onDone(e.digits.joinToString(""))
val corner = RoundedCornerShape(8.dp)
Column(Modifier.background(Navy, corner).border(2.dp, Yellow, corner).padding(horizontal = 22.dp, vertical = 14.dp).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
val typed = if (native.keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9) native.keyCode - KeyEvent.KEYCODE_0 else null
val ok = native.keyCode == KeyEvent.KEYCODE_DPAD_CENTER || native.keyCode == KeyEvent.KEYCODE_ENTER
// On release, so the press that submits can't also act on what's behind.
if (native.action == KeyEvent.ACTION_UP) {
if (ok || native.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { if (entry.onLastSlot) { if (ok) submit(entry) } else entry = entry.next() }
return@onPreviewKeyEvent ok || native.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
}
when {
native.keyCode == KeyEvent.KEYCODE_DPAD_UP -> entry = entry.dial(1)
native.keyCode == KeyEvent.KEYCODE_DPAD_DOWN -> entry = entry.dial(-1)
native.keyCode == KeyEvent.KEYCODE_DPAD_LEFT -> entry = entry.previous()
typed != null -> { val next = entry.type(typed); if (entry.onLastSlot) submit(next) else entry = next }
ok || native.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT -> {}
else -> return@onPreviewKeyEvent false
}
true
}, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
entry.digits.forEachIndexed { i, d ->
Box(Modifier.size(40.dp, 46.dp).background(if (i == entry.slot) Yellow else Panel, RoundedCornerShape(4.dp)), contentAlignment = Alignment.Center) {
Text(if (i == entry.slot) d.toString() else if (i < entry.slot) "•" else "", color = if (i == entry.slot) Navy else Color.White, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
}
}
}
Text(problem ?: "▲▼ Change OK Next Back Cancel", color = if (problem != null) Yellow else Muted, fontSize = 11.sp)
}
}
private enum class LockStep { None, Unlock, NewPin, ConfirmPin, CheckForChange, CheckForOff }
/** Settings › After Dark lock. */
@Composable
internal fun LockDetail(firstSwitch: FocusRequester, modifier: Modifier) {
val context = LocalContext.current
var step by remember { mutableStateOf(LockStep.None) }
var first by remember { mutableStateOf("") }
var problem by remember { mutableStateOf<String?>(null) }
var note by remember { mutableStateOf<String?>(null) }
fun begin(s: LockStep) { step = s; problem = null; note = null }
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"
else -> "Locked: After Dark and The Late Show are hidden"
}, color = Color.White, 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)
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) })
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"
}, 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"
else if (step == LockStep.CheckForOff) { AfterDarkLock.clear(context); step = LockStep.None; note = "The lock is off." }
else begin(LockStep.NewPin)
}, onCancel = { step = LockStep.None })
LockStep.NewPin -> key(step) { PinEntry("Choose a four-digit PIN", null, onDone = { pin -> first = pin; begin(LockStep.ConfirmPin) }, onCancel = { step = LockStep.None }) }
LockStep.ConfirmPin -> key(step) { PinEntry("Enter it again", problem, onDone = { pin ->
if (pin == first) { AfterDarkLock.set(context, pin); step = LockStep.None; note = "PIN set. After Dark and The Late Show are now locked." }
else { step = LockStep.NewPin; note = "Those didn't match. Choose it again." }
}, onCancel = { step = LockStep.None }) }
}
Text("Forgotten the PIN? Signing out in Emby account clears it, along with this box's schedules.", color = Muted, fontSize = 11.sp)
}
}
@@ -66,7 +66,8 @@ internal data class PairsGame(val cards: List<Int>, val matched: Set<Int> = empt
}
/** The ten channels whose logos make the pairs. */
private val PairsChannels = listOf(101, 102, 201, 203, 301, AFTER_DARK_NUMBER, 309, 401, 601, 612)
// A family game, so After Dark isn't among the cards.
private val PairsChannels = listOf(101, 102, 201, 203, 301, 302, 309, 401, 601, 612)
private val Marquee = FontFamily(Font(R.font.anton_regular))
private val Honey = Color(0xFFFFD84A)
@@ -348,7 +348,7 @@ internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } }
val minute = now / 60_000
val bills = remember(plan, minute) { plan?.let { driveInBills(it).filter { bill -> bill.endMs > now } }.orEmpty().take(DRIVE_IN_LISTED) }
val bills = remember(plan, minute, AfterDarkLock.locked) { plan?.let { driveInBills(it).filter { bill -> bill.endMs > now && !lockedOut(bill) } }.orEmpty().take(DRIVE_IN_LISTED) }
var chosen by remember(visit) { mutableStateOf<Long?>(null) }
val selected = bills.firstOrNull { it.startMs == chosen } ?: bills.firstOrNull()
val index = bills.indexOf(selected)
@@ -352,7 +352,13 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val active = session ?: return
scope.launch(Dispatchers.IO) { runCatching { scheduler.markFailed(active.accountId, itemId) } }
}
// The After Dark lock hides its channels from everything that lists or steps through channels, Catch Up included.
val availableChannels = availableSampleChannels(channel?.let(::onAir), afterDarkChannel?.let(::onAir), toonamiChannel?.let(::onAir), *extraChannels.map(::onAir).toTypedArray())
.filterNot { lockedOut(it.number) }
// Locked while on After Dark: back to the first channel, and out of full screen.
LaunchedEffect(AfterDarkLock.locked) {
if (AfterDarkLock.locked && lockedOut(selectedNumber)) { watchingChannel = false; channelClockOffset = 0; selectedNumber = availableChannels.firstOrNull()?.number ?: 101 }
}
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
if (mainDriveIn && liveSession != null) {
val booked = driveInPlan
@@ -403,7 +409,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
when {
mainHome -> HomeScreen(availableChannels, modifier, onCategory = { guideCategory = it; onGuide() })
mainCatchUp -> CatchUpScreen(availableChannels, modifier, onOpen = { catchUpOpen = it })
mainWhatsOn -> WhatsOnScreen(liveSession, scheduledChannels.mapValues { it.value.plan }, driveInPlan, modifier, reminders,
mainWhatsOn -> WhatsOnScreen(liveSession, scheduledChannels.filterKeys { !lockedOut(it) }.mapValues { it.value.plan }, driveInPlan, modifier, reminders,
onWatch = { tune(it, 0) }, onDriveIn = onDriveIn, onBack = onHome,
onRemind = { item -> saveReminders(liveReminders(reminders, System.currentTimeMillis()) + ProgrammeReminder(item.channel, item.startMs, item.title)) })
else -> GuideGrid(channelsIn(guideCategory, availableChannels).ifEmpty { availableChannels }, selectedNumber, modifier, guideCategory, homeCategories(availableChannels.map { it.number }), picture,
@@ -549,7 +555,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
decision == "deferred" -> "${idea.theme} deferred: it can come back in four weeks."
else -> "${idea.theme} dismissed: it won't be proposed again unless its films change." }
} }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
onSignOut = { job?.cancel(); closeSession(forget = true) }, windingDown = windingDown, onWindDown = { on ->
onSignOut = { job?.cancel(); closeSession(forget = true); AfterDarkLock.clear(appContext) }, windingDown = windingDown, onWindDown = { on ->
val tots = scheduledChannels[TOTS_NUMBER]
val active = session
if (tots != null && active != null) scope.launch(Dispatchers.IO) {
@@ -58,6 +58,8 @@ class MainActivity : ComponentActivity() {
intent?.getStringExtra("orbit_play_test")?.takeIf(EmbyClient::validId)?.let { item ->
startActivity(android.content.Intent(this, PlaybackTestActivity::class.java).putExtra(PlaybackTestActivity.ITEM, item))
}
// Before anything is drawn, so a locked After Dark never shows for a moment.
AfterDarkLock.load(this)
// One typeface everywhere, matching the channel logos.
setContent { MaterialTheme { CompositionLocalProvider(androidx.tv.material3.LocalTextStyle provides androidx.compose.ui.text.TextStyle(fontFamily = Barlow)) { OrbitApp() } } }
}
@@ -107,7 +109,7 @@ 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.
if (event == Lifecycle.Event.ON_STOP) SleepTimer.clear()
if (event == Lifecycle.Event.ON_STOP) { SleepTimer.clear(); AfterDarkLock.lock() }
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
@@ -111,6 +111,7 @@ internal enum class SettingsSection(val label: String, val icon: Int) {
Viewing("Watching", R.drawable.orbit_icon_entertainment),
Arrivals("New arrivals", R.drawable.orbit_icon_movies),
Director("Director's Chair", R.drawable.orbit_icon_entertainment),
Lock("After Dark lock", R.drawable.orbit_icon_profile),
Account("Emby account", R.drawable.orbit_icon_profile),
}
@@ -286,6 +287,7 @@ internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSectio
SettingsSection.Viewing -> ViewingDetail(isOn(TOTS_NUMBER), windingDown, onWindDown, firstSwitch, detail)
SettingsSection.Arrivals -> arrivals(detail, firstSwitch)
SettingsSection.Director -> director(detail, firstSwitch)
SettingsSection.Lock -> LockDetail(firstSwitch, detail)
SettingsSection.Account -> AccountDetail(server, titles, onSignOut, firstSwitch, detail)
}
}
@@ -86,7 +86,7 @@ internal fun WhatsOnScreen(session: EmbySampleSession, plans: Map<Int, ChannelPl
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } }
val minute = now / 60_000
val page = remember(plans, driveIn, minute) { whatsOnPage(plans, driveIn, now) }
val page = remember(plans, driveIn, minute, AfterDarkLock.locked) { whatsOnPage(plans, driveIn, now) }
var open by remember { mutableStateOf<WhatsOnItem?>(null) }
val first = remember { FocusRequester() }
val nav = LocalNavFocus.current
@@ -71,7 +71,7 @@ internal fun whatsOnPage(plans: Map<Int, ChannelPlan>, driveIn: ChannelPlan?, no
val horizon = edition.plusDays(1 + COMING_UP_DAYS).atTime(6, 0).atZone(zone).toInstant().toEpochMilli()
val candidates = plans.flatMap { (channel, plan) ->
plan.entries.mapNotNull { entry -> whatsOnRule(channel, entry, zone)?.let { (tag, reason, score) -> WhatsOnItem(channel, entry, tag, reason, score) } }
} + driveIn?.let { plan -> driveInBills(plan, zone).map(::driveInItem) }.orEmpty()
} + driveIn?.let { plan -> driveInBills(plan, zone).filterNot { lockedOut(it) }.map(::driveInItem) }.orEmpty()
val live = candidates.filter { it.endMs > nowMs }
// A programme booked across several channels' events (a premiere's second episode) is shown once, at its first.
val firstOfRuns = live.sortedBy { it.startMs }.distinctBy { Triple(it.channel, it.tag, whatsOnEdition(it.startMs, zone)) }
@@ -0,0 +1,26 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
class AfterDarkLockTest {
@Test fun thePinIsKeptOnlyAsASaltedHash() {
val a = pinHash("salt-one", "1234")
assertEquals(a, pinHash("salt-one", "1234"))
assertNotEquals(a, pinHash("salt-two", "1234"))
assertNotEquals(a, pinHash("salt-one", "1235"))
assertFalse("1234" in a)
assertEquals(64, a.length)
}
@Test fun lockedHidesAfterDarkAndTheLateShowOnly() {
assertTrue(lockedOut(AFTER_DARK_NUMBER, locked = true))
assertFalse(lockedOut(AFTER_DARK_NUMBER, locked = false))
assertFalse(lockedOut(101, locked = true)); assertFalse(lockedOut(TOTS_NUMBER, locked = true))
val media = ScheduledMedia(SampleItem("A film", "Movie", 90, "", "f1", 90 * 60_000L), "s1", 90 * 60_000L)
val entry = ScheduleEntry("f1", EpisodeMark(1, 1), media, 0L)
assertTrue(lockedOut(DriveInBill(DriveInKind.LateShow, listOf(entry)), locked = true))
assertFalse(lockedOut(DriveInBill(DriveInKind.Marquee, listOf(entry)), locked = true))
assertFalse(lockedOut(DriveInBill(DriveInKind.LateShow, listOf(entry)), locked = false))
}
}