feat(client/android): punktfunk:// links open a stream

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.
This commit is contained in:
2026-07-29 12:17:18 +02:00
parent 46461eb0b6
commit b63f9f4ac0
7 changed files with 841 additions and 18 deletions
@@ -90,6 +90,21 @@
<!-- TV launcher entry. -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- punktfunk:// deep links (design/client-deep-links.md §2): an external tool, an OS
shortcut or a wiki page opens a stream on a host this device already trusts. The
URL carries only REFERENCES to things that exist here (a host record, a settings
profile, a library id) — never resolution/bitrate/codec values, and never a
pairing route; MainActivity's router enforces the rest. BROWSABLE is what lets a
browser hand it over (behind its own "Open Punktfunk?" prompt).
NOTE: launchMode deliberately stays `standard` and the configChanges set above is
untouched — its `keyboard` entry is what keeps an SC2 claim from killing a running
stream, and neither has anything to gain from this filter. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="punktfunk" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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 },
@@ -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 })
@@ -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<String?>(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()
@@ -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. */