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

@@ -438,6 +438,7 @@ class NativeEntryViewerTest {
keystore: MemoryKeystore
) {
var image = ByteArray(0)
var cameraReceiver: ((CameraFrame) -> Unit)? = null
val platform = object : SettingsPlatform {
override fun autofill() = ProviderStatus(true, false, false)
override fun credentials() =
@@ -446,6 +447,13 @@ class NativeEntryViewerTest {
override fun version() = "native-settings-fixture"
override fun openAutofill() = true
override fun openCredentials() = true
override fun cameraPreview(
frame: (CameraFrame) -> Unit,
failure: () -> Unit
): CameraPreview? {
cameraReceiver = frame
return null
}
override fun chooseQrImages() = true
override suspend fun readQrImage(uri: String) = image.copyOf()
}
@@ -533,6 +541,18 @@ class NativeEntryViewerTest {
image = output.toByteArray()
request { settings.closeTransfer() }
assertTrue(frame.modules.all { it.toInt() == 0 })
assertNull(request { settings.beginTransfer() }.error)
withContext(Dispatchers.Main.immediate) { settings.cameraPreview() }
val luminance = ByteArray(size * size) { index ->
if (pixels[index] == android.graphics.Color.BLACK) 0 else 255.toByte()
}
requireNotNull(
cameraReceiver
)(CameraFrame(luminance, size.toUInt(), size.toUInt(), size.toUInt(), 1u, 90u))
withTimeout(30000) { settings.state.first { it.transfer.ready != null } }
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
assertTrue(luminance.all { it.toInt() == 0 })
request { settings.closeTransfer() }
val qrStart = android.os.SystemClock.elapsedRealtime()
assertNull(request { settings.addQrImages(listOf("content://public-fixture/qr")) }.error)
val qrMs = android.os.SystemClock.elapsedRealtime() - qrStart
@@ -559,10 +579,14 @@ class NativeEntryViewerTest {
assertNotNull(request { settings.setDefaultKey(bob.fingerprint) }.error)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
assertNull(request { settings.beginTransfer() }.error)
assertNull(request { settings.exportKey(fingerprint, false, "") }.error)
assertNull(request { settings.exportKey(fingerprint, true, passphrase) }.error)
assertTrue(settings.state.value.transfer.exportedKey!!.privateKey)
val privateFrames = settings.state.value.transfer.frames
assertTrue(privateFrames.isNotEmpty())
withContext(Dispatchers.Main.immediate) { authentication.presentation.clear() }
assertFalse(settings.state.value.transfer.open)
assertTrue(settings.state.value.transfer.frames.isEmpty())
assertTrue(privateFrames.all { frame -> frame.modules.all { it.toInt() == 0 } })
assertNull(request { settings.setDefaultKey(fingerprint) }.error)
assertFalse(authentication.state.value.unlocked)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
@@ -2,6 +2,7 @@ package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileCameraFrame
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransfer
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferImport
@@ -10,7 +11,9 @@ import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferProgress
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation
import de.rfc1437.ironstorage.bridge.generated.mobileKeyTransfer
import de.rfc1437.ironstorage.bridge.generated.mobileLocalKeyTransferImporter
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -31,10 +34,12 @@ class NativeSettingsRepository(
private val mutable = MutableStateFlow(SettingsState())
override val state: StateFlow<SettingsState> = mutable
private val operation = Mutex()
private val cameraBusy = AtomicBoolean()
@Volatile private var epoch = 0L
@Volatile private var discovery: MobileTotpOperation? = null
private var visibility: Job? = null
private var transfer: MobileKeyTransfer? = null
private var importer: MobileKeyTransferImport? = null
init {
@@ -126,19 +131,27 @@ class NativeSettingsRepository(
)
}
}
override fun beginTransfer() = run { ticket ->
override fun beginTransfer() = begin(false)
override fun beginSetupTransfer() = begin(true)
private fun begin(setup: Boolean) = run { ticket ->
withContext(Dispatchers.IO) {
session.storage()
transfer = mobileKeyTransfer()
importer = requireNotNull(transfer).importer()
releaseTransfer()
if (setup) {
importer = mobileLocalKeyTransferImporter()
} else {
transfer = mobileKeyTransfer()
importer = requireNotNull(transfer).importer()
}
}
val keys = withContext(Dispatchers.IO) { requireNotNull(transfer).keys() }
val keys = withContext(Dispatchers.IO) { transfer?.keys().orEmpty() }
if (ticket ==
epoch
) {
mutable.value = mutable.value.copy(
transfer = KeyTransferState(
open = true,
setup = setup,
keys = keys.map {
TransferKey(
it.fingerprint,
@@ -152,6 +165,12 @@ class NativeSettingsRepository(
}
override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) =
run { ticket ->
visibility?.cancel()
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value =
mutable.value.copy(
transfer = mutable.value.transfer.copy(frames = emptyList(), exportedKey = null)
)
val result = withContext(Dispatchers.IO) {
requireNotNull(transfer).export(
fingerprint,
@@ -165,11 +184,28 @@ class NativeSettingsRepository(
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(
transfer = mutable.value.transfer.copy(
exportedKey = TransferKey(
result.key.fingerprint,
result.key.title,
result.key.kind == MobileKeyTransferKind.PRIVATE
),
frames = result.frames.map {
TransferFrame(it.sequence, it.total, it.width, it.modules)
}
)
)
visibility = scope.launch {
while (ticket == epoch) {
kotlinx.coroutines.delay(250)
val visible = operation.withLock {
withContext(Dispatchers.IO) { transfer?.exportVisible() == true }
}
if (ticket == epoch && !visible) {
closeTransfer()
break
}
}
}
} else {
result.frames.forEach { it.modules.fill(0) }
}
@@ -177,9 +213,11 @@ class NativeSettingsRepository(
private fun progress(ticket: Long, progress: MobileKeyTransferProgress) {
if (ticket != epoch) return
mutable.value = mutable.value.copy(
error = null,
transfer = mutable.value.transfer.copy(
received = progress.received,
total = progress.total,
duplicate = progress.duplicate,
ready = progress.key?.let {
TransferKey(
it.fingerprint,
@@ -190,6 +228,65 @@ class NativeSettingsRepository(
)
)
}
override fun cameraPreview(): CameraPreview? {
val ticket = epoch
return platform.cameraPreview({ frame ->
if (!cameraBusy.compareAndSet(false, true)) {
frame.pixels.fill(0)
} else {
scope.launch {
try {
operation.withLock {
if (ticket != epoch || !mutable.value.transfer.open ||
mutable.value.transfer.ready != null || mutable.value.busy
) {
return@withLock
}
val result = withContext(Dispatchers.IO) {
requireNotNull(importer).addCamera(
MobileCameraFrame(
frame.width,
frame.height,
frame.rowStride,
frame.pixelStride,
frame.pixels
),
frame.rotationDegrees
)
}
frame.pixels.fill(0)
if (result != null) progress(ticket, result)
}
} catch (error: MobileKeyTransferFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value = mutable.value.copy(error = error.rustMessage)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_update_retry))
}
} finally {
frame.pixels.fill(0)
cameraBusy.set(false)
}
}
}
}, {
scope.launch {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_camera_unavailable))
}
}
})
}
override fun chooseQrImages() {
if (!platform.chooseQrImages()) {
mutable.value =
@@ -246,6 +343,15 @@ class NativeSettingsRepository(
progress(ticket, withContext(Dispatchers.IO) { requireNotNull(importer).addFrame(payload) })
}
override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) = run {
if (mutable.value.transfer.setup) {
withContext(Dispatchers.IO) {
session.storage().androidOnboarding(session.backend()).use {
it.importScanned(requireNotNull(importer), passphrase.ifEmpty { null })
}
}
closeTransfer()
return@run
}
val result = withContext(Dispatchers.IO) {
requireNotNull(importer).import(passphrase.ifEmpty { null }, makeDefault, overwrite)
}
@@ -264,6 +370,8 @@ class NativeSettingsRepository(
transfer = null
}
override fun closeTransfer() {
visibility?.cancel()
visibility = null
epoch += 1
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState(), busy = false)
@@ -295,7 +403,11 @@ class NativeSettingsRepository(
mutable.value.copy(error = error.title + "\n" + error.detail)
}
} catch (error: MobileKeyTransferFfiException.Failed) {
if (ticket == epoch) mutable.value = mutable.value.copy(error = error.message)
if (ticket ==
epoch
) {
mutable.value = mutable.value.copy(error = error.rustMessage)
}
} catch (_: MobilePrivateStorageFfiException) {
if (ticket ==
epoch
@@ -12,6 +12,20 @@ interface SettingsPlatform {
fun openCredentials(): Boolean
fun chooseQrImages(): Boolean
suspend fun readQrImage(uri: String): ByteArray
fun cameraPreview(frame: (CameraFrame) -> Unit, failure: () -> Unit): CameraPreview?
}
data class CameraFrame(
val pixels: ByteArray,
val width: UInt,
val height: UInt,
val rowStride: UInt,
val pixelStride: UInt,
val rotationDegrees: UInt
) {
override fun toString() = "CameraFrame([REDACTED])"
}
interface CameraPreview : AutoCloseable {
val view: android.view.View
}
data class SettingsPage(
val repositoryTitle: String,
@@ -45,7 +59,10 @@ data class KeyTransferState(
val frames: List<TransferFrame> = emptyList(),
val received: UInt = 0u,
val total: UInt = 0u,
val ready: TransferKey? = null
val ready: TransferKey? = null,
val duplicate: Boolean = false,
val setup: Boolean = false,
val exportedKey: TransferKey? = null
) {
override fun toString() = "KeyTransferState([REDACTED])"
}
@@ -69,6 +86,8 @@ interface SettingsRepository {
fun openAutofill()
fun openCredentials()
fun beginTransfer()
fun beginSetupTransfer()
fun cameraPreview(): CameraPreview?
fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String)
fun chooseQrImages()
fun addQrImages(uris: List<String>)
@@ -38,4 +38,5 @@
<string name="core_copied">Copied. The clipboard will be cleared automatically.</string>
<string name="core_password_recovery">Unlock the device or recover the private store and refresh.</string>
<string name="core_password_storage">Private storage is unavailable. Unlock the device and refresh.</string>
<string name="core_camera_unavailable">The camera is unavailable. Pause scanning and retry, or choose QR images.</string>
</resources>