Implement Rust-owned adaptive Android password navigation (#96)
Android ARM verification / rust (push) Failing after 46m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-09 12:25:02 +02:00
1 parent 36e5f55283
commit 8c9341f214
26 files changed
+1553 -16

No files matched your search

@@ -173,7 +173,7 @@ private fun NativeFailureKind.toFacade(): NativeFailure = when (this) {
NativeFailureKind.MainThreadCall -> NativeFailure.MainThreadCall
}
private fun MobileIcon.toFacade(): ShellIcon = when (this) {
internal fun MobileIcon.toFacade(): ShellIcon = when (this) {
MobileIcon.HOME -> ShellIcon.Home
MobileIcon.KEY -> ShellIcon.Key
MobileIcon.SEARCH -> ShellIcon.Search
@@ -0,0 +1,202 @@
package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthentication
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativePasswordRepository(
private val session: NativePrivateSession,
private val scope: CoroutineScope
) : PasswordRepository {
private val mutable = MutableStateFlow(PasswordState())
override val state: StateFlow<PasswordState> = mutable
private val sequence = AtomicLong()
private val mutex = Mutex()
private var authentication: MobileAuthentication? = null
private val clock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
init {
scope.launch {
while (true) {
delay(1000)
if (mutable.value.sections != null) {
mutex.withLock {
val active = try {
withContext(Dispatchers.IO) {
authentication?.state()?.unlocked == true
}
} catch (_: MobileAuthenticationFfiException) {
false
} catch (_: NativeBridgeException) {
false
}
if (!active) mutable.value = mutable.value.copy(sections = null)
}
}
}
}
}
override fun restore(path: String, selected: String?, revision: String?) =
navigate(path, selected, revision, false)
override fun refresh() {
val current = mutable.value
navigate(current.path, current.selected?.id, current.revision.ifEmpty { null }, false)
}
override fun activate(id: String, revision: String) =
navigate(mutable.value.path, id, revision, true)
override fun inspect(id: String, revision: String) =
navigate(mutable.value.path, id, revision, false)
override fun back() {
val current = mutable.value
navigate(
if (current.selected !=
null
) {
current.path
} else {
current.parent ?: ""
},
null,
null,
false
)
}
private fun navigate(path: String, selected: String?, revision: String?, activate: Boolean) =
run {
authentication?.manualLock()
authentication = null
session.storage().passwordNavigation(path, selected, revision, activate).toFacade()
}
override fun unlock(passphrase: String) {
val current = mutable.value
val selected = current.selected ?: return
if (selected.kind != PasswordKind.Entry) return
run {
val verified = session.storage().passwordNavigation(
current.path,
selected.id,
current.revision,
false
).toFacade()
if (verified.selected == null) return@run verified
val auth =
authentication
?: session.storage().authentication(session.backend(), clock).also {
authentication =
it
}
val page = auth.navigationEntry(
current.path,
selected.id,
current.revision,
passphrase.ifEmpty { null },
(System.currentTimeMillis() / 1000).toULong()
)
verified.copy(
sections = page.page.sections.map { section ->
PasswordSection(
section.title,
section.fields.map { field ->
PasswordField(
field.id,
field.label,
field.value ?: field.maskedValue,
field.diagnostic
)
}
)
}
)
}
}
override fun lock() {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(sections = null, loading = false)
scope.launch {
mutex.withLock {
try {
withContext(Dispatchers.IO) { authentication?.manualLock() }
} catch (_: MobileAuthenticationFfiException) {
mutable.value = mutable.value.copy(
error = "Protected content was cleared. Retry locking the device storage."
)
}
}
}
}
private fun run(action: () -> PasswordState) {
val ticket = sequence.incrementAndGet()
mutable.value = mutable.value.copy(loading = true, error = null, sections = null)
scope.launch {
mutex.withLock {
if (sequence.get() != ticket) return@withLock
val result = try {
withContext(Dispatchers.IO) { action() }
} catch (error: MobilePasswordFfiException.Failed) {
mutable.value.copy(error = error.detail, selected = null)
} catch (error: MobileAuthenticationFfiException.Failed) {
mutable.value.copy(error = error.detail)
} catch (_: MobilePrivateStorageFfiException) {
mutable.value.copy(
error = "Unlock the device or recover the private store and refresh."
)
} catch (_: NativeBridgeException) {
mutable.value.copy(error = "Update the application and retry.")
} catch (_: java.io.IOException) {
mutable.value.copy(
error = "Private storage is unavailable. Unlock the device and refresh.",
selected = null
)
}
if (sequence.get() == ticket) {
mutable.value = result.copy(loading = false)
} else {
withContext(Dispatchers.IO) { authentication?.manualLock() }
}
}
}
}
}
private fun MobilePasswordNavigation.toFacade(): PasswordState {
fun row(value: de.rfc1437.ironstorage.bridge.generated.MobileNavigationRow): PasswordRow =
PasswordRow(
value.row.id,
value.row.path,
value.row.title,
value.row.detail,
when (value.row.kind) {
MobilePasswordRowKind.DIRECTORY -> PasswordKind.Folder
MobilePasswordRowKind.ENTRY -> PasswordKind.Entry
},
value.revision,
value.row.icon.toFacade()
)
return PasswordState(
path,
title,
parent,
revision,
rows.map(::row),
selected?.let(::row),
stale = stale
)
}
@@ -0,0 +1,43 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class PasswordKind { Folder, Entry }
data class PasswordRow(
val id: String,
val path: String,
val title: String,
val detail: String,
val kind: PasswordKind,
val revision: String,
val icon: ShellIcon
)
data class PasswordField(
val id: ULong,
val label: String,
val value: String,
val diagnostic: String?
)
data class PasswordSection(val title: String, val fields: List<PasswordField>)
data class PasswordState(
val path: String = "",
val title: String = "Passwords",
val parent: String? = null,
val revision: String = "",
val rows: List<PasswordRow> = emptyList(),
val selected: PasswordRow? = null,
val loading: Boolean = false,
val stale: Boolean = false,
val error: String? = null,
val sections: List<PasswordSection>? = null
)
interface PasswordRepository {
val state: StateFlow<PasswordState>
fun restore(path: String, selected: String?, revision: String?)
fun refresh()
fun activate(id: String, revision: String)
fun inspect(id: String, revision: String)
fun back()
fun unlock(passphrase: String)
fun lock()
}