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:
1 parent
99bcc341c1
commit
da2c110429
5 files changed
+56
-13
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in new issue
Block a user