From 2b3a18f1897fcb42d62631cca0c77f6b3af4c00d Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 17 Jul 2026 01:41:35 +0100 Subject: [PATCH] =?UTF-8?q?feat(android):=20QR=20pairing=20=E2=80=94=20sca?= =?UTF-8?q?n,=20deep=20link,=20one-step=20auto-login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion side of docs/companion-pairing-qr.md (v1 contract): - Connect screen lands on Scan Node's QR / Enter Manually; NESMenu gains an add-server-by-QR icon button - QrScannerOverlay: CameraX + ZXing core (pinned, on-device, no ML Kit) - ServerQrParser: archipelago://pair?v=1&url=..[&pw=..]; unknown major v -> 'update the app'; upsert by origin so re-scans never duplicate - archipelago:// deep link registered (singleTask + onNewIntent) for the modal's 'Open in companion app' button - WebView auto-fills and submits Login.vue's password form when a stored password exists — demo QR is one step to a logged-in session - Web modal + handoff doc copy synced to the real label: Scan Node's QR - v0.4.13 (versionCode 17) Co-Authored-By: Claude Fable 5 --- Android/app/build.gradle.kts | 10 +- Android/app/src/main/AndroidManifest.xml | 12 + .../java/com/archipelago/app/MainActivity.kt | 21 +- .../archipelago/app/data/ServerPreferences.kt | 33 ++ .../archipelago/app/data/ServerQrParser.kt | 67 ++++ .../archipelago/app/ui/components/NESMenu.kt | 34 +- .../app/ui/components/QrScannerOverlay.kt | 298 ++++++++++++++++++ .../archipelago/app/ui/navigation/NavGraph.kt | 46 ++- .../app/ui/screens/RemoteInputScreen.kt | 17 + .../app/ui/screens/ServerConnectScreen.kt | 102 +++++- .../app/ui/screens/WebViewScreen.kt | 46 +++ Android/app/src/main/res/values/strings.xml | 9 + docs/companion-pairing-qr.md | 6 +- .../src/components/CompanionIntroOverlay.vue | 2 +- 14 files changed, 681 insertions(+), 22 deletions(-) create mode 100644 Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 2215d582..a4562f4c 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 16 - versionName = "0.4.12" + versionCode = 17 + versionName = "0.4.13" vectorDrawables { useSupportLibrary = true @@ -111,6 +111,12 @@ dependencies { // OkHttp for WebSocket (remote input) implementation("com.squareup.okhttp3:okhttp:4.12.0") + // CameraX + ZXing (Apache-2.0, on-device, no telemetry) for pairing-QR scanning + implementation("androidx.camera:camera-camera2:1.3.4") + implementation("androidx.camera:camera-lifecycle:1.3.4") + implementation("androidx.camera:camera-view:1.3.4") + implementation("com.google.zxing:core:3.5.3") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index 8759972a..9180dfb9 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + @@ -26,6 +30,14 @@ + + + + + + + diff --git a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt index e268d489..3e066864 100644 --- a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt +++ b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt @@ -1,22 +1,41 @@ package com.archipelago.app +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.archipelago.app.ui.navigation.AppNavHost import com.archipelago.app.ui.theme.ArchipelagoTheme +import kotlinx.coroutines.flow.MutableStateFlow class MainActivity : ComponentActivity() { + + // Pairing deep link (archipelago://pair?...) from the launch intent or a + // later one (launchMode=singleTask). Consumed by AppNavHost. + private val pendingPairUri = MutableStateFlow(null) + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) + pendingPairUri.value = intent?.dataString setContent { ArchipelagoTheme { - AppNavHost() + val pairUri by pendingPairUri.collectAsState() + AppNavHost( + pairUri = pairUri, + onPairUriConsumed = { pendingPairUri.value = null }, + ) } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + pendingPairUri.value = intent.dataString + } } diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt index 255f0b05..6c3e31e9 100644 --- a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt @@ -143,6 +143,39 @@ class ServerPreferences(private val context: Context) { } } + /** + * Add a server, or update the entry with the same connection identity + * (address/port/scheme) — used by QR pairing so re-scanning a node never + * duplicates it. A blank incoming password/name keeps the stored value + * (a real node's QR never carries the password). Returns the merged entry. + */ + suspend fun upsertServer(server: ServerEntry): ServerEntry { + var merged = server + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull { + it.address == server.address && + it.port == server.port && + it.useHttps == server.useHttps + } + if (existing != null) { + merged = server.copy( + password = server.password.ifBlank { existing.password }, + name = server.name.ifBlank { existing.name }, + ) + } + val filtered = current.filterNot { raw -> + val e = ServerEntry.deserialize(raw) + e != null && + e.address == server.address && + e.port == server.port && + e.useHttps == server.useHttps + }.toSet() + prefs[savedServersKey] = filtered + merged.serialize() + } + return merged + } + suspend fun removeSavedServer(server: ServerEntry) { context.dataStore.edit { prefs -> val current = prefs[savedServersKey] ?: emptySet() diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt new file mode 100644 index 00000000..1ce7f5d6 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt @@ -0,0 +1,67 @@ +package com.archipelago.app.data + +import android.net.Uri + +/** + * Result of parsing a pairing QR / deep link. + * + * UnsupportedVersion means the payload is structurally a pairing URI but its + * major version is newer than this app understands — the UI should tell the + * user to update the app rather than call the code invalid. + */ +sealed class PairResult { + data class Success(val server: ServerEntry) : PairResult() + object UnsupportedVersion : PairResult() + object Invalid : PairResult() +} + +/** + * Parser for the companion pairing QR / OS deep link. Contract: + * docs/companion-pairing-qr.md (repo root). + * + * archipelago://pair?v=1&url=[&pw=] + * + * - `url` is a full origin including scheme (http for LAN/mDNS nodes, https + * for the public demo); trailing slashes are normalized away. + * - `pw` is only ever present for the public demo. + * - Unknown extra query params are tolerated (forward compat under v=1 — + * `name` is already honored if present). + */ +object ServerQrParser { + private const val SUPPORTED_MAJOR = 1 + + fun parse(raw: String): PairResult { + val uri = try { + Uri.parse(raw.trim()) + } catch (_: Exception) { + return PairResult.Invalid + } + if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return PairResult.Invalid + if (uri.isOpaque || !"pair".equals(uri.host, ignoreCase = true)) return PairResult.Invalid + + val major = uri.getQueryParameter("v") + ?.trim() + ?.takeWhile { it.isDigit() } + ?.toIntOrNull() + ?: return PairResult.Invalid + if (major != SUPPORTED_MAJOR) return PairResult.UnsupportedVersion + + val serverUrl = uri.getQueryParameter("url")?.trim()?.trimEnd('/') + if (serverUrl.isNullOrBlank()) return PairResult.Invalid + val server = Uri.parse(serverUrl) + val scheme = server.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return PairResult.Invalid + val host = server.host + if (host.isNullOrBlank()) return PairResult.Invalid + + return PairResult.Success( + ServerEntry( + address = host, + useHttps = scheme == "https", + port = if (server.port != -1) server.port.toString() else "", + password = uri.getQueryParameter("pw") ?: "", + name = uri.getQueryParameter("name") ?: "", + ) + ) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt index 2eb41d43..929573a3 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text @@ -36,6 +39,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -44,6 +48,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.archipelago.app.R import com.archipelago.app.data.ServerEntry import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.ControllerStyle @@ -75,6 +80,7 @@ fun NESMenu( onDismiss: () -> Unit, onSelectServer: (ServerEntry) -> Unit, onAddServer: (ServerEntry) -> Unit, + onScanQr: (() -> Unit)? = null, onEditServer: (ServerEntry, ServerEntry) -> Unit, onRemoveServer: (ServerEntry) -> Unit, onToggleMode: () -> Unit, @@ -88,7 +94,7 @@ fun NESMenu( contentAlignment = Alignment.Center, ) { AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) { - MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView) + MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView) } } } @@ -103,6 +109,7 @@ private fun MenuPanel( onDismiss: () -> Unit, onSelectServer: (ServerEntry) -> Unit, onAddServer: (ServerEntry) -> Unit, + onScanQr: (() -> Unit)?, onEditServer: (ServerEntry, ServerEntry) -> Unit, onRemoveServer: (ServerEntry) -> Unit, onToggleMode: () -> Unit, @@ -235,7 +242,30 @@ private fun MenuPanel( } } } else { - MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true }) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.weight(1f)) { + MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true }) + } + if (onScanQr != null) { + // Add server by scanning the node's pairing QR + Box( + Modifier + .size(ROW_H) + .clip(RoundedCornerShape(ROW_R)) + .background(RowBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .clickable { onScanQr() }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.QrCodeScanner, + contentDescription = stringResource(R.string.add_server_qr), + tint = BitcoinOrange, + modifier = Modifier.size(24.dp), + ) + } + } + } } Spacer(Modifier.height(2.dp)) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt new file mode 100644 index 00000000..2c9eaacc --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt @@ -0,0 +1,298 @@ +package com.archipelago.app.ui.components + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.archipelago.app.R +import com.archipelago.app.data.PairResult +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.data.ServerQrParser +import com.archipelago.app.ui.screens.GlassButton +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer +import kotlinx.coroutines.delay +import java.util.concurrent.Executors + +/** + * Full-screen camera overlay that scans the node pairing QR + * (docs/companion-pairing-qr.md) and reports the decoded server entry. + * Handles the camera permission itself; foreign/invalid codes show a hint + * and scanning continues. + */ +@Composable +fun QrScannerOverlay( + visible: Boolean, + onDismiss: () -> Unit, + onServerScanned: (ServerEntry) -> Unit, +) { + val context = LocalContext.current + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + var hintRes by remember { mutableStateOf(null) } + var handled by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> hasPermission = granted } + + LaunchedEffect(visible) { + if (visible) { + handled = false + hintRes = null + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + hasPermission = granted + if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + // Foreign-code hint fades after a moment so scanning feels live again. + LaunchedEffect(hintRes) { + if (hintRes != null) { + delay(2500) + hintRes = null + } + } + + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + BackHandler { onDismiss() } + Box( + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + if (hasPermission) { + CameraQrPreview( + onDecoded = { text -> + if (!handled) { + when (val result = ServerQrParser.parse(text)) { + is PairResult.Success -> { + handled = true + onServerScanned(result.server) + } + is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr + is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr + } + } + }, + ) + // Aim frame + Box( + Modifier + .align(Alignment.Center) + .size(260.dp) + .border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)), + ) + } else { + Column( + Modifier + .align(Alignment.Center) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.camera_permission_needed), + color = TextPrimary, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + GlassButton( + text = stringResource(R.string.grant_camera_access), + onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + } + } + + // Top bar: title + close + Row( + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.scan_node_qr), + color = TextPrimary, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 12.dp), + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary) + } + } + + // Bottom hints + Column( + Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 32.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + hintRes?.let { res -> + Text( + text = stringResource(res), + color = BitcoinOrange, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + } + if (hasPermission) { + Text( + text = stringResource(R.string.scan_qr_hint), + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Composable +private fun CameraQrPreview(onDecoded: (String) -> Unit) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnDecoded by rememberUpdatedState(onDecoded) + val previewView = remember { + PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER } + } + + DisposableEffect(Unit) { + val analysisExecutor = Executors.newSingleThreadExecutor() + val mainExecutor = ContextCompat.getMainExecutor(context) + val providerFuture = ProcessCameraProvider.getInstance(context) + var provider: ProcessCameraProvider? = null + + providerFuture.addListener({ + val p = providerFuture.get() + provider = p + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer( + analysisExecutor, + QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } }, + ) + } + try { + p.unbindAll() + p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis) + } catch (_: Exception) { + // Camera unavailable — the user can dismiss and enter details manually. + } + }, mainExecutor) + + onDispose { + provider?.unbindAll() + analysisExecutor.shutdown() + } + } + + AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) +} + +/** ZXing-based QR decoder over the camera's Y (luminance) plane. */ +private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer { + private val reader = MultiFormatReader().apply { + setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) + } + + override fun analyze(image: ImageProxy) { + try { + val plane = image.planes[0] + val buffer = plane.buffer + // Copy into a rowStride-wide array; the last row of the plane buffer + // may be short of the full stride, so the tail stays zero-padded. + val data = ByteArray(plane.rowStride * image.height) + buffer.get(data, 0, minOf(buffer.remaining(), data.size)) + val source = PlanarYUVLuminanceSource( + data, plane.rowStride, image.height, + 0, 0, image.width, image.height, + false, + ) + val result = reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))) + onDecoded(result.text) + } catch (_: NotFoundException) { + // No QR in this frame — keep scanning. + } catch (_: Exception) { + // Malformed frame; skip it. + } finally { + reader.reset() + image.close() + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt index 4a58bc4c..a8880612 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt @@ -1,15 +1,21 @@ package com.archipelago.app.ui.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.archipelago.app.data.PairResult +import com.archipelago.app.data.ServerEntry import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.data.ServerQrParser import com.archipelago.app.ui.screens.IntroScreen import com.archipelago.app.ui.screens.RemoteInputScreen import com.archipelago.app.ui.screens.ServerConnectScreen @@ -24,7 +30,10 @@ object Routes { } @Composable -fun AppNavHost() { +fun AppNavHost( + pairUri: String? = null, + onPairUriConsumed: () -> Unit = {}, +) { val context = LocalContext.current val prefs = remember { ServerPreferences(context) } val navController = rememberNavController() @@ -33,8 +42,41 @@ fun AppNavHost() { val introSeen by prefs.introSeen.collectAsState(initial = null) val activeServer by prefs.activeServer.collectAsState(initial = null) + // Pairing entry from a deep link that carried no password — prefills the + // connect form so the user lands on the password prompt for that server. + var pairPrefill by remember { mutableStateOf(null) } + if (introSeen == null) return + // Declared after the introSeen gate so it can't fire before the NavHost + // below has set the nav graph; pairUri stays pending until consumed here. + LaunchedEffect(pairUri) { + val raw = pairUri ?: return@LaunchedEffect + onPairUriConsumed() + when (val result = ServerQrParser.parse(raw)) { + is PairResult.Success -> { + // Pairing implies the app is installed and in use — skip the intro. + prefs.markIntroSeen() + val merged = prefs.upsertServer(result.server) + if (merged.password.isNotBlank()) { + // Demo flow: password came with the link — connect in one step. + prefs.setActiveServer(merged) + navController.navigate(Routes.WEB_VIEW) { + popUpTo(0) { inclusive = true } + } + } else { + pairPrefill = merged + navController.navigate(Routes.SERVER_CONNECT) { + popUpTo(0) { inclusive = true } + } + } + } + else -> { + // Invalid or too-new pairing link — ignore; normal startup continues. + } + } + } + val startDestination = when { introSeen == false -> Routes.INTRO activeServer != null -> Routes.WEB_VIEW @@ -65,6 +107,7 @@ fun AppNavHost() { popUpTo(Routes.SERVER_CONNECT) { inclusive = true } } }, + initialServer = pairPrefill, ) } @@ -81,6 +124,7 @@ fun AppNavHost() { } else { WebViewScreen( serverUrl = server.toUrl(), + serverPassword = server.password, onDisconnect = { scope.launch { prefs.clearActiveServer() diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt index ad35d89e..08ad93df 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -43,6 +43,7 @@ import com.archipelago.app.ui.components.NESController import com.archipelago.app.ui.components.NESKeyboard import com.archipelago.app.ui.components.NESMenu import com.archipelago.app.ui.components.NESPortraitController +import com.archipelago.app.ui.components.QrScannerOverlay import com.archipelago.app.ui.components.Trackpad import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.ControllerStyle @@ -63,6 +64,7 @@ fun RemoteInputScreen(onBack: () -> Unit) { var isGamepadMode by remember { mutableStateOf(true) } var showModal by remember { mutableStateOf(false) } + var showQrScanner by remember { mutableStateOf(false) } var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) } var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2 @@ -216,6 +218,7 @@ fun RemoteInputScreen(onBack: () -> Unit) { onAddServer = { server -> scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) } }, + onScanQr = { showQrScanner = true }, onEditServer = { original, updated -> scope.launch { prefs.updateSavedServer(original, updated) @@ -247,5 +250,19 @@ fun RemoteInputScreen(onBack: () -> Unit) { }, onBackToWebView = { showModal = false; onBack() }, ) + + // Pairing-QR scan launched from the menu's Add Server row. The menu stays + // open behind the scanner so the new entry appears as soon as it closes. + QrScannerOverlay( + visible = showQrScanner, + onDismiss = { showQrScanner = false }, + onServerScanned = { server -> + showQrScanner = false + scope.launch { + val merged = prefs.upsertServer(server) + if (activeServer == null) prefs.setActiveServer(merged) + } + }, + ) } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt index 2edaafe5..6029b226 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -72,6 +73,7 @@ import androidx.compose.ui.unit.sp import com.archipelago.app.R import com.archipelago.app.data.ServerEntry import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.ui.components.QrScannerOverlay import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.ErrorRed import com.archipelago.app.ui.theme.SurfaceBlack @@ -93,6 +95,9 @@ import javax.net.ssl.X509TrustManager fun ServerConnectScreen( onConnected: (String) -> Unit, onRemoteInput: () -> Unit = {}, + // Prefill from a pairing deep link (archipelago://pair) that carried no + // password — opens the manual form on the password prompt for that server. + initialServer: ServerEntry? = null, ) { val context = LocalContext.current val prefs = remember { ServerPreferences(context) } @@ -109,6 +114,9 @@ fun ServerConnectScreen( var errorMessage by remember { mutableStateOf(null) } // The saved server currently being edited, or null when adding/connecting. var editingServer by remember { mutableStateOf(null) } + // Landing shows Scan/Manual choice; the form appears in manual mode or while editing. + var manualMode by remember { mutableStateOf(false) } + var showScanner by remember { mutableStateOf(false) } val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) @@ -173,6 +181,37 @@ fun ServerConnectScreen( } } + fun prefill(server: ServerEntry) { + name = server.name + address = server.address + port = server.port + password = server.password + useHttps = server.useHttps + } + + // Pairing QR scanned: dedupe against saved servers, then either auto-connect + // (payload carried a password — the demo flow) or land on the password + // prompt with everything else filled in (real nodes never embed one). + fun onQrScanned(scanned: ServerEntry) { + showScanner = false + scope.launch { + val merged = prefs.upsertServer(scanned) + prefill(merged) + if (merged.password.isNotBlank()) { + connect(merged) + } else { + manualMode = true + } + } + } + + LaunchedEffect(initialServer) { + if (initialServer != null) { + prefill(prefs.upsertServer(initialServer)) + manualMode = true + } + } + Box( modifier = Modifier .fillMaxSize() @@ -226,8 +265,10 @@ fun ServerConnectScreen( textAlign = TextAlign.Center, ) + val showForm = manualMode || editingServer != null + Text( - text = stringResource(R.string.server_address_hint), + text = if (showForm) stringResource(R.string.server_address_hint) else stringResource(R.string.connect_landing_hint), style = MaterialTheme.typography.bodyMedium, color = TextMuted, textAlign = TextAlign.Center, @@ -235,8 +276,25 @@ fun ServerConnectScreen( Spacer(modifier = Modifier.height(4.dp)) + if (!showForm) { + // Landing: scan the pairing QR, or fall back to manual entry + GlassButton( + text = stringResource(R.string.scan_node_qr), + onClick = { showScanner = true }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + GlassButton( + text = stringResource(R.string.enter_manually), + onClick = { + errorMessage = null + manualMode = true + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + } + // Glass card with form - Box( + if (showForm) Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) @@ -458,16 +516,30 @@ fun ServerConnectScreen( modifier = Modifier.weight(1f).height(56.dp), ) } - } else { - // Connect button — glass style - GlassButton( - text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect), - onClick = { - keyboard?.hide() - connect(ServerEntry(address, useHttps, port, password, name)) - }, - modifier = Modifier.fillMaxWidth().height(56.dp), - ) + } else if (manualMode) { + // Back to the Scan/Manual landing + Connect + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GlassButton( + text = stringResource(R.string.back), + onClick = { + keyboard?.hide() + manualMode = false + clearForm() + }, + modifier = Modifier.weight(1f).height(56.dp), + ) + GlassButton( + text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect), + onClick = { + keyboard?.hide() + connect(ServerEntry(address, useHttps, port, password, name)) + }, + modifier = Modifier.weight(2f).height(56.dp), + ) + } } if (isConnecting) { @@ -499,6 +571,12 @@ fun ServerConnectScreen( } } } + + QrScannerOverlay( + visible = showScanner, + onDismiss = { showScanner = false }, + onServerScanned = { onQrScanned(it) }, + ) } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index 23cbc5a3..3bd16fff 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -136,6 +136,10 @@ fun WebViewScreen( serverUrl: String, onDisconnect: () -> Unit, onRemoteInput: () -> Unit = {}, + // Stored password for this server (from QR pairing or manual entry). When + // non-blank, the login page is auto-filled and submitted — the one-step + // demo flow from docs/companion-pairing-qr.md. + serverPassword: String = "", ) { var isLoading by remember { mutableStateOf(true) } var loadProgress by remember { mutableIntStateOf(0) } @@ -310,6 +314,12 @@ fun WebViewScreen( """.trimIndent(), null, ) + + // Auto-login with the stored password (QR pairing / + // saved server) — only on our own server's pages. + if (serverPassword.isNotBlank() && url != null && url.startsWith(serverUrl)) { + view.evaluateJavascript(buildAutoLoginScript(serverPassword), null) + } } override fun onReceivedError( @@ -700,3 +710,39 @@ private fun InAppBrowser( } } } + +/** + * JS that fills the web UI login form (Login.vue's #login-password) with the + * stored password and submits it once the form is interactive — the one-step + * pairing flow. No-ops when the login step never appears (already + * authenticated, first-boot setup, TOTP). At most two attempts per page load, + * then it stops for good so a wrong stored password can't spam the node. + */ +private fun buildAutoLoginScript(password: String): String { + val quoted = org.json.JSONObject.quote(password) + return """ + (function () { + if (window.__archyAutoLogin) return; + window.__archyAutoLogin = true; + var pw = $quoted; + var attempts = 0; + var started = Date.now(); + var timer = setInterval(function () { + if (Date.now() - started > 45000) { clearInterval(timer); return; } + var el = document.getElementById('login-password'); + if (!el) { + // Field gone after we submitted = success or step change - stop. + if (attempts > 0) clearInterval(timer); + return; + } + if (el.disabled) return; // form waits on serverReady + if (attempts >= 2) { clearInterval(timer); return; } + attempts++; + var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(el, pw); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }, 1500); + })(); + """.trimIndent() +} diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index db51acea..c422e135 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -29,6 +29,15 @@ Server Name (optional) My Archipelago Edit + Scan Node\'s QR + Enter Manually + Scan the pairing QR from your node\'s Companion popup, or enter the address manually + Point the camera at the pairing QR shown in the Companion popup + Camera access is needed to scan the pairing QR. You can also enter the server details manually. + Grant Camera Access + Not an Archipelago pairing code + This pairing code needs a newer app version — please update the companion app + Add server by QR Edit Server Save Changes Cancel diff --git a/docs/companion-pairing-qr.md b/docs/companion-pairing-qr.md index 3f6b7af9..e596ff0c 100644 --- a/docs/companion-pairing-qr.md +++ b/docs/companion-pairing-qr.md @@ -42,9 +42,9 @@ Examples the web UI actually emits: ## Companion app requirements -1. **Scan entry point:** a "Scan node code" action (screen-2 copy in the web UI - says: *"In the companion app, choose 'Scan node code' and point your phone - here"* — keep that wording or tell me the real label so I update the modal). +1. **Scan entry point:** the app's action is labeled **"Scan Node's QR"** + (implemented 2026-07-17; the modal copy in CompanionIntroOverlay.vue was + updated to match). 2. **Parse the URI** (from camera scan AND from an OS deep-link intent — register the `archipelago://` scheme so the "Open in companion app" button on phones works). diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue index 5252b090..ca2bbb75 100644 --- a/neode-ui/src/components/CompanionIntroOverlay.vue +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -86,7 +86,7 @@

Connect your app

- In the companion app, choose “Scan node code” and point your phone here — the server details fill in automatically. + In the companion app, choose “Scan Node's QR” and point your phone here — the server details fill in automatically.