package org.meshbay.client.cast import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities import org.json.JSONArray import org.json.JSONObject import org.meshbay.client.bridge.Refused import java.net.Inet4Address import java.net.InetAddress import java.util.Base64 /** * main.js `cast:*`: the relay, the receiver, and what keeps both alive. * * `onCasting(true)` is called once the relay is up (start the foreground * service, keep the WebView visible), `onCasting(false)` once it is down. */ class CastChannels( private val context: Context, private val onCasting: (Boolean) -> Unit, private val tell: (String) -> Unit = {}, ) { val control = CastControl(context) val relay = CastRelay(::lanAddress, java.io.File(context.cacheDir, "cast-relay")) fun handles(channel: String) = channel.startsWith("cast:") /** What must reach the relay in the order the page sent it: start, pushes, subtitle, finish, stop. */ fun ordered(channel: String) = channel in setOf("cast:start", "cast:push", "cast:subtitle", "cast:finish", "cast:stop") fun call(channel: String, a: JSONArray): Any? = when (channel) { "cast:start" -> { val o = a.optJSONObject(0) ?: JSONObject() val init = o.optString("initSegment", "").takeIf { it.isNotEmpty() }?.let { Base64.getDecoder().decode(it) } val started = relay.start(init, o.optJSONObject("subtitle")) onCasting(true) started } "cast:subtitle" -> relay.setSubtitle(a.optJSONObject(0)) "cast:push" -> { // the base64 path, for a WebView without ArrayBuffer messages relay.push(Base64.getDecoder().decode(a.optString(0, ""))); true } "cast:stop" -> { relay.stop(); onCasting(false); true } "cast:finish" -> { relay.finish(); true } "cast:status" -> JSONObject().put("active", relay.active).put("url", relay.url ?: JSONObject.NULL) .put("subtitle", relay.subtitleInfo() ?: JSONObject.NULL).put("chromecast", control.status()) "cast:scan" -> { control.startScan(); true } "cast:devices" -> control.devices() "cast:chromecast:connect" -> { val o = a.optJSONObject(0) ?: JSONObject() reported { control.connect(o.optString("deviceId", ""), relayUrl(o), subtitleOf(o)) } } "cast:chromecast:reload" -> { val o = a.optJSONObject(0) ?: JSONObject() reported { control.reload(relayUrl(o), subtitleOf(o)) } } "cast:chromecast:pause" -> control.pause() "cast:chromecast:play" -> control.play() "cast:chromecast:disconnect" -> control.disconnect() else -> throw Refused("Refused: no such channel") } /** * A failed cast says why, on the screen. The player catches the error and * stops the relay without a word, which leaves the television on the * receiver's idle screen and nobody knowing whether it ever reached the * phone — so the receiver's reason and that fact are shown here. */ private fun reported(block: () -> T): T = try { block() } catch (e: Exception) { val reached = if (relay.streamRequests > 0) "the television did reach this phone" else "the television never reached this phone at ${relay.url?.substringBefore("/stream") ?: "?"}" tell("Cast failed: ${e.message} — $reached") throw e } fun push(bytes: ByteArray, offset: Int, length: Int): Boolean { relay.push(bytes, offset, length); return true } /** * The receiver is only ever pointed at this relay. A URL the page names is * accepted when it is the relay's own, and refused otherwise — a page * cannot use this application to make a television fetch anything else. */ private fun relayUrl(o: JSONObject): String { val asked = o.optString("mediaUrl", "") val ours = relay.url ?: throw Refused("The cast relay is not running") if (asked != ours) throw Refused("Refused: not this relay's stream") return ours } /** As main.js: no subtitle named means the relay's current one. */ private fun subtitleOf(o: JSONObject): JSONObject? = if (o.has("subtitle") && o.get("subtitle") != JSONObject.NULL) o.optJSONObject("subtitle") else relay.subtitleInfo() /** * The Wi-Fi (or Ethernet) address, never the mobile network's: a TV cannot * be reached there. Nor a VPN's: a VPN network carries the transports of * the network under it, Wi-Fi included, and its address is a tunnel the * television cannot route to. */ private fun lanAddress(): InetAddress? { val cm = context.getSystemService(ConnectivityManager::class.java) for (network in cm.allNetworks) { val caps = cm.getNetworkCapabilities(network) ?: continue if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue if (!caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && !caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) continue val addr = cm.getLinkProperties(network)?.linkAddresses?.map { it.address } ?.firstOrNull { it is Inet4Address && !it.isLoopbackAddress } if (addr != null) return addr } return null } }