Implement complete Android settings and recovery controls (#104)
This commit is contained in:
1 parent
06575363a2
commit
43f660ace3
34 files changed
+2720
-64
No files matched your search
@@ -0,0 +1,13 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
xjMEZZIAgBYJKwYBBAHaRw8BAQdANd08TxPj0ZuIl1cMvEf0FMCxubDwlzXDLX6w
|
||||
9M+959XNJUJvYiBGaXh0dXJlIDxib2JAaXJvbnN0b3JhZ2UuaW52YWxpZD7CiQQT
|
||||
FggAMQUCanjntBYhBLNwJ7VvxAa9P2piKyrANJK5ktBvAhsDAh4BAwsJBwIVCAEW
|
||||
AScCGQEACgkQKsA0krmS0G9BCwD/daZy2BdmNQX8TldPBtoLTrugeaFmtrcVDQZ3
|
||||
AzWEMcMA/16q87Zkaw2ozanLSBcRiq4iZnBTAmU/7bUSlA2myZoHzjgEZZIAgBIK
|
||||
KwYBBAGXVQEFAQEHQByJpci1aMxEcUVlw9mByZUPyaQ7kI1emdJOuN1hcLEKAwEI
|
||||
B8J4BBgWCAAgBQJqeOe1AhsMFiEEs3AntW/EBr0/amIrKsA0krmS0G8ACgkQKsA0
|
||||
krmS0G+2lwEAvD9R7o52H37GsfeMS15UJBeIHBaYHjpsIyfoOWq0vsEA/j0oL4IG
|
||||
SIakL3z1C1KHU+4iz3rzz2vNnhgGQd7+DS8H
|
||||
=vS0q
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
+148
-3
@@ -1,6 +1,7 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClock
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
||||
import java.io.File
|
||||
@@ -29,9 +30,10 @@ class NativeEntryViewerTest {
|
||||
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
|
||||
File(it, name).apply { mkdirs() }.canonicalPath
|
||||
}
|
||||
val keystore = MemoryKeystore()
|
||||
val session = NativePrivateSession(context, {
|
||||
PrivateDirectories(roots[0], roots[1], roots[2])
|
||||
}, MemoryKeystore())
|
||||
}, keystore)
|
||||
withContext(Dispatchers.IO) { session.initialize() }
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
val clipboard = MemoryClipboard()
|
||||
@@ -250,6 +252,13 @@ class NativeEntryViewerTest {
|
||||
scope,
|
||||
password
|
||||
)
|
||||
settingsUseTheSameNativeOwnerAndProtectedTransfer(
|
||||
session,
|
||||
authentication,
|
||||
scope,
|
||||
password,
|
||||
keystore
|
||||
)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { repository.lock() }
|
||||
scope.cancel()
|
||||
@@ -364,8 +373,8 @@ class NativeEntryViewerTest {
|
||||
authentication.authenticated { it.setAuthenticationTimeout(2u) }
|
||||
withTimeout(10000) { editor.state.first { it.locked } }
|
||||
assertTrue(editor.state.value.fields.isEmpty())
|
||||
assertNull(request { editor.resume(passphrase) }.error)
|
||||
authentication.authenticated { it.setAuthenticationTimeout(300u) }
|
||||
assertNull(request { editor.resume(passphrase) }.error)
|
||||
request { editor.requestClose() }
|
||||
if (editor.state.value.discardRequested) request { editor.confirmDiscard() }
|
||||
assertFalse(editor.state.value.open)
|
||||
@@ -373,6 +382,137 @@ class NativeEntryViewerTest {
|
||||
withContext(Dispatchers.Main.immediate) { authentication.lock() }
|
||||
}
|
||||
}
|
||||
private suspend fun settingsUseTheSameNativeOwnerAndProtectedTransfer(
|
||||
session: NativePrivateSession,
|
||||
authentication: NativeAuthenticationRepository,
|
||||
scope: CoroutineScope,
|
||||
passphrase: String,
|
||||
keystore: MemoryKeystore
|
||||
) {
|
||||
var image = ByteArray(0)
|
||||
val platform = object : SettingsPlatform {
|
||||
override fun autofill() = ProviderStatus(true, false, false)
|
||||
override fun credentials() =
|
||||
ProviderStatus(android.os.Build.VERSION.SDK_INT >= 34, false, false)
|
||||
override fun biometricsAvailable() = false
|
||||
override fun version() = "native-settings-fixture"
|
||||
override fun openAutofill() = true
|
||||
override fun openCredentials() = true
|
||||
override fun chooseQrImages() = true
|
||||
override suspend fun readQrImage(uri: String) = image.copyOf()
|
||||
}
|
||||
val settings = NativeSettingsRepository(session, authentication, platform, scope)
|
||||
suspend fun request(action: () -> Unit): SettingsState {
|
||||
withContext(Dispatchers.Main.immediate) { action() }
|
||||
return withTimeout(30000) { settings.state.first { !it.busy } }
|
||||
}
|
||||
val initial = request { settings.reload() }
|
||||
assertNull(initial.error)
|
||||
assertEquals("native-settings-fixture", initial.page!!.version)
|
||||
assertFalse(initial.page.biometricAvailable)
|
||||
assertNull(
|
||||
request {
|
||||
settings.setGitIdentity("Android settings author", "android-settings@example.test")
|
||||
}.error
|
||||
)
|
||||
assertEquals("Android settings author", settings.state.value.page!!.gitName)
|
||||
assertNotNull(request { settings.setGitIdentity("candidate", "invalid\nemail") }.error)
|
||||
assertEquals("Android settings author", settings.state.value.page!!.gitName)
|
||||
assertNull(request { settings.setTimeout("45") }.error)
|
||||
assertEquals(45uL, settings.state.value.page!!.timeout)
|
||||
assertNotNull(request { settings.setTimeout("0") }.error)
|
||||
assertEquals(45uL, settings.state.value.page!!.timeout)
|
||||
assertNotNull(request { settings.removeToken() }.error)
|
||||
assertNull(settings.state.value.page!!.account)
|
||||
assertNull(request { settings.rebuildCache(passphrase) }.error)
|
||||
assertTrue(settings.state.value.page!!.cacheDetail.contains("current TOTP entries"))
|
||||
val beforeEnrollment = keystore.keyCount
|
||||
authentication.authenticated { it.enrollBiometricUnlock(passphrase) }
|
||||
assertTrue(authentication.state.value.biometricEnabled)
|
||||
assertEquals(beforeEnrollment + 1, keystore.keyCount)
|
||||
withContext(Dispatchers.IO) {
|
||||
session.storage().androidHomeOperation(
|
||||
session.backend(),
|
||||
object : MobileClock {
|
||||
override fun monotonicMilliseconds(): ULong =
|
||||
android.os.SystemClock.elapsedRealtime().toULong()
|
||||
}
|
||||
).use { home ->
|
||||
assertEquals("Android settings author", home.identity().name)
|
||||
home.cached()
|
||||
}
|
||||
}
|
||||
assertTrue(authentication.authenticated { it.state().unlocked })
|
||||
keystore.invalidated = true
|
||||
authentication.authenticated { it.setBiometricUnlock(false) }
|
||||
assertFalse(authentication.state.value.biometricEnabled)
|
||||
assertFalse(authentication.state.value.unlocked)
|
||||
assertEquals(beforeEnrollment, keystore.keyCount)
|
||||
keystore.invalidated = false
|
||||
assertNull(request { settings.beginTransfer() }.error)
|
||||
val fingerprint = settings.state.value.transfer.keys.first { it.privateKey }.fingerprint
|
||||
assertNotNull(request { settings.exportKey(fingerprint, true, "incorrect") }.error)
|
||||
assertTrue(settings.state.value.transfer.frames.isEmpty())
|
||||
assertNull(request { settings.exportKey(fingerprint, false, "") }.error)
|
||||
val frame = settings.state.value.transfer.frames.single()
|
||||
val size = (frame.width.toInt() + 8) * 4
|
||||
val pixels = IntArray(size * size) { android.graphics.Color.WHITE }
|
||||
for (row in 0 until frame.width.toInt()) {
|
||||
for (column in 0 until frame.width.toInt()) {
|
||||
if (frame.modules[row * frame.width.toInt() + column].toInt() == 1) {
|
||||
for (dy in 0 until 4) {
|
||||
for (dx in 0 until 4) {
|
||||
pixels[((row + 4) * 4 + dy) * size + (column + 4) * 4 + dx] =
|
||||
android.graphics.Color.BLACK
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val bitmap = android.graphics.Bitmap.createBitmap(
|
||||
pixels,
|
||||
size,
|
||||
size,
|
||||
android.graphics.Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val output = java.io.ByteArrayOutputStream()
|
||||
assertTrue(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, output))
|
||||
bitmap.recycle()
|
||||
image = output.toByteArray()
|
||||
request { settings.closeTransfer() }
|
||||
assertTrue(frame.modules.all { it.toInt() == 0 })
|
||||
assertNull(request { settings.addQrImages(listOf("content://public-fixture/qr")) }.error)
|
||||
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
|
||||
assertNotNull(request { settings.importKey("", false, false) }.error)
|
||||
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
|
||||
request { settings.closeTransfer() }
|
||||
assertNull(request { settings.beginTransfer() }.error)
|
||||
val armor = InstrumentationRegistry.getInstrumentation().context.assets.open(
|
||||
"bob-public.asc"
|
||||
).bufferedReader().use {
|
||||
it.readText()
|
||||
}
|
||||
assertNull(request { settings.addQrPayload(armor) }.error)
|
||||
assertFalse(settings.state.value.transfer.ready!!.privateKey)
|
||||
assertNull(request { settings.importKey("", false, false) }.error)
|
||||
assertFalse(settings.state.value.transfer.open)
|
||||
assertFalse(authentication.state.value.unlocked)
|
||||
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
|
||||
assertNull(request { settings.beginTransfer() }.error)
|
||||
val bob = settings.state.value.transfer.keys.single { it.title.contains("Bob") }
|
||||
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)
|
||||
withContext(Dispatchers.Main.immediate) { authentication.presentation.clear() }
|
||||
assertFalse(settings.state.value.transfer.open)
|
||||
assertTrue(settings.state.value.transfer.frames.isEmpty())
|
||||
assertNull(request { settings.setDefaultKey(fingerprint) }.error)
|
||||
assertFalse(authentication.state.value.unlocked)
|
||||
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
|
||||
request { settings.setTimeout("300") }
|
||||
}
|
||||
|
||||
private class MemoryClipboard : SecretClipboard {
|
||||
override fun beginCopy(): SecretClipboard = this
|
||||
|
||||
@@ -387,8 +527,10 @@ class NativeEntryViewerTest {
|
||||
}
|
||||
private class MemoryKeystore : AndroidKeystore {
|
||||
private val keys = mutableSetOf<String>()
|
||||
val keyCount get() = keys.size
|
||||
var invalidated = false
|
||||
override fun deviceState() =
|
||||
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, false, false)
|
||||
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, true, false)
|
||||
override fun createKey(specification: KeystoreKeySpecification) {
|
||||
keys.add(specification.alias)
|
||||
}
|
||||
@@ -412,6 +554,9 @@ class NativeEntryViewerTest {
|
||||
aad: ByteArray,
|
||||
value: KeystoreCiphertext
|
||||
): ByteArray {
|
||||
if (invalidated && authorization == KeystoreAuthorization.StrongBiometric) {
|
||||
throw KeystoreException(KeystoreFailure.Invalidated)
|
||||
}
|
||||
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
|
||||
val record =
|
||||
ciphertexts[value.bytes.toList()]
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransfer
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferImport
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferKind
|
||||
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.mobileTotpOperation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class NativeSettingsRepository(
|
||||
private val session: NativePrivateSession,
|
||||
private val authentication: NativeAuthenticationRepository,
|
||||
private val platform: SettingsPlatform,
|
||||
private val scope: CoroutineScope
|
||||
) : SettingsRepository {
|
||||
private val mutable = MutableStateFlow(SettingsState())
|
||||
override val state: StateFlow<SettingsState> = mutable
|
||||
private val operation = Mutex()
|
||||
|
||||
@Volatile private var epoch = 0L
|
||||
|
||||
@Volatile private var discovery: MobileTotpOperation? = null
|
||||
private var transfer: MobileKeyTransfer? = null
|
||||
private var importer: MobileKeyTransferImport? = null
|
||||
init {
|
||||
val registration = authentication.presentation.register {
|
||||
if (mutable.value.transfer.open) closeTransfer()
|
||||
}
|
||||
scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() }
|
||||
}
|
||||
private suspend fun page(): SettingsPage {
|
||||
val values = authentication.authenticated {
|
||||
Triple(it.preferences(), it.gitIdentity(), it.totpCacheStatus())
|
||||
}
|
||||
val (preferences, identity, cache) = values
|
||||
return SettingsPage(
|
||||
preferences.repositoryTitle, preferences.repositoryUrl,
|
||||
preferences.serverTitle, preferences.serverIdentity, preferences.applicationAccount,
|
||||
preferences.defaultKeyTitle, preferences.defaultKeyFingerprint,
|
||||
identity.name, identity.email, preferences.authenticationTimeoutSeconds,
|
||||
cache.detail, platform.biometricsAvailable(), platform.autofill(),
|
||||
platform.credentials(), platform.version()
|
||||
)
|
||||
}
|
||||
override fun reload() = run { mutable.value = mutable.value.copy(page = page()) }
|
||||
override fun setDefaultKey(fingerprint: String) = run {
|
||||
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
|
||||
mutable.value = mutable.value.copy(transfer = KeyTransferState())
|
||||
releaseTransfer()
|
||||
authentication.authenticated { it.setDefaultKey(fingerprint) }
|
||||
authentication.lock()
|
||||
mutable.value = mutable.value.copy(page = page(), notice = "Default GPG key updated.")
|
||||
}
|
||||
override fun setGitIdentity(name: String, email: String) = run {
|
||||
authentication.authenticated { it.setGitIdentity(name, email) }
|
||||
mutable.value = mutable.value.copy(page = page(), notice = "Git author updated.")
|
||||
}
|
||||
override fun setTimeout(seconds: String) = run {
|
||||
authentication.authenticated { it.setAuthenticationTimeoutText(seconds) }
|
||||
mutable.value = mutable.value.copy(page = page(), notice = "Inactivity timeout updated.")
|
||||
}
|
||||
override fun removeToken() = run {
|
||||
authentication.authenticated { it.removeApplicationToken() }
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
page = page(),
|
||||
notice = "Application token removed from protected storage."
|
||||
)
|
||||
}
|
||||
override fun rebuildCache(passphrase: String) = run {
|
||||
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
|
||||
discovery = active
|
||||
try {
|
||||
val result = authentication.authenticated {
|
||||
it.unlockTotp(passphrase.ifEmpty { null })
|
||||
it.rebuildTotpCache(active)
|
||||
}
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
page = page(),
|
||||
notice =
|
||||
result.cacheNotice ?: "TOTP cache rebuilt."
|
||||
)
|
||||
} finally {
|
||||
discovery = null
|
||||
active.close()
|
||||
}
|
||||
}
|
||||
override fun openAutofill() {
|
||||
if (!platform.openAutofill()) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
error = "Autofill provider settings are unavailable on this device."
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun openCredentials() {
|
||||
if (!platform.openCredentials()) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
error = "Credential provider settings are unavailable on this device."
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun beginTransfer() = run { ticket ->
|
||||
withContext(Dispatchers.IO) {
|
||||
session.storage()
|
||||
transfer = mobileKeyTransfer()
|
||||
importer = requireNotNull(transfer).importer()
|
||||
}
|
||||
val keys = withContext(Dispatchers.IO) { requireNotNull(transfer).keys() }
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value = mutable.value.copy(
|
||||
transfer = KeyTransferState(
|
||||
open = true,
|
||||
keys = keys.map {
|
||||
TransferKey(
|
||||
it.fingerprint,
|
||||
it.title,
|
||||
it.kind == MobileKeyTransferKind.PRIVATE
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) =
|
||||
run { ticket ->
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
requireNotNull(transfer).export(
|
||||
fingerprint,
|
||||
if (privateKey) MobileKeyTransferKind.PRIVATE else MobileKeyTransferKind.PUBLIC,
|
||||
passphrase.ifEmpty { null }
|
||||
)
|
||||
}
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
|
||||
mutable.value = mutable.value.copy(
|
||||
transfer = mutable.value.transfer.copy(
|
||||
frames = result.frames.map {
|
||||
TransferFrame(it.sequence, it.total, it.width, it.modules)
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
result.frames.forEach { it.modules.fill(0) }
|
||||
}
|
||||
}
|
||||
private fun progress(ticket: Long, progress: MobileKeyTransferProgress) {
|
||||
if (ticket != epoch) return
|
||||
mutable.value = mutable.value.copy(
|
||||
transfer = mutable.value.transfer.copy(
|
||||
received = progress.received,
|
||||
total = progress.total,
|
||||
ready = progress.key?.let {
|
||||
TransferKey(
|
||||
it.fingerprint,
|
||||
it.title,
|
||||
it.kind == MobileKeyTransferKind.PRIVATE
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun chooseQrImages() {
|
||||
if (!platform.chooseQrImages()) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "The image picker is unavailable on this device.")
|
||||
}
|
||||
}
|
||||
override fun addQrImages(uris: List<String>) {
|
||||
scope.launch {
|
||||
mutable.first { !it.busy }
|
||||
run { ticket ->
|
||||
if (importer == null) {
|
||||
val keys = withContext(Dispatchers.IO) {
|
||||
session.storage()
|
||||
transfer = mobileKeyTransfer()
|
||||
importer = requireNotNull(transfer).importer()
|
||||
requireNotNull(transfer).keys()
|
||||
}
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
transfer = KeyTransferState(
|
||||
open = true,
|
||||
keys = keys.map {
|
||||
TransferKey(
|
||||
it.fingerprint,
|
||||
it.title,
|
||||
it.kind == MobileKeyTransferKind.PRIVATE
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
for (uri in uris) {
|
||||
if (ticket != epoch) break
|
||||
val bytes = withContext(Dispatchers.IO) { platform.readQrImage(uri) }
|
||||
try {
|
||||
progress(
|
||||
ticket,
|
||||
withContext(Dispatchers.IO) {
|
||||
requireNotNull(importer).addImage(bytes)
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
bytes.fill(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun addQrPayload(payload: String) = run { ticket ->
|
||||
progress(ticket, withContext(Dispatchers.IO) { requireNotNull(importer).addFrame(payload) })
|
||||
}
|
||||
override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) = run {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
requireNotNull(importer).import(passphrase.ifEmpty { null }, makeDefault, overwrite)
|
||||
}
|
||||
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
|
||||
mutable.value = mutable.value.copy(transfer = KeyTransferState())
|
||||
releaseTransfer()
|
||||
authentication.lock()
|
||||
withContext(Dispatchers.IO) { session.storage().keyMaterialChanged() }
|
||||
mutable.value =
|
||||
mutable.value.copy(page = page(), notice = result.title + "\n" + result.detail)
|
||||
}
|
||||
private fun releaseTransfer() {
|
||||
importer?.close()
|
||||
transfer?.close()
|
||||
importer = null
|
||||
transfer = null
|
||||
}
|
||||
override fun closeTransfer() {
|
||||
epoch += 1
|
||||
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
|
||||
mutable.value = mutable.value.copy(transfer = KeyTransferState(), busy = false)
|
||||
scope.launch { operation.withLock { releaseTransfer() } }
|
||||
}
|
||||
override fun cancel() {
|
||||
epoch += 1
|
||||
discovery?.cancel()
|
||||
closeTransfer()
|
||||
}
|
||||
private fun run(action: suspend (Long) -> Unit) {
|
||||
if (mutable.value.busy) return
|
||||
val ticket = epoch
|
||||
mutable.value = mutable.value.copy(busy = true, error = null, notice = null)
|
||||
scope.launch {
|
||||
operation.withLock {
|
||||
if (ticket != epoch) return@withLock
|
||||
try {
|
||||
action(ticket)
|
||||
if (ticket != epoch) {
|
||||
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
|
||||
mutable.value = mutable.value.copy(transfer = KeyTransferState())
|
||||
}
|
||||
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = error.title + "\n" + error.detail)
|
||||
}
|
||||
} catch (error: MobileKeyTransferFfiException.Failed) {
|
||||
if (ticket == epoch) mutable.value = mutable.value.copy(error = error.message)
|
||||
} catch (_: MobilePrivateStorageFfiException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
error =
|
||||
"Private storage recovery required. " +
|
||||
"Unlock the device and reload; " +
|
||||
"missing or invalidated Keystore data requires " +
|
||||
"reimporting the GPG key."
|
||||
)
|
||||
}
|
||||
} catch (_: NativeBridgeException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "Update the application and retry.")
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "The selected QR image could not be read.")
|
||||
}
|
||||
} finally {
|
||||
if (ticket == epoch) mutable.value = mutable.value.copy(busy = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class ProviderStatus(val supported: Boolean, val installed: Boolean, val enabled: Boolean)
|
||||
interface SettingsPlatform {
|
||||
fun autofill(): ProviderStatus
|
||||
fun credentials(): ProviderStatus
|
||||
fun biometricsAvailable(): Boolean
|
||||
fun version(): String
|
||||
fun openAutofill(): Boolean
|
||||
fun openCredentials(): Boolean
|
||||
fun chooseQrImages(): Boolean
|
||||
suspend fun readQrImage(uri: String): ByteArray
|
||||
}
|
||||
data class SettingsPage(
|
||||
val repositoryTitle: String,
|
||||
val repositoryUrl: String,
|
||||
val serverTitle: String,
|
||||
val serverIdentity: String,
|
||||
val account: String?,
|
||||
val defaultKeyTitle: String,
|
||||
val defaultKeyFingerprint: String,
|
||||
val gitName: String,
|
||||
val gitEmail: String,
|
||||
val timeout: ULong,
|
||||
val cacheDetail: String,
|
||||
val biometricAvailable: Boolean,
|
||||
val autofill: ProviderStatus,
|
||||
val credentials: ProviderStatus,
|
||||
val version: String
|
||||
)
|
||||
data class TransferKey(val fingerprint: String, val title: String, val privateKey: Boolean)
|
||||
data class TransferFrame(
|
||||
val sequence: UInt,
|
||||
val total: UInt,
|
||||
val width: UInt,
|
||||
val modules: ByteArray
|
||||
) {
|
||||
override fun toString() = "TransferFrame([REDACTED])"
|
||||
}
|
||||
data class KeyTransferState(
|
||||
val open: Boolean = false,
|
||||
val keys: List<TransferKey> = emptyList(),
|
||||
val frames: List<TransferFrame> = emptyList(),
|
||||
val received: UInt = 0u,
|
||||
val total: UInt = 0u,
|
||||
val ready: TransferKey? = null
|
||||
) {
|
||||
override fun toString() = "KeyTransferState([REDACTED])"
|
||||
}
|
||||
data class SettingsState(
|
||||
val busy: Boolean = false,
|
||||
val page: SettingsPage? = null,
|
||||
val error: String? = null,
|
||||
val notice: String? = null,
|
||||
val transfer: KeyTransferState = KeyTransferState()
|
||||
) {
|
||||
override fun toString() = "SettingsState(busy=$busy, [REDACTED])"
|
||||
}
|
||||
interface SettingsRepository {
|
||||
val state: StateFlow<SettingsState>
|
||||
fun reload()
|
||||
fun setDefaultKey(fingerprint: String)
|
||||
fun setGitIdentity(name: String, email: String)
|
||||
fun setTimeout(seconds: String)
|
||||
fun removeToken()
|
||||
fun rebuildCache(passphrase: String)
|
||||
fun openAutofill()
|
||||
fun openCredentials()
|
||||
fun beginTransfer()
|
||||
fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String)
|
||||
fun chooseQrImages()
|
||||
fun addQrImages(uris: List<String>)
|
||||
fun addQrPayload(payload: String)
|
||||
fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean)
|
||||
fun closeTransfer()
|
||||
fun cancel()
|
||||
}
|
||||
Reference in new issue
Block a user