mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-26 22:30:50 +00:00
feat(android): pairing flow, deep link, DI graphs, navigation skeleton (B-M1d)
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user