diff --git a/PLAN.md b/PLAN.md index 092b52b..f790c63 100644 --- a/PLAN.md +++ b/PLAN.md @@ -334,10 +334,16 @@ complete path out of everything spawning one created. ANSI), encoding `aiapp://enroll?host=…&port=…&token=…`. The CA stays embedded in the APK (`PinnedCert.kt` pattern), so the QR carries no trust material — photographing the terminal leaks only the token - (rotatable), never a way to weaken pinning. The app side needs no QR - library at all: it registers an intent filter for the `aiapp://enroll` - scheme, and the stock camera app hands the scanned URI straight to - `MainActivity` (2026-08-24). + (rotatable), never a way to weaken pinning. The app registers an intent + filter for the `aiapp://enroll` scheme as a fallback, for a camera app + that redirects a scanned URI straight to `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 is enough for high-entropy random input; buys that a leaked config doesn't leak the credential). No "show token again" — lost means rotate. diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts index e0697c2..abb9dc9 100644 --- a/app/androidApp/build.gradle.kts +++ b/app/androidApp/build.gradle.kts @@ -126,4 +126,5 @@ dependencies { implementation(libs.compose.material3) implementation(libs.compose.ui) implementation(libs.androidx.activity.compose) + implementation(libs.zxing.embedded) } diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml index 775dd36..6e0ac26 100644 --- a/app/androidApp/src/main/AndroidManifest.xml +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -27,8 +27,12 @@ + terminal. This intent filter is the fallback path for a + 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. --> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt index bcc3f06..0c0b4b1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.example.aiapp +import android.net.Uri import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -21,12 +23,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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 - * terminal QR (which lands in MainActivity and never shows this screen); - * these fields are the fallback for typing the same three values by hand. - * [onBack] is null on first run, when there is nothing to go back to. + * Server address and token. The normal path is the "Scan QR code" button + * below, which decodes the server's terminal QR itself; these fields are + * the fallback for typing the same three values by hand. [onBack] is null + * on first run, when there is nothing to go back to. */ @Composable fun SettingsScreen( @@ -42,6 +48,19 @@ fun SettingsScreen( var token by remember { mutableStateOf("") } var error by remember { mutableStateOf(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)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( @@ -55,13 +74,19 @@ fun SettingsScreen( } Spacer(Modifier.height(8.dp)) Text( - "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.", + "The easy way: run ai-server on the backend and scan the QR it prints. " + + "Or type the same values here.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) 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( value = host, onValueChange = { host = it }, diff --git a/app/gradle/libs.versions.toml b/app/gradle/libs.versions.toml index 9d37cf0..b344074 100644 --- a/app/gradle/libs.versions.toml +++ b/app/gradle/libs.versions.toml @@ -1,5 +1,6 @@ # 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] agp = "9.3.2" 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. compose-material3 = "1.9.0" androidx-activityCompose = "1.13.0" +zxing-embedded = "4.3.0" [libraries] 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, # which are deprecated as of CMP 1.11. compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }