feat(android): pairing flow, deep link, DI graphs, navigation skeleton (B-M1d)

This commit is contained in:
weishu
2026-08-17 15:39:29 +08:00
parent f275bae4cb
commit 3cf2a669c3
25 changed files with 2183 additions and 66 deletions
@@ -53,9 +53,10 @@ data class BindLink(
/**
* Decodes an `application/x-www-form-urlencoded` query string.
* First occurrence wins for duplicate keys. Returns null if any
* component carries invalid escapes.
* component carries invalid escapes. Internal so [PairingLinks] can
* decode the web direct-access QR with identical semantics.
*/
private fun parseFormQuery(rawQuery: String): Map<String, String>? {
internal fun parseFormQuery(rawQuery: String): Map<String, String>? {
val result = LinkedHashMap<String, String>()
for (pair in rawQuery.split('&')) {
if (pair.isEmpty()) continue
@@ -79,7 +80,7 @@ data class BindLink(
null // unreachable: UTF-8 is always supported
}
private fun isHttpUrl(value: String): Boolean {
internal fun isHttpUrl(value: String): Boolean {
val uri = try {
URI(value)
} catch (_: URISyntaxException) {
@@ -0,0 +1,47 @@
package app.hapi.protocol.pairing
import java.net.URI
import java.net.URISyntaxException
/**
* Scanner-side pairing-link parsing, accepting **both** QR codes the hub
* prints (`hub/src/startHub.ts`, `docs/api/client-contract/auth.md#pairing`):
*
* 1. the companion deeplink `hapicompanion://bind?hub=<url>&code=<accessToken>`
* (canonical for natives — [BindLink.parse]), and
* 2. the web direct-access URL `https://app.hapi.run/?hub=<url>&token=<accessToken>`
* — same credentials under `token=` instead of `code=`. Users will
* inevitably point the in-app scanner at the wrong QR of the pair, so
* [parse] extracts it equivalently as a convenience.
*
* The intent-filter deep-link path stays [BindLink.parse]-only: the OS never
* routes web URLs to the app.
*/
object PairingLinks {
/** Parses [raw] as a companion deeplink, falling back to the web QR form. */
fun parse(raw: String): BindLink? = BindLink.parse(raw) ?: parseWebUrl(raw)
/**
* Parses the web direct-access QR: any absolute http(s) URL whose query
* carries non-blank `hub` (itself an http(s) URL) and `token` params. The
* host is deliberately not pinned to `app.hapi.run` — self-hosters may
* serve the web app from their own origin. Returns null otherwise.
*/
fun parseWebUrl(raw: String): BindLink? {
val uri = try {
URI(raw.trim())
} catch (_: URISyntaxException) {
return null
}
val scheme = uri.scheme?.lowercase() ?: return null
if (scheme != "http" && scheme != "https") return null
if ((uri.host ?: uri.authority).isNullOrBlank()) return null
val params = BindLink.parseFormQuery(uri.rawQuery ?: return null) ?: return null
val hub = params["hub"]?.takeIf { it.isNotBlank() } ?: return null
val token = params["token"]?.takeIf { it.isNotBlank() } ?: return null
if (!BindLink.isHttpUrl(hub)) return null
return BindLink(hubUrl = hub, accessToken = token)
}
}
@@ -0,0 +1,83 @@
package app.hapi.protocol.pairing
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class PairingLinksTest {
@Test
fun `parse accepts the companion deeplink form`() {
val link = PairingLinks.parse("hapicompanion://bind?hub=http://192.168.1.20:3006&code=abc123")
assertEquals(BindLink(hubUrl = "http://192.168.1.20:3006", accessToken = "abc123"), link)
}
@Test
fun `parse accepts the web direct-access form`() {
// Exactly what the hub prints as the first QR (startHub.ts):
// `${webUrl}/?${new URLSearchParams({hub, token})}`.
val link = PairingLinks.parse(
"https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&token=tok_9f8%3Adefault"
)
assertEquals("https://demo.hapi.run", link?.hubUrl)
assertEquals("tok_9f8:default", link?.accessToken)
}
@Test
fun `web form is not pinned to the hosted web origin`() {
val link = PairingLinks.parseWebUrl(
"http://selfhosted.example:8080/?hub=http%3A%2F%2Fhub.example%3A3006&token=x"
)
assertEquals("http://hub.example:3006", link?.hubUrl)
assertEquals("x", link?.accessToken)
}
@Test
fun `web form ignores extra params and keeps first duplicate`() {
val link = PairingLinks.parseWebUrl(
"https://app.hapi.run/?v=1&hub=https%3A%2F%2Fa.example.com&token=first&token=second"
)
assertEquals("https://a.example.com", link?.hubUrl)
assertEquals("first", link?.accessToken)
}
@Test
fun `web form requires both hub and token`() {
assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=https%3A%2F%2Fh.example.com"))
assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?token=abc"))
assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=&token=abc"))
assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=https%3A%2F%2Fh.example.com&token="))
assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/"))
}
@Test
fun `web form rejects a code param - that spelling belongs to the deeplink`() {
assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=https%3A%2F%2Fh.example.com&code=abc"))
}
@Test
fun `web form requires hub to be an http url`() {
assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=ftp%3A%2F%2Fh.example.com&token=x"))
assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=not-a-url&token=x"))
}
@Test
fun `web form rejects non-http schemes and malformed input`() {
assertNull(PairingLinks.parseWebUrl("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=x"))
assertNull(PairingLinks.parseWebUrl("ftp://app.hapi.run/?hub=https%3A%2F%2Fh.example.com&token=x"))
assertNull(PairingLinks.parseWebUrl(""))
assertNull(PairingLinks.parseWebUrl("not a uri at all"))
assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=https%3A%2F%2Fh.example.com&token=%zz"))
}
@Test
fun `parse returns null for arbitrary non-pairing content`() {
assertNull(PairingLinks.parse("WIFI:T:WPA;S:mynetwork;P:pass;;"))
assertNull(PairingLinks.parse("https://example.com/some/page"))
assertNull(PairingLinks.parse("hello world"))
}
}