The After Dark lock (from 0.108.33)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user