Implement Android TOTP cached discovery and search (#110)
This commit is contained in:
1 parent
0c8366d34f
commit
f4a6edbc68
18 files changed
+1070
-165
No files matched your search
@@ -10,6 +10,8 @@ Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android
|
||||
Passwords and Preferences share [Rust-owned biometric authentication](../docs/android/authentication.md).
|
||||
CameraX GPG scanning and QR export are described in
|
||||
[key transfer](../docs/android/key-transfer.md).
|
||||
The TOTP destination uses [cached discovery and search](../docs/android/totp-discovery.md)
|
||||
while locked, with authenticated incremental refresh for new or changed entries.
|
||||
Remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
|
||||
ARM-only runner/toolchain policy and artifact audits.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
@@ -70,19 +71,27 @@ class EntryViewerTest {
|
||||
}
|
||||
compose.onNodeWithText("654321").assertDoesNotExist()
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
compose.onNodeWithText("alice 日本語").performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithText("123 456").performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithContentDescription("Reveal PIN").performScrollTo()
|
||||
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("alice 日本語"))
|
||||
compose.onNodeWithText("alice 日本語").assertIsDisplayed()
|
||||
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("123 456"))
|
||||
compose.onNodeWithText("123 456").assertIsDisplayed()
|
||||
compose.onNodeWithTag(
|
||||
"entry-detail"
|
||||
).performScrollToNode(hasContentDescription("Reveal PIN"))
|
||||
compose.onNodeWithContentDescription("Reveal PIN")
|
||||
.performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) }
|
||||
assertEquals(2uL, repository.lastId)
|
||||
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("654321"))
|
||||
compose.onNodeWithText("654321").assertIsDisplayed()
|
||||
compose.onNodeWithContentDescription("Hide PIN").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Hide PIN"))
|
||||
compose.onNodeWithContentDescription("Hide PIN").performClick()
|
||||
compose.onNodeWithText("654321").assertDoesNotExist()
|
||||
compose.onNodeWithContentDescription("Copy PIN").performScrollTo()
|
||||
compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Copy PIN"))
|
||||
compose.onNodeWithContentDescription("Copy PIN")
|
||||
.performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) }
|
||||
assertEquals(2uL, repository.lastId)
|
||||
compose.onNodeWithContentDescription("Edit PIN").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Edit PIN"))
|
||||
compose.onNodeWithContentDescription("Edit PIN").performClick()
|
||||
compose.onNode(hasSetTextAction()).performTextReplacement("987654")
|
||||
compose.onNodeWithText("Save").performClick()
|
||||
assertEquals("987654", repository.saved)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import dagger.hilt.EntryPoints
|
||||
import de.rfc1437.ironstorage.core.EditorFieldKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TotpDiscoveryFixtureTest {
|
||||
/** Public test entry only, guarded by the retained fixture's exact key identity. */
|
||||
@Test fun addPublicTotpForComputerUse(): Unit = runBlocking {
|
||||
assumeTrue(InstrumentationRegistry.getArguments().getString("totpFixtureSetup") == "true")
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java)
|
||||
val setup = access.onboarding()
|
||||
withContext(Dispatchers.Main.immediate) { setup.refresh() }
|
||||
val before = withTimeout(30000) { setup.setup.first { !it.busy } }
|
||||
check(
|
||||
before.configured &&
|
||||
before.keys.single().fingerprint == "0C1ABFD707B2103E495B42ACF34D59DF60521FE3"
|
||||
)
|
||||
val catalog = access.totp()
|
||||
withContext(Dispatchers.Main.immediate) { catalog.enter(null, null) }
|
||||
val cached = withTimeout(30000) { catalog.state.first { !it.loading } }
|
||||
check(cached.error == null)
|
||||
if (cached.rows.any { it.path == "discovery-fixture" }) {
|
||||
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
|
||||
return@runBlocking
|
||||
}
|
||||
val editor = access.editor()
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
editor.create("", "discovery-fixture", "public-provider-fixture-passphrase")
|
||||
}
|
||||
val created = withTimeout(30000) { editor.state.first { !it.busy } }
|
||||
check(created.open && !created.locked && created.error == null) { created.error.orEmpty() }
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
editor.add(
|
||||
EditorFieldKind.OtpUri,
|
||||
null,
|
||||
"otpauth://totp/Public:discovery?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Public"
|
||||
)
|
||||
}
|
||||
withTimeout(30000) {
|
||||
editor.state.first {
|
||||
!it.busy &&
|
||||
it.fields.any { field -> field.kind == EditorFieldKind.OtpUri }
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main.immediate) { editor.save() }
|
||||
val saved = withTimeout(30000) { editor.state.first { !it.busy && !it.open } }
|
||||
check(saved.error == null)
|
||||
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,15 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.core.PasswordOtp
|
||||
import de.rfc1437.ironstorage.core.PasswordOtpKind
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.TotpDiscoveryPhase
|
||||
import de.rfc1437.ironstorage.core.TotpDiscoveryProgress
|
||||
import de.rfc1437.ironstorage.core.TotpRepository
|
||||
import de.rfc1437.ironstorage.core.TotpState
|
||||
import de.rfc1437.ironstorage.features.TotpScreen
|
||||
@@ -118,6 +121,27 @@ class TotpScreenTest {
|
||||
compose.onNodeWithTag("otp-code").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun discoveryShowsTypedProgressCancellationAndCachedQuery() {
|
||||
val fixture = Fixture()
|
||||
fixture.state.value = fixture.state.value.copy(
|
||||
refreshing = true,
|
||||
progress = TotpDiscoveryProgress(TotpDiscoveryPhase.Inspecting, 10, 4, 3, 2, 1)
|
||||
)
|
||||
display(fixture)
|
||||
compose.onNodeWithText("Inspecting entries").assertIsDisplayed()
|
||||
compose.onNodeWithText(
|
||||
"Inspected: 4 / 10 · Cache hits: 3 · Matches: 2 · Unavailable: 1"
|
||||
).assertIsDisplayed()
|
||||
compose.onNodeWithTag("totp-progress").assertIsDisplayed()
|
||||
compose.onNodeWithTag("totp-query").performTextInput("cached")
|
||||
assertEquals("cached", fixture.state.value.query)
|
||||
compose.onNodeWithText("Cancel").performClick()
|
||||
compose.onNodeWithText(
|
||||
"Cancelled. Completed checkpoints are retained; refresh to resume."
|
||||
).assertIsDisplayed()
|
||||
compose.onNodeWithText("Apple Watch").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun inspectDisposableTotpWithComputerUse() {
|
||||
val arguments = InstrumentationRegistry.getArguments()
|
||||
assumeTrue(arguments.getString("otpInspect") == "true")
|
||||
@@ -194,6 +218,9 @@ class TotpScreenTest {
|
||||
}
|
||||
override fun enter(selected: String?, revision: String?) = Unit
|
||||
override fun reload() = Unit
|
||||
override fun query(value: String) {
|
||||
state.value = state.value.copy(query = value)
|
||||
}
|
||||
override fun open(id: String, revision: String) {
|
||||
state.value =
|
||||
state.value.copy(detail = true)
|
||||
@@ -203,7 +230,12 @@ class TotpScreenTest {
|
||||
state.value = state.value.copy(detail = false)
|
||||
}
|
||||
override fun discover(passphrase: String) = Unit
|
||||
override fun cancel() = Unit
|
||||
override fun cancel() {
|
||||
state.value = state.value.copy(
|
||||
refreshing = false,
|
||||
progress = state.value.progress?.copy(phase = TotpDiscoveryPhase.Cancelled)
|
||||
)
|
||||
}
|
||||
override fun leave() = viewer.leave()
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package de.rfc1437.ironstorage
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.rfc1437.ironstorage.core.EditorRepository
|
||||
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.NativeCredentialRepository
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.TotpRepository
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@@ -13,4 +15,6 @@ interface ProviderFixtureAccess {
|
||||
fun onboarding(): OnboardingRepository
|
||||
fun authentication(): NativeAuthenticationRepository
|
||||
fun credentials(): NativeCredentialRepository
|
||||
fun editor(): EditorRepository
|
||||
fun totp(): TotpRepository
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeTotpDiscoveryTest {
|
||||
@Test fun cachedSearchRefreshRecoveryAndProcessRecreationUseRust(): Unit = runBlocking {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
|
||||
File(it, NativeEntryViewerTest.rootName).apply { mkdirs() }
|
||||
}
|
||||
val directories =
|
||||
PrivateDirectories(
|
||||
roots[0].canonicalPath,
|
||||
roots[1].canonicalPath,
|
||||
roots[2].canonicalPath
|
||||
)
|
||||
val keystore = NativeEntryViewerTest.MemoryKeystore()
|
||||
val session = NativePrivateSession(context, { directories }, keystore)
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
val auth = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
|
||||
val viewer =
|
||||
NativePasswordRepository(
|
||||
session,
|
||||
scope,
|
||||
auth,
|
||||
Dispatchers.IO,
|
||||
NativeEntryViewerTest.MemoryClipboard()
|
||||
)
|
||||
val catalog = NativeTotpRepository(session, auth, viewer, scope)
|
||||
val passphrase = "public-discovery-fixture"
|
||||
suspend fun catalogAction(action: () -> Unit): TotpState {
|
||||
withContext(Dispatchers.Main.immediate) { action() }
|
||||
return withTimeout(30000) { catalog.state.first { !it.loading } }
|
||||
}
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val setup = session.storage().androidOnboarding(session.backend())
|
||||
val key =
|
||||
requireNotNull(setup.generate("Discovery fixture", passphrase).selectedKey)
|
||||
setup.setupLocal(key, passphrase)
|
||||
}
|
||||
auth.authenticated { native ->
|
||||
for (name in listOf("first", "second", "ordinary")) {
|
||||
val editor = native.beginCreateEntry("", name, passphrase)
|
||||
val page = if (name != "ordinary") {
|
||||
native.addEntryEditorField(
|
||||
editor.id,
|
||||
MobileEntryEditorFieldKind.OTP_URI,
|
||||
null,
|
||||
"otpauth://totp/Public:$name?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||
)
|
||||
} else {
|
||||
editor.page
|
||||
}
|
||||
native.saveEntryEditor(
|
||||
editor.id,
|
||||
page.fields.map {
|
||||
MobileEntryEditorInput(
|
||||
it.id,
|
||||
it.name,
|
||||
if (it.kind == MobileEntryEditorFieldKind.PASSWORD) {
|
||||
"public-discovery-password"
|
||||
} else {
|
||||
it.value.orEmpty()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main.immediate) { auth.lock() }
|
||||
assertEquals(2, catalogAction { catalog.enter(null, null) }.rows.size)
|
||||
assertFalse(auth.state.value.unlocked)
|
||||
val warm = catalogAction { catalog.discover("") }
|
||||
assertNull(warm.error)
|
||||
assertEquals(TotpDiscoveryPhase.Complete, warm.progress!!.phase)
|
||||
assertEquals(3L, warm.progress.cacheHits)
|
||||
assertFalse(auth.state.value.unlocked)
|
||||
val latest = catalogAction {
|
||||
catalog.query("first")
|
||||
catalog.query("second")
|
||||
catalog.query("missing")
|
||||
}
|
||||
assertEquals("missing", latest.query)
|
||||
assertTrue(latest.rows.isEmpty())
|
||||
assertEquals("first", catalogAction { catalog.query(" FIRST ") }.rows.single().title)
|
||||
assertFalse(auth.state.value.unlocked)
|
||||
val cache = File(roots[1], "ironstorage/totp-catalog.toml")
|
||||
assertTrue(cache.isFile)
|
||||
assertFalse(cache.readText().contains("otpauth://"))
|
||||
// Only this isolated test catalog is damaged; production state is never reset.
|
||||
cache.writeText("damaged fixture catalog")
|
||||
assertFalse(catalogAction { catalog.query("") }.hasCatalog)
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
catalog.discover(passphrase)
|
||||
catalog.cancel()
|
||||
}
|
||||
assertEquals(TotpDiscoveryPhase.Cancelled, catalog.state.value.progress!!.phase)
|
||||
val recovered = catalogAction { catalog.discover(passphrase) }
|
||||
assertNull(recovered.error)
|
||||
assertEquals(2, recovered.rows.size)
|
||||
assertEquals(0L, recovered.progress!!.cacheHits)
|
||||
assertEquals(3L, recovered.progress.inspected)
|
||||
withContext(Dispatchers.Main.immediate) { auth.lock() }
|
||||
val reopened = NativePrivateSession(context, { directories }, keystore)
|
||||
val recreatedAuth = NativeAuthenticationRepository(reopened, scope, Dispatchers.IO)
|
||||
val recreatedViewer =
|
||||
NativePasswordRepository(
|
||||
reopened,
|
||||
scope,
|
||||
recreatedAuth,
|
||||
Dispatchers.IO,
|
||||
NativeEntryViewerTest.MemoryClipboard()
|
||||
)
|
||||
val recreated = NativeTotpRepository(reopened, recreatedAuth, recreatedViewer, scope)
|
||||
withContext(Dispatchers.Main.immediate) { recreated.enter(null, null) }
|
||||
val retained = withTimeout(30000) { recreated.state.first { !it.loading } }
|
||||
assertNull(retained.error)
|
||||
assertEquals(2, retained.rows.size)
|
||||
assertFalse(recreatedAuth.state.value.unlocked)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { scope.cancel() }
|
||||
roots.forEach { it.deleteRecursively() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -76,6 +76,8 @@ class NativeAuthenticationRepository(
|
||||
internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T =
|
||||
access(true, block)
|
||||
|
||||
internal suspend fun <T> metadata(block: (MobileAuthentication) -> T): T = access(false, block)
|
||||
|
||||
internal suspend fun <T> observe(block: (MobileAuthentication) -> T): T? =
|
||||
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
|
||||
if (mutable.value.busy) null else access(false, block)
|
||||
|
||||
@@ -3,11 +3,16 @@ package de.rfc1437.ironstorage.core
|
||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryPhase
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryProgress
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation
|
||||
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -24,28 +29,68 @@ class NativeTotpRepository(
|
||||
private val mutable = MutableStateFlow(TotpState())
|
||||
override val state: StateFlow<TotpState> = mutable
|
||||
private val serial = Mutex()
|
||||
private var searchEpoch = 0L
|
||||
private var refreshEpoch = 0L
|
||||
private var debounce: Job? = null
|
||||
|
||||
@Volatile private var epoch = 0L
|
||||
@Volatile private var search: MobileTotpOperation? = null
|
||||
|
||||
@Volatile private var discovery: MobileTotpOperation? = null
|
||||
|
||||
init {
|
||||
val cleanup = authentication.presentation.register(::cancel)
|
||||
scope.coroutineContext[Job]?.invokeOnCompletion { cleanup.close() }
|
||||
val cleanup = authentication.presentation.register(::cancelDiscovery)
|
||||
val metadata = authentication.presentation.registerMetadata(::cancel)
|
||||
scope.coroutineContext[Job]?.invokeOnCompletion {
|
||||
cleanup.close()
|
||||
metadata.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun enter(selected: String?, revision: String?) = load(selected, revision)
|
||||
override fun reload() = load(null, null)
|
||||
private fun load(selected: String?, revision: String?) = run { ticket ->
|
||||
val rows = authentication.authenticated { it.cachedTotpNavigation() }
|
||||
if (ticket == epoch) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
rows = rows.orEmpty().map { it.toFacade() },
|
||||
hasCatalog =
|
||||
rows != null
|
||||
)
|
||||
if (selected != null && revision != null) open(selected, revision)
|
||||
override fun query(value: String) {
|
||||
mutable.value = mutable.value.copy(query = value)
|
||||
load(null, null, true)
|
||||
}
|
||||
private fun load(selected: String?, revision: String?, wait: Boolean = false) {
|
||||
val ticket = ++searchEpoch
|
||||
search?.cancel()
|
||||
debounce?.cancel()
|
||||
val query = mutable.value.query
|
||||
mutable.value = mutable.value.copy(searching = true, error = null)
|
||||
// Debounce only queues work; native calls finish under the serial gate after cancellation.
|
||||
debounce = scope.launch {
|
||||
if (wait) delay(200)
|
||||
scope.launch {
|
||||
serial.withLock {
|
||||
if (ticket != searchEpoch) return@withLock
|
||||
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
|
||||
search = active
|
||||
if (ticket != searchEpoch) active.cancel()
|
||||
try {
|
||||
val rows = authentication.metadata {
|
||||
it.searchCachedTotpNavigation(query, active)
|
||||
}
|
||||
if (ticket == searchEpoch) {
|
||||
mutable.value = mutable.value.copy(
|
||||
rows = rows.orEmpty().map { it.toFacade() },
|
||||
hasCatalog = rows != null
|
||||
)
|
||||
if (selected != null && revision != null) open(selected, revision)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
if (ticket == searchEpoch) failure(error)
|
||||
} finally {
|
||||
if (search === active) search = null
|
||||
active.close()
|
||||
if (ticket ==
|
||||
searchEpoch
|
||||
) {
|
||||
mutable.value = mutable.value.copy(searching = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun open(id: String, revision: String) {
|
||||
@@ -58,74 +103,116 @@ class NativeTotpRepository(
|
||||
mutable.value = mutable.value.copy(detail = false)
|
||||
reload()
|
||||
}
|
||||
override fun discover(passphrase: String) = run { ticket ->
|
||||
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
|
||||
discovery = active
|
||||
if (ticket != epoch) active.cancel()
|
||||
try {
|
||||
val rows = authentication.authenticated {
|
||||
it.unlockTotp(passphrase.ifEmpty { null })
|
||||
it.totpPage(active)
|
||||
it.cachedTotpNavigation()
|
||||
override fun discover(passphrase: String) {
|
||||
if (mutable.value.refreshing) return
|
||||
val ticket = ++refreshEpoch
|
||||
mutable.value = mutable.value.copy(
|
||||
refreshing = true,
|
||||
error = null,
|
||||
notice = null,
|
||||
progress = TotpDiscoveryProgress(TotpDiscoveryPhase.Preparing, 0, 0, 0, 0, 0)
|
||||
)
|
||||
scope.launch {
|
||||
serial.withLock {
|
||||
if (ticket != refreshEpoch) return@withLock
|
||||
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
|
||||
discovery = active
|
||||
if (ticket != refreshEpoch) active.cancel()
|
||||
try {
|
||||
coroutineScope {
|
||||
val polling = launch {
|
||||
while (true) {
|
||||
val progress =
|
||||
withContext(Dispatchers.IO) { active.progress().facade() }
|
||||
if (ticket ==
|
||||
refreshEpoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(progress = progress)
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val result = authentication.authenticated {
|
||||
it.refreshTotpCatalog(active, passphrase.ifEmpty { null })
|
||||
}
|
||||
if (ticket ==
|
||||
refreshEpoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
progress = result.progress.facade(),
|
||||
notice = result.cacheNotice
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
polling.cancelAndJoin()
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
if (ticket == refreshEpoch) failure(error)
|
||||
} finally {
|
||||
if (discovery === active) discovery = null
|
||||
active.close()
|
||||
if (ticket == refreshEpoch) {
|
||||
// Keep loading true until the refreshed rows have been published.
|
||||
if (mutable.value.error == null) reload()
|
||||
mutable.value = mutable.value.copy(refreshing = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
rows = rows.orEmpty().map { it.toFacade() },
|
||||
hasCatalog =
|
||||
rows != null
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
discovery = null
|
||||
active.close()
|
||||
}
|
||||
}
|
||||
override fun cancel() {
|
||||
epoch += 1
|
||||
searchEpoch++
|
||||
debounce?.cancel()
|
||||
search?.cancel()
|
||||
cancelDiscovery()
|
||||
mutable.value = mutable.value.copy(searching = false)
|
||||
}
|
||||
private fun cancelDiscovery() {
|
||||
refreshEpoch++
|
||||
discovery?.cancel()
|
||||
mutable.value = mutable.value.copy(loading = false)
|
||||
mutable.value = mutable.value.copy(
|
||||
refreshing = false,
|
||||
progress = if (mutable.value.refreshing) {
|
||||
mutable.value.progress?.copy(
|
||||
phase = TotpDiscoveryPhase.Cancelled
|
||||
)
|
||||
} else {
|
||||
mutable.value.progress
|
||||
}
|
||||
)
|
||||
}
|
||||
override fun leave() {
|
||||
cancel()
|
||||
viewer.leave()
|
||||
}
|
||||
private fun run(action: suspend (Long) -> Unit) {
|
||||
if (mutable.value.loading) return
|
||||
val ticket = epoch
|
||||
mutable.value = mutable.value.copy(loading = true, error = null)
|
||||
scope.launch {
|
||||
serial.withLock {
|
||||
if (ticket != epoch) return@withLock
|
||||
try {
|
||||
action(ticket)
|
||||
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = error.title + "\n" + error.detail)
|
||||
}
|
||||
} catch (_: MobilePrivateStorageFfiException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = session.text(R.string.core_private_recovery))
|
||||
}
|
||||
} catch (_: NativeBridgeException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = session.text(R.string.core_update_retry))
|
||||
}
|
||||
} finally {
|
||||
if (ticket == epoch) mutable.value = mutable.value.copy(loading = false)
|
||||
}
|
||||
}
|
||||
private fun failure(error: Exception) {
|
||||
val text = when (error) {
|
||||
is MobileAuthenticationFfiException.Failed -> error.title + "\n" + error.detail
|
||||
is MobilePrivateStorageFfiException -> session.text(R.string.core_private_recovery)
|
||||
is NativeBridgeException -> session.text(R.string.core_update_retry)
|
||||
is java.io.IOException -> session.text(R.string.core_private_recovery)
|
||||
is kotlinx.coroutines.CancellationException -> throw error
|
||||
else -> throw error
|
||||
}
|
||||
mutable.value = mutable.value.copy(error = text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MobileTotpDiscoveryProgress.facade() = TotpDiscoveryProgress(
|
||||
when (phase) {
|
||||
MobileTotpDiscoveryPhase.PREPARING -> TotpDiscoveryPhase.Preparing
|
||||
MobileTotpDiscoveryPhase.INSPECTING -> TotpDiscoveryPhase.Inspecting
|
||||
MobileTotpDiscoveryPhase.SAVING -> TotpDiscoveryPhase.Saving
|
||||
MobileTotpDiscoveryPhase.COMPLETE -> TotpDiscoveryPhase.Complete
|
||||
MobileTotpDiscoveryPhase.CANCELLED -> TotpDiscoveryPhase.Cancelled
|
||||
},
|
||||
total.toLong(),
|
||||
inspected.toLong(),
|
||||
cacheHits.toLong(),
|
||||
matches.toLong(),
|
||||
unavailable.toLong()
|
||||
)
|
||||
@@ -2,13 +2,28 @@ package de.rfc1437.ironstorage.core
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class TotpDiscoveryPhase { Preparing, Inspecting, Saving, Complete, Cancelled }
|
||||
data class TotpDiscoveryProgress(
|
||||
val phase: TotpDiscoveryPhase,
|
||||
val total: Long,
|
||||
val inspected: Long,
|
||||
val cacheHits: Long,
|
||||
val matches: Long,
|
||||
val unavailable: Long
|
||||
)
|
||||
|
||||
data class TotpState(
|
||||
val rows: List<PasswordRow> = emptyList(),
|
||||
val loading: Boolean = false,
|
||||
val refreshing: Boolean = false,
|
||||
val searching: Boolean = false,
|
||||
val query: String = "",
|
||||
val progress: TotpDiscoveryProgress? = null,
|
||||
val hasCatalog: Boolean = false,
|
||||
val detail: Boolean = false,
|
||||
val notice: String? = null,
|
||||
val error: String? = null
|
||||
) {
|
||||
val loading get() = refreshing || searching
|
||||
override fun toString() = "TotpState(loading=$loading, [REDACTED])"
|
||||
}
|
||||
interface TotpRepository {
|
||||
@@ -16,6 +31,7 @@ interface TotpRepository {
|
||||
val viewer: PasswordRepository
|
||||
fun enter(selected: String?, revision: String?)
|
||||
fun reload()
|
||||
fun query(value: String)
|
||||
fun open(id: String, revision: String)
|
||||
fun back()
|
||||
fun discover(passphrase: String)
|
||||
|
||||
@@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -24,15 +28,20 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.TotpDiscoveryPhase
|
||||
import de.rfc1437.ironstorage.core.TotpRepository
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TotpScreen(repository: TotpRepository, authentication: AuthenticationRepository) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
@@ -67,46 +76,118 @@ fun TotpScreen(repository: TotpRepository, authentication: AuthenticationReposit
|
||||
val expanded = maxWidth >= 600.dp
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (expanded || !state.detail) {
|
||||
LazyColumn(
|
||||
Modifier.weight(1f).padding(16.dp).testTag("totp-list"),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.refreshing,
|
||||
onRefresh = requestCatalog,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.entry_totp),
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
TextButton(
|
||||
enabled = !state.loading && !lease.busy,
|
||||
onClick = requestCatalog
|
||||
) {
|
||||
Text(stringResource(R.string.totp_refresh_catalog))
|
||||
}
|
||||
if (state.loading) {
|
||||
CircularProgressIndicator()
|
||||
TextButton(onClick = repository::cancel) {
|
||||
Text(stringResource(R.string.auth_cancel))
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(16.dp).testTag("totp-list"),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.entry_totp),
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
TextButton(
|
||||
enabled = !state.loading && !lease.busy,
|
||||
onClick = requestCatalog
|
||||
) {
|
||||
Text(stringResource(R.string.totp_refresh_catalog))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.query,
|
||||
onValueChange = repository::query,
|
||||
label = { Text(stringResource(R.string.totp_search)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("totp-query")
|
||||
)
|
||||
state.progress?.let { progress ->
|
||||
Column(Modifier.semantics { liveRegion = LiveRegionMode.Polite }) {
|
||||
Text(
|
||||
stringResource(
|
||||
when (progress.phase) {
|
||||
TotpDiscoveryPhase.Preparing ->
|
||||
R.string.totp_preparing
|
||||
TotpDiscoveryPhase.Inspecting ->
|
||||
R.string.totp_inspecting
|
||||
TotpDiscoveryPhase.Saving ->
|
||||
R.string.totp_saving
|
||||
TotpDiscoveryPhase.Complete ->
|
||||
R.string.totp_complete
|
||||
TotpDiscoveryPhase.Cancelled ->
|
||||
R.string.totp_cancelled
|
||||
}
|
||||
)
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.totp_progress_counts,
|
||||
progress.inspected,
|
||||
progress.total,
|
||||
progress.cacheHits,
|
||||
progress.matches,
|
||||
progress.unavailable
|
||||
)
|
||||
)
|
||||
if (progress.total > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = {
|
||||
(
|
||||
progress.inspected.toFloat() /
|
||||
progress.total
|
||||
).coerceIn(
|
||||
0f,
|
||||
1f
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().testTag(
|
||||
"totp-progress"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.loading) {
|
||||
if (state.searching ||
|
||||
state.progress?.phase == TotpDiscoveryPhase.Preparing
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
TextButton(onClick = repository::cancel) {
|
||||
Text(stringResource(R.string.auth_cancel))
|
||||
}
|
||||
}
|
||||
state.notice?.let { Text(it) }
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (!state.hasCatalog) {
|
||||
Text(stringResource(R.string.totp_needs_catalog))
|
||||
} else if (state.rows.isEmpty() && !state.searching) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.query.isEmpty()) {
|
||||
R.string.totp_empty
|
||||
} else {
|
||||
R.string.totp_no_matches
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (!state.hasCatalog) {
|
||||
Text(stringResource(R.string.totp_needs_catalog))
|
||||
} else if (state.rows.isEmpty()) {
|
||||
Text(stringResource(R.string.totp_empty))
|
||||
}
|
||||
}
|
||||
items(state.rows, key = { it.id }) { row ->
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clickable(
|
||||
enabled = !state.loading,
|
||||
role = Role.Button,
|
||||
onClick = {
|
||||
repository.open(row.id, row.revision)
|
||||
}
|
||||
).padding(vertical = 12.dp)
|
||||
) {
|
||||
Text(row.title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(row.detail)
|
||||
items(state.rows, key = { it.id }) { row ->
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clickable(
|
||||
enabled = !state.loading,
|
||||
role = Role.Button,
|
||||
onClick = {
|
||||
repository.open(row.id, row.revision)
|
||||
}
|
||||
).padding(vertical = 12.dp)
|
||||
) {
|
||||
Text(row.title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(row.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,14 @@
|
||||
<item quantity="other">%1$s seconds remaining</item>
|
||||
</plurals>
|
||||
<string name="totp_refresh_catalog">Refresh TOTP catalog</string>
|
||||
<string name="totp_search">Search cached TOTP entries</string>
|
||||
<string name="totp_no_matches">No cached TOTP entries match this search.</string>
|
||||
<string name="totp_preparing">Preparing discovery</string>
|
||||
<string name="totp_inspecting">Inspecting entries</string>
|
||||
<string name="totp_saving">Saving catalog</string>
|
||||
<string name="totp_complete">Discovery complete</string>
|
||||
<string name="totp_cancelled">Cancelled. Completed checkpoints are retained; refresh to resume.</string>
|
||||
<string name="totp_progress_counts">Inspected: %1$d / %2$d · Cache hits: %3$d · Matches: %4$d · Unavailable: %5$d</string>
|
||||
<string name="totp_needs_catalog">Refresh the catalog after unlocking to find TOTP entries.</string>
|
||||
<string name="totp_empty">No TOTP entries found.</string>
|
||||
<string name="totp_choose">Choose a TOTP entry.</string>
|
||||
|
||||
Reference in new issue
Block a user