aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastChannels.kt
blob: 74a086b507c4caa886e89dfdf8797753c020aa6c (plain) (blame)
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
    }
}