diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 26a8851..792939e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,7 +2,7 @@
-
+
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 2763a7f..450e2b8 100644
--- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt
+++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt
@@ -5,7 +5,7 @@ import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.net.URL
import java.util.UUID
-import javax.net.ssl.HttpsURLConnection
+import java.net.HttpURLConnection
internal class EmbyCheckException(message: String, val httpStatus: Int? = null, val retryable: Boolean = false) : Exception(message)
internal data class SampleItem(val title: String, val type: String, val minutes: Long?, val format: String, val id: String = "", val durationMs: Long? = minutes?.takeIf { it in 1..(Long.MAX_VALUE / 60_000) }?.times(60_000))
@@ -14,7 +14,7 @@ internal class EmbyRequest(val method: String, val path: String, val body: Strin
internal class EmbyReply(val status: Int, val body: String)
internal fun interface EmbyTransport { fun execute(request: EmbyRequest): EmbyReply }
-/** Network boundary: fixed HTTPS origin, standard TLS checks, no redirects or response logging. */
+/** Network boundary: one fixed origin (HTTPS, or a home server: see [EmbyServer]), standard TLS checks, no redirects or response logging. */
internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUID().toString(),
/** The one server this transport ever talks to, fixed when it's made, so a session never follows a change of server. */
internal val origin: String = EmbyServer.origin) : EmbyTransport {
@@ -38,7 +38,8 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
/** One picture from the provider, or nothing. Same fixed origin and no redirects, and failures are never shown or logged. */
fun fetchImage(path: String, token: String): ByteArray? {
require(path.startsWith("/") && !path.startsWith("//"))
- val connection = URL("$origin/emby$path").openConnection() as HttpsURLConnection
+ if (runCatching { EmbyServer.checkHomeConnection(origin) }.isFailure) return null
+ val connection = URL("$origin/emby$path").openConnection() as HttpURLConnection
try {
connection.instanceFollowRedirects = false
connection.connectTimeout = 10_000
@@ -62,7 +63,10 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
override fun execute(request: EmbyRequest): EmbyReply {
// Paths come from the protocol below, never from a provider response or a text field.
require(request.path.startsWith("/") && !request.path.startsWith("//"))
- val connection = URL("$origin/emby${request.path}").openConnection() as HttpsURLConnection
+ // HTTPS, or plain HTTP to a home server whose name still leads only to private addresses.
+ require(origin.startsWith("https://") || EmbyServer.isHome(origin))
+ EmbyServer.checkHomeConnection(origin)
+ val connection = URL("$origin/emby${request.path}").openConnection() as HttpURLConnection
try {
connection.requestMethod = request.method
connection.instanceFollowRedirects = false
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 40c4152..e9a57e1 100644
--- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
+++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
@@ -632,7 +632,7 @@ 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 = Ink, fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
- // The server's address (release essentials, 6 November 2026): typed in, HTTPS only.
+ // The server's address (release essentials, 6 November 2026): typed in, HTTPS, or plain HTTP to a home server 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() },
@@ -647,7 +647,12 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
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("For example https://emby.example.com, or a server at home: http://192.168.1.20:8096", color = Muted, fontSize = 11.sp)
+ // A home server over plain HTTP: say plainly what that means before the password goes.
+ if (serverText.trim().startsWith("http://", ignoreCase = true)) Text(
+ if (EmbyServer.normalise(serverText) != null) "Home network only: your sign-in and programmes travel unencrypted over your Wi-Fi. Orbit won't use plain http for anything outside your home."
+ else "Plain http:// is only for a server on your home network (like 192.168.x.x or name.local). Anywhere else needs https://.",
+ color = Yellow, 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", "") },
@@ -686,7 +691,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
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 }
+ if (server == null) { message = "Enter your server's https:// address, like https://emby.example.com, or http:// with a home-network address."; return@OrbitButton }
clearPassword(); keyboard?.hide(); focus.clearFocus()
closeSession()
val ticket = sessionSlot.ticket()
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 e742f7f..455f895 100644
--- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt
+++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt
@@ -16,14 +16,16 @@ internal class EmbyPlaybackPlan(val item: SampleItem, val url: String, val sourc
internal object PlaybackUrls {
fun isProvider(uri: URI, server: String = EmbyServer.origin): Boolean {
val origin = URI(server)
- 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 portOf(u: URI) = if (u.port != -1) u.port else if (u.scheme.equals("https", true)) 443 else 80
+ return uri.scheme.equals(origin.scheme, true) && uri.host.equals(origin.host, true) && portOf(uri) == portOf(origin)
}
fun resolve(value: String, base: String = HttpsEmbyTransport.SERVER + "/emby/", token: String = "", server: String = EmbyServer.origin): String {
fun isProvider(uri: URI) = isProvider(uri, server)
try {
val uri = URI(base).resolve(value)
- if (!uri.scheme.equals("https", true) || uri.host.isNullOrBlank() || uri.userInfo != null || uri.fragment != null)
+ // Plain HTTP only to the home server itself; anywhere else, HTTPS as always.
+ val secure = uri.scheme.equals("https", true) || (uri.scheme.equals("http", true) && EmbyServer.isHome(server) && isProvider(uri))
+ if (!secure || uri.host.isNullOrBlank() || uri.userInfo != null || uri.fragment != null)
throw EmbyCheckException("This stream does not provide a supported secure media address.")
if (!isProvider(uri) && token.isNotEmpty() && (URLDecoder.decode(uri.rawPath, "UTF-8").contains(token) || uri.rawAuthority.contains(token)))
throw EmbyCheckException("The provider login cannot be sent to another media host.")
diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt b/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt
index 060f020..817fa7f 100644
--- a/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt
+++ b/android/app/src/main/java/tv/orbit/classic/EmbyServer.kt
@@ -1,12 +1,18 @@
package tv.orbit.classic
+import java.net.Inet4Address
+import java.net.Inet6Address
+import java.net.InetAddress
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.
+ * Which Emby server Orbit talks to (release essentials, Paul, 6 November 2026). It's typed in at sign-in and saved with the
+ * sign-in, so anyone can use Orbit with their own server. HTTPS is the default and the rule everywhere, with standard
+ * certificate checks, no redirects, and every request to exactly this origin.
+ *
+ * The one exception is a server on the home network (Paul, 6 November 2026): plain http:// is accepted only for a private
+ * address (192.168.x.x, 10.x.x.x, 172.16-31.x.x, an IPv6 fd00::/8 address) or a home-network name (.local, .lan, .home.arpa),
+ * and every connection checks again that the name still leads to a private address. Never a public one.
*/
internal object EmbyServer {
/** Paul's server: what a box signed in before addresses could be typed was always using. */
@@ -15,21 +21,56 @@ internal object EmbyServer {
val host: String get() = URI(origin).host
val port: Int get() = URI(origin).port
+ private val homeNames = listOf(".local", ".lan", ".home.arpa")
+
+ /** A private address on a home network: never on the internet. */
+ fun isHomeAddress(address: InetAddress): Boolean = when (address) {
+ is Inet4Address -> address.address.map { it.toInt() and 255 }.let { b ->
+ b[0] == 10 || (b[0] == 172 && b[1] in 16..31) || (b[0] == 192 && b[1] == 168) }
+ is Inet6Address -> (address.address[0].toInt() and 0xfe) == 0xfc
+ else -> false
+ }
+
+ /** A host that's a home address written out, or a home-network name, as typed. Names are checked again when connecting. */
+ fun isHomeHost(host: String): Boolean {
+ val bare = host.removePrefix("[").removeSuffix("]")
+ if (homeNames.any { bare.endsWith(it, ignoreCase = true) && bare.length > it.length }) return true
+ val literal = Regex("\\d{1,3}(\\.\\d{1,3}){3}").matches(bare) || (':' in bare && bare.all { it.isLetterOrDigit() || it == ':' })
+ return literal && runCatching { isHomeAddress(InetAddress.getByName(bare)) }.getOrDefault(false)
+ }
+
+ /** Whether [origin] is a plain-HTTP home server. */
+ fun isHome(origin: String) = origin.startsWith("http://")
+
/**
- * 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.
+ * Before each connection to a home server: every address its name leads to must be private, so a name that's been pointed
+ * somewhere else is refused. [resolve] is for tests.
+ */
+ fun checkHomeConnection(origin: String, resolve: (String) -> List = { InetAddress.getAllByName(it).toList() }) {
+ if (!isHome(origin)) return
+ val host = URI(origin).host?.removePrefix("[")?.removeSuffix("]") ?: throw EmbyCheckException("That home server address isn't valid.")
+ val addresses = runCatching { resolve(host) }.getOrElse { throw EmbyCheckException("Your home server could not be found on this network.", retryable = true) }
+ if (addresses.isEmpty() || !addresses.all(::isHomeAddress)) throw EmbyCheckException("That address isn't on your home network, so Orbit won't use it without https.")
+ }
+
+ /**
+ * The address someone typed, as an origin Orbit will use (`https://host[:port]`, or `http://host[:port]` for a home server),
+ * 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 to anything but a home address.
*/
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 scheme = uri.scheme?.lowercase() ?: return null
+ if (scheme !in setOf("https", "http") || 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
+ if (scheme == "http" && !isHomeHost(host)) 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"
+ val usual = if (scheme == "https") 443 else 80
+ return "$scheme://$host" + if (port == -1 || port == usual) "" else ":$port"
}
}
diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt
index 7c43ca6..da708a0 100644
--- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt
+++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt
@@ -9,14 +9,14 @@ import java.io.InputStream
import java.net.InetAddress
import java.net.URI
import java.net.URL
-import javax.net.ssl.HttpsURLConnection
+import java.net.HttpURLConnection
/** Redirect handling is explicit so provider credentials never follow a stream to a CDN. */
internal class MediaDeliveryException(message: String) : IOException(message)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal class SecureMediaDataSource(private val session: EmbySampleSession) : BaseDataSource(true) {
- private var connection: HttpsURLConnection? = null
+ private var connection: HttpURLConnection? = null
private var stream: InputStream? = null
private var currentUri: Uri? = null
private var remaining = C.LENGTH_UNSET.toLong()
@@ -26,9 +26,15 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B
try {
var address = session.mediaUrl(dataSpec.uri.toString())
for (redirect in 0..5) {
- val host = URI(address).host
- if (InetAddress.getAllByName(host).any { !isPublicMediaAddress(it) }) throw MediaDeliveryException("Media delivery resolved to a non-public address.")
- val next = URL(address).openConnection() as HttpsURLConnection
+ // Media comes from public HTTPS hosts, never from inside the network; the one exception is the home server
+ // itself, when that's what you signed in to, and only while its name still leads to private addresses.
+ val home = EmbyServer.isHome(session.origin) && PlaybackUrls.isProvider(URI(address), session.origin)
+ if (home) try { EmbyServer.checkHomeConnection(session.origin) } catch (_: EmbyCheckException) { throw MediaDeliveryException("The home server isn't on this network.") }
+ else {
+ val host = URI(address).host
+ if (!address.startsWith("https://") || InetAddress.getAllByName(host).any { !isPublicMediaAddress(it) }) throw MediaDeliveryException("Media delivery resolved to a non-public address.")
+ }
+ val next = URL(address).openConnection() as HttpURLConnection
connection = next
next.instanceFollowRedirects = false
next.connectTimeout = 10_000
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..f913746
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/test/java/tv/orbit/classic/HomeServerTest.kt b/android/app/src/test/java/tv/orbit/classic/HomeServerTest.kt
new file mode 100644
index 0000000..d1e71a5
--- /dev/null
+++ b/android/app/src/test/java/tv/orbit/classic/HomeServerTest.kt
@@ -0,0 +1,58 @@
+package tv.orbit.classic
+
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Test
+import java.net.InetAddress
+import java.net.URI
+
+/** Home servers over plain HTTP (Paul, 6 November 2026): private addresses only, checked on every connection; HTTPS everywhere else. */
+class HomeServerTest {
+ @After fun reset() { EmbyServer.origin = EmbyServer.DEFAULT }
+ private fun ip(text: String) = InetAddress.getByName(text)
+
+ @Test fun plainHttpOnlyForAHomeAddress() {
+ assertEquals("http://192.168.1.20:8096", EmbyServer.normalise("http://192.168.1.20:8096"))
+ assertEquals("http://10.0.0.5:8096", EmbyServer.normalise("http://10.0.0.5:8096/emby"))
+ assertEquals("http://172.20.1.2", EmbyServer.normalise("HTTP://172.20.1.2:80/"))
+ assertEquals("http://nas.local:8096", EmbyServer.normalise("http://NAS.local:8096"))
+ assertEquals("http://media.home.arpa:8096", EmbyServer.normalise("http://media.home.arpa:8096"))
+ assertEquals("http://[fd12::5]:8096", EmbyServer.normalise("http://[fd12::5]:8096"))
+ // Public addresses, ordinary names and the edges of the private ranges: refused.
+ listOf("http://8.8.8.8:8096", "http://emby.example.com", "http://172.32.0.1", "http://172.15.0.1", "http://192.169.0.1", "http://127.0.0.1:8096",
+ "http://169.254.1.1", "http://local", "http://example.com.local.evil.com", "http://[2001:db8::1]")
+ .forEach { assertNull(it, EmbyServer.normalise(it)) }
+ // A bare address still means HTTPS.
+ assertEquals("https://192.168.1.20:8096", EmbyServer.normalise("192.168.1.20:8096"))
+ }
+
+ @Test fun everyConnectionChecksTheNameStillLeadsHome() {
+ EmbyServer.checkHomeConnection("http://nas.local:8096") { listOf(ip("192.168.0.9")) }
+ assertThrows(EmbyCheckException::class.java) { EmbyServer.checkHomeConnection("http://nas.local:8096") { listOf(ip("192.168.0.9"), ip("93.184.216.34")) } }
+ assertThrows(EmbyCheckException::class.java) { EmbyServer.checkHomeConnection("http://nas.local:8096") { listOf(ip("93.184.216.34")) } }
+ assertThrows(EmbyCheckException::class.java) { EmbyServer.checkHomeConnection("http://nas.local:8096") { emptyList() } }
+ // HTTPS servers aren't touched by this check.
+ EmbyServer.checkHomeConnection(EmbyServer.DEFAULT) { throw AssertionError("not resolved") }
+ }
+
+ @Test fun mediaUsesPlainHttpOnlyToTheHomeServerItself() {
+ val home = "http://192.168.1.20:8096"
+ assertTrue(PlaybackUrls.isProvider(URI("$home/emby/Videos/1/stream.mkv"), home))
+ assertFalse(PlaybackUrls.isProvider(URI("https://192.168.1.20:8096/emby/Videos/1/stream.mkv"), home))
+ assertFalse(PlaybackUrls.isProvider(URI("http://192.168.1.21:8096/v"), home))
+ assertEquals("$home/emby/Videos/1/stream.mkv", PlaybackUrls.resolve("/emby/Videos/1/stream.mkv?api_key=t", "$home/emby/", "t", home))
+ // Plain HTTP anywhere else is refused, home server or not.
+ assertThrows(EmbyCheckException::class.java) { PlaybackUrls.resolve("http://cdn.example.com/v.mkv", "$home/emby/", "t", home) }
+ assertThrows(EmbyCheckException::class.java) { PlaybackUrls.resolve("http://cdn.example.com/v.mkv", "${EmbyServer.DEFAULT}/emby/", "t", EmbyServer.DEFAULT) }
+ assertThrows(EmbyCheckException::class.java) { PlaybackUrls.resolve("/emby/v.mkv", "http://192.168.1.20:8096/emby/", "t", EmbyServer.DEFAULT) }
+ // A session on a home server sends its token to that server only.
+ val session = EmbySampleSession(HttpsEmbyTransport("fixture-device", home), "fixture-token", "fixture-user", EmbySample(emptyList(), 0))
+ assertEquals(mapOf("X-Emby-Token" to "fixture-token"), session.mediaHeaders("$home/emby/Videos/1/stream.mkv"))
+ assertTrue(session.mediaHeaders("http://192.168.1.21:8096/v").isEmpty())
+ }
+
+ @Test fun aHomeServerSignInIsSavedAndItsWorkspaceIsItsOwn() {
+ assertTrue(Workspaces.keyOf("http://192.168.1.20:8096") != Workspaces.keyOf("https://192.168.1.20:8096"))
+ assertEquals("http://192.168.1.20:8096", EmbyServer.normalise("http://192.168.1.20:8096"))
+ }
+}