Implement CameraX GPG QR transfer on Android (#106)
Android ARM verification / rust (push) Failing after 47m42s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s

This commit is contained in:
hugo committed 2026-10-12 09:49:38 +02:00
1 parent 6669b77f4e
commit d466b170ab
33 files changed
+1616 -162

No files matched your search

@@ -19,6 +19,7 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
@@ -54,9 +55,17 @@ class SetupFormViewModel @Inject constructor() : ViewModel() {
fun OnboardingScreen(
repository: OnboardingRepository,
secrets: SetupFormViewModel,
settings: de.rfc1437.ironstorage.core.SettingsRepository,
authentication: de.rfc1437.ironstorage.core.AuthenticationRepository,
modifier: Modifier = Modifier,
scroll: Boolean = true
) {
val transfer by settings.state.collectAsStateWithLifecycle()
DisposableEffect(transfer.transfer.open) {
val wasOpen = transfer.transfer.open
onDispose { if (wasOpen) repository.refresh() }
}
if (transfer.transfer.open) KeyTransferDialog(settings, authentication)
ProtectedInputSurface()
val state by repository.setup.collectAsStateWithLifecycle()
var step by rememberSaveable { mutableStateOf("choice") }
@@ -183,6 +192,9 @@ fun OnboardingScreen(
}
"import" -> {
Text(stringResource(R.string.setup_import_help))
TextButton(onClick = settings::beginSetupTransfer) {
Text(stringResource(R.string.qr_scan))
}
SecretField(
stringResource(R.string.setup_armor),
secrets.armor,
@@ -0,0 +1,95 @@
package de.rfc1437.ironstorage.features
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import de.rfc1437.ironstorage.core.SettingsRepository
@Composable
internal fun QrCameraPane(repository: SettingsRepository) {
val context = LocalContext.current
fun allowed() = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
var permitted by remember { mutableStateOf(allowed()) }
var scanning by remember { mutableStateOf(false) }
val permission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
permitted = it
scanning = it
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { permitted = allowed() }
if (permitted && scanning) {
val camera = remember(repository) { repository.cameraPreview() }
DisposableEffect(camera) { onDispose { camera?.close() } }
if (camera == null) {
Text(stringResource(R.string.qr_camera_missing))
} else {
AndroidView(factory = {
camera.view
}, modifier = Modifier.fillMaxWidth().height(280.dp).clipToBounds())
}
TextButton(onClick = { scanning = false }) { Text(stringResource(R.string.qr_pause)) }
} else {
TextButton(onClick = {
if (permitted) {
scanning = true
} else {
permission.launch(Manifest.permission.CAMERA)
}
}) { Text(stringResource(if (permitted) R.string.qr_resume else R.string.qr_scan)) }
if (!permitted) {
Text(stringResource(R.string.qr_camera_denied))
TextButton(onClick = {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
"package:${context.packageName}".toUri()
)
)
}) { Text(stringResource(R.string.qr_camera_settings)) }
}
}
}
@Composable
internal fun QrDisplayWindow() {
val view = androidx.compose.ui.platform.LocalView.current
val window = (view.parent as? androidx.compose.ui.window.DialogWindowProvider)?.window
DisposableEffect(window, view) {
val brightness = window?.attributes?.screenBrightness
val keep = view.keepScreenOn
view.keepScreenOn = true
window?.let { it.attributes = it.attributes.apply { screenBrightness = 1f } }
onDispose {
view.keepScreenOn = keep
if (brightness !=
null
) {
window.attributes = window.attributes.apply { screenBrightness = brightness }
}
}
}
}
@@ -169,6 +169,8 @@ fun SettingsScreen(
OnboardingScreen(
onboarding,
setup,
repository,
authentication,
scroll = false
)
}
@@ -290,7 +292,7 @@ fun SettingsScreen(
SettingsEditDialog(repository, authentication, action) { edit = null }
}
}
if (state.transfer.open) KeyTransferDialog(repository, authentication)
if (state.transfer.open && !showSetup) KeyTransferDialog(repository, authentication)
if (licenses) {
val resources = LocalResources.current
val loading = stringResource(R.string.settings_license_loading)
@@ -422,7 +424,7 @@ private fun SettingsEditDialog(
}
@Composable
private fun KeyTransferDialog(
internal fun KeyTransferDialog(
repository: SettingsRepository,
authentication: AuthenticationRepository
) {
@@ -436,6 +438,15 @@ private fun KeyTransferDialog(
var frame by remember { mutableStateOf(0) }
var passphrase by rememberProtectedInput(authentication.presentation)
var payload by rememberProtectedInput(authentication.presentation)
var exportConfirmed by remember(fingerprint, privateKey) { mutableStateOf(false) }
var importConfirmed by remember(state.transfer.ready) { mutableStateOf(false) }
var animate by remember { mutableStateOf(false) }
LaunchedEffect(animate, state.transfer.frames) {
while (animate && state.transfer.frames.size > 1) {
kotlinx.coroutines.delay(1200)
frame = (frame + 1) % state.transfer.frames.size
}
}
ProtectedWindow()
AlertDialog(
properties = DialogProperties(
@@ -454,91 +465,126 @@ private fun KeyTransferDialog(
ProtectedInputSurface(authentication.presentation)
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (state.busy) CircularProgressIndicator()
state.transfer.keys.forEach { key ->
Row(
Modifier.fillMaxWidth().selectable(
selected = fingerprint == key.fingerprint,
enabled = !state.busy,
role = Role.RadioButton,
onClick = { fingerprint = key.fingerprint }
)
) {
RadioButton(selected = fingerprint == key.fingerprint, onClick = null)
Column(Modifier.weight(1f)) {
Text(key.title)
Text(key.fingerprint)
}
}
}
TextButton(
enabled =
!state.busy &&
state.transfer.keys.any {
it.fingerprint == fingerprint && it.privateKey
},
onClick = { repository.setDefaultKey(fingerprint) }
) { Text(stringResource(R.string.settings_default_key_select)) }
Text(stringResource(R.string.settings_private_warning))
SettingsToggle(stringResource(R.string.settings_private_export), privateKey) {
privateKey =
it
}
SecretField(stringResource(R.string.setup_passphrase), passphrase) {
passphrase = it
}
TextButton(enabled = !state.busy && fingerprint.isNotEmpty(), onClick = {
repository.exportKey(fingerprint, privateKey, passphrase)
passphrase = ""
frame =
0
}) { Text(stringResource(R.string.settings_export_confirm)) }
state.transfer.frames.getOrNull(frame)?.let { qr ->
val description =
stringResource(
R.string.settings_qr_frame,
qr.sequence.toInt(),
qr.total.toInt()
)
Text(description)
Canvas(
Modifier.fillMaxWidth().height(280.dp).semantics {
contentDescription =
description
}
) {
drawRect(Color.White)
val width = qr.width.toInt()
val scale = minOf(size.width, size.height) / (width + 8)
val x = (size.width - scale * width) / 2
val y = (size.height - scale * width) / 2
for (row in 0 until width) {
for (column in 0 until width) {
if (qr.modules[
row *
width +
column
].toInt() ==
1
) {
drawRect(
Color.Black,
Offset(x + column * scale, y + row * scale),
Size(scale, scale)
)
}
if (!state.transfer.setup) {
state.transfer.keys.forEach { key ->
Row(
Modifier.fillMaxWidth().selectable(
selected = fingerprint == key.fingerprint,
enabled = !state.busy,
role = Role.RadioButton,
onClick = { fingerprint = key.fingerprint }
)
) {
RadioButton(selected = fingerprint == key.fingerprint, onClick = null)
Column(Modifier.weight(1f)) {
Text(key.title)
Text(key.fingerprint)
}
}
}
FlowRow {
TextButton(enabled = frame > 0, onClick = {
frame -= 1
}) { Text(stringResource(R.string.settings_previous)) }
TextButton(enabled = frame < state.transfer.frames.lastIndex, onClick = {
frame +=
1
}) { Text(stringResource(R.string.settings_next)) }
TextButton(
enabled =
!state.busy &&
state.transfer.keys.any {
it.fingerprint == fingerprint && it.privateKey
},
onClick = { repository.setDefaultKey(fingerprint) }
) { Text(stringResource(R.string.settings_default_key_select)) }
Text(stringResource(R.string.settings_private_warning))
SettingsToggle(stringResource(R.string.settings_private_export), privateKey) {
privateKey =
it
}
SecretField(stringResource(R.string.setup_passphrase), passphrase) {
passphrase = it
}
if (privateKey) {
SettingsToggle(
stringResource(R.string.qr_export_private_confirm),
exportConfirmed
) {
exportConfirmed =
it
}
}
TextButton(
enabled =
!state.busy && fingerprint.isNotEmpty() && (!privateKey || exportConfirmed),
onClick = {
repository.exportKey(fingerprint, privateKey, passphrase)
passphrase = ""
frame =
0
}
) { Text(stringResource(R.string.settings_export_confirm)) }
state.transfer.frames.getOrNull(frame)?.let { qr ->
state.transfer.exportedKey?.let { exported ->
Text(exported.title)
Text(exported.fingerprint)
}
QrDisplayWindow()
val description =
stringResource(
R.string.settings_qr_frame,
qr.sequence.toInt(),
qr.total.toInt()
)
Text(description)
Canvas(
Modifier.fillMaxWidth().height(280.dp).semantics {
contentDescription =
description
}
) {
drawRect(Color.White)
val width = qr.width.toInt()
val scale = minOf(size.width, size.height) / (width + 8)
val x = (size.width - scale * width) / 2
val y = (size.height - scale * width) / 2
for (row in 0 until width) {
for (column in 0 until width) {
if (qr.modules[
row *
width +
column
].toInt() ==
1
) {
drawRect(
Color.Black,
Offset(x + column * scale, y + row * scale),
Size(scale, scale)
)
}
}
}
}
FlowRow {
TextButton(enabled = frame > 0, onClick = {
frame -= 1
}) { Text(stringResource(R.string.settings_previous)) }
TextButton(
enabled = frame < state.transfer.frames.lastIndex,
onClick = {
frame +=
1
}
) { Text(stringResource(R.string.settings_next)) }
}
}
if (state.transfer.frames.size > 1) {
SettingsToggle(stringResource(R.string.qr_animate), animate) {
animate = it
}
}
}
if (state.transfer.setup) {
SecretField(stringResource(R.string.setup_passphrase), passphrase) {
passphrase =
it
}
}
if (state.transfer.ready == null && !state.busy) QrCameraPane(repository)
TextButton(enabled = !state.busy, onClick = repository::chooseQrImages) {
Text(stringResource(R.string.settings_qr_images))
}
@@ -564,22 +610,50 @@ private fun KeyTransferDialog(
state.transfer.total.toInt()
)
)
if (state.transfer.duplicate) Text(stringResource(R.string.qr_duplicate))
state.transfer.ready?.let {
Text(it.title)
Text(it.fingerprint)
SettingsToggle(stringResource(R.string.settings_make_default), makeDefault) {
makeDefault =
it
if (!state.transfer.setup) {
SettingsToggle(
stringResource(R.string.settings_make_default),
makeDefault
) {
makeDefault =
it
}
SettingsToggle(stringResource(R.string.settings_replace_key), overwrite) {
overwrite =
it
}
}
SettingsToggle(stringResource(R.string.settings_replace_key), overwrite) {
overwrite =
it
if (it.privateKey) {
SettingsToggle(
stringResource(R.string.qr_import_private_confirm),
importConfirmed
) {
importConfirmed =
it
}
}
TextButton(
enabled = !state.busy && (!it.privateKey || importConfirmed),
onClick = {
repository.importKey(passphrase, makeDefault, overwrite)
passphrase =
""
}
) {
Text(
stringResource(
if (state.transfer.setup) {
R.string.qr_setup_import
} else {
R.string.settings_import_confirm
}
)
)
}
TextButton(enabled = !state.busy, onClick = {
repository.importKey(passphrase, makeDefault, overwrite)
passphrase =
""
}) { Text(stringResource(R.string.settings_import_confirm)) }
}
}
},
@@ -171,7 +171,13 @@ private fun ShellDestination(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OnboardingScreen(viewModel.onboarding, setupForm, scroll = false)
OnboardingScreen(
viewModel.onboarding,
setupForm,
viewModel.settings,
viewModel.authentication,
scroll = false
)
}
}
ready && route == ShellRoute.Passwords -> {
@@ -224,4 +224,15 @@
<item quantity="other">%1$d results</item>
</plurals>
<string name="settings_license_loading">Loading license…</string>
<string name="qr_scan">Scan GPG QR codes</string>
<string name="qr_pause">Pause camera</string>
<string name="qr_resume">Resume camera</string>
<string name="qr_camera_denied">Camera access is required to scan. If permission was denied, allow it in app settings or choose QR images.</string>
<string name="qr_camera_settings">Open camera permission settings</string>
<string name="qr_camera_missing">The camera is unavailable. Choose QR images or paste an armored key.</string>
<string name="qr_duplicate">Duplicate frame; progress is unchanged.</string>
<string name="qr_import_private_confirm">I checked this fingerprint and authorize importing its private key material.</string>
<string name="qr_export_private_confirm">I checked this fingerprint and authorize displaying its private QR codes.</string>
<string name="qr_animate">Automatically advance frames</string>
<string name="qr_setup_import">Prepare the scanned key</string>
</resources>