From f53a1ca214b856711179e563b794dcdd201bd206 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Tue, 25 Aug 2026 21:11:27 -0400 Subject: [PATCH] Hold the camera permission before the scanner opens The library's capture activity asks for the camera itself, and opens the camera without waiting for the answer. The first scan on a fresh install therefore comes up as a live preview with "Sorry, the Android camera encountered a problem. You may need to restart the device." over it, and works on the second try. Nothing is wrong with the camera, so nothing should say there is. Asking before launching it means the activity always starts with the permission already held. A denial now says what to do instead of leaving a dialog blaming the device. The scan options move to one function while there are two callers -- the button when the permission is already held, and the permission result when it has just been granted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL --- .../com/example/aiapp/SettingsScreen.kt | 67 ++++++++++++++----- 1 file changed, 50 insertions(+), 17 deletions(-) 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 e99820c..88ef4c3 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,7 @@ package com.example.aiapp +import android.Manifest +import android.content.pm.PackageManager import android.net.Uri import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -24,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import com.google.zxing.client.android.Intents import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanIntentResult @@ -62,6 +65,17 @@ fun SettingsScreen( } } + val requestCamera = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + scanLauncher.launch(enrollmentScanOptions()) + } else { + error = "Scanning needs the camera. Grant it in the system settings, " + + "or type the host, port and token in below." + } + } + Column(Modifier.fillMaxSize().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( @@ -84,23 +98,21 @@ fun SettingsScreen( OutlinedButton( onClick = { - scanLauncher.launch( - ScanOptions() - .setDesiredBarcodeFormats(ScanOptions.QR_CODE) - .setCaptureActivity(EnrollmentScanActivity::class.java) - // Follow the phone, not the library's landscape pin. - .setOrientationLocked(false) - // Decode both polarities: ZXing otherwise looks only for a - // dark code on a light ground, and ai-server's QR is block - // characters in the terminal's foreground colour -- so on a - // dark-themed terminal it comes out as a photographic - // negative that the scanner silently never matches. Which - // way round it renders is the terminal's business, not - // something this app should depend on. MIXED_SCAN alternates - // normal and inverted frames, so it costs half the frame - // rate at each polarity and nothing else. - .addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN) - ) + // Hold the camera permission before the scanner starts. + // Letting its activity ask on our behalf is what the + // library does by default, and it opens the camera without + // waiting for the answer: the first-ever scan comes up as + // a live preview with "Sorry, the Android camera + // encountered a problem" over it, and works on the second + // try. Nothing is wrong with the camera, so nothing should + // say there is. + if (context.checkSelfPermission(Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED + ) { + scanLauncher.launch(enrollmentScanOptions()) + } else { + requestCamera.launch(Manifest.permission.CAMERA) + } }, modifier = Modifier.fillMaxWidth(), ) { Text("Scan QR code") } @@ -152,3 +164,24 @@ fun SettingsScreen( }) { Text("Save") } } } + +/** + * How the enrollment QR is scanned, in one place because two callers reach + * it -- straight from the button when the camera permission is already + * held, and from the permission result when it has just been granted. + * + * MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a + * dark code on a light ground, and ai-server's QR is block characters in + * the terminal's foreground colour, so on a dark-themed terminal it comes + * out as a photographic negative the scanner silently never matches. Which + * way round it renders is the terminal's business, not something this app + * should depend on. The mixed decoder alternates normal and inverted + * frames, costing half the frame rate at each polarity and nothing else. + */ +private fun enrollmentScanOptions(): ScanOptions = + ScanOptions() + .setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setCaptureActivity(EnrollmentScanActivity::class.java) + // Follow the phone, not the library's landscape pin. + .setOrientationLocked(false) + .addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)