feat(android): companion QR pairing — scan, deep link, auto-login (v0.4.13) #92
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 16
|
versionCode = 17
|
||||||
versionName = "0.4.12"
|
versionName = "0.4.13"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@ -111,6 +111,12 @@ dependencies {
|
|||||||
// OkHttp for WebSocket (remote input)
|
// OkHttp for WebSocket (remote input)
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
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-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ArchipelagoApp"
|
android:name=".ArchipelagoApp"
|
||||||
@ -19,6 +22,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
android:theme="@style/Theme.Archipelago.Splash"
|
android:theme="@style/Theme.Archipelago.Splash"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
|
||||||
@ -26,6 +30,14 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Pairing deep link from the web UI's Companion popup:
|
||||||
|
archipelago://pair?v=1&url=...[&pw=...] (docs/companion-pairing-qr.md) -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="archipelago" android:host="pair" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,41 @@
|
|||||||
package com.archipelago.app
|
package com.archipelago.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.archipelago.app.ui.navigation.AppNavHost
|
import com.archipelago.app.ui.navigation.AppNavHost
|
||||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
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<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
pendingPairUri.value = intent?.dataString
|
||||||
setContent {
|
setContent {
|
||||||
ArchipelagoTheme {
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
suspend fun removeSavedServer(server: ServerEntry) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = prefs[savedServersKey] ?: emptySet()
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
|
|||||||
@ -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=<percent-encoded origin>[&pw=<password>]
|
||||||
|
*
|
||||||
|
* - `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") ?: "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.widthIn
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -36,6 +39,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.ControllerStyle
|
import com.archipelago.app.ui.theme.ControllerStyle
|
||||||
@ -75,6 +80,7 @@ fun NESMenu(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectServer: (ServerEntry) -> Unit,
|
onSelectServer: (ServerEntry) -> Unit,
|
||||||
onAddServer: (ServerEntry) -> Unit,
|
onAddServer: (ServerEntry) -> Unit,
|
||||||
|
onScanQr: (() -> Unit)? = null,
|
||||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||||
onRemoveServer: (ServerEntry) -> Unit,
|
onRemoveServer: (ServerEntry) -> Unit,
|
||||||
onToggleMode: () -> Unit,
|
onToggleMode: () -> Unit,
|
||||||
@ -88,7 +94,7 @@ fun NESMenu(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
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,
|
onDismiss: () -> Unit,
|
||||||
onSelectServer: (ServerEntry) -> Unit,
|
onSelectServer: (ServerEntry) -> Unit,
|
||||||
onAddServer: (ServerEntry) -> Unit,
|
onAddServer: (ServerEntry) -> Unit,
|
||||||
|
onScanQr: (() -> Unit)?,
|
||||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||||
onRemoveServer: (ServerEntry) -> Unit,
|
onRemoveServer: (ServerEntry) -> Unit,
|
||||||
onToggleMode: () -> Unit,
|
onToggleMode: () -> Unit,
|
||||||
@ -235,7 +242,30 @@ private fun MenuPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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))
|
Spacer(Modifier.height(2.dp))
|
||||||
|
|||||||
@ -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<Int?>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,21 @@
|
|||||||
package com.archipelago.app.ui.navigation
|
package com.archipelago.app.ui.navigation
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.ServerPreferences
|
||||||
|
import com.archipelago.app.data.ServerQrParser
|
||||||
import com.archipelago.app.ui.screens.IntroScreen
|
import com.archipelago.app.ui.screens.IntroScreen
|
||||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||||
@ -24,7 +30,10 @@ object Routes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost() {
|
fun AppNavHost(
|
||||||
|
pairUri: String? = null,
|
||||||
|
onPairUriConsumed: () -> Unit = {},
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = remember { ServerPreferences(context) }
|
val prefs = remember { ServerPreferences(context) }
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
@ -33,8 +42,41 @@ fun AppNavHost() {
|
|||||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
||||||
val activeServer by prefs.activeServer.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<ServerEntry?>(null) }
|
||||||
|
|
||||||
if (introSeen == null) return
|
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 {
|
val startDestination = when {
|
||||||
introSeen == false -> Routes.INTRO
|
introSeen == false -> Routes.INTRO
|
||||||
activeServer != null -> Routes.WEB_VIEW
|
activeServer != null -> Routes.WEB_VIEW
|
||||||
@ -65,6 +107,7 @@ fun AppNavHost() {
|
|||||||
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
|
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
initialServer = pairPrefill,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +124,7 @@ fun AppNavHost() {
|
|||||||
} else {
|
} else {
|
||||||
WebViewScreen(
|
WebViewScreen(
|
||||||
serverUrl = server.toUrl(),
|
serverUrl = server.toUrl(),
|
||||||
|
serverPassword = server.password,
|
||||||
onDisconnect = {
|
onDisconnect = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
prefs.clearActiveServer()
|
prefs.clearActiveServer()
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import com.archipelago.app.ui.components.NESController
|
|||||||
import com.archipelago.app.ui.components.NESKeyboard
|
import com.archipelago.app.ui.components.NESKeyboard
|
||||||
import com.archipelago.app.ui.components.NESMenu
|
import com.archipelago.app.ui.components.NESMenu
|
||||||
import com.archipelago.app.ui.components.NESPortraitController
|
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.components.Trackpad
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.ControllerStyle
|
import com.archipelago.app.ui.theme.ControllerStyle
|
||||||
@ -63,6 +64,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
|
|
||||||
var isGamepadMode by remember { mutableStateOf(true) }
|
var isGamepadMode by remember { mutableStateOf(true) }
|
||||||
var showModal by remember { mutableStateOf(false) }
|
var showModal by remember { mutableStateOf(false) }
|
||||||
|
var showQrScanner by remember { mutableStateOf(false) }
|
||||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||||
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
||||||
|
|
||||||
@ -216,6 +218,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
onAddServer = { server ->
|
onAddServer = { server ->
|
||||||
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
||||||
},
|
},
|
||||||
|
onScanQr = { showQrScanner = true },
|
||||||
onEditServer = { original, updated ->
|
onEditServer = { original, updated ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
prefs.updateSavedServer(original, updated)
|
prefs.updateSavedServer(original, updated)
|
||||||
@ -247,5 +250,19 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
},
|
},
|
||||||
onBackToWebView = { showModal = false; onBack() },
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import androidx.compose.material3.Switch
|
|||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -72,6 +73,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.data.ServerPreferences
|
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.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.ErrorRed
|
import com.archipelago.app.ui.theme.ErrorRed
|
||||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||||
@ -93,6 +95,9 @@ import javax.net.ssl.X509TrustManager
|
|||||||
fun ServerConnectScreen(
|
fun ServerConnectScreen(
|
||||||
onConnected: (String) -> Unit,
|
onConnected: (String) -> Unit,
|
||||||
onRemoteInput: () -> 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 context = LocalContext.current
|
||||||
val prefs = remember { ServerPreferences(context) }
|
val prefs = remember { ServerPreferences(context) }
|
||||||
@ -109,6 +114,9 @@ fun ServerConnectScreen(
|
|||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
// The saved server currently being edited, or null when adding/connecting.
|
// The saved server currently being edited, or null when adding/connecting.
|
||||||
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
|
var editingServer by remember { mutableStateOf<ServerEntry?>(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())
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -208,7 +247,10 @@ fun ServerConnectScreen(
|
|||||||
.padding(horizontal = 24.dp)
|
.padding(horizontal = 24.dp)
|
||||||
.padding(top = 48.dp, bottom = 32.dp),
|
.padding(top = 48.dp, bottom = 32.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
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
|
// Circular badge logo
|
||||||
Image(
|
Image(
|
||||||
@ -226,8 +268,10 @@ fun ServerConnectScreen(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val showForm = manualMode || editingServer != null
|
||||||
|
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = TextMuted,
|
color = TextMuted,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@ -235,8 +279,25 @@ fun ServerConnectScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
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
|
// Glass card with form
|
||||||
Box(
|
if (showForm) Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
@ -458,16 +519,30 @@ fun ServerConnectScreen(
|
|||||||
modifier = Modifier.weight(1f).height(56.dp),
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else if (manualMode) {
|
||||||
// Connect button — glass style
|
// Back to the Scan/Manual landing + Connect
|
||||||
GlassButton(
|
Row(
|
||||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = {
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
keyboard?.hide()
|
) {
|
||||||
connect(ServerEntry(address, useHttps, port, password, name))
|
GlassButton(
|
||||||
},
|
text = stringResource(R.string.back),
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
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) {
|
if (isConnecting) {
|
||||||
@ -499,6 +574,12 @@ fun ServerConnectScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QrScannerOverlay(
|
||||||
|
visible = showScanner,
|
||||||
|
onDismiss = { showScanner = false },
|
||||||
|
onServerScanned = { onQrScanned(it) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -136,6 +136,10 @@ fun WebViewScreen(
|
|||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
onRemoteInput: () -> 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 isLoading by remember { mutableStateOf(true) }
|
||||||
var loadProgress by remember { mutableIntStateOf(0) }
|
var loadProgress by remember { mutableIntStateOf(0) }
|
||||||
@ -310,6 +314,12 @@ fun WebViewScreen(
|
|||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
null,
|
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(
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@ -29,6 +29,15 @@
|
|||||||
<string name="server_name_label">Server Name (optional)</string>
|
<string name="server_name_label">Server Name (optional)</string>
|
||||||
<string name="server_name_placeholder">My Archipelago</string>
|
<string name="server_name_placeholder">My Archipelago</string>
|
||||||
<string name="edit_server">Edit</string>
|
<string name="edit_server">Edit</string>
|
||||||
|
<string name="scan_node_qr">Scan Node\'s QR</string>
|
||||||
|
<string name="enter_manually">Enter Manually</string>
|
||||||
|
<string name="connect_landing_hint">Scan the pairing QR from your node\'s Companion popup, or enter the address manually</string>
|
||||||
|
<string name="scan_qr_hint">Point the camera at the pairing QR shown in the Companion popup</string>
|
||||||
|
<string name="camera_permission_needed">Camera access is needed to scan the pairing QR. You can also enter the server details manually.</string>
|
||||||
|
<string name="grant_camera_access">Grant Camera Access</string>
|
||||||
|
<string name="invalid_pairing_qr">Not an Archipelago pairing code</string>
|
||||||
|
<string name="update_app_for_qr">This pairing code needs a newer app version — please update the companion app</string>
|
||||||
|
<string name="add_server_qr">Add server by QR</string>
|
||||||
<string name="edit_server_title">Edit Server</string>
|
<string name="edit_server_title">Edit Server</string>
|
||||||
<string name="save_changes">Save Changes</string>
|
<string name="save_changes">Save Changes</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
|
|||||||
@ -42,9 +42,9 @@ Examples the web UI actually emits:
|
|||||||
|
|
||||||
## Companion app requirements
|
## Companion app requirements
|
||||||
|
|
||||||
1. **Scan entry point:** a "Scan node code" action (screen-2 copy in the web UI
|
1. **Scan entry point:** the app's action is labeled **"Scan Node's QR"**
|
||||||
says: *"In the companion app, choose 'Scan node code' and point your phone
|
(implemented 2026-07-17; the modal copy in CompanionIntroOverlay.vue was
|
||||||
here"* — keep that wording or tell me the real label so I update the modal).
|
updated to match).
|
||||||
2. **Parse the URI** (from camera scan AND from an OS deep-link intent —
|
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
|
register the `archipelago://` scheme so the "Open in companion app" button on
|
||||||
phones works).
|
phones works).
|
||||||
|
|||||||
Binary file not shown.
@ -86,7 +86,7 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h3 class="text-lg font-semibold text-white mb-1">Connect your app</h3>
|
<h3 class="text-lg font-semibold text-white mb-1">Connect your app</h3>
|
||||||
<p class="text-sm text-white/60 leading-relaxed">
|
<p class="text-sm text-white/60 leading-relaxed">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user