1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
|
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 <T> 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
}
}
|