From b63f9f4ac0f8ea7a9bbd97030cf76bf2881256b4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 01:37:55 +0200 Subject: [PATCH] feat(client/android): punktfunk:// links open a stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android was the last client with no URL door at all: no VIEW intent filter, no `onNewIntent`, no parser. Now a Playnite entry, an OS shortcut, a Stream Deck macro or a wiki link can open a stream on a host this device already trusts. The parser is a PORT, not a new design. `clients/shared/deeplink-vectors.json` is the cross-language contract, and the Kotlin suite runs the same 44 cases the Rust one does — including every refusal code — so three parsers cannot drift into three different security postures. It is resolved from the shared path rather than copied, because a copy would be a fourth contract free to go stale. Strict percent-decoding with a REPORTING UTF-8 decoder, so `%FF` is a refusal rather than a U+FFFD that survives into a log line or a filename. The routing lives in `ConnectScreen` because that is what owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a link must go THROUGH all of it, never around it. The rules are absolute and each one is a branch you can point at: a known, pinned host does exactly what tapping its card does; an unknown or never-pinned one gets the confirmation sheet, from which the normal pairing flow proceeds under the user's eyes; an `fp=` that contradicts the stored pin is a hard refusal; an ambiguous host name or a profile this device doesn't have refuses with a notice naming what failed, because a "Work" shortcut streaming with the wrong settings is worse than an error; a link arriving mid-stream never preempts it (pointing at the host already being streamed is a no-op — the intent has already brought the app forward, which is what focusing it means). `wake` and `browse` parse, and are refused with a notice rather than silently connecting. `launch=` and `profile=` ride the whole path, including through a trust decision: a link to a host that still needs pairing keeps its game and its profile across the confirmation instead of quietly landing on a plain desktop session. `launchMode` stays `standard` and the `configChanges` set is untouched — its `keyboard` entry is what keeps an SC2 claim from killing a running stream. The VIEW intent is read in both `onCreate` and `onNewIntent`, which is what that launch mode requires. --- .../android/app/src/main/AndroidManifest.xml | 15 + .../src/main/kotlin/io/unom/punktfunk/App.kt | 41 +- .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 152 +++++- .../kotlin/io/unom/punktfunk/MainActivity.kt | 26 + .../io/unom/punktfunk/models/UiModels.kt | 14 + .../io/unom/punktfunk/kit/link/DeepLink.kt | 451 ++++++++++++++++++ .../unom/punktfunk/kit/link/DeepLinkTest.kt | 160 +++++++ 7 files changed, 841 insertions(+), 18 deletions(-) create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/link/DeepLink.kt create mode 100644 clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/link/DeepLinkTest.kt diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml index 798b8a9b..c30c953f 100644 --- a/clients/android/app/src/main/AndroidManifest.xml +++ b/clients/android/app/src/main/AndroidManifest.xml @@ -90,6 +90,21 @@ + + + + + + + diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index 8e87cec4..f3587604 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,12 +42,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp +import android.widget.Toast +import io.unom.punktfunk.kit.link.DeepLinkResult +import io.unom.punktfunk.kit.link.DeepLinks +import io.unom.punktfunk.kit.link.HostResolution +import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.models.ActiveSession import io.unom.punktfunk.models.Tab @Composable fun App(forceGamepadUi: Boolean = false) { val context = LocalContext.current + val activity = context as? MainActivity val settingsStore = remember { SettingsStore(context) } var settings by remember { mutableStateOf(settingsStore.load()) } // The active session (null = not streaming). It carries the settings the connect resolved, @@ -60,6 +67,27 @@ fun App(forceGamepadUi: Boolean = false) { val controllerConnected by rememberControllerConnected() val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi) + // A `punktfunk://` URL that arrived while a session is live is REFUSED, never honoured: a URL + // may not preempt a stream (design/client-deep-links.md §3.2). Pointing at the host already + // being streamed is the one exception, and its right answer is to do nothing — the intent has + // already brought the app forward, which is exactly what "focus it" means here. + val pendingLink = activity?.pendingDeepLink + LaunchedEffect(pendingLink, session) { + val url = pendingLink ?: return@LaunchedEffect + val live = session ?: return@LaunchedEffect // not streaming: ConnectScreen routes it + activity.pendingDeepLink = null + val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect + val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all()) + val sameHost = target is HostResolution.Known && target.host.id == live.hostId + if (!sameHost) { + Toast.makeText( + context, + "Already streaming — end this session first.", + Toast.LENGTH_LONG, + ).show() + } + } + AnimatedContent( targetState = session, transitionSpec = { @@ -75,6 +103,8 @@ fun App(forceGamepadUi: Boolean = false) { settings = settings, onSettingsChange = { settings = it; settingsStore.save(it) }, onConnected = { session = it }, + deepLink = pendingLink, + onDeepLinkHandled = { activity?.pendingDeepLink = null }, ) } else { // Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail @@ -105,7 +135,12 @@ fun App(forceGamepadUi: Boolean = false) { label = "TabTransition" ) { targetTab -> when (targetTab) { - Tab.Connect -> ConnectScreen(settings = settings, onConnected = { session = it }) + Tab.Connect -> ConnectScreen( + settings = settings, + onConnected = { session = it }, + deepLink = pendingLink, + onDeepLinkHandled = { activity?.pendingDeepLink = null }, + ) Tab.Settings -> SettingsScreen( initial = settings, onChange = { settings = it; settingsStore.save(it) }, @@ -170,6 +205,8 @@ fun GamepadShell( settings: Settings, onSettingsChange: (Settings) -> Unit, onConnected: (ActiveSession) -> Unit, + deepLink: String? = null, + onDeepLinkHandled: () -> Unit = {}, ) { val context = LocalContext.current var screen by remember { mutableStateOf(GamepadScreen.Home) } @@ -196,6 +233,8 @@ fun GamepadShell( GamepadScreen.Home -> ConnectScreen( settings = settings, onConnected = onConnected, + deepLink = deepLink, + onDeepLinkHandled = onDeepLinkHandled, gamepadUi = true, onOpenSettings = { screen = GamepadScreen.Settings }, onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library }, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index a0bb96a7..aac7f42c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -59,6 +59,11 @@ import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.discovery.DiscoveredHost import io.unom.punktfunk.kit.discovery.HostDiscovery +import io.unom.punktfunk.kit.link.DeepLinkResult +import io.unom.punktfunk.kit.link.DeepLinks +import io.unom.punktfunk.kit.link.HostResolution +import io.unom.punktfunk.kit.link.LinkError +import io.unom.punktfunk.kit.link.LinkRoute import io.unom.punktfunk.kit.security.ClientIdentity import io.unom.punktfunk.kit.security.IdentityStore import io.unom.punktfunk.kit.security.KnownHost @@ -111,6 +116,11 @@ fun ConnectScreen( onOpenSettings: () -> Unit = {}, onOpenLibrary: (KnownHost) -> Unit = {}, navGate: Boolean = true, // false while the console home is cross-fading out + // A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because + // it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a + // link must go through all of them, not around them. + deepLink: String? = null, + onDeepLinkHandled: () -> Unit = {}, ) { val scope = rememberCoroutineScope() val context = LocalContext.current @@ -281,9 +291,10 @@ fun ConnectScreen( pinHex: String, timeoutMs: Int, profile: StreamProfile?, + launch: String?, ): Long = connectToHost( context, settings.effectiveFor(profile), id, targetHost, targetPort, pinHex, - launch = null, timeoutMs = timeoutMs, + launch = launch, timeoutMs = timeoutMs, ) // What the stream screen is handed: the settings this connect actually used, plus the HOST's @@ -294,6 +305,7 @@ fun ConnectScreen( settings.effectiveFor(profile), clipboardSync = record?.clipboardSync ?: true, profileName = profile?.name, + hostId = record?.id, ) // The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint @@ -307,6 +319,7 @@ fun ConnectScreen( name: String, pinHex: String?, profile: StreamProfile?, + launch: String? = null, onFailure: (() -> Unit)? = null, ) { val id = identity ?: run { @@ -319,7 +332,8 @@ fun ConnectScreen( status = null discovery.stop() // free the Wi-Fi radio before the stream session scope.launch { - val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS, profile) + val handle = + connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS, profile, launch) // Cancelled mid-dial: the UI's already been returned (and discovery restarted) by // cancelConnect — drop the just-opened session silently rather than navigating into it. if (thisAttempt.cancelled.get()) { @@ -373,7 +387,14 @@ fun ConnectScreen( // only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…" // overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already // seen live) dial straight through. - fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?, oneOffProfile: String?) { + fun doConnect( + targetHost: String, + targetPort: Int, + name: String, + pinHex: String?, + oneOffProfile: String?, + launch: String? = null, + ) { if (identity == null) { status = "Identity not ready yet — try again in a moment" return @@ -392,7 +413,7 @@ fun ConnectScreen( if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) { // Fire-and-forget first packet (harmless if it's awake), then dial-first. scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) } - doConnectDirect(targetHost, targetPort, name, pinHex, profile, onFailure = { + doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch, onFailure = { waker.start( hostName = name, connectsAfter = true, @@ -408,12 +429,15 @@ fun ConnectScreen( knownHostStore.save(kh.copy(address = live.host, port = live.port)) savedHosts = knownHostStore.all() } - doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex, profile) + doConnectDirect( + live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex, + profile, launch, + ) }, ) }) } else { - doConnectDirect(targetHost, targetPort, name, pinHex, profile) + doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch) } } @@ -440,7 +464,10 @@ fun ConnectScreen( val pinHex = target.advertisedFp ?: "" // A host being trusted for the first time can't have a binding yet, so this is always // the plain defaults — a profile only ever enters via a later, deliberate choice. - val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS, null) + val handle = connectNative( + id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS, + profile = null, launch = target.launch, + ) // Cancelled while we were parked: tear the (possibly just-approved) session down and // don't touch UI a fresh action may now own. if (req.cancelled.get()) { @@ -485,6 +512,8 @@ fun ConnectScreen( // `""` = force the global defaults, which is a real choice on a bound host and must // therefore survive as a value rather than collapsing into "unset". NEVER rebinds. oneOffProfile: String? = null, + // A library id the host should boot straight into (`launch=` on a link). + launch: String? = null, ) { // Every dial/pair path funnels through here — with local network access denied the connect // can only EPERM its way to a 10 s timeout, so ask instead of pretending to try. @@ -500,18 +529,24 @@ fun ConnectScreen( when { // Known host whose advertised fp still matches the pin → silent pinned reconnect. known != null && (adv == null || adv == known.fpHex) -> - doConnect(targetHost, targetPort, known.name, known.fpHex, oneOffProfile) + doConnect(targetHost, targetPort, known.name, known.fpHex, oneOffProfile, launch) // Known host whose fp changed → force re-pairing (no silent re-trust shortcut). - known != null -> pendingTrust = - PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED) + known != null -> pendingTrust = PendingTrust( + targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED, + oneOffProfile, launch, + ) // Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it, // clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null. - dh?.pairingRequired == false -> pendingTrust = - PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW) + dh?.pairingRequired == false -> pendingTrust = PendingTrust( + targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW, + oneOffProfile, launch, + ) // pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN // "request access" (approve in the console) or the SPAKE2 PIN ceremony. - else -> pendingTrust = - PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS) + else -> pendingTrust = PendingTrust( + targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS, + oneOffProfile, launch, + ) } } @@ -565,6 +600,89 @@ fun ConnectScreen( listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) } } + // ---- punktfunk:// routing (design/client-deep-links.md §3) -------------------------------- + // + // The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust + // decisions. So it never pairs, never trusts on its own, and carries references rather than + // values. Everything below is either "do exactly what the card does" or "refuse and say why" — + // a shortcut that can't honour its reference must say so, because streaming with the wrong + // settings is worse than an explanatory notice. + LaunchedEffect(deepLink, identity, savedHosts) { + val url = deepLink ?: return@LaunchedEffect + // Wait for the identity rather than refusing: it arrives a beat after first composition and + // the effect re-runs when it does. + if (identity == null) return@LaunchedEffect + onDeepLinkHandled() + val parsed = DeepLinks.parse(url) + if (parsed is DeepLinkResult.Refused) { + // A link for someone else's scheme is not our business to complain about. + if (parsed.error != LinkError.NOT_OUR_SCHEME) status = parsed.message() + return@LaunchedEffect + } + val link = (parsed as DeepLinkResult.Parsed).link + if (link.route != LinkRoute.CONNECT) { + // `wake` and `browse` are reserved in the grammar and parse today; a front-end that + // hasn't implemented them refuses with a notice rather than silently connecting. + status = "Punktfunk on Android can't do “${link.route.word}” links yet." + return@LaunchedEffect + } + // A profile reference that can't be honoured refuses: a "Work" shortcut streaming with the + // wrong settings is worse than an error naming what failed. + val profileRef = link.profile + if (profileRef != null) { + val (_, resolution) = profileStore.resolve(profileRef) + if (resolution != ProfileResolution.FOUND) { + status = if (resolution == ProfileResolution.AMBIGUOUS) { + "More than one profile is called “$profileRef” — rename one and try again." + } else { + "That link asks for a profile called “$profileRef”, which isn't on this device." + } + return@LaunchedEffect + } + } + when (val resolved = DeepLinks.resolveHost(link, savedHosts)) { + // Known AND pinned is the one-click contract: do exactly what tapping its card does. + is HostResolution.Known -> { + // A pin that contradicts the stored one is the link being stale or lying. Hard + // refusal: this is the one case where doing what the card does would be wrong. + if (link.pinConflict(resolved.host)) { + status = "That link's fingerprint doesn't match the one pinned for " + + "${resolved.host.name} — it's out of date, or it isn't that host." + return@LaunchedEffect + } + if (resolved.host.fpHex.isEmpty()) { + // Saved but never pinned (nothing writes such a record today, but the rule is + // absolute): a link may not establish trust, so this is a confirmation. + pendingTrust = PendingTrust( + resolved.host.address, resolved.host.port, resolved.host.name, + link.fp, PendingTrust.Kind.REQUEST_ACCESS, profileRef, link.launch, + ) + return@LaunchedEffect + } + connect( + resolved.host.address, resolved.host.port, + oneOffProfile = profileRef, launch = link.launch, + ) + } + // Unknown, or known only by address: the confirmation sheet, from which the normal + // pairing flow proceeds under the user's eyes. Never a silent trust. + is HostResolution.Unknown -> pendingTrust = PendingTrust( + resolved.address, + resolved.port, + link.name ?: resolved.address, + resolved.fp, + PendingTrust.Kind.REQUEST_ACCESS, + profileRef, + link.launch, + ) + HostResolution.Ambiguous -> + status = "More than one saved host is called “${link.hostRef}” — " + + "rename one, or use its address." + HostResolution.Unresolvable -> + status = "That link points at a host this device doesn't know." + } + } + var showManualSheet by remember { mutableStateOf(false) } if (gamepadUi) { @@ -880,12 +998,12 @@ fun ConnectScreen( knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true) savedHosts = knownHostStore.all() pendingTrust = null - doConnect(pt.host, pt.port, pt.name, fp, null) + doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch) } when (pt.kind) { PendingTrust.Kind.TRUST_NEW -> - if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, null) }, onPair, { pendingTrust = null }) - else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, null) }, onPair, { pendingTrust = null }) + if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null }) + else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null }) PendingTrust.Kind.FP_CHANGED -> if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null }) else FingerprintChangedDialog(pt, onPair, { pendingTrust = null }) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index b8867d26..dedf5857 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -96,6 +96,17 @@ class MainActivity : ComponentActivity() { var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC) private set + /** + * A `punktfunk://` URL waiting to be routed — set from the VIEW intent that started (or + * re-entered) this activity, cleared by whoever handles it. Compose observes it. + * + * Read in BOTH [onCreate] and [onNewIntent] on purpose: `launchMode` is `standard`, so a second + * link usually arrives as a fresh activity instance (onCreate) and only sometimes as a new + * intent on this one (a caller that set `FLAG_ACTIVITY_SINGLE_TOP`). A link arriving while an + * earlier one is still unhandled replaces it — the user's latest intent is the live one. + */ + var pendingDeepLink by mutableStateOf(null) + /** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */ private var highRefreshModeId = 0 @@ -125,6 +136,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + pendingDeepLink = deepLinkFrom(intent) lastPadIsGamepad = !isTvDevice(this) lastPadStyle = Gamepad.styleFor(Gamepad.firstPad()) resolveHighRefreshMode() @@ -185,6 +197,20 @@ class MainActivity : ComponentActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Keep `getIntent()` truthful for anything that reads it later (the gamepad-UI dev flag). + setIntent(intent) + deepLinkFrom(intent)?.let { pendingDeepLink = it } + } + + /** + * The `punktfunk://` URL of a VIEW intent, or null. Only VIEW: the launcher's MAIN intent + * carries no data, and nothing else may inject a URL into the router. + */ + private fun deepLinkFrom(intent: Intent?): String? = + intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data?.toString() + override fun onResume() { super.onResume() startSc2MenuNav() diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt index da365398..b33f4ac2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt @@ -25,6 +25,14 @@ data class PendingTrust( val name: String, val advertisedFp: String?, val kind: Kind, + /** + * What the connect on the far side of this decision should carry — a `punktfunk://` link's + * one-off profile and library id. A link to an unknown host goes through the confirmation + * first, and the user's stated intent must survive that detour rather than being silently + * dropped on the way to a plain desktop session. + */ + val profile: String? = null, + val launch: String? = null, ) { enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS } } @@ -47,6 +55,12 @@ data class ActiveSession( * so "which profile am I on?" is answerable from inside the stream, as on the other clients. */ val profileName: String? = null, + /** + * The stable id of the host being streamed, when it is a saved one — so a `punktfunk://` link + * that arrives mid-stream can tell "this same host" (a no-op; the intent already focused us) + * from "a different host" (a notice; a URL may never preempt a live session). + */ + val hostId: String? = null, ) /** Trust state of a host, shown as a colored pill on its card. */ diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/link/DeepLink.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/link/DeepLink.kt new file mode 100644 index 00000000..00b6dc2b --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/link/DeepLink.kt @@ -0,0 +1,451 @@ +package io.unom.punktfunk.kit.link + +import io.unom.punktfunk.kit.security.KnownHost +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets + +/** + * The `punktfunk://` URL grammar (design/client-deep-links.md §2). A **port**, not a new design: + * the Rust `crates/pf-client-core/src/deeplink.rs` is the reference, Swift keeps a third copy, and + * all three are held together by `clients/shared/deeplink-vectors.json`, which each language's test + * suite runs verbatim — so the three parsers cannot drift into three different security postures. + * + * ```text + * punktfunk://connect/[?fp=<64-hex>][&host=][&launch=] + * [&profile=][&name=