Implement adaptive Android shell and safe navigation (#102)
Android ARM verification / rust (push) Failing after 47m11s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 25s

This commit is contained in:
hugo committed 2026-10-09 20:33:31 +02:00
1 parent c7703d8efd
commit d22d71eb5e
32 files changed
+2194 -104

No files matched your search

@@ -59,6 +59,26 @@ class NativeEntryViewerTest {
val key = setup.generate("Viewer fixture", password).selectedKey
setup.setupLocal(requireNotNull(key), password)
}
for (appearance in Appearance.entries) {
withContext(Dispatchers.Main.immediate) { authentication.setAppearance(appearance) }
withTimeout(30000) {
authentication.state.first {
!it.busy &&
it.appearance == appearance
}
}
assertEquals(appearance, authentication.state.value.appearance)
assertFalse(authentication.state.value.unlocked)
}
val restoredAppearance = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
withContext(Dispatchers.Main.immediate) { restoredAppearance.reload() }
withTimeout(30000) {
restoredAppearance.state.first {
!it.busy &&
it.appearance == Appearance.Dark
}
}
assertFalse(restoredAppearance.state.value.unlocked)
authentication.authenticated { auth ->
val draft = auth.beginCreateEntry("", "viewer", password)
val first = draft.page.fields.first()
@@ -2,6 +2,8 @@ package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class Appearance { System, Light, Dark }
enum class AuthenticationFailureKind {
PassphraseRequired,
InvalidPassphrase,
@@ -24,11 +26,13 @@ data class AuthenticationState(
val biometricEnabled: Boolean = false,
val remainingSeconds: ULong = 0u,
val busy: Boolean = false,
val failure: AuthenticationFailure? = null
val failure: AuthenticationFailure? = null,
val appearance: Appearance = Appearance.System
)
interface AuthenticationRepository {
val presentation: ProtectedPresentation
val state: StateFlow<AuthenticationState>
fun setAppearance(appearance: Appearance)
fun reload()
fun unlock()
fun enroll(passphrase: String)
@@ -2,6 +2,7 @@ package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAppearance
import de.rfc1437.ironstorage.bridge.generated.MobileAuthentication
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
@@ -48,7 +49,7 @@ class NativeAuthenticationRepository(
try {
val value = withContext(io) { current?.state() }
if (ticket == sequence.get() && value != null) {
publish(value.toFacade())
publish(value.toFacade().copy(appearance = mutable.value.appearance))
}
} catch (_: MobileAuthenticationFfiException) {
if (ticket == sequence.get()) lock()
@@ -83,14 +84,19 @@ class NativeAuthenticationRepository(
}
result
}
val value = withContext(io) { current!!.state() }
if (ticket == sequence.get()) publish(value.toFacade())
val value = withContext(io) {
current!!.state().toFacade().copy(
appearance = current!!.mobileAppearance().toFacade()
)
}
if (ticket == sequence.get()) publish(value)
result
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket == sequence.get()) {
val value = withContext(io) { current?.state() }
publish(
(value?.toFacade() ?: AuthenticationState()).copy(
appearance = mutable.value.appearance,
failure = error.toFacade()
)
)
@@ -110,6 +116,15 @@ class NativeAuthenticationRepository(
}
}
}
override fun setAppearance(appearance: Appearance) = run {
it.setMobileAppearance(
when (appearance) {
Appearance.System -> MobileAppearance.SYSTEM
Appearance.Light -> MobileAppearance.LIGHT
Appearance.Dark -> MobileAppearance.DARK
}
)
}
override fun reload() = run { it.state() }
override fun unlock() = run { it.restoreBiometricLease() }
override fun enroll(passphrase: String) = run { it.enrollBiometricUnlock(passphrase) }
@@ -137,6 +152,7 @@ class NativeAuthenticationRepository(
private fun unavailable() {
publish(
AuthenticationState(
appearance = mutable.value.appearance,
failure = AuthenticationFailure(
AuthenticationFailureKind.Configuration,
"Authentication is unavailable",
@@ -243,3 +259,9 @@ private fun MobileAuthenticationFfiException.Failed.toFacade() = AuthenticationF
title,
detail
)
private fun MobileAppearance.toFacade(): Appearance = when (this) {
MobileAppearance.SYSTEM -> Appearance.System
MobileAppearance.LIGHT -> Appearance.Light
MobileAppearance.DARK -> Appearance.Dark
}
@@ -33,12 +33,14 @@ class NativeFixtureShellRepository internal constructor(
override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot
private val revision = AtomicLong()
private var job: Job? = null
private var selectedByUi = false
init {
refresh()
}
override fun selectTab(tab: ShellTab) {
selectedByUi = true
mutableSnapshot.update { it.copy(selectedTab = tab) }
}
@@ -63,7 +65,13 @@ class NativeFixtureShellRepository internal constructor(
mutableSnapshot.update {
result.copy(
fixture = fixture,
selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab
selectedTab = if (!selectedByUi &&
it.pages.isEmpty()
) {
result.selectedTab
} else {
it.selectedTab
}
)
}
}
@@ -145,6 +153,7 @@ private fun MobileShellState.toFacade(): ShellState = when (this) {
MobileShellState.READY -> ShellState.Ready
MobileShellState.LOCKED -> ShellState.Locked
MobileShellState.ERROR -> ShellState.Error
MobileShellState.STALE -> ShellState.Stale
}
private fun ShellState.toNative(): MobileShellState = when (this) {
@@ -153,6 +162,7 @@ private fun ShellState.toNative(): MobileShellState = when (this) {
ShellState.Ready -> MobileShellState.READY
ShellState.Locked -> MobileShellState.LOCKED
ShellState.Error -> MobileShellState.ERROR
ShellState.Stale -> MobileShellState.STALE
}
private fun MobileTab.toFacade(): ShellTab = when (this) {
@@ -80,6 +80,7 @@ class NativeOnboardingRepository(
}
}
val discovery = request.discover()
if (revoked.get()) throw interrupted()
pollProgress = false
mutableSetup.update {
it.copy(
@@ -116,6 +117,7 @@ class NativeOnboardingRepository(
}
override fun replaceToken(account: String, token: String) = runSetup {
newOperation().replaceToken(account, token)
if (revoked.get()) throw interrupted()
pollProgress = false
mutableSetup.update {
it.copy(
@@ -143,6 +145,7 @@ class NativeOnboardingRepository(
}
}
private fun completed(outcome: MobileOnboardingOutcome) {
if (revoked.get()) throw interrupted()
updateStatus(requireNotNull(operation).status())
mutableSetup.update {
it.copy(title = outcome.title, detail = outcome.detail, branches = emptyList())
@@ -152,6 +155,7 @@ class NativeOnboardingRepository(
shell.refresh()
}
private fun updateStatus(status: MobileAndroidSetupStatus) {
if (revoked.get()) throw interrupted()
pollProgress = false
mutableSetup.update {
it.copy(
@@ -134,6 +134,11 @@ class NativePasswordRepository(
verified.present(page)
}
}
override fun leave() {
sequence.incrementAndGet()
mutable.value = mutable.value.cleared().copy(loading = false)
authentication.lifecycle(AuthenticationLifecycle.Background)
}
override fun lock() {
sequence.incrementAndGet()
mutable.value = mutable.value.cleared().copy(loading = false)
@@ -80,6 +80,7 @@ interface PasswordRepository {
fun inspect(id: String, revision: String)
fun back()
fun unlock(passphrase: String)
fun leave() = lock()
fun lock()
fun reveal(id: ULong)
fun hide(id: ULong)
@@ -12,23 +12,25 @@ class ProtectedPresentation {
windows += 1
return AutoCloseable { windows -= 1 }
}
private val metadata = linkedSetOf<() -> Unit>()
private val metadata = linkedMapOf<Any, () -> Unit>()
fun registerMetadata(clear: () -> Unit): AutoCloseable {
synchronized(metadata) { metadata.add(clear) }
return AutoCloseable { synchronized(metadata) { metadata.remove(clear) } }
val registration = Any()
synchronized(metadata) { metadata[registration] = clear }
return AutoCloseable { synchronized(metadata) { metadata.remove(registration) } }
}
fun clearMetadata() {
synchronized(metadata) { metadata.toList() }.forEach { it() }
synchronized(metadata) { metadata.values.toList() }.forEach { it() }
}
private val listeners = linkedSetOf<() -> Unit>()
private val listeners = linkedMapOf<Any, () -> Unit>()
private val mutableGeneration = MutableStateFlow(0L)
val generation: StateFlow<Long> = mutableGeneration
fun register(clear: () -> Unit): AutoCloseable {
synchronized(listeners) { listeners.add(clear) }
return AutoCloseable { synchronized(listeners) { listeners.remove(clear) } }
val registration = Any()
synchronized(listeners) { listeners[registration] = clear }
return AutoCloseable { synchronized(listeners) { listeners.remove(registration) } }
}
fun clear() {
synchronized(listeners) { listeners.toList() }.forEach { it() }
synchronized(listeners) { listeners.values.toList() }.forEach { it() }
mutableGeneration.value += 1
}
}
@@ -2,7 +2,7 @@ package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class ShellState { Loading, Empty, Ready, Locked, Error }
enum class ShellState { Loading, Empty, Ready, Locked, Error, Stale }
enum class ShellTab { Home, Passwords, Search, Totp, Preferences }
enum class ShellIcon {
@@ -10,6 +10,19 @@ import org.junit.Test
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class NativeFixtureShellRepositoryTest {
@Test
fun restoredTabWinsOverInitialNativeCompletion() = runTest {
val pending = CompletableDeferred<Unit>()
val repository = NativeFixtureShellRepository(backgroundScope) { state ->
pending.await()
ShellSnapshot(state, emptyList(), selectedTab = ShellTab.Home)
}
repository.selectTab(ShellTab.Preferences)
pending.complete(Unit)
runCurrent()
assertEquals(ShellTab.Preferences, repository.snapshot.value.selectedTab)
}
@Test
fun cancelledNativeCompletionCannotReplaceNewerStateOrTabSelection() = runTest {
val pending = CompletableDeferred<Unit>()
@@ -29,4 +29,31 @@ class ProtectedPresentationTest {
assertEquals("disposed", secret)
assertEquals(2L, presentation.generation.value)
}
@Test fun overlappingScreensCannotUnregisterAnotherOwnersIdenticalCallback() {
val presentation = ProtectedPresentation()
for (metadata in listOf(false, true)) {
var calls = 0
val callback = { calls += 1 }
fun register() = if (metadata) {
presentation.registerMetadata(
callback
)
} else {
presentation.register(callback)
}
fun clear() = if (metadata) presentation.clearMetadata() else presentation.clear()
val first = register()
val second = register()
clear()
assertEquals(2, calls)
first.close()
first.close()
clear()
assertEquals(3, calls)
second.close()
clear()
assertEquals(3, calls)
}
}
}