diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 154c02f..16d0321 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -27,6 +27,8 @@ dependencies {
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.media3:media3-exoplayer:1.8.0")
implementation("androidx.media3:media3-ui:1.8.0")
+ // QR codes for the phone page (Create your own): pure Java, no Google services.
+ implementation("com.google.zxing:core:3.5.3")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20250107")
}
diff --git a/android/app/src/main/assets/phone/app.html b/android/app/src/main/assets/phone/app.html
new file mode 100644
index 0000000..10f33a5
--- /dev/null
+++ b/android/app/src/main/assets/phone/app.html
@@ -0,0 +1,81 @@
+
+
Orbit
+
+
diff --git a/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt
index 7e85e11..82b868a 100644
--- a/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt
+++ b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt
@@ -22,6 +22,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -45,7 +46,7 @@ internal class ChannelMaker(
/** A channel's shows as they stand, for editing. */
val showsOf: suspend (Int) -> List = { emptyList() })
-internal enum class CreateStep(val title: String) { Start("Create a channel"), Name("Name and number"), Shows("Shows"), Style("How it plays"), Hours("When it's on") }
+internal enum class CreateStep(val title: String) { Start("Create a channel"), Name("Name and number"), Shows("Shows and films"), Style("How it plays"), Hours("When it's on") }
private val HourChoices = listOf(null to "All day", OnHours(18, 0) to "Evenings, 18:00 to midnight", OnHours(6, 12) to "Mornings, 06:00 to noon", OnHours(9, 17) to "Daytime, 09:00 to 17:00")
@@ -80,12 +81,12 @@ internal fun CreateChannelFlow(maker: ChannelMaker, editing: CustomChannel?, onD
CreateStep.Start -> Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ChoiceCard("On this TV", listOf("Name it and pick its number", "Add shows, or let the Director's Chair suggest them",
"Choose how it plays", "Choose when it's on"), Modifier.weight(1f).focusRequester(first)) { step = CreateStep.Name }
- ChoiceCard("On your phone", listOf("Scan a code with your phone to make channels there", "Comes in the next update"), Modifier.weight(1f), enabled = false) { }
+ PhoneCard(Modifier.weight(1f))
}
CreateStep.Name -> {
Text("Your channel goes in Yours, as $number.", color = Muted, fontSize = 13.sp)
OrbitField(name, { if (it.length <= 40) name = it }, "Name, like 90s Sci-Fi", Modifier.fillMaxWidth().focusRequester(first))
- OrbitButton("Next: shows", Modifier.width(220.dp), enabled = name.isNotBlank()) { step = CreateStep.Shows }
+ OrbitButton("Next: shows and films", Modifier.width(220.dp), enabled = name.isNotBlank()) { step = CreateStep.Shows }
}
CreateStep.Shows -> Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Column(Modifier.weight(1.7f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -178,6 +179,37 @@ private fun OrbitField(value: String, onChange: (String) -> Unit, hint: String,
}
}
+/** On your phone: a QR code and a code to type, both one-time, while this screen shows. */
+@Composable
+private fun PhoneCard(modifier: Modifier) {
+ val pairing = remember { PhoneLink.beginPairing() }
+ val url = remember { PhoneLink.pairingUrl(pairing) }
+ val qr = remember(url) { url?.let { qrBitmap(it, 360) } }
+ Column(modifier.background(Panel, RoundedCornerShape(12.dp)).padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("On your phone", color = Ink, fontFamily = Anton, fontSize = 22.sp)
+ if (url == null || qr == null) { Text("This TV isn't on a home Wi-Fi network, so a phone can't reach it.", color = Muted, fontSize = 12.sp); return@Column }
+ Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
+ androidx.compose.foundation.Image(qr, "QR code for the phone page", Modifier.size(130.dp).background(Color.White, RoundedCornerShape(6.dp)).padding(6.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text("Scan with your phone's camera", color = Muted, fontSize = 12.sp)
+ Text("or go to", color = Muted, fontSize = 12.sp)
+ Text(url.substringBefore("/join"), color = Ink, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
+ Text("and enter", color = Muted, fontSize = 12.sp)
+ Text(pairing.code, Modifier.background(Navy, RoundedCornerShape(6.dp)).padding(horizontal = 10.dp, vertical = 2.dp), color = Yellow, fontFamily = Anton, fontSize = 24.sp)
+ }
+ }
+ Text(PhoneLink.active?.let { "Connected: $it" } ?: "Same Wi-Fi as this TV. The code works once, for two minutes.", color = if (PhoneLink.active != null) Mint else Muted, fontSize = 11.sp)
+ }
+}
+
+/** A QR code as a picture, black on white, by ZXing. */
+internal fun qrBitmap(text: String, size: Int): androidx.compose.ui.graphics.ImageBitmap {
+ val matrix = com.google.zxing.qrcode.QRCodeWriter().encode(text, com.google.zxing.BarcodeFormat.QR_CODE, size, size, mapOf(com.google.zxing.EncodeHintType.MARGIN to 1))
+ val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() }
+ return android.graphics.Bitmap.createBitmap(pixels, size, size, android.graphics.Bitmap.Config.ARGB_8888).asImageBitmapCompat()
+}
+private fun android.graphics.Bitmap.asImageBitmapCompat(): androidx.compose.ui.graphics.ImageBitmap = asImageBitmap()
+
/** A channel of your own outside its hours: its name, and when it's back. */
@Composable
internal fun OffAirCard(channel: SampleChannel, back: String) {
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 fec0845..01bc687 100644
--- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
+++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
@@ -480,8 +480,6 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
return
}
- if (!visible) return
- if (onboarding && session != null) return
fun setSmartSchedule(programming: ChannelProgramming, turnOn: Boolean) {
val active = session ?: return
val existing = smartLoop(programming)
@@ -566,6 +564,36 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
finally { busy = false; savingSuggestions = false }
}
}
+ val channelMaker = session?.let { active -> ChannelMaker(
+ suggest = { description, chosen, skipped -> withContext(Dispatchers.IO) {
+ val wish = wishOf(description)
+ val rows = catalogue.chairRows(wish.genres, wish.years, wish.type, if (wish.genres.isEmpty() && wish.years == null) description else null)
+ chairSuggestions(description, rows, catalogue.chairRowsById(chosen.map { it.id }), skipped) } },
+ search = { text -> withContext(Dispatchers.IO) { catalogue.chairRows(titleLike = text, limit = 30).map { Suggestion(it.id, it.title, it.year, it.type, "", "In your library") } } },
+ showsOf = { number -> withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, number).accepted } },
+ create = { channel, shows ->
+ withContext(Dispatchers.IO) {
+ CustomChannels.upsert(workspace.scheduleDir, channel)
+ val before = scheduleStore.loadChoices(active.accountId, channel.number)
+ scheduleStore.saveChoices(active.accountId, channel.number, ChannelChoices(shows, before.rejected))
+ }
+ android.util.Log.i("OrbitSchedule", "yours channel=${channel.number} style=${channel.style} titles=${shows.size}")
+ SmartChannels.firstOrNull { it.number == channel.number }?.let { setSmartSchedule(it, true) }
+ "${channel.name} is ready on ${channel.number}. Its first programmes are being planned now."
+ }) }
+ // The phone page (Create your own from a phone) asks the same of this screen as Settings does, and can tune the TV.
+ DisposableEffect(channelMaker) {
+ PhoneLink.handler = channelMaker?.let { m -> PhoneHandler(m, isOn = { n -> scheduledChannels.containsKey(n) }, watch = { n ->
+ if (!lockedOut(n) && !profileHides(n) && SmartChannels.any { it.number == n }) {
+ if (selectedNumber != n) { lastNumber = selectedNumber; selectedNumber = n }
+ channelClockOffset = 0; watchingChannel = true
+ if (!mainGuide && !mainHome && !mainWhatsOn && !mainCatchUp) onGuide()
+ }
+ }) }
+ onDispose { PhoneLink.handler = null }
+ }
+ if (!visible) return
+ if (onboarding && session != null) return
suggesting?.let { programming ->
SuggestionsScreen(programming, suggestions, tickedSuggestions, busy, message, modifier,
onToggle = { id -> tickedSuggestions = if (id in tickedSuggestions) tickedSuggestions - id else tickedSuggestions + id },
@@ -620,23 +648,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
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(signOut = true) }, onRemoveServer = { job?.cancel(); closeSession(remove = true) },
- maker = session?.let { active -> ChannelMaker(
- suggest = { description, chosen, skipped -> withContext(Dispatchers.IO) {
- val wish = wishOf(description)
- val rows = catalogue.chairRows(wish.genres, wish.years, wish.type, if (wish.genres.isEmpty() && wish.years == null) description else null)
- chairSuggestions(description, rows, catalogue.chairRowsById(chosen.map { it.id }), skipped) } },
- search = { text -> withContext(Dispatchers.IO) { catalogue.chairRows(titleLike = text, limit = 30).map { Suggestion(it.id, it.title, it.year, it.type, "", "In your library") } } },
- showsOf = { number -> withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, number).accepted } },
- create = { channel, shows ->
- withContext(Dispatchers.IO) {
- CustomChannels.upsert(workspace.scheduleDir, channel)
- val before = scheduleStore.loadChoices(active.accountId, channel.number)
- scheduleStore.saveChoices(active.accountId, channel.number, ChannelChoices(shows, before.rejected))
- }
- android.util.Log.i("OrbitSchedule", "yours channel=${channel.number} style=${channel.style} titles=${shows.size}")
- SmartChannels.firstOrNull { it.number == channel.number }?.let { setSmartSchedule(it, true) }
- "${channel.name} is ready on ${channel.number}. Its first programmes are being planned now."
- }) },
+ maker = channelMaker,
windingDown = windingDown, onWindDown = { on ->
val tots = scheduledChannels[TOTS_NUMBER]
val active = session
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 f0d391a..71152b9 100644
--- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt
+++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt
@@ -44,6 +44,8 @@ internal val Barlow = FontFamily(Font(R.font.barlow_regular), Font(R.font.barlow
internal fun clock(time: Long) = SimpleDateFormat("HH:mm", Locale.UK).format(Date(time))
class MainActivity : ComponentActivity() {
+ override fun onStart() { super.onStart(); if (PhoneLink.phones.isNotEmpty()) PhoneLink.start() }
+ override fun onStop() { PhoneLink.stop(); super.onStop() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -56,6 +58,8 @@ class MainActivity : ComponentActivity() {
}
// Before anything is drawn, so a locked After Dark never shows for a moment.
AfterDarkLock.load(this)
+ // The phone page's server runs only while Orbit is open (Create your own from a phone).
+ PhoneLink.init(this)
Profiles.load(this)
// The chosen look, before the first frame, so a light look never flashes navy.
OrbitTheme.load(this)
diff --git a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
new file mode 100644
index 0000000..7a30f30
--- /dev/null
+++ b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
@@ -0,0 +1,236 @@
+package tv.orbit.classic
+
+import android.content.Context
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.BufferedInputStream
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.Inet4Address
+import java.net.InetAddress
+import java.net.NetworkInterface
+import java.net.ServerSocket
+import java.net.Socket
+import java.net.URLDecoder
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.util.concurrent.Executors
+
+/**
+ * Create your own from a phone (Paul, 6 November 2026, from the approved design/create-your-own): the TV serves a small page
+ * on the home Wi-Fi, which a phone opens by scanning a QR code on the TV. No app, no account, and the Emby sign-in never
+ * leaves the TV. The rules:
+ * - Only addresses on the home network are answered; anything else is closed unread.
+ * - A phone joins with a one-time code from the TV, good for two minutes and five tries, and the Grown-ups PIN if one is set.
+ * - A joined phone holds a long random token in a cookie (HttpOnly, SameSite=Strict); the TV keeps only its hash, and
+ * Settings lists phones and removes them. Changes also need a header a cross-site page can't send.
+ * - The server runs only while Orbit is open, and only once a phone has joined or one is joining.
+ */
+internal class Pairing(val code: String, val secret: String, val untilMs: Long) { var tries = 0 }
+
+/** What the phone may ask of the screen that holds the library: Create your own's [ChannelMaker], what's on, and tuning. */
+internal class PhoneHandler(val maker: ChannelMaker, val isOn: (Int) -> Boolean, val watch: (Int) -> Unit)
+
+/** Where joined phones are kept: their token's hash, with a name and when they joined. A map stands in for it in tests. */
+internal interface PhoneStore { fun all(): Map; fun get(id: String): String?; fun put(id: String, value: String); fun remove(id: String) }
+private class PrefsPhoneStore(context: Context) : PhoneStore {
+ private val prefs = context.getSharedPreferences("orbit_phones", Context.MODE_PRIVATE)
+ override fun all() = prefs.all.mapNotNull { (k, v) -> (v as? String)?.let { k to it } }.toMap()
+ override fun get(id: String) = prefs.getString(id, null)
+ override fun put(id: String, value: String) { prefs.edit().putString(id, value).apply() }
+ override fun remove(id: String) { prefs.edit().remove(id).apply() }
+}
+
+internal object PhoneLink {
+ const val PORT = 8099
+ private const val COOKIE = "orbit_phone"
+ private val random = SecureRandom()
+ @Volatile private var server: ServerSocket? = null
+ private val workers = Executors.newFixedThreadPool(4)
+ @Volatile var handler: PhoneHandler? = null
+ var pairing by mutableStateOf(null)
+ private set
+ /** The phone that last did something, for the TV to say so. */
+ var active by mutableStateOf(null)
+ var phones by mutableStateOf>>(emptyList())
+ private set
+ private var appContext: Context? = null
+ private var store: PhoneStore? = null
+ /** Answers a PIN for joining; the After Dark lock's, unless a test says otherwise. */
+ internal var pinCheck: (String) -> Boolean = { pin -> appContext?.let { AfterDarkLock.check(it, pin) } == true }
+ internal var pinNeeded: () -> Boolean = { AfterDarkLock.pinSet }
+ /** Who may talk to the server: addresses on the home network only. */
+ internal var allowed: (InetAddress) -> Boolean = EmbyServer::isHomeAddress
+ /** Where the server listens: every interface on the TV (tests keep it to this computer). */
+ internal var bindTo: InetAddress? = null
+ internal var port = PORT
+ /** Changes the phone asks for run on the screen's own thread. */
+ internal var mainDispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Main
+
+ internal fun context() = appContext
+ private fun prefs() = store!!
+ private fun hash(token: String) = MessageDigest.getInstance("SHA-256").digest(token.toByteArray()).joinToString("") { "%02x".format(it) }
+ private fun loadPhones() { phones = prefs().all().map { (k, v) -> k to v.substringBefore('|') }.sortedBy { it.second } }
+
+ /** The TV's address on the home network, for the QR code. */
+ fun homeAddress(): String? = runCatching {
+ // The Wi-Fi or Ethernet address: 192.168.x first, then 10.x, then 172.16-31.x, never a virtual interface's.
+ NetworkInterface.getNetworkInterfaces().toList().filter { it.isUp && !it.isLoopback && !it.isVirtual && !Regex("^(docker|br-|veth|virbr|tun|rmnet)").containsMatchIn(it.name) }
+ .flatMap { it.inetAddresses.toList() }.filter { it is Inet4Address && EmbyServer.isHomeAddress(it) }
+ .minByOrNull { a -> when ((a.address[0].toInt() and 255)) { 192 -> 0; 10 -> 1; else -> 2 } }?.hostAddress
+ }.getOrNull()
+
+ fun init(context: Context) { appContext = context.applicationContext; store = PrefsPhoneStore(context.applicationContext); loadPhones(); if (phones.isNotEmpty()) start() }
+ /** For tests: a store of its own, no Android. */
+ internal fun initForTest(testStore: PhoneStore) { store = testStore; loadPhones() }
+
+ /** A new one-time code, and the server running to receive it. */
+ fun beginPairing(): Pairing {
+ val code = "%04d".format(random.nextInt(10_000))
+ val secret = ByteArray(16).also(random::nextBytes).joinToString("") { "%02x".format(it) }
+ return Pairing(code, secret, System.currentTimeMillis() + 2 * 60_000L).also { pairing = it; start() }
+ }
+ fun pairingUrl(p: Pairing): String? = homeAddress()?.let { "http://$it:$PORT/join?k=${p.secret}" }
+
+ fun forget(id: String) { prefs().remove(id); loadPhones(); if (phones.isEmpty() && pairing == null) stop() }
+
+ @Synchronized fun start() {
+ if (server != null || store == null) return
+ val socket = runCatching { ServerSocket().apply { reuseAddress = true; bind(java.net.InetSocketAddress(bindTo, port), 8) } }.getOrNull() ?: return
+ server = socket
+ Thread({
+ while (!socket.isClosed) {
+ val client = runCatching { socket.accept() }.getOrNull() ?: break
+ workers.execute { runCatching { client.use { serve(it) } } }
+ }
+ }, "orbit-phone-link").apply { isDaemon = true }.start()
+ }
+ @Synchronized fun stop() { runCatching { server?.close() }; server = null }
+
+ // --- HTTP, kept small: one request per connection, bounded sizes. ---
+ private class Request(val method: String, val path: String, val query: Map, val headers: Map, val body: String)
+
+ private fun readRequest(input: InputStream): Request? {
+ val head = ByteArrayOutputStream()
+ var last4 = 0
+ while (head.size() < 8_192) {
+ val b = input.read(); if (b < 0) return null
+ head.write(b); last4 = (last4 shl 8) or b
+ if (last4 == 0x0d0a0d0a) break
+ }
+ val lines = head.toString("ISO-8859-1").split("\r\n")
+ val parts = lines.first().split(' ')
+ if (parts.size < 2) return null
+ val headers = lines.drop(1).filter { ':' in it }.associate { it.substringBefore(':').trim().lowercase() to it.substringAfter(':').trim() }
+ val length = headers["content-length"]?.toIntOrNull()?.coerceIn(0, 65_536) ?: 0
+ val body = ByteArray(length).also { var read = 0; while (read < length) { val n = input.read(it, read, length - read); if (n < 0) break; read += n } }.toString(Charsets.UTF_8)
+ val path = parts[1].substringBefore('?')
+ val query = parts[1].substringAfter('?', "").split('&').filter { '=' in it }.associate { URLDecoder.decode(it.substringBefore('='), "UTF-8") to URLDecoder.decode(it.substringAfter('='), "UTF-8") }
+ return Request(parts[0], path, query, headers, body)
+ }
+
+ private fun respond(out: OutputStream, status: Int, type: String, body: ByteArray, extra: List = emptyList()) {
+ val reason = mapOf(200 to "OK", 302 to "Found", 400 to "Bad Request", 403 to "Forbidden", 404 to "Not Found", 429 to "Too Many Requests")[status] ?: "OK"
+ val head = (listOf("HTTP/1.1 $status $reason", "Content-Type: $type", "Content-Length: ${body.size}", "Cache-Control: no-store",
+ "X-Content-Type-Options: nosniff", "X-Frame-Options: DENY", "Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'",
+ "Connection: close") + extra).joinToString("\r\n") + "\r\n\r\n"
+ out.write(head.toByteArray(Charsets.ISO_8859_1)); out.write(body); out.flush()
+ }
+ private fun json(out: OutputStream, status: Int, value: Any) = respond(out, status, "application/json; charset=utf-8", value.toString().toByteArray())
+ private fun page(out: OutputStream, html: String, extra: List = emptyList()) = respond(out, 200, "text/html; charset=utf-8", html.toByteArray(), extra)
+
+ private fun phoneOf(r: Request): String? {
+ val token = r.headers["cookie"]?.split(';')?.map { it.trim() }?.firstOrNull { it.startsWith("$COOKIE=") }?.substringAfter('=') ?: return null
+ if (token.length != 64) return null
+ val id = hash(token)
+ return prefs().get(id)?.let { id }
+ }
+
+ private fun serve(socket: Socket) {
+ socket.soTimeout = 15_000
+ if (!allowed(socket.inetAddress)) return
+ val input = BufferedInputStream(socket.getInputStream()); val out = socket.getOutputStream()
+ val r = readRequest(input) ?: return
+ val phone = phoneOf(r)
+ when {
+ r.path == "/join" -> join(r, out)
+ phone == null -> page(out, PhonePages.join(null))
+ r.path == "/" -> page(out, PhonePages.app())
+ r.method == "POST" && r.headers["x-orbit"] != "1" -> json(out, 403, JSONObject().put("error", "Refused"))
+ else -> api(r, out, phone)
+ }
+ }
+
+ private fun join(r: Request, out: OutputStream) {
+ val p = pairing
+ // The code and PIN come in a posted form, so they never sit in an address or history.
+ val form = if (r.method == "POST") r.body.split('&').filter { '=' in it }.associate { URLDecoder.decode(it.substringBefore('='), "UTF-8") to URLDecoder.decode(it.substringAfter('='), "UTF-8") } else emptyMap()
+ val given = form["k"] ?: r.query["k"]; val code = form["code"]?.filter(Char::isDigit); val pin = form["pin"]?.filter(Char::isDigit)
+ if (p == null || System.currentTimeMillis() > p.untilMs) return page(out, PhonePages.join("That code has run out. Ask the TV for a new one."))
+ if (given == null && code == null) return page(out, PhonePages.join(null))
+ if (p.tries++ >= 5) { pairing = null; return page(out, PhonePages.join("Too many tries. Ask the TV for a new code.")) }
+ val ok = (given != null && MessageDigest.isEqual(given.toByteArray(), p.secret.toByteArray())) || (code != null && code == p.code)
+ if (!ok) return page(out, PhonePages.join("That code didn't match. Check the TV."))
+ if (pinNeeded() && (pin == null || !pinCheck(pin))) return page(out, PhonePages.pin(given, code, pin != null))
+ // Joined: a long random token the phone keeps; the TV keeps only its hash.
+ val token = ByteArray(32).also(random::nextBytes).joinToString("") { "%02x".format(it) }
+ val name = phoneName(r.headers["user-agent"].orEmpty())
+ prefs().put(hash(token), "$name|${System.currentTimeMillis()}")
+ pairing = null; loadPhones(); active = name
+ respond(out, 302, "text/plain", ByteArray(0), listOf("Location: /", "Set-Cookie: $COOKIE=$token; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000"))
+ }
+
+ private fun phoneName(agent: String) = when {
+ "iPhone" in agent -> "iPhone"; "iPad" in agent -> "iPad"
+ "Android" in agent -> Regex("Android [^;]*; ([^;)]+)").find(agent)?.groupValues?.get(1)?.trim()?.take(30) ?: "Android phone"
+ else -> "Phone"
+ }
+
+ private fun suggestionJson(s: Suggestion) = JSONObject().put("id", s.id).put("title", s.title).put("year", s.year ?: JSONObject.NULL).put("type", s.type).put("reason", s.reason)
+ private fun suggestionOf(o: JSONObject) = Suggestion(o.getString("id").also { require(EmbyClient.validId(it)) }, o.getString("title").take(160),
+ if (o.isNull("year")) null else o.optInt("year"), o.optString("type").takeIf { it == "Movie" || it == "Series" } ?: "Series", "", "Chosen for your channel")
+
+ private fun api(r: Request, out: OutputStream, phone: String) {
+ val h = handler ?: return json(out, 403, JSONObject().put("error", "The TV isn't signed in to a library right now."))
+ active = prefs().get(phone)?.substringBefore('|')
+ when ("${r.method} ${r.path}") {
+ "GET /api/channels" -> json(out, 200, JSONObject().put("next", CustomChannels.nextNumber() ?: JSONObject.NULL).put("channels", JSONArray(CustomChannels.list.map { c ->
+ c.toJson().put("on", h.isOn(c.number)).put("styleTitle", c.style.title).put("hoursLabel", c.hours?.label ?: "All day") })))
+ "GET /api/shows" -> {
+ val number = r.query["number"]?.toIntOrNull() ?: return json(out, 400, JSONObject())
+ json(out, 200, JSONArray(runBlocking { h.maker.showsOf(number) }.map(::suggestionJson)))
+ }
+ "GET /api/suggest" -> {
+ val chosen = r.query["chosen"].orEmpty().split(',').filter(EmbyClient::validId).map { Suggestion(it, "", null, "Series", "", "") }
+ val skipped = r.query["skipped"].orEmpty().split(',').filter(EmbyClient::validId).toSet()
+ val d = r.query["d"].orEmpty().take(120)
+ json(out, 200, JSONArray(runBlocking { h.maker.suggest(d, chosen, skipped).ifEmpty { h.maker.search(d) } }.map(::suggestionJson)))
+ }
+ "POST /api/channels" -> {
+ val o = runCatching { JSONObject(r.body) }.getOrNull() ?: return json(out, 400, JSONObject().put("error", "Unreadable"))
+ val number = o.optInt("number").takeIf { it in YOURS_FIRST..YOURS_LAST } ?: CustomChannels.nextNumber() ?: return json(out, 400, JSONObject().put("error", "Every number is taken."))
+ val shows = runCatching { o.getJSONArray("shows").let { a -> (0 until minOf(a.length(), 200)).map { suggestionOf(a.getJSONObject(it)) } } }.getOrNull()
+ ?: return json(out, 400, JSONObject().put("error", "Unreadable shows"))
+ val channel = runCatching { CustomChannel(number, o.getString("name").trim().take(40), runCatching { PlayStyle.valueOf(o.optString("style")) }.getOrDefault(PlayStyle.Standard),
+ o.optJSONObject("hours")?.let { OnHours(it.getInt("from").coerceIn(0, 23), it.getInt("to").coerceIn(0, 24)) }, o.optString("description").take(120), shows.map { it.title }) }.getOrNull()
+ ?: return json(out, 400, JSONObject().put("error", "A channel needs a name."))
+ if (shows.isEmpty()) return json(out, 400, JSONObject().put("error", "Add at least one show or film."))
+ val said = runBlocking { withContext(mainDispatcher) { h.maker.create(channel, shows) } }
+ json(out, 200, JSONObject().put("message", said).put("number", number))
+ }
+ "POST /api/watch" -> {
+ val number = runCatching { JSONObject(r.body).getInt("number") }.getOrNull() ?: return json(out, 400, JSONObject())
+ runBlocking { withContext(mainDispatcher) { h.watch(number) } }
+ json(out, 200, JSONObject().put("ok", true))
+ }
+ else -> json(out, 404, JSONObject().put("error", "Not found"))
+ }
+ }
+}
diff --git a/android/app/src/main/java/tv/orbit/classic/PhonePages.kt b/android/app/src/main/java/tv/orbit/classic/PhonePages.kt
new file mode 100644
index 0000000..8e61492
--- /dev/null
+++ b/android/app/src/main/java/tv/orbit/classic/PhonePages.kt
@@ -0,0 +1,19 @@
+package tv.orbit.classic
+
+/** The phone's pages: joining (a code, then the PIN if there is one) and the app itself, from assets/phone/app.html. */
+internal object PhonePages {
+ private fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'")
+ private const val STYLE = """"""
+ fun join(note: String?) = """Orbit$STYLEOrbit
+Enter the code shown on the TV in Settings › Channels › Create a channel › On your phone.
${note?.let { "${esc(it)}
" }.orEmpty()}
+"""
+ fun pin(k: String?, code: String?, wrong: Boolean) = """Orbit$STYLEGrown-ups PIN
+Making channels is for Grown-ups. Enter the PIN you use on the TV.
${if (wrong) "That PIN didn't match.
" else ""}
+"""
+ fun app(): String = runCatching { PhoneLink.context()?.assets?.open("phone/app.html")?.use { it.readBytes().toString(Charsets.UTF_8) } }.getOrNull() ?: join("The page couldn't be loaded.")
+}
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 d8e4ddf..5512458 100644
--- a/android/app/src/main/java/tv/orbit/classic/Settings.kt
+++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt
@@ -296,6 +296,9 @@ private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit,
}
}
} else SettingLine("Remove this server", "For leaving a box or starting again: deletes this server's data from this box only.") { OrbitButton("Remove", onClick = { removing = true }) }
+ // Phones that have joined for Create your own: each can be removed here.
+ if (PhoneLink.phones.isEmpty()) SettingLine("Phones", "No phones have joined. Create a channel › On your phone shows a code.") {}
+ else PhoneLink.phones.forEach { (id, name) -> SettingLine("Phone: $name", "Can make and edit your channels, and tune this TV.") { OrbitButton("Remove", onClick = { PhoneLink.forget(id) }) } }
SettingLine("Welcome tour", "See the first-launch steps a new box goes through, from Ollie's welcome to You're on air. Nothing is changed by it.") {
OrbitButton("Show it", onClick = { Onboarding.start(tour = true) })
}
diff --git a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt
new file mode 100644
index 0000000..760a394
--- /dev/null
+++ b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt
@@ -0,0 +1,94 @@
+package tv.orbit.classic
+
+import kotlinx.coroutines.Dispatchers
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import java.net.HttpURLConnection
+import java.net.InetAddress
+import java.net.URL
+
+/**
+ * The phone page's server (Create your own from a phone, Paul, 6 November 2026), run on this computer only: loopback stands
+ * in for the home network here, and nothing listens beyond this computer.
+ */
+class PhoneLinkTest {
+ private val phones = mutableMapOf()
+ private val watched = mutableListOf()
+ private var made: Pair>? = null
+ private val port = 18_099
+ private val base = "http://127.0.0.1:$port"
+
+ @Before fun up() {
+ PhoneLink.initForTest(object : PhoneStore {
+ override fun all() = phones.toMap(); override fun get(id: String) = phones[id]
+ override fun put(id: String, value: String) { phones[id] = value }; override fun remove(id: String) { phones.remove(id) } })
+ PhoneLink.bindTo = InetAddress.getLoopbackAddress(); PhoneLink.port = port
+ PhoneLink.allowed = { it.isLoopbackAddress }; PhoneLink.mainDispatcher = Dispatchers.Unconfined
+ PhoneLink.pinNeeded = { false }
+ PhoneLink.handler = PhoneHandler(ChannelMaker({ d, _, _ -> listOf(Suggestion("x1", "The X-Files", 1993, "Series", "", "Science Fiction, 1990s")).filter { d.isNotBlank() } },
+ { emptyList() }, { c, s -> made = c to s; "${c.name} is ready" }, { emptyList() }), { false }, { watched += it })
+ PhoneLink.start()
+ }
+ @After fun down() { PhoneLink.stop(); PhoneLink.handler = null; PhoneLink.allowed = EmbyServer::isHomeAddress; PhoneLink.bindTo = null; PhoneLink.port = PhoneLink.PORT; CustomChannels.clear() }
+
+ private class Reply(val status: Int, val body: String, val cookie: String?)
+ private fun call(path: String, method: String = "GET", body: String? = null, cookie: String? = null, form: Boolean = false, orbit: Boolean = true): Reply {
+ val c = URL(base + path).openConnection() as HttpURLConnection
+ c.instanceFollowRedirects = false; c.requestMethod = method
+ cookie?.let { c.setRequestProperty("Cookie", it) }
+ if (orbit && !form && method == "POST") c.setRequestProperty("X-Orbit", "1")
+ if (body != null) { c.doOutput = true; c.setRequestProperty("Content-Type", if (form) "application/x-www-form-urlencoded" else "application/json"); c.outputStream.use { it.write(body.toByteArray()) } }
+ val status = c.responseCode
+ val text = (if (status < 400) c.inputStream else c.errorStream)?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty()
+ return Reply(status, text, c.getHeaderField("Set-Cookie")?.substringBefore(';')).also { c.disconnect() }
+ }
+
+ @Test fun onlyTheHomeNetworkIsAnsweredAndPrivateAddressesAreTheRule() {
+ assertTrue(EmbyServer.isHomeAddress(InetAddress.getByName("192.168.0.20"))); assertFalse(EmbyServer.isHomeAddress(InetAddress.getByName("8.8.8.8")))
+ assertFalse(EmbyServer.isHomeAddress(InetAddress.getLoopbackAddress()))
+ PhoneLink.allowed = { false }
+ assertThrows(java.io.IOException::class.java) { call("/") }
+ }
+
+ @Test fun aPhoneJoinsWithTheTvsCodeAndThenMakesAChannel() {
+ assertTrue(call("/").body.contains("Enter the code")) // not joined: the join page
+ assertTrue(call("/join", "POST", "code=0000", form = true).body.contains("run out")) // no code on the TV yet
+ val p = PhoneLink.beginPairing()
+ val wrong = if (p.code == "1111") "2222" else "1111"
+ assertTrue(call("/join", "POST", "code=$wrong", form = true).body.contains("Check the TV"))
+ val joined = call("/join", "POST", "code=${p.code}", form = true)
+ assertEquals(302, joined.status); val cookie = joined.cookie!!
+ assertEquals(1, phones.size); assertNull(PhoneLink.pairing)
+ assertTrue(call("/join", "POST", "code=${p.code}", form = true).body.contains("run out")) // once only
+ // The API, joined.
+ assertEquals(0, JSONObject(call("/api/channels", cookie = cookie).body).getJSONArray("channels").length())
+ assertEquals("The X-Files", JSONArray(call("/api/suggest?d=90s%20sci-fi", cookie = cookie).body).getJSONObject(0).getString("title"))
+ val body = JSONObject().put("name", "90s Sci-Fi").put("style", "Marathon").put("hours", JSONObject().put("from", 18).put("to", 0))
+ .put("shows", JSONArray().put(JSONObject().put("id", "x1").put("title", "The X-Files").put("year", 1993).put("type", "Series"))).toString()
+ assertEquals(403, call("/api/channels", "POST", body, cookie = cookie, orbit = false).status) // a cross-site form can't do it
+ assertEquals(200, call("/api/channels", "POST", body, cookie = cookie).status)
+ assertEquals(PlayStyle.Marathon, made!!.first.style); assertEquals(701, made!!.first.number); assertEquals(listOf("x1"), made!!.second.map { it.id })
+ assertEquals(200, call("/api/watch", "POST", """{"number":701}""", cookie = cookie).status); assertEquals(listOf(701), watched)
+ // Removed in Settings: the phone's cookie stops working (and with no phones left, the server stops altogether).
+ PhoneLink.forget(phones.keys.single())
+ assertTrue(runCatching { call("/api/channels", cookie = cookie).body.contains("Enter the code") }.getOrDefault(true))
+ }
+
+ @Test fun guessingRunsOutAndThePinIsAskedWhenThereIsOne() {
+ val p = PhoneLink.beginPairing()
+ val wrong = if (p.code == "1111") "2222" else "1111"
+ repeat(5) { call("/join", "POST", "code=$wrong", form = true) }
+ assertTrue(call("/join", "POST", "code=${p.code}", form = true).body.contains("Too many tries"))
+ assertTrue(phones.isEmpty())
+ PhoneLink.pinNeeded = { true }; PhoneLink.pinCheck = { it == "4321" }
+ val q = PhoneLink.beginPairing()
+ assertTrue(call("/join", "POST", "code=${q.code}", form = true).body.contains("Grown-ups PIN"))
+ assertTrue(call("/join", "POST", "code=${q.code}&pin=1234", form = true).body.contains("didn't match"))
+ assertEquals(302, call("/join", "POST", "code=${q.code}&pin=4321", form = true).status)
+ PhoneLink.pinNeeded = { false }
+ }
+}
diff --git a/design/create-your-own/shots/21-create-start.png b/design/create-your-own/shots/21-create-start.png
index 4aba5e0..856884d 100644
Binary files a/design/create-your-own/shots/21-create-start.png and b/design/create-your-own/shots/21-create-start.png differ
diff --git a/design/create-your-own/shots/phone-pages.png b/design/create-your-own/shots/phone-pages.png
new file mode 100644
index 0000000..540bc30
Binary files /dev/null and b/design/create-your-own/shots/phone-pages.png differ