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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user