Implement Android TOTP cached discovery and search (#110)
Android ARM verification / rust (push) Failing after 49m58s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 22s

This commit is contained in:
hugo committed 2026-10-12 20:38:33 +02:00
1 parent 0c8366d34f
commit f4a6edbc68
18 files changed
+1070 -165

No files matched your search

@@ -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
}