From 42bd5be94147fdf73126636517662b03eba062ac Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 01:52:17 +0200 Subject: [PATCH] fix(client/android): a link must not end the stream it isn't allowed to preempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on glass. "A URL may never preempt a live session" was enforced inside the composition — which is the one place that can't see it happen. With `launchMode = standard` a `punktfunk://` link arrives as a SECOND activity instance in its own task; that instance's session state is empty, so it routed the link as a fresh connect, and the streaming task being backgrounded ended the session it was supposed to protect. The rule held only for the rare `FLAG_ACTIVITY_SINGLE_TOP` caller that lands on `onNewIntent`. The live session is now published process-wide, and `onCreate` refuses there — before this instance is ever resumed, so finishing it leaves the streaming task in front, untouched. Static state is what crosses the gap between two activity instances that know nothing about each other; the process dying resets it, which is also the right answer. Verified on the emulator against a real host: with a stream up, a link naming a DIFFERENT host now leaves it running (same session, same HUD, no second connect) instead of tearing it down. --- .../src/main/kotlin/io/unom/punktfunk/App.kt | 18 +++++-- .../kotlin/io/unom/punktfunk/MainActivity.kt | 51 +++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) 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 f3587604..a7242928 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -67,10 +68,19 @@ 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. + // Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND + // activity instance (the normal case under `launchMode = standard`) can refuse it before that + // instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity + // destroyed mid-stream doesn't leave a ghost that blocks every future link. + DisposableEffect(session) { + MainActivity.liveStream = session?.let { MainActivity.LiveStream(it.hostId) } + onDispose { MainActivity.liveStream = null } + } + + // The same rule for the rare in-instance case (a caller that set FLAG_ACTIVITY_SINGLE_TOP, so + // the link reached `onNewIntent` on the streaming activity itself). 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 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 dedf5857..8163af3f 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 @@ -13,6 +13,7 @@ import android.view.InputDevice import android.view.KeyCharacterMap import android.view.KeyEvent import android.view.MotionEvent +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent @@ -27,6 +28,10 @@ import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.Keymap import io.unom.punktfunk.kit.NativeBridge +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 /** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */ private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION" @@ -136,6 +141,27 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // A URL may never preempt a live session (design/client-deep-links.md §3.2). With + // `launchMode = standard` a link normally arrives as a NEW activity instance in a new task + // — the streaming one gets backgrounded, and backgrounding ends a session — so the refusal + // has to happen HERE, before this instance is resumed, not inside the composition (which + // only ever sees the rare `onNewIntent` case). Finishing now leaves the streaming task in + // front, untouched. + val live = liveStream + if (live != null && deepLinkFrom(intent) != null) { + // 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 + // what "focus it" means here. + if (!targetsHost(intent, live)) { + Toast.makeText( + this, + "Already streaming — end this session first.", + Toast.LENGTH_LONG, + ).show() + } + finish() + return + } pendingDeepLink = deepLinkFrom(intent) lastPadIsGamepad = !isTvDevice(this) lastPadStyle = Gamepad.styleFor(Gamepad.firstPad()) @@ -567,4 +593,29 @@ class MainActivity : ComponentActivity() { -> true else -> KeyEvent.isGamepadButton(kc) } + + /** Does [intent]'s link resolve to the host [live] is already streaming? */ + private fun targetsHost(intent: Intent?, live: LiveStream): Boolean { + val url = deepLinkFrom(intent) ?: return false + val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return false + val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(this).all()) + return target is HostResolution.Known && target.host.id == live.hostId + } + + /** The host a live stream is on — see [liveStream]. */ + data class LiveStream(val hostId: String?) + + companion object { + /** + * The live stream, PROCESS-wide (null = not streaming), published by the composition that + * owns it. + * + * Deliberately not per-instance state: `launchMode` is `standard`, so a `punktfunk://` + * link arrives as a second activity instance that knows nothing about the first — and the + * one thing it must know is that a session is already running. Static state is what + * crosses that gap; the process dying resets it, which is also correct. + */ + @Volatile + var liveStream: LiveStream? = null + } }