Implement adaptive Android shell and safe navigation (#102)
This commit is contained in:
1 parent
c7703d8efd
commit
d22d71eb5e
32 files changed
+2194
-104
No files matched your search
+20
@@ -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()
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+25
-3
@@ -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
|
||||
}
|
||||
+11
-1
@@ -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 {
|
||||
|
||||
+13
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user