diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 0ccdb20..229ff1c 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -17,7 +17,8 @@ internal fun interface EmbyTransport { fun execute(request: EmbyRequest): EmbyRe /** Network boundary: fixed HTTPS origin, standard TLS checks, no redirects or response logging. */ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUID().toString()) : EmbyTransport { companion object { - const val SERVER = "https://bk2.appboxmanager.xyz" + /** The server in use: see [EmbyServer]. */ + val SERVER get() = EmbyServer.origin const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024 const val MAX_IMAGE_BYTES = 6 * 1024 * 1024 internal fun readBounded(stream: InputStream): String { @@ -90,6 +91,18 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI /** One explicit sign-in and one bounded metadata page; ownership passes to the sample screen. */ internal class EmbyClient(private val transport: EmbyTransport = HttpsEmbyTransport()) { + /** + * Before any password goes to a typed-in address: it must answer as an Emby server. Returns the server's own name. Jellyfin + * answers the same question, so it's turned away by name until Orbit supports it. + */ + fun checkServer(): String { + val reply = transport.execute(EmbyRequest("GET", "/System/Info/Public")) + if (reply.status !in 200..299) throw EmbyCheckException("That address didn't answer as an Emby server.", reply.status) + val info = runCatching { JSONObject(reply.body) }.getOrNull() ?: throw EmbyCheckException("That address didn't answer as an Emby server.") + if (info.optString("Id").isBlank() || info.optString("Version").isBlank()) throw EmbyCheckException("That address didn't answer as an Emby server.") + if (info.optString("ProductName").contains("Jellyfin", true)) throw EmbyCheckException("That's a Jellyfin server. Orbit works with Emby for now; Jellyfin is coming.") + return info.optString("ServerName").take(80).filterNot { it.isISOControl() }.ifBlank { "Emby" } + } fun connect(username: String, password: String): EmbySampleSession { if (username.isBlank() || username.length > 256 || password.isEmpty() || password.length > 1024) throw EmbyCheckException("Enter your username and password to check the connection.") 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 285644b..d4251ab 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -69,6 +69,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea var restoreFailures by remember { mutableIntStateOf(0) } var saveTicket by remember { mutableLongStateOf(savedStore.ticket()) } var username by remember { mutableStateOf("") } + var serverText by remember { mutableStateOf(EmbyServer.origin) } // Deliberately not rememberSaveable: credentials never enter saved instance state. val password = remember { TextFieldState() } var busy by remember { mutableStateOf(false) } @@ -183,6 +184,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea if (saved?.identity?.token != null && session == null) { val slotTicket = sessionSlot.ticket() val resumed = withContext(Dispatchers.IO) { + // The server this sign-in was saved with. + EmbyServer.origin = saved.origin val active = EmbyClient(HttpsEmbyTransport(saved.identity.deviceId)).resume(saved) if (!sessionSlot.attach(active, slotTicket)) { active.detach(); throw CancellationException() } active @@ -620,7 +623,22 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(Modifier.width(460.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Sign in to Emby", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.SemiBold) - Text(HttpsEmbyTransport.SERVER, color = Muted, fontSize = 14.sp) + // The server's address (release essentials, 6 November 2026): typed in, HTTPS only. + Text("Server address", color = Muted, fontSize = 14.sp) + var serverFocused by remember { mutableStateOf(false) } + BasicTextField(value = serverText, onValueChange = { if (it.length <= 255) serverText = it.replace("\n", "").trim() }, + enabled = !busy && !restoring, singleLine = true, textStyle = TextStyle(color = Color.White, fontSize = 18.sp), + cursorBrush = SolidColor(Yellow), + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth().onFocusChanged { serverFocused = it.isFocused } + .background(Panel).border(2.dp, if (serverFocused) Yellow else Blue) + .onPreviewKeyEvent { event -> + if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_CENTER) { + if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP) keyboard?.show() + true + } else false + }.padding(12.dp)) + Text("For example https://emby.example.com or https://myserver.example.com:8920", color = Muted, fontSize = 11.sp) Text("Username", color = Muted, fontSize = 14.sp) var userFocused by remember { mutableStateOf(false) } BasicTextField(value = username, onValueChange = { if (it.length <= 256) username = it.replace("\n", "") }, @@ -658,6 +676,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea if (!ready) return@OrbitButton val loginName = username.trim() val secret = password.text.toString() + val server = EmbyServer.normalise(serverText) + if (server == null) { message = "Enter your server's https:// address, like https://emby.example.com."; return@OrbitButton } clearPassword(); keyboard?.hide(); focus.clearFocus() closeSession() val ticket = sessionSlot.ticket() @@ -667,6 +687,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea job = scope.launch { try { val connected = withContext(Dispatchers.IO) { + // It must answer as an Emby server before the password goes anywhere. + val previous = EmbyServer.origin + EmbyServer.origin = server + try { EmbyClient().checkServer() } catch (e: Exception) { EmbyServer.origin = previous; throw e } val opened = EmbyClient().connect(loginName, secret) if (!sessionSlot.attach(opened, ticket)) { opened.close(); throw CancellationException() } opened diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index b9305a8..b15d34a 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -14,8 +14,11 @@ internal class EmbyPlaybackPlan(val item: SampleItem, val url: String, val sourc val playSessionId: String, val subtitles: List, val durationMs: Long? = item.durationMs) internal object PlaybackUrls { - private val origin = URI(HttpsEmbyTransport.SERVER) - fun isProvider(uri: URI) = uri.scheme.equals("https", true) && uri.host.equals(origin.host, true) && (uri.port == -1 || uri.port == 443) + fun isProvider(uri: URI): Boolean { + val origin = URI(EmbyServer.origin) + val port = if (origin.port == -1) 443 else origin.port + return uri.scheme.equals("https", true) && uri.host.equals(origin.host, true) && (if (uri.port == -1) 443 else uri.port) == port + } fun resolve(value: String, base: String = HttpsEmbyTransport.SERVER + "/emby/", token: String = ""): String { try { val uri = URI(base).resolve(value) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt b/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt new file mode 100644 index 0000000..060f020 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt @@ -0,0 +1,35 @@ +package tv.orbit.classic + +import java.net.URI + +/** + * Which Emby server Orbit talks to (release essentials, Paul, 6 November 2026). It used to be fixed in the app; now it's typed in + * at sign-in and saved with the sign-in, so anyone can use Orbit with their own server. The security rules stay as they were: + * HTTPS only, standard certificate checks, no redirects, and every request goes to exactly this origin. A plain-HTTP server on + * the home network is a separate, later change, because media from private addresses is refused by design. + */ +internal object EmbyServer { + /** Paul's server: what a box signed in before addresses could be typed was always using. */ + const val DEFAULT = "https://bk2.appboxmanager.xyz" + @Volatile var origin: String = DEFAULT + val host: String get() = URI(origin).host + val port: Int get() = URI(origin).port + + /** + * The address someone typed, as an origin Orbit will use (`https://host` or `https://host:port`), or null if it isn't one. + * A bare host gets https://. Anything after the host other than a trailing slash or /emby is refused, as are user names, + * queries and plain http. + */ + fun normalise(typed: String): String? { + val text = typed.trim().let { if ("://" in it) it else "https://$it" } + if (text.length > 255 || text.any { it.isWhitespace() || it.isISOControl() }) return null + val uri = runCatching { URI(text) }.getOrNull() ?: return null + if (!uri.scheme.equals("https", true) || uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) return null + val host = uri.host?.lowercase()?.takeIf { it.isNotBlank() && it.matches(Regex("[a-z0-9.\\-\\[\\]:]+")) } ?: return null + val path = uri.rawPath.orEmpty().trimEnd('/') + if (path.isNotEmpty() && !path.equals("/emby", true)) return null + val port = uri.port + if (port != -1 && port !in 1..65535) return null + return "https://$host" + if (port == -1 || port == 443) "" else ":$port" + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/PlaybackTestActivity.kt b/android/app/src/main/java/tv/orbit/classic/PlaybackTestActivity.kt index 06172e2..d108e8b 100644 --- a/android/app/src/main/java/tv/orbit/classic/PlaybackTestActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/PlaybackTestActivity.kt @@ -89,6 +89,7 @@ private fun PlaybackTest(itemId: String, done: () -> Unit) { try { withContext(Dispatchers.IO) { val saved = RememberedConnectionStore.get(context).load() ?: throw EmbyCheckException("No saved connection.") + EmbyServer.origin = saved.origin val active = EmbyClient(HttpsEmbyTransport(saved.identity.deviceId)).resume(saved) session = active val item = SampleItem("Playback test", "Episode", null, "", itemId) diff --git a/android/app/src/main/java/tv/orbit/classic/RememberedState.kt b/android/app/src/main/java/tv/orbit/classic/RememberedState.kt index d34a68f..fa5010f 100644 --- a/android/app/src/main/java/tv/orbit/classic/RememberedState.kt +++ b/android/app/src/main/java/tv/orbit/classic/RememberedState.kt @@ -8,9 +8,11 @@ internal class RememberedIdentity(val userId: String, val token: String?, val de internal class RememberedState(val identity: RememberedIdentity, val sample: EmbySample, val matches: List, val approved: Set, val episodes: List?, val orbitOne: SampleChannel?, val toonami: SampleChannel?, val selectedNumber: Int, - val afterDark: SampleChannel? = null, val darkMovies: List = emptyList(), val approvedDark: Set = emptySet(), val extra: List = emptyList()) { + val afterDark: SampleChannel? = null, val darkMovies: List = emptyList(), val approvedDark: Set = emptySet(), val extra: List = emptyList(), + /** The Emby server this sign-in belongs to. */ + val origin: String = EmbyServer.origin) { fun withoutToken() = RememberedState(RememberedIdentity(identity.userId, null, identity.deviceId), sample, - matches, approved, episodes, orbitOne, toonami, selectedNumber, afterDark, darkMovies, approvedDark, extra) + matches, approved, episodes, orbitOne, toonami, selectedNumber, afterDark, darkMovies, approvedDark, extra, origin) } internal object RememberedStateCodec { @@ -24,7 +26,7 @@ internal object RememberedStateCodec { .put("entries", arr(it.lineup.map { m -> JSONObject().put("item", item(m.item)).put("source", m.sourceId).put("duration", m.durationMs) })) } ?: JSONObject.NULL fun encode(state: RememberedState): ByteArray { val i = state.identity - val root = JSONObject().put("schema", 1).put("origin", HttpsEmbyTransport.SERVER) + val root = JSONObject().put("schema", 1).put("origin", state.origin) .put("user", i.userId).put("token", i.token ?: JSONObject.NULL).put("device", i.deviceId) .put("sample", arr(state.sample.items.map(::item))).put("total", state.sample.total ?: JSONObject.NULL) .put("matches", arr(state.matches.map { m -> JSONObject().put("requested", m.requested).put("more", m.moreAvailable).put("candidates", arr(m.candidates.map(::candidate))) })) @@ -66,7 +68,9 @@ internal object RememberedStateCodec { } fun decode(bytes: ByteArray): RememberedState { require(bytes.size <= MAX_BYTES) - val o = JSONObject(bytes.toString(Charsets.UTF_8)); require(o.getInt("schema") == 1 && o.getString("origin") == HttpsEmbyTransport.SERVER) + val o = JSONObject(bytes.toString(Charsets.UTF_8)); require(o.getInt("schema") == 1) + // Any server typed in at sign-in, as long as it's still a valid HTTPS origin exactly as saved. + val origin = o.getString("origin").also { require(EmbyServer.normalise(it) == it) } val token = if (o.isNull("token")) null else o.getString("token").also { require(it.isNotEmpty() && it.length <= 4096 && it.all { c -> c.code in 33..126 }) } val identity = RememberedIdentity(id(o, "user"), token, id(o, "device")) val sample = EmbySample(list(o, "sample", 20).map(::readItem), if (o.isNull("total")) null else o.getLong("total").also { require(it >= 0) }) @@ -104,6 +108,6 @@ internal object RememberedStateCodec { require(extra.map { it.number }.distinct().size == extra.size) val selected = o.optInt("selectedChannel", o.getInt("selected")).let { if (o.optInt("numbering", 1) >= 2) it else OldChannelNumbers[it] ?: it } require(SmartChannels.any { it.number == selected }) - return RememberedState(identity, sample, matches, approved, episodes, one, toonami, selected, dark, darkMovies, approvedDark, extra) + return RememberedState(identity, sample, matches, approved, episodes, one, toonami, selected, dark, darkMovies, approvedDark, extra, origin) } } diff --git a/android/app/src/test/java/tv/orbit/classic/EmbyServerTest.kt b/android/app/src/test/java/tv/orbit/classic/EmbyServerTest.kt new file mode 100644 index 0000000..a5ed3a9 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/EmbyServerTest.kt @@ -0,0 +1,58 @@ +package tv.orbit.classic + +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import java.net.URI + +class EmbyServerTest { + @After fun reset() { EmbyServer.origin = EmbyServer.DEFAULT } + + @Test fun typedAddressesBecomeHttpsOrigins() { + assertEquals("https://emby.example.com", EmbyServer.normalise("emby.example.com")) + assertEquals("https://emby.example.com", EmbyServer.normalise(" HTTPS://Emby.Example.com/ ")) + assertEquals("https://emby.example.com", EmbyServer.normalise("https://emby.example.com/emby/")) + assertEquals("https://emby.example.com", EmbyServer.normalise("https://emby.example.com:443")) + assertEquals("https://emby.example.com:8920", EmbyServer.normalise("emby.example.com:8920")) + } + + @Test fun unsafeOrOddAddressesAreRefused() { + listOf("", "http://emby.example.com", "ftp://emby.example.com", "https://user:pw@emby.example.com", "https://emby.example.com/?a=1", + "https://emby.example.com/#x", "https://emby.example.com/web/index.html", "https://emby example.com", "https://emby.example.com:99999") + .forEach { assertNull(it, EmbyServer.normalise(it)) } + } + + @Test fun savedSignInKeepsItsServer() { + val original = EmbyServerFixtures.state("https://emby.example.com:8920") + val restored = RememberedStateCodec.decode(RememberedStateCodec.encode(original)) + assertEquals("https://emby.example.com:8920", restored.origin) + } + + @Test fun providerMatchesHostAndPort() { + EmbyServer.origin = "https://emby.example.com:8920" + assertTrue(PlaybackUrls.isProvider(URI("https://emby.example.com:8920/emby/Videos/1/stream"))) + assertFalse(PlaybackUrls.isProvider(URI("https://emby.example.com/emby/Videos/1/stream"))) + assertFalse(PlaybackUrls.isProvider(URI("https://cdn.example.com:8920/v"))) + EmbyServer.origin = "https://emby.example.com" + assertTrue(PlaybackUrls.isProvider(URI("https://emby.example.com:443/emby/Videos/1/stream"))) + } + + @Test fun checkServerAcceptsEmbyOnly() { + fun check(status: Int, body: String) = EmbyClient { EmbyReply(status, body) }.checkServer() + assertEquals("Home", check(200, """{"Id":"abc","Version":"4.8.0","ServerName":"Home"}""")) + assertEquals("Emby", check(200, """{"Id":"abc","Version":"4.8.0"}""")) + for ((status, body) in listOf(404 to "", 200 to "", 200 to """{"Version":"4.8"}""", 200 to """{"Id":"a","Version":"10.9","ProductName":"Jellyfin Server"}""")) { + try { check(status, body); fail("Accepted $body") } catch (_: EmbyCheckException) { } + } + } +} + +private object EmbyServerFixtures { + fun state(origin: String): RememberedState { + val series = SeriesCandidate("fixture-series", "Fixture series", 1996, "TV-PG") + val episodes = SeriesEpisodes(series, List(2) { EpisodeCandidate("episode-$it", "Fixture $it", 1, it + 1, 60_000) }, false) + val channel = SampleChannel(episodes.episodes.map { ScheduledMedia(episodeSampleItem(series, it), "source-${it.id}", 60_000) }, "Toonami", 601) + return RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"), EmbySample(emptyList(), 0), + listOf(SeriesMatches(ToonamiSearches.first(), listOf(series), false)), setOf(series.id), listOf(episodes), null, channel, 601, origin = origin) + } +} diff --git a/android/app/src/test/java/tv/orbit/classic/RememberedStateTest.kt b/android/app/src/test/java/tv/orbit/classic/RememberedStateTest.kt index ee75956..c850213 100644 --- a/android/app/src/test/java/tv/orbit/classic/RememberedStateTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/RememberedStateTest.kt @@ -36,7 +36,7 @@ class RememberedStateTest { } @Test fun wrongSchemaOriginAndOversizedRecordsAreRejected() { val original = JSONObject(String(RememberedStateCodec.encode(state()))) - for (changed in listOf(JSONObject(original.toString()).put("schema", 999), JSONObject(original.toString()).put("origin", "https://other.example"), + for (changed in listOf(JSONObject(original.toString()).put("schema", 999), JSONObject(original.toString()).put("origin", "http://other.example"), JSONObject(original.toString()).put("user", "../bad"), JSONObject(original.toString()).put("approved", JSONArray().put("unmatched")))) { try { RememberedStateCodec.decode(changed.toString().toByteArray()); fail("Invalid saved record accepted") } catch (_: IllegalArgumentException) { }