Create your own from a phone: the TV's own page, by QR code, on home Wi-Fi only
Ported from release 0.108.47; screenshots of the phone page and the TV's start screen in design/create-your-own/shots. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Orbit</title>
|
||||
<style>
|
||||
:root{--navy:#071632;--panel:#12356b;--blue:#194c91;--gold:#ffde59;--muted:#b7cbe8;--mint:#7ee0a8}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--navy);color:#fff;font:16px/1.4 system-ui,-apple-system,sans-serif}
|
||||
main{max-width:480px;margin:0 auto;padding:20px 16px 90px}h1{font-size:30px;margin:0}h2{font-size:24px;margin:0 0 4px}.sub{color:var(--muted);font-size:14px;margin:2px 0 14px}
|
||||
.label{color:var(--muted);font-size:12px;font-weight:700;letter-spacing:.06em;margin:16px 0 8px}
|
||||
.card{background:var(--panel);border-radius:14px;padding:12px 14px;margin-bottom:10px}.row{display:flex;gap:8px;align-items:center}
|
||||
.num{color:var(--gold);font-weight:700;margin-right:8px}.title{font-weight:700;font-size:18px}.meta{color:var(--muted);font-size:13px;margin:4px 0 10px}
|
||||
button{border:0;border-radius:10px;padding:9px 14px;font:inherit;font-weight:700;background:var(--blue);color:#fff}button.gold{background:var(--gold);color:var(--navy)}
|
||||
button.wide{width:100%;padding:15px;font-size:18px}button:disabled{opacity:.5}input{width:100%;padding:12px;border-radius:10px;border:2px solid var(--blue);background:var(--panel);color:#fff;font:inherit}
|
||||
.pick{display:flex;gap:8px;align-items:center}.pick .grow{flex:1;min-width:0}.reason{color:var(--muted);font-size:13px}.choice{border:3px solid transparent}.choice.on{border-color:var(--mint)}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:8px}.chips button.on{background:var(--gold);color:var(--navy)}.bar{position:fixed;left:0;right:0;bottom:0;padding:12px 16px;background:linear-gradient(transparent,var(--navy) 30%)}
|
||||
.bar div{max-width:480px;margin:0 auto}.note{color:var(--gold);margin:10px 0}.chosen li{margin:4px 0}.ok{color:var(--mint);font-size:13px}
|
||||
</style></head><body><main id="app"></main><div class="bar"><div id="bar"></div></div>
|
||||
<script>
|
||||
const $ = s => document.querySelector(s); const app = $('#app'), bar = $('#bar');
|
||||
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const api = async (path, body) => { const r = await fetch(path, body ? {method:'POST', headers:{'Content-Type':'application/json','X-Orbit':'1'}, body:JSON.stringify(body)} : {}); const j = await r.json(); if (!r.ok) throw new Error(j.error || 'Something went wrong'); return j; };
|
||||
const STYLES = [['Standard','Standard TV',"As Orbit's own channels: shows take turns, episodes in order, shorts in slots"],['Shuffled','Shuffled','Episodes in a mixed order, every one before any repeats'],
|
||||
['Blocks','Blocks','A timetable: each show at its own times, every day'],['Marathon','Marathon','One show at a time, several episodes back to back']];
|
||||
const HOURS = [[null,'All day'],[{from:18,to:0},'Evenings, 18:00 to midnight'],[{from:6,to:12},'Mornings, 06:00 to noon'],[{from:9,to:17},'Daytime, 09:00 to 17:00']];
|
||||
let draft = null, note = '';
|
||||
async function home() {
|
||||
draft = null; let data;
|
||||
try { data = await api('/api/channels'); } catch (e) { app.innerHTML = `<h1>Orbit</h1><p class="note">${esc(e.message)}</p>`; bar.innerHTML = ''; return; }
|
||||
app.innerHTML = `<h1>Orbit</h1><div class="sub ok">Connected to your TV</div>${note ? `<p class="note">${esc(note)}</p>` : ''}<div class="label">YOUR CHANNELS</div>` +
|
||||
(data.channels.length ? data.channels.map(c => `<div class="card"><div><span class="num">${c.number}</span><span class="title">${esc(c.name)}</span></div>
|
||||
<div class="meta">${esc(c.styleTitle)} · ${esc(c.hoursLabel)}${c.on ? '' : ' · off'}</div><div class="row"><button data-watch="${c.number}">Watch on TV</button><button data-edit="${c.number}">Edit</button></div></div>`).join('')
|
||||
: `<p class="sub">No channels of your own yet.</p>`);
|
||||
note = '';
|
||||
bar.innerHTML = data.next ? `<button class="gold wide" id="new">+ Create a channel</button>` : '';
|
||||
app.querySelectorAll('[data-watch]').forEach(b => b.onclick = async () => { b.disabled = true; try { await api('/api/watch', {number:+b.dataset.watch}); b.textContent = 'On the TV'; } catch (e) { b.textContent = e.message; } });
|
||||
app.querySelectorAll('[data-edit]').forEach(b => b.onclick = async () => { const c = data.channels.find(x => x.number == b.dataset.edit);
|
||||
const shows = await api('/api/shows?number=' + c.number); draft = {number:c.number, name:c.name, description:c.description||'', style:c.style, hours:c.hours, shows, skipped:[], offered:[]}; name(); });
|
||||
if ($('#new')) $('#new').onclick = () => { draft = {name:'', description:'', style:'Standard', hours:null, shows:[], skipped:[], offered:[]}; name(); };
|
||||
}
|
||||
function name() {
|
||||
app.innerHTML = `<h2>${draft.number ? 'Edit ' + esc(draft.name) : 'New channel'}</h2><div class="sub">Step 1 of 4 · Name</div><input id="name" maxlength="40" placeholder="Name, like 90s Sci-Fi" value="${esc(draft.name)}">`;
|
||||
bar.innerHTML = `<div class="row"><button id="back">Back</button><button class="gold wide" id="next">Next: shows and films</button></div>`;
|
||||
$('#back').onclick = home; $('#next').onclick = () => { draft.name = $('#name').value.trim(); if (draft.name) shows(); };
|
||||
}
|
||||
function shows() {
|
||||
const offered = draft.offered.filter(s => !draft.shows.some(c => c.id === s.id) && !draft.skipped.includes(s.id));
|
||||
app.innerHTML = `<h2>Shows and films</h2><div class="sub">Step 2 of 4 · describe it, or search for a title</div>
|
||||
<div class="row"><input id="d" placeholder="90s sci-fi, late evenings" value="${esc(draft.description)}"><button class="gold" id="go">Suggest</button></div>
|
||||
${note ? `<p class="note">${esc(note)}</p>` : ''}<div class="label">THE CHAIR SUGGESTS</div>` +
|
||||
offered.map(s => `<div class="card pick"><div class="grow"><div class="title">${esc(s.title)}</div><div class="reason">${s.type === 'Movie' ? 'Film' : 'Series'}${s.year ? ' · ' + s.year : ''} · ${esc(s.reason)}</div></div>
|
||||
<button class="gold" data-add="${esc(s.id)}">Add</button><button data-skip="${esc(s.id)}">Skip</button></div>`).join('') +
|
||||
`<div class="label">IN YOUR CHANNEL (${draft.shows.length})</div><ul class="chosen">${draft.shows.map(s => `<li>${esc(s.title)} <button data-out="${esc(s.id)}">Remove</button></li>`).join('')}</ul>`;
|
||||
note = '';
|
||||
bar.innerHTML = `<div class="row"><button id="back">Back</button><button class="gold wide" id="next" ${draft.shows.length ? '' : 'disabled'}>Next: how it plays</button></div>`;
|
||||
$('#go').onclick = async () => { draft.description = $('#d').value.trim(); if (!draft.description) return; $('#go').disabled = true;
|
||||
try { draft.offered = await api('/api/suggest?d=' + encodeURIComponent(draft.description) + '&chosen=' + draft.shows.map(s => s.id).join(',') + '&skipped=' + draft.skipped.join(','));
|
||||
if (!draft.offered.length) note = 'Nothing in your library fits that yet. Try other words, or a title.'; } catch (e) { note = e.message; } shows(); };
|
||||
app.querySelectorAll('[data-add]').forEach(b => b.onclick = () => { draft.shows.push(draft.offered.find(s => s.id === b.dataset.add)); shows(); });
|
||||
app.querySelectorAll('[data-skip]').forEach(b => b.onclick = () => { draft.skipped.push(b.dataset.skip); shows(); });
|
||||
app.querySelectorAll('[data-out]').forEach(b => b.onclick = () => { draft.shows = draft.shows.filter(s => s.id !== b.dataset.out); shows(); });
|
||||
$('#back').onclick = name; $('#next').onclick = style;
|
||||
}
|
||||
function style() {
|
||||
app.innerHTML = `<h2>How it plays</h2><div class="sub">Step 3 of 4 · it shapes what's planned from then on, never what's booked</div>` +
|
||||
STYLES.map(([k, t, l]) => `<div class="card choice ${draft.style === k ? 'on' : ''}" data-style="${k}"><div class="title">${t}</div><div class="reason">${l}</div></div>`).join('');
|
||||
bar.innerHTML = `<div class="row"><button id="back">Back</button><button class="gold wide" id="next">Next: when it's on</button></div>`;
|
||||
app.querySelectorAll('[data-style]').forEach(c => c.onclick = () => { draft.style = c.dataset.style; style(); });
|
||||
$('#back').onclick = shows; $('#next').onclick = hours;
|
||||
}
|
||||
function hours() {
|
||||
const same = h => JSON.stringify(h) === JSON.stringify(draft.hours);
|
||||
app.innerHTML = `<h2>When it's on</h2><div class="sub">Step 4 of 4 · outside its hours the channel shows a "back at" card</div><div class="chips">` +
|
||||
HOURS.map(([h, l], i) => `<button class="${same(h) ? 'on' : ''}" data-h="${i}">${l}</button>`).join('') + `</div>
|
||||
<div class="card" style="margin-top:18px"><div class="title">${esc(draft.name)}</div><div class="meta">Yours · ${draft.shows.length} titles · ${STYLES.find(s => s[0] === draft.style)[1]} · ${HOURS.find(([h]) => same(h))?.[1] || 'Set hours'}</div></div>`;
|
||||
bar.innerHTML = `<div class="row"><button id="back">Back</button><button class="gold wide" id="make">${draft.number ? 'Save changes' : 'Create and switch on'}</button></div>`;
|
||||
app.querySelectorAll('[data-h]').forEach(b => b.onclick = () => { draft.hours = HOURS[+b.dataset.h][0]; hours(); });
|
||||
$('#back').onclick = style;
|
||||
$('#make').onclick = async () => { $('#make').disabled = true;
|
||||
try { const r = await api('/api/channels', {number:draft.number, name:draft.name, description:draft.description, style:draft.style, hours:draft.hours, shows:draft.shows}); note = r.message; }
|
||||
catch (e) { note = e.message; } home(); };
|
||||
}
|
||||
home();
|
||||
</script></body></html>
|
||||
@@ -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<Suggestion> = { 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String, String>; 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<Pairing?>(null)
|
||||
private set
|
||||
/** The phone that last did something, for the TV to say so. */
|
||||
var active by mutableStateOf<String?>(null)
|
||||
var phones by mutableStateOf<List<Pair<String, String>>>(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<String, String>, val headers: Map<String, String>, 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<String> = 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<String> = 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = """<meta name="viewport" content="width=device-width, initial-scale=1"><style>
|
||||
body{margin:0;background:#071632;color:#fff;font:16px/1.4 system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center}
|
||||
main{width:min(92vw,380px)}h1{font-size:30px;margin:0 0 6px}p{color:#b7cbe8}input{width:100%;box-sizing:border-box;font-size:28px;letter-spacing:8px;text-align:center;
|
||||
padding:12px;border-radius:12px;border:2px solid #194c91;background:#12356b;color:#fff;margin:12px 0}button{width:100%;padding:14px;border:0;border-radius:12px;
|
||||
background:#ffde59;color:#071632;font-size:18px;font-weight:700}.note{color:#ffde59}</style>"""
|
||||
fun join(note: String?) = """<!doctype html><html><head><title>Orbit</title>$STYLE</head><body><main><h1>Orbit</h1>
|
||||
<p>Enter the code shown on the TV in Settings › Channels › Create a channel › On your phone.</p>${note?.let { "<p class='note'>${esc(it)}</p>" }.orEmpty()}
|
||||
<form method="post" action="/join"><input name="code" inputmode="numeric" maxlength="4" autocomplete="one-time-code" autofocus><button>Join</button></form></main></body></html>"""
|
||||
fun pin(k: String?, code: String?, wrong: Boolean) = """<!doctype html><html><head><title>Orbit</title>$STYLE</head><body><main><h1>Grown-ups PIN</h1>
|
||||
<p>Making channels is for Grown-ups. Enter the PIN you use on the TV.</p>${if (wrong) "<p class='note'>That PIN didn't match.</p>" else ""}
|
||||
<form method="post" action="/join">${k?.let { "<input type='hidden' name='k' value='${esc(it)}'>" }.orEmpty()}${code?.let { "<input type='hidden' name='code' value='${esc(it)}'>" }.orEmpty()}
|
||||
<input name="pin" type="password" inputmode="numeric" maxlength="8" autofocus><button>Continue</button></form></main></body></html>"""
|
||||
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.")
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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<String, String>()
|
||||
private val watched = mutableListOf<Int>()
|
||||
private var made: Pair<CustomChannel, List<Suggestion>>? = 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 }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 446 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Reference in New Issue
Block a user