Implement Rust-owned adaptive Android password navigation (#96)
This commit is contained in:
1 parent
36e5f55283
commit
8c9341f214
26 files changed
+1553
-16
No files matched your search
+1
-1
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user