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..d62f197e 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()
@@ -208,7 +247,10 @@ fun ServerConnectScreen(
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp),
+ // Center the content vertically — the landing (logo + two buttons) is
+ // short and looks stranded at the top otherwise. Taller content (the
+ // manual form, saved servers) still scrolls from the top as normal.
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
// Circular badge logo
Image(
@@ -226,8 +268,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 +279,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 +519,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 +574,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 ArchipelagoEdit
+ 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 QREdit ServerSave ChangesCancel
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/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk
index bea6f918..20a05312 100644
Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ
diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue
index 80730a43..b059c14a 100644
--- a/neode-ui/src/components/CompanionIntroOverlay.vue
+++ b/neode-ui/src/components/CompanionIntroOverlay.vue
@@ -87,7 +87,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.