fix(android): launch games from the library
The library browser was browse-only — the A button (and a tap) did nothing. Wire it to connect + boot straight into the selected title: thread a `launch` id (the store-qualified library id `steam:<appid>` / `custom:<id>`) through nativeConnect → NativeClient's Hello.launch (was hardcoded None), add a shared connectToHost() the ConnectScreen and the library launcher both use, and have LibraryScreen dial the host with launch=game.id on A / tap — with a launching overlay + an "A Launch" hint. Verified: native compiles (cargo-ndk arm64), app+kit Kotlin compiles (gradle, 3 ABIs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -208,6 +208,8 @@ fun GamepadShell(
|
||||
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||
LibraryScreen(
|
||||
host = host,
|
||||
settings = settings,
|
||||
onLaunched = onConnected,
|
||||
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
||||
navActive = s == screen,
|
||||
)
|
||||
|
||||
@@ -63,9 +63,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park
|
||||
* window (~180 s) so a slow operator approval still lands on this same parked connection rather than
|
||||
@@ -181,25 +178,10 @@ fun ConnectScreen(
|
||||
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
||||
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
||||
|
||||
// The one place the full nativeConnect is issued (shared by the normal connect and the
|
||||
// request-access path), including the HDR/gamepad derivation both need.
|
||||
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long {
|
||||
// Advertise HDR only when the user enabled it AND this device's display can present it
|
||||
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
||||
// explicit choice is passed through unchanged.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
return withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
targetHost, targetPort, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Issue the native connect (shared by the normal connect and the request-access path). A plain
|
||||
// desktop connect (no library launch) — the library launcher calls [connectToHost] with an id.
|
||||
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long =
|
||||
connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs)
|
||||
|
||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||
// the host presented (as an unpaired known host) so the next connect goes straight through and it
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Handshake budget for a normal / library-launch connect (not the long request-access park). */
|
||||
const val CONNECT_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* The one place [NativeBridge.nativeConnect] is assembled — shared by [ConnectScreen] and the library
|
||||
* launcher ([LibraryScreen]). Derives the mode / HDR / gamepad settings the host needs from
|
||||
* [settings]. [pinHex] is the pinned fingerprint (empty ⇒ TOFU). [launch] is a store-qualified library
|
||||
* id (`steam:<appid>` / `custom:<id>`) to boot straight into a game, or `null` for the desktop.
|
||||
* Returns the session handle, or `0` on failure. Call off the main thread.
|
||||
*/
|
||||
suspend fun connectToHost(
|
||||
context: Context,
|
||||
settings: Settings,
|
||||
identity: ClientIdentity,
|
||||
host: String,
|
||||
port: Int,
|
||||
pinHex: String,
|
||||
launch: String?,
|
||||
timeoutMs: Int = CONNECT_TIMEOUT_MS,
|
||||
): Long {
|
||||
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
|
||||
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val (w, h, hz) = settings.effectiveMode(context)
|
||||
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
return withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
host, port, w, h, hz,
|
||||
identity.certPem, identity.privateKeyPem, pinHex,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
|
||||
launch,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@@ -57,6 +59,7 @@ import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
import io.unom.punktfunk.kit.library.LibraryResult
|
||||
import io.unom.punktfunk.kit.library.mtlsHttpClient
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.IdentityStore
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
@@ -73,17 +76,27 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
private sealed class LibState {
|
||||
object Loading : LibState()
|
||||
data class Ready(val games: List<GameEntry>, val loader: ImageLoader) : LibState()
|
||||
// Carries the client identity so a launch can dial the host over the same pinned mTLS trust.
|
||||
data class Ready(val games: List<GameEntry>, val loader: ImageLoader, val identity: ClientIdentity) : LibState()
|
||||
data class Message(val text: String) : LibState() // unauthorized / empty / error
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) {
|
||||
fun LibraryScreen(
|
||||
host: KnownHost,
|
||||
settings: Settings,
|
||||
onLaunched: (Long) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val hazeState = remember { HazeState() }
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
||||
// A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice.
|
||||
var launching by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||
state = LibState.Loading
|
||||
@@ -101,7 +114,7 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true
|
||||
LibState.Message("No games found on this host.")
|
||||
} else {
|
||||
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
|
||||
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build())
|
||||
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build(), id)
|
||||
}
|
||||
is LibraryResult.Unauthorized -> LibState.Message(res.message)
|
||||
is LibraryResult.Error -> LibState.Message(res.message)
|
||||
@@ -118,11 +131,45 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true
|
||||
when (val s = state) {
|
||||
is LibState.Loading -> LoadingState()
|
||||
is LibState.Message -> MessageState(s.text)
|
||||
is LibState.Ready -> Coverflow(s.games, s.loader, navActive)
|
||||
is LibState.Ready -> Coverflow(s.games, s.loader, navActive && !launching) { game ->
|
||||
if (!launching) {
|
||||
launching = true
|
||||
scope.launch {
|
||||
// Dial the host over the same pinned mTLS trust, booting straight
|
||||
// into this title (the host resolves `launch` = its library id).
|
||||
val handle = connectToHost(
|
||||
context, settings, s.identity,
|
||||
host.address, host.port, host.fpHex, launch = game.id,
|
||||
)
|
||||
launching = false
|
||||
if (handle != 0L) onLaunched(handle)
|
||||
else Toast.makeText(
|
||||
context,
|
||||
"Launch failed — check the host and try again.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs.
|
||||
if (launching) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Floating legend at the shared spot — same landscape-aware inset as every other console
|
||||
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
||||
Box(
|
||||
@@ -130,7 +177,13 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState)
|
||||
GamepadHintBar(
|
||||
buildList {
|
||||
if (state is LibState.Ready) add(PadGlyph.hint('A', "Launch"))
|
||||
add(PadGlyph.hint('B', "Close", onClick = onBack))
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,7 +208,12 @@ private fun MessageState(text: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Boolean) {
|
||||
private fun Coverflow(
|
||||
games: List<GameEntry>,
|
||||
loader: ImageLoader,
|
||||
navActive: Boolean,
|
||||
onLaunch: (GameEntry) -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
|
||||
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
|
||||
@@ -167,16 +225,15 @@ private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Bo
|
||||
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||
val current = games.getOrNull(navTarget)
|
||||
|
||||
// Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a
|
||||
// coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes
|
||||
// via the screen's BackHandler.
|
||||
// Controller nav: the pad drives the coverflow. Left/right steps a coalesced target the pager
|
||||
// chases; A launches the centred title; B closes via the screen's BackHandler.
|
||||
GamepadNavEffect(
|
||||
active = navActive && games.isNotEmpty(),
|
||||
onMove = { dir ->
|
||||
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
|
||||
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
|
||||
},
|
||||
onActivate = { /* launch a title — browse-only for now */ },
|
||||
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
|
||||
)
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||
@@ -198,6 +255,11 @@ private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Bo
|
||||
.zIndex(-d) // centred cover on top, neighbours stacked behind
|
||||
.width(coverWidth)
|
||||
.height(coverHeight)
|
||||
// Touch: tap the centred cover to launch it; tap a neighbour to bring it centre.
|
||||
.clickable {
|
||||
if (page == pagerState.currentPage) onLaunch(games[page])
|
||||
else scope.launch { pagerState.animateScrollToPage(page) }
|
||||
}
|
||||
.graphicsLayer {
|
||||
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
|
||||
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
|
||||
|
||||
@@ -51,6 +51,9 @@ object NativeBridge {
|
||||
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
|
||||
preferredCodec: Int,
|
||||
timeoutMs: Int,
|
||||
/** Store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game,
|
||||
* or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */
|
||||
launch: String?,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
|
||||
@@ -33,7 +33,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs): Long`.
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`.
|
||||
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
|
||||
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
|
||||
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
|
||||
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
|
||||
@@ -63,6 +64,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio_channels: jint,
|
||||
preferred_codec: jint,
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -74,6 +76,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
.unwrap_or_default();
|
||||
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
|
||||
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
|
||||
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game;
|
||||
// null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
|
||||
let launch: Option<String> = env
|
||||
.get_string(&launch)
|
||||
.map(Into::into)
|
||||
.ok()
|
||||
.filter(|s: &String| !s.is_empty());
|
||||
|
||||
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
|
||||
None
|
||||
@@ -124,7 +133,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below.
|
||||
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
|
||||
preferred_codec.clamp(0, u8::MAX as jint) as u8,
|
||||
None, // launch: default app
|
||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||
|
||||
Reference in New Issue
Block a user