Implement CameraX GPG QR transfer on Android (#106)
This commit is contained in:
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
-93
@@ -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>
|
||||
Reference in new issue
Block a user