From 8455bd7ef0dcf3e50278bd32ebf09ac365ac2856 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Thu, 5 Nov 2026 22:53:21 +0000 Subject: [PATCH] The After Dark lock (from 0.108.33) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/tv/orbit/classic/AfterDarkLock.kt | 158 ++++++++++++++++++ .../main/java/tv/orbit/classic/ArcadePairs.kt | 3 +- .../src/main/java/tv/orbit/classic/DriveIn.kt | 2 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 10 +- .../java/tv/orbit/classic/MainActivity.kt | 4 +- .../main/java/tv/orbit/classic/Settings.kt | 2 + .../src/main/java/tv/orbit/classic/WhatsOn.kt | 2 +- .../main/java/tv/orbit/classic/WhatsOnPage.kt | 2 +- .../tv/orbit/classic/AfterDarkLockTest.kt | 26 +++ 9 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/java/tv/orbit/classic/AfterDarkLock.kt create mode 100644 android/app/src/test/java/tv/orbit/classic/AfterDarkLockTest.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 new file mode 100644 index 0000000..7a0c7cd --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/AfterDarkLock.kt @@ -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(null) } + var note by remember { mutableStateOf(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) + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt b/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt index 99f7f0a..6e484e3 100644 --- a/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt +++ b/android/app/src/main/java/tv/orbit/classic/ArcadePairs.kt @@ -66,7 +66,8 @@ internal data class PairsGame(val cards: List, val matched: Set = 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) diff --git a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt index 8b8c502..f50ef0e 100644 --- a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt +++ b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt @@ -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(null) } val selected = bills.firstOrNull { it.startMs == chosen } ?: bills.firstOrNull() val index = bills.indexOf(selected) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 1da1675..06a4663 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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) { 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 9fb1cfd..34adad8 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -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) } diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index 5ebfc4b..8a12b8b 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -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) } } diff --git a/android/app/src/main/java/tv/orbit/classic/WhatsOn.kt b/android/app/src/main/java/tv/orbit/classic/WhatsOn.kt index 389b750..b239602 100644 --- a/android/app/src/main/java/tv/orbit/classic/WhatsOn.kt +++ b/android/app/src/main/java/tv/orbit/classic/WhatsOn.kt @@ -86,7 +86,7 @@ internal fun WhatsOnScreen(session: EmbySampleSession, plans: Map(null) } val first = remember { FocusRequester() } val nav = LocalNavFocus.current diff --git a/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt b/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt index 9f5196f..39c4e88 100644 --- a/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt +++ b/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt @@ -71,7 +71,7 @@ internal fun whatsOnPage(plans: Map, 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)) } diff --git a/android/app/src/test/java/tv/orbit/classic/AfterDarkLockTest.kt b/android/app/src/test/java/tv/orbit/classic/AfterDarkLockTest.kt new file mode 100644 index 0000000..f624772 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/AfterDarkLockTest.kt @@ -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)) + } +}