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
+25
-1
@@ -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)
|
||||
|
||||
+117
-5
@@ -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>
|
||||
Reference in new issue
Block a user