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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-25 21:11:27 -04:00
1 parent bf82166736
commit f53a1ca214
1 file changed
+50 -17
@@ -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)