Scan the enrollment QR in-app instead of relying on the camera app

Not every phone's camera app hands a scanned aiapp:// URI off to the app
reliably, which made enrollment an annoying multi-step process. The
Settings screen now scans the QR itself via zxing-android-embedded's
ScanContract (offline, no Play Services/ML Kit download) and feeds the
decoded URI through the existing parseEnrollmentUri. The aiapp://enroll
deep link stays as a fallback for cameras that do redirect.

Verified on the emulator: Scan QR code launches the scanner, prompts for
camera permission, shows a live preview, and backing out returns to
Settings cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Sonnet 5 committed 2026-08-25 16:25:14 -04:00
1 parent 99bcc341c1
commit da2c110429
5 files changed
+56 -13

No files matched your search

+10 -4
View File
@@ -334,10 +334,16 @@ complete path out of everything spawning one created.
ANSI), encoding `aiapp://enroll?host=…&port=…&token=…`. The CA stays ANSI), encoding `aiapp://enroll?host=…&port=…&token=…`. The CA stays
embedded in the APK (`PinnedCert.kt` pattern), so the QR carries no embedded in the APK (`PinnedCert.kt` pattern), so the QR carries no
trust material — photographing the terminal leaks only the token trust material — photographing the terminal leaks only the token
(rotatable), never a way to weaken pinning. The app side needs no QR (rotatable), never a way to weaken pinning. The app registers an intent
library at all: it registers an intent filter for the `aiapp://enroll` filter for the `aiapp://enroll` scheme as a fallback, for a camera app
scheme, and the stock camera app hands the scanned URI straight to that redirects a scanned URI straight to `MainActivity` (2026-08-24).
`MainActivity` (2026-08-24). That was meant to be the only path — "the app side needs no QR library
at all" — but reversed the same day: not every phone's stock camera
redirects a scanned URI to an app reliably, so the Settings screen also
scans in-app via `zxing-android-embedded`'s `ScanContract` (a ready-made
scanner Activity reached through the AndroidX Activity Result API,
fully offline, no Play Services/ML Kit model download) and feeds the
decoded URI to the same `parseEnrollmentUri` (2026-08-25).
- **Storage**: server keeps only the SHA-256 in `config.json` (plain hash - **Storage**: server keeps only the SHA-256 in `config.json` (plain hash
is enough for high-entropy random input; buys that a leaked config is enough for high-entropy random input; buys that a leaked config
doesn't leak the credential). No "show token again" — lost means rotate. doesn't leak the credential). No "show token again" — lost means rotate.
+1
View File
@@ -126,4 +126,5 @@ dependencies {
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.zxing.embedded)
} }
+6 -2
View File
@@ -27,8 +27,12 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Enrollment: the server prints its aiapp://enroll QR to the <!-- Enrollment: the server prints its aiapp://enroll QR to the
terminal; the phone's camera app opens the URI here, so no terminal. This intent filter is the fallback path for a
camera code or QR library is needed in this app. --> camera app that redirects a scanned aiapp:// URI here
directly; the Settings screen's own "Scan QR code" button
(zxing-android-embedded) is the primary path and needs no
filter, since it decodes the QR itself and hands the URI
to parseEnrollmentUri in-process. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@@ -1,5 +1,6 @@
package com.example.aiapp package com.example.aiapp
import android.net.Uri
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -21,12 +23,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.activity.compose.rememberLauncherForActivityResult
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanIntentResult
import com.journeyapps.barcodescanner.ScanOptions
/** /**
* Server address and token. The normal path is scanning the server's * Server address and token. The normal path is the "Scan QR code" button
* terminal QR (which lands in MainActivity and never shows this screen); * below, which decodes the server's terminal QR itself; these fields are
* these fields are the fallback for typing the same three values by hand. * the fallback for typing the same three values by hand. [onBack] is null
* [onBack] is null on first run, when there is nothing to go back to. * on first run, when there is nothing to go back to.
*/ */
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -42,6 +48,19 @@ fun SettingsScreen(
var token by remember { mutableStateOf("") } var token by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
// Null contents means the user backed out of the scanner -- not an
// error, so nothing to report.
val contents = result.contents ?: return@rememberLauncherForActivityResult
val settings = parseEnrollmentUri(Uri.parse(contents))
if (settings == null) {
error = "Not a valid enrollment code"
} else {
saveServerSettings(context, settings)
onSaved(settings)
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text( Text(
@@ -55,13 +74,19 @@ fun SettingsScreen(
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
"The easy way: run ai-server on the backend and scan the QR it prints " + "The easy way: run ai-server on the backend and scan the QR it prints. " +
"with the phone's camera. Or type the same values here.", "Or type the same values here.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
OutlinedButton(
onClick = { scanLauncher.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE)) },
modifier = Modifier.fillMaxWidth(),
) { Text("Scan QR code") }
Spacer(Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = host, value = host,
onValueChange = { host = it }, onValueChange = { host = it },
+8 -1
View File
@@ -1,5 +1,6 @@
# Latest stable versions as of 2026-08-24 (checked against Google Maven / # Latest stable versions as of 2026-08-24 (checked against Google Maven /
# Maven Central; prereleases deliberately skipped). # Maven Central; prereleases deliberately skipped). zxing-embedded checked
# 2026-08-25.
[versions] [versions]
agp = "9.3.2" agp = "9.3.2"
kotlin = "2.4.10" kotlin = "2.4.10"
@@ -7,9 +8,15 @@ compose-multiplatform = "1.11.1"
# material3 ships on its own release train, separate from the CMP version. # material3 ships on its own release train, separate from the CMP version.
compose-material3 = "1.9.0" compose-material3 = "1.9.0"
androidx-activityCompose = "1.13.0" androidx-activityCompose = "1.13.0"
zxing-embedded = "4.3.0"
[libraries] [libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
# In-app QR scanner: a ready-made scanning Activity (camera preview, runtime
# permission prompt, flashlight toggle) reached through the AndroidX Activity
# Result API (ScanContract, added in 4.3.0). Fully offline -- no Play
# Services / ML Kit model download involved.
zxing-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxing-embedded" }
# Declared directly rather than through the plugin's `compose.*` accessors, # Declared directly rather than through the plugin's `compose.*` accessors,
# which are deprecated as of CMP 1.11. # which are deprecated as of CMP 1.11.
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }