From c7703d8efda325574e615361cffe7b3e2596e174 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 9 Oct 2026 19:30:15 +0200 Subject: [PATCH] Implement Rust-backed Android name search (#101) --- README.md | 3 + .../de/rfc1437/ironstorage/EntryViewerTest.kt | 3 + .../rfc1437/ironstorage/PasswordScreenTest.kt | 17 ++ .../rfc1437/ironstorage/SearchScreenTest.kt | 199 ++++++++++++++++++ .../de/rfc1437/ironstorage/ShellModule.kt | 14 ++ .../ironstorage/core/NativeEntryViewerTest.kt | 30 +++ .../core/NativeAuthenticationRepository.kt | 6 +- .../core/NativePasswordRepository.kt | 47 +++-- .../ironstorage/core/PasswordRepository.kt | 1 + .../ironstorage/core/ProtectedPresentation.kt | 8 + .../ironstorage/core/SearchRepository.kt | 114 ++++++++++ .../core/NativeSearchRepositoryTest.kt | 88 ++++++++ .../ironstorage/features/PasswordScreen.kt | 2 +- .../ironstorage/features/SearchScreen.kt | 118 +++++++++++ .../ironstorage/features/ShellScreen.kt | 5 +- .../ironstorage/features/ShellViewModel.kt | 12 +- .../features/src/main/res/values/strings.xml | 11 + .../mobile-bridge/src/password_navigation.rs | 17 ++ crates/storage/src/mobile_passwords.rs | 2 +- .../src/mobile_passwords/navigation.rs | 187 ++++++++++++++++ docs/android/name-search.md | 53 +++++ 21 files changed, 910 insertions(+), 27 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/SearchScreenTest.kt create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SearchRepository.kt create mode 100644 android/core/src/test/kotlin/de/rfc1437/ironstorage/core/NativeSearchRepositoryTest.kt create mode 100644 android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SearchScreen.kt create mode 100644 docs/android/name-search.md diff --git a/README.md b/README.md index e07fbbf..19654d1 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,9 @@ The structured Android viewer and its validation are documented in Lifecycle, capture, input, and clipboard protections and platform limits are documented in [`docs/android/presentation-security.md`](docs/android/presentation-security.md). +Name-only Android search and its state/security policy are documented in +[`docs/android/name-search.md`](docs/android/name-search.md). + ## Project layout ```text diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt index 766d90d..e044a68 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt @@ -255,6 +255,9 @@ class EntryViewerTest { ) ) ) + override fun openSearch(id: String, revision: String) { + error("Search is not used by this fixture") + } override fun restore(path: String, selected: String?, revision: String?) = Unit override fun refresh() = Unit override fun activate(id: String, revision: String) = Unit diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt index 4fdc4b0..499e39e 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt @@ -112,6 +112,18 @@ class PasswordScreenTest { compose.onNodeWithText("仕事").assertIsDisplayed() } + @Test fun resumingDoesNotReplaceAPendingSearchSelection() { + val repository = Fixture() + repository.state.value = repository.state.value.copy(loading = true) + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + MaterialTheme { PasswordScreen(repository, repository.authentication) } + } + } + compose.waitForIdle() + assertEquals(0, repository.refreshes) + } + @Test fun expandedPanesAndLargeFontsKeepTheSelectedEntryAndControlsUsable() { compose.activity.runOnUiThread { compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE @@ -184,6 +196,7 @@ class PasswordScreenTest { } var lastId: String? = null var replaceOnRefresh = false + var refreshes = 0 override fun reveal(id: ULong) = Unit override fun hide(id: ULong) = Unit override fun copy(id: ULong) = Unit @@ -194,8 +207,12 @@ class PasswordScreenTest { override fun prepareMutation(action: de.rfc1437.ironstorage.core.PasswordMutation) = Unit override fun dismissMutation() = Unit override fun mutate(destination: String?, overwrite: Boolean) = Unit + override fun openSearch(id: String, revision: String) { + error("Search is not used by this fixture") + } override fun restore(path: String, selected: String?, revision: String?) = Unit override fun refresh() { + refreshes++ if (!replaceOnRefresh) return state.value = state.value.copy(selected = null, sections = null, stale = true) diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/SearchScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/SearchScreenTest.kt new file mode 100644 index 0000000..65bbf99 --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/SearchScreenTest.kt @@ -0,0 +1,199 @@ +package de.rfc1437.ironstorage + +import android.content.pm.ActivityInfo +import android.os.Bundle +import android.os.Parcel +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.ViewModelProvider +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.core.PasswordKind +import de.rfc1437.ironstorage.core.PasswordRow +import de.rfc1437.ironstorage.core.SearchRepository +import de.rfc1437.ironstorage.core.SearchState +import de.rfc1437.ironstorage.core.ShellIcon +import de.rfc1437.ironstorage.features.SearchScreen +import de.rfc1437.ironstorage.features.ShellViewModel +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test + +class SearchScreenTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test fun inspectSearchWithComputerUse() { + assumeTrue(InstrumentationRegistry.getArguments().getString("searchInspect") == "true") + val repository = Fixture() + val done = CountDownLatch(1) + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + MaterialTheme { + Column { + Text("Public name search fixture") + TextButton(onClick = { + repository.complete() + }) { Text("Load public matches") } + TextButton(onClick = { + done.countDown() + }) { Text("Finish search inspection") } + SearchScreen(repository, { opened -> + assertEquals("entry:仕事/銀行/login", opened.id) + }, androidx.compose.ui.Modifier.weight(1f)) + } + } + } + } + val start = android.os.SystemClock.elapsedRealtime() + while (!done.await(100, TimeUnit.MILLISECONDS)) { + compose.mainClock.advanceTimeBy(100) + compose.waitForIdle() + org.junit.Assert.assertTrue(android.os.SystemClock.elapsedRealtime() - start < 600000) + } + } + + @Test fun queryKeyboardActionsCanonicalLocationsAndRecoveryRemainAccessible() { + compose.activity.runOnUiThread { + compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + compose.waitUntil(10000) { + compose.activity.resources.configuration.orientation == + android.content.res.Configuration.ORIENTATION_PORTRAIT + } + val repository = Fixture() + var opened: PasswordRow? = null + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + MaterialTheme { SearchScreen(repository, { opened = it }) } + } + } + compose.onNodeWithText("Enter an entry name, folder, or path.").assertIsDisplayed() + compose.onNodeWithText("Entry name or path").performTextInput("login") + compose.onNodeWithText("Searching names…").assertIsDisplayed() + compose.onNodeWithText("Cancel search").performClick() + assertEquals("login", repository.state.value.query) + compose.onNodeWithText("Search cancelled. Refresh to try again.").assertIsDisplayed() + compose.onNodeWithText("Entry name or path").performImeAction() + compose.runOnIdle { repository.complete() } + compose.onNodeWithText("仕事/銀行").assertIsDisplayed() + compose.onNodeWithText("仕事/銀行").performSemanticsAction(SemanticsActions.OnClick) { it() } + assertEquals("entry:仕事/銀行/login", opened?.id) + assertEquals("public-revision", opened?.revision) + compose.runOnIdle { repository.state.value = SearchState("none") } + compose.onNodeWithText("No matching entries.").assertIsDisplayed() + compose.runOnIdle { + repository.state.value = + SearchState("none", error = "Public typed error") + } + compose.onNodeWithText("Public typed error").assertIsDisplayed() + compose.onNodeWithText("Refresh results").performClick() + compose.onNodeWithText("Searching names…").assertIsDisplayed() + compose.onNodeWithText("Clear search").performClick() + compose.onNodeWithText("Enter an entry name, folder, or path.").assertIsDisplayed() + } + + @Test fun nativeQuerySurvivesRotationWithoutEnteringSavedStateAndClearsOnManualLock() { + val marker = "public-name-query-marker-101" + val repository = ViewModelProvider(compose.activity)[ShellViewModel::class.java].search + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + MaterialTheme { SearchScreen(repository, {}) } + } + } + compose.onNodeWithText("Entry name or path").performTextInput(marker) + compose.runOnIdle { + val saved = Bundle() + InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState( + compose.activity, + saved + ) + val parcel = Parcel.obtain() + try { + parcel.writeBundle(saved) + val bytes = parcel.marshall() + assertFalse(bytes.toString(Charsets.UTF_8).contains(marker)) + assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker)) + } finally { + parcel.recycle() + } + } + compose.activityRule.scenario.recreate() + compose.runOnIdle { + val current = ViewModelProvider(compose.activity)[ShellViewModel::class.java].search + assertEquals(marker, current.state.value.query) + compose.activity.authentication.lock() + assertEquals("", current.state.value.query) + assertEquals(emptyList(), current.state.value.results) + } + } + + @Test fun expandedWindowKeepsQueryAndCanonicalResultUsable() { + val repository = Fixture() + repository.complete() + compose.activity.runOnUiThread { + compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + compose.waitUntil(10000) { + compose.activity.resources.configuration.orientation == + android.content.res.Configuration.ORIENTATION_LANDSCAPE + } + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + MaterialTheme { SearchScreen(repository, {}) } + } + } + compose.runOnIdle { repository.complete() } + compose.onNodeWithText("Entry name or path").assertIsDisplayed() + compose.onNodeWithText("仕事/銀行").assertIsDisplayed() + compose.onNodeWithText("Clear search").assertIsDisplayed() + } + private class Fixture : SearchRepository { + override val state = MutableStateFlow(SearchState()) + override fun query(value: String) { + state.value = SearchState(value, loading = true) + } + override fun refresh() { + if (state.value.query.isNotBlank()) { + state.value = + SearchState(state.value.query, loading = true) + } + } + override fun cancel() { + state.value = state.value.copy(loading = false, cancelled = state.value.loading) + } + override fun clear() { + state.value = SearchState() + } + fun complete() { + state.value = + SearchState( + "login", + results = listOf( + PasswordRow( + "entry:仕事/銀行/login", + "仕事/銀行/login", + "login", + "仕事/銀行", + PasswordKind.Entry, + "public-revision", + ShellIcon.Key + ) + ) + ) + } + } +} diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt index e7e435e..62730e9 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt @@ -14,8 +14,10 @@ import de.rfc1437.ironstorage.core.NativeHomeRepository import de.rfc1437.ironstorage.core.NativeOnboardingRepository import de.rfc1437.ironstorage.core.NativePasswordRepository import de.rfc1437.ironstorage.core.NativePrivateSession +import de.rfc1437.ironstorage.core.NativeSearchRepository import de.rfc1437.ironstorage.core.OnboardingRepository import de.rfc1437.ironstorage.core.PasswordRepository +import de.rfc1437.ironstorage.core.SearchRepository import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter import de.rfc1437.ironstorage.platform.AndroidSecretClipboard @@ -28,6 +30,18 @@ import kotlinx.coroutines.SupervisorJob @Module @InstallIn(SingletonComponent::class) object ShellModule { + @Provides + @Singleton + fun searchRepository( + session: NativePrivateSession, + authentication: NativeAuthenticationRepository + ): SearchRepository = NativeSearchRepository( + session, + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + Dispatchers.IO, + authentication.presentation + ) + @Provides @Singleton fun homeRepository( diff --git a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt index 3abdfd5..d8ac078 100644 --- a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt +++ b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt @@ -105,6 +105,36 @@ class NativeEntryViewerTest { auth.createDirectory("", "destination") auth.setAuthenticationTimeout(30u) } + val search = + NativeSearchRepository(session, scope, Dispatchers.IO, authentication.presentation) + withContext(Dispatchers.Main.immediate) { + authentication.lock() + search.query("VIEWER") + } + val searched = withTimeout(30000) { + search.state.first { + !it.loading && + it.query == "VIEWER" + } + } + assertNull(searched.error) + val result = searched.results.single() + assertEquals("viewer", result.path) + assertFalse(authentication.state.value.unlocked) + val selected = request { + repository.openSearch(result.id, result.revision) + assertTrue(repository.state.value.loading) + assertEquals("", repository.state.value.revision) + assertNull(repository.state.value.selected) + } + assertEquals(result.id, selected.selected?.id) + assertNull(selected.sections) + withContext(Dispatchers.Main.immediate) { + authentication.lifecycle(AuthenticationLifecycle.Background) + assertEquals("VIEWER", search.state.value.query) + authentication.lock() + assertEquals("", search.state.value.query) + } open() val sensitive = fields().filter { it.sensitive } assertEquals(3, sensitive.size) diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt index 2d86ce4..8323813 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt @@ -145,7 +145,10 @@ class NativeAuthenticationRepository( ) ) } - override fun lock() = revoke(false) + override fun lock() { + presentation.clearMetadata() + revoke(false) + } override fun cancel() = revoke(true) private fun revoke(cancel: Boolean, event: MobileLifecycleEvent? = null) { val ticket = sequence.incrementAndGet() @@ -183,6 +186,7 @@ class NativeAuthenticationRepository( } } override fun lifecycle(event: AuthenticationLifecycle) { + if (event == AuthenticationLifecycle.ProcessRecreated) presentation.clearMetadata() val native = when (event) { AuthenticationLifecycle.Foreground -> MobileLifecycleEvent.FOREGROUND AuthenticationLifecycle.Background -> MobileLifecycleEvent.BACKGROUND diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt index 7135c92..59ced40 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt @@ -79,6 +79,10 @@ class NativePasswordRepository( } override fun restore(path: String, selected: String?, revision: String?) = navigate(path, selected, revision, false) + override fun openSearch(id: String, revision: String) { + mutable.value = PasswordState() + run { session.storage().openPasswordSearchResult(id, revision).toFacade() } + } override fun refresh() { val current = mutable.value navigate(current.path, current.selected?.id, current.revision.ifEmpty { null }, false) @@ -397,30 +401,29 @@ class NativePasswordRepository( } } -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, +internal fun de.rfc1437.ironstorage.bridge.generated.MobileNavigationRow.toFacade(): PasswordRow = + PasswordRow( + row.id, + row.path, + row.title, + row.detail, + when (row.kind) { + MobilePasswordRowKind.DIRECTORY -> PasswordKind.Folder + MobilePasswordRowKind.ENTRY -> PasswordKind.Entry + }, revision, - rows.map(::row), - selected?.let(::row), - stale = stale + row.icon.toFacade() ) -} + +internal fun MobilePasswordNavigation.toFacade(): PasswordState = PasswordState( + path, + title, + parent, + revision, + rows.map { it.toFacade() }, + selected?.toFacade(), + stale = stale +) private fun unixSeconds() = (System.currentTimeMillis() / 1000).toULong() private fun PasswordState.cleared() = copy( diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt index 1a64958..87b7fd1 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt @@ -74,6 +74,7 @@ data class PasswordState( interface PasswordRepository { val state: StateFlow fun restore(path: String, selected: String?, revision: String?) + fun openSearch(id: String, revision: String) fun refresh() fun activate(id: String, revision: String) fun inspect(id: String, revision: String) diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt index 989efd6..6bcbe5b 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt @@ -12,6 +12,14 @@ class ProtectedPresentation { windows += 1 return AutoCloseable { windows -= 1 } } + private val metadata = linkedSetOf<() -> Unit>() + fun registerMetadata(clear: () -> Unit): AutoCloseable { + synchronized(metadata) { metadata.add(clear) } + return AutoCloseable { synchronized(metadata) { metadata.remove(clear) } } + } + fun clearMetadata() { + synchronized(metadata) { metadata.toList() }.forEach { it() } + } private val listeners = linkedSetOf<() -> Unit>() private val mutableGeneration = MutableStateFlow(0L) val generation: StateFlow = mutableGeneration diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SearchRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SearchRepository.kt new file mode 100644 index 0000000..e7e74da --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SearchRepository.kt @@ -0,0 +1,114 @@ +package de.rfc1437.ironstorage.core + +import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class SearchState( + val query: String = "", + val loading: Boolean = false, + val results: List = emptyList(), + val error: String? = null, + val cancelled: Boolean = false +) { + override fun toString() = "SearchState(loading=$loading, [REDACTED])" +} +interface SearchRepository { + val state: StateFlow + fun query(value: String) + fun refresh() + fun cancel() + fun clear() +} + +/** Only the Rust loader matches names; cancellation prevents stale native results publishing. */ +class NativeSearchRepository internal constructor( + private val scope: CoroutineScope, + presentation: ProtectedPresentation, + private val load: suspend (String) -> List +) : SearchRepository { + constructor( + session: NativePrivateSession, + scope: CoroutineScope, + io: CoroutineDispatcher, + presentation: ProtectedPresentation + ) : this(scope, presentation, { query -> + withContext(io) { + session.storage().passwordNameSearch(query).map { it.toFacade() } + } + }) + private val mutable = MutableStateFlow(SearchState()) + override val state: StateFlow = mutable + private var sequence = 0L + private var pending: Job? = null + init { + val registration = presentation.registerMetadata(::clear) + scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() } + } + override fun query(value: String) { + cancel() + mutable.value = SearchState(query = value) + request(true) + } + override fun refresh() { + cancel() + request(false) + } + private fun request(debounce: Boolean) { + val query = mutable.value.query + if (query.isBlank()) return + val ticket = sequence + mutable.value = + mutable.value.copy( + loading = true, + results = emptyList(), + error = null, + cancelled = false + ) + pending = scope.launch { + if (debounce) delay(250) + try { + val rows = load(query) + if (sequence == ticket) mutable.value = SearchState(query, results = rows) + } catch (error: CancellationException) { + throw error + } catch (_: MobilePasswordFfiException) { + failure(ticket, query) + } catch (_: MobilePrivateStorageFfiException) { + failure(ticket, query) + } catch (_: de.rfc1437.ironstorage.bridge.NativeBridgeException) { + failure(ticket, query) + } catch (_: java.io.IOException) { + failure(ticket, query) + } + } + } + private fun failure(ticket: Long, query: String) { + if (sequence == ticket) { + mutable.value = SearchState( + query, + error = "Search is unavailable. Unlock the device or recover the store, then retry." + ) + } + } + override fun cancel() { + val cancelled = mutable.value.loading + sequence += 1 + pending?.cancel() + pending = null + mutable.value = + mutable.value.copy(loading = false, results = emptyList(), cancelled = cancelled) + } + override fun clear() { + cancel() + mutable.value = SearchState() + } +} diff --git a/android/core/src/test/kotlin/de/rfc1437/ironstorage/core/NativeSearchRepositoryTest.kt b/android/core/src/test/kotlin/de/rfc1437/ironstorage/core/NativeSearchRepositoryTest.kt new file mode 100644 index 0000000..661ade1 --- /dev/null +++ b/android/core/src/test/kotlin/de/rfc1437/ironstorage/core/NativeSearchRepositoryTest.kt @@ -0,0 +1,88 @@ +package de.rfc1437.ironstorage.core + +import java.io.IOException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class NativeSearchRepositoryTest { + private fun row(query: String) = PasswordRow( + "entry:$query", + query, + query, + "public folder", + PasswordKind.Entry, + "revision", + ShellIcon.Key + ) + + @Test fun debounceAndLatestQueryWinEvenWhenNativeCallCannotCancel() = runTest { + val calls = mutableListOf() + val repository = NativeSearchRepository(backgroundScope, ProtectedPresentation()) { query -> + calls += query + withContext(NonCancellable) { delay(if (query == "older") 1000 else 10) } + listOf(row(query)) + } + repository.query("unused") + advanceTimeBy(100) + repository.query("older") + advanceTimeBy(251) + runCurrent() + repository.query("latest") + advanceTimeBy(300) + runCurrent() + assertEquals(listOf("older", "latest"), calls) + assertEquals("latest", repository.state.value.results.single().title) + advanceTimeBy(1000) + runCurrent() + assertEquals("latest", repository.state.value.results.single().title) + repository.query("cancelled") + repository.cancel() + advanceTimeBy(1000) + runCurrent() + assertTrue(repository.state.value.results.isEmpty()) + assertFalse(repository.state.value.loading) + assertTrue(repository.state.value.cancelled) + assertEquals(listOf("older", "latest"), calls) + } + + @Test fun refreshErrorsEmptyQueriesAndLockPolicyRemainExplicit() = runTest { + val presentation = ProtectedPresentation() + var fail = false + var calls = 0 + val repository = NativeSearchRepository(backgroundScope, presentation) { query -> + calls++ + if (fail) throw IOException("public fixture error") + listOf(row(query)) + } + repository.query("public-query") + advanceTimeBy(251) + runCurrent() + presentation.clear() + assertEquals("public-query", repository.state.value.query) + fail = true + repository.refresh() + runCurrent() + assertTrue(repository.state.value.error != null) + fail = false + repository.refresh() + runCurrent() + assertEquals(3, calls) + assertEquals(1, repository.state.value.results.size) + presentation.clearMetadata() + assertEquals(SearchState(), repository.state.value) + repository.query(" ") + advanceTimeBy(1000) + runCurrent() + assertEquals(3, calls) + assertFalse(repository.state.value.loading) + } +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt index dca2b8a..74bb2a0 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt @@ -90,7 +90,7 @@ fun PasswordScreen( repository.lock() } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - if (state.revision.isNotEmpty()) repository.refresh() + if (!state.loading && state.revision.isNotEmpty()) repository.refresh() } BackHandler(state.selected != null || state.parent != null) { actions = false diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SearchScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SearchScreen.kt new file mode 100644 index 0000000..5877a05 --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SearchScreen.kt @@ -0,0 +1,118 @@ +package de.rfc1437.ironstorage.features + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +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.PasswordRow +import de.rfc1437.ironstorage.core.SearchRepository + +@Composable +fun SearchScreen( + repository: SearchRepository, + open: (PasswordRow) -> Unit, + modifier: Modifier = Modifier +) { + val state by repository.state.collectAsStateWithLifecycle() + val keyboard = LocalSoftwareKeyboardController.current + val focus = LocalFocusManager.current + DisposableEffect(repository) { + repository.refresh() + onDispose { repository.cancel() } + } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { repository.refresh() } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { repository.cancel() } + Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.search_title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() } + ) + OutlinedTextField( + value = state.query, + onValueChange = repository::query, + label = { Text(stringResource(R.string.search_query)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { + keyboard?.hide() + focus.clearFocus() + repository.refresh() + }) + ) + FlowRow { + TextButton(enabled = state.query.isNotEmpty(), onClick = repository::clear) { + Text(stringResource(R.string.search_clear)) + } + TextButton(enabled = state.query.isNotBlank(), onClick = repository::refresh) { + Text(stringResource(R.string.search_refresh)) + } + if (state.loading) { + TextButton(onClick = repository::cancel) { + Text(stringResource(R.string.search_cancel)) + } + } + } + Column(Modifier.semantics { liveRegion = LiveRegionMode.Polite }) { + if (state.loading) { + CircularProgressIndicator() + Text(stringResource(R.string.search_loading)) + } else if (state.error != null) { + Text(requireNotNull(state.error), color = MaterialTheme.colorScheme.error) + } else if (state.cancelled) { + Text(stringResource(R.string.search_cancelled)) + } else if (state.results.isEmpty()) { + Text( + stringResource( + if (state.query.isBlank()) R.string.search_prompt else R.string.search_empty + ) + ) + } else { + Text(stringResource(R.string.search_count, state.results.size)) + } + } + LazyColumn(Modifier.weight(1f).fillMaxSize()) { + items(state.results, key = { it.id }) { row -> + ListItem( + headlineContent = { Text(row.title) }, + supportingContent = { Text(row.detail) }, + modifier = Modifier.clickable( + onClickLabel = stringResource(R.string.search_open) + ) { + keyboard?.hide() + open(row) + } + ) + } + } + } +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt index e9109d2..306233a 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt @@ -58,8 +58,9 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) { (snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked) val browsing = ready && snapshot.selectedTab == ShellTab.Passwords + val searching = ready && snapshot.selectedTab == ShellTab.Search val home = ready && snapshot.selectedTab == ShellTab.Home - if (!browsing && !home) { + if (!browsing && !home && !searching) { Text( stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge @@ -98,6 +99,8 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) { } else if (browsing) { LaunchedEffect(viewModel) { viewModel.openPasswords() } PasswordScreen(viewModel.passwords, viewModel.authentication, Modifier.weight(1f)) + } else if (searching) { + SearchScreen(viewModel.search, viewModel::openSearch, Modifier.weight(1f)) } else if (home) { HomeScreen(viewModel.home, Modifier.weight(1f)) } else { diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt index b79e947..ae6cf7c 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt @@ -8,6 +8,8 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository import de.rfc1437.ironstorage.core.HomeRepository import de.rfc1437.ironstorage.core.OnboardingRepository import de.rfc1437.ironstorage.core.PasswordRepository +import de.rfc1437.ironstorage.core.PasswordRow +import de.rfc1437.ironstorage.core.SearchRepository import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellTab import javax.inject.Inject @@ -19,6 +21,7 @@ class ShellViewModel @Inject constructor( val onboarding: OnboardingRepository, val passwords: PasswordRepository, val home: HomeRepository, + val search: SearchRepository, val authentication: AuthenticationRepository, private val saved: SavedStateHandle ) : ViewModel() { @@ -36,15 +39,22 @@ class ShellViewModel @Inject constructor( } } private var passwordsRestored = false + fun openSearch(row: PasswordRow) { + passwords.openSearch(row.id, row.revision) + repository.selectTab(ShellTab.Passwords) + } fun openPasswords() { if (!passwordsRestored) { passwordsRestored = true + // Preserve live navigation through rotation and pending search handoffs. + val current = passwords.state.value + if (current.loading || current.revision.isNotEmpty()) return passwords.restore( saved["password.path"] ?: "", saved["password.selection"], saved["password.revision"] ) - } else { + } else if (!passwords.state.value.loading) { passwords.refresh() } } diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index fb0e7cc..4e2878a 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -102,4 +102,15 @@ Replacing the existing entry confirmed Confirm Selection copies the stored field. Save edits first to copy changes. + Search + Entry name or path + Clear search + Refresh results + Cancel search + Searching names… + Enter an entry name, folder, or path. + No matching entries. + Results: %1$d + Open entry + Search cancelled. Refresh to try again. diff --git a/crates/mobile-bridge/src/password_navigation.rs b/crates/mobile-bridge/src/password_navigation.rs index 73ae7d7..9b90d8c 100644 --- a/crates/mobile-bridge/src/password_navigation.rs +++ b/crates/mobile-bridge/src/password_navigation.rs @@ -48,6 +48,23 @@ impl From for MobilePasswordNavigation { } #[uniffi::export] impl MobilePrivateRuntime { + pub fn password_name_search( + &self, + query: String, + ) -> Result, MobilePasswordFfiError> { + ironstorage::mobile_passwords::navigation::search(&self.storage, &query) + .map(|rows| rows.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + pub fn open_password_search_result( + &self, + id: String, + revision: String, + ) -> Result { + ironstorage::mobile_passwords::navigation::open_search_result(&self.storage, &id, &revision) + .map(Into::into) + .map_err(Into::into) + } pub fn password_navigation( &self, path: String, diff --git a/crates/storage/src/mobile_passwords.rs b/crates/storage/src/mobile_passwords.rs index 88bba6f..d67a857 100644 --- a/crates/storage/src/mobile_passwords.rs +++ b/crates/storage/src/mobile_passwords.rs @@ -121,7 +121,7 @@ impl MobilePasswordPage { }) } - fn search_repository( + pub(crate) fn search_repository( repository: &Repository, query: &str, ) -> Result { diff --git a/crates/storage/src/mobile_passwords/navigation.rs b/crates/storage/src/mobile_passwords/navigation.rs index 1424832..b53d499 100644 --- a/crates/storage/src/mobile_passwords/navigation.rs +++ b/crates/storage/src/mobile_passwords/navigation.rs @@ -46,6 +46,82 @@ pub fn navigate( navigate_repository(&repository, path, selected, revision, activate) } +/// Name-only search with identities that are rechecked before opening a result. +pub fn search( + storage: &PrivateStorage, + query: &str, +) -> Result, MobilePasswordError> { + let root = + Dir::open_ambient_dir(storage.root(), ambient_authority()).map_err(|_| unavailable())?; + let _lock = exclusive_lock(&root, "runtime.lock").map_err(|_| unavailable())?; + let config = storage + .configuration() + .map_err(|_| unavailable())? + .ok_or_else(unavailable)?; + let repository = Repository::open(config.vault()).map_err(MobilePasswordError::repository)?; + search_repository(&repository, query) +} + +fn search_repository( + repository: &Repository, + query: &str, +) -> Result, MobilePasswordError> { + MobilePasswordPage::search_repository(repository, query)? + .rows + .into_iter() + .map(|row| { + let revision = repository + .navigation_revision(std::path::Path::new(row.path()), true) + .map_err(MobilePasswordError::repository)?; + Ok(NavigationRow { + row, + revision: HEXLOWER.encode(&Sha256::digest(revision.as_bytes())), + }) + }) + .collect() +} + +pub fn open_search_result( + storage: &PrivateStorage, + id: &str, + revision: &str, +) -> Result { + let root = + Dir::open_ambient_dir(storage.root(), ambient_authority()).map_err(|_| unavailable())?; + let _lock = exclusive_lock(&root, "runtime.lock").map_err(|_| unavailable())?; + let config = storage + .configuration() + .map_err(|_| unavailable())? + .ok_or_else(unavailable)?; + let repository = Repository::open(config.vault()).map_err(MobilePasswordError::repository)?; + open_search_repository(&repository, id, revision) +} + +fn open_search_repository( + repository: &Repository, + id: &str, + revision: &str, +) -> Result { + let path = id + .strip_prefix("entry:") + .ok_or_else(MobilePasswordError::invalid_path)?; + let path = + crate::repository::EntryPath::parse(path).map_err(MobilePasswordError::repository)?; + let parent = path + .as_path() + .parent() + .and_then(|p| p.to_str()) + .unwrap_or(""); + let mut page = navigate_repository(repository, parent, None, None, false)?; + page.selected = page + .rows + .iter() + .find(|row| row.row.id() == id && row.revision == revision) + .cloned(); + page.stale = page.selected.is_none(); + Ok(page) +} + fn unavailable() -> MobilePasswordError { MobilePasswordError { kind: MobilePasswordErrorKind::Configuration, @@ -137,6 +213,117 @@ mod tests { use std::fs; use tempfile::tempdir; + #[test] + fn search_uses_shared_unicode_path_rules_without_decryption_and_rejects_stale_objects() { + let temporary = tempdir().unwrap(); + for directory in ["仕事/銀行", "personal", ".hidden", ".extensions"] { + fs::create_dir_all(temporary.path().join(directory)).unwrap(); + } + for path in [ + "仕事/銀行/LOGIN", + "personal/login", + "personal/Äccount", + ".hidden/login", + ".extensions/login", + ] { + fs::write( + temporary.path().join(format!("{path}.gpg")), + b"invalid ciphertext", + ) + .unwrap(); + } + let repository = Repository::open(temporary.path()).unwrap(); + let rows = search_repository(&repository, " LOGIN ").unwrap(); + assert_eq!(rows.len(), 3); + assert!(rows.iter().any(|row| row.row.path() == ".hidden/login")); + assert!( + !rows + .iter() + .any(|row| row.row.path().starts_with(".extensions/")) + ); + assert_eq!( + search_repository(&repository, "銀行/loGIN").unwrap().len(), + 1 + ); + assert_eq!(search_repository(&repository, "äCCOUNT").unwrap().len(), 1); + assert!( + search_repository(&repository, "銀行\\LOGIN") + .unwrap() + .is_empty() + ); + assert!(search_repository(&repository, ".*").unwrap().is_empty()); + assert!(search_repository(&repository, " ").unwrap().is_empty()); + let shared = MobilePasswordPage::search_repository(&repository, " LOGIN ").unwrap(); + assert_eq!( + rows.iter().map(|r| &r.row).collect::>(), + shared.rows().iter().collect::>() + ); + let row = rows + .iter() + .find(|r| r.row.path() == "personal/login") + .unwrap(); + let page = open_search_repository(&repository, row.row.id(), &row.revision).unwrap(); + assert_eq!(page.selected.unwrap().row.path(), "personal/login"); + fs::write(temporary.path().join("replacement"), b"invalid ciphertext").unwrap(); + fs::rename( + temporary.path().join("replacement"), + temporary.path().join("personal/login.gpg"), + ) + .unwrap(); + let stale = open_search_repository(&repository, row.row.id(), &row.revision).unwrap(); + assert!(stale.stale); + assert!(stale.selected.is_none()); + fs::remove_file(temporary.path().join("personal/login.gpg")).unwrap(); + assert!( + open_search_repository(&repository, row.row.id(), &row.revision) + .unwrap() + .selected + .is_none() + ); + fs::write( + temporary.path().join("personal/login-new.gpg"), + b"not encrypted", + ) + .unwrap(); + let refreshed = search_repository(&repository, "login").unwrap(); + assert!( + refreshed + .iter() + .any(|r| r.row.path() == "personal/login-new") + ); + assert!(open_search_repository(&repository, "entry:../escape", "revision").is_err()); + } + + #[test] + fn large_search_returns_every_identity_with_canonical_location() { + let temporary = tempdir().unwrap(); + for folder in 0..10 { + let directory = temporary.path().join(format!("folder-{folder}")); + fs::create_dir(&directory).unwrap(); + for entry in 0..500 { + fs::write( + directory.join(format!("login-{entry}.gpg")), + b"not ciphertext", + ) + .unwrap(); + } + } + let repository = Repository::open(temporary.path()).unwrap(); + let rows = search_repository(&repository, "login").unwrap(); + assert_eq!(rows.len(), 5000); + assert_eq!( + rows.iter() + .map(|r| r.row.id()) + .collect::>() + .len(), + 5000 + ); + assert!( + rows.iter() + .all(|r| r.row.detail().starts_with("folder-") && !r.revision.is_empty()) + ); + } + #[test] fn traverses_without_reading_or_decrypting_entries_and_restores_verified_selection() { let temporary = tempdir().unwrap(); diff --git a/docs/android/name-search.md b/docs/android/name-search.md new file mode 100644 index 0000000..352c6f6 --- /dev/null +++ b/docs/android/name-search.md @@ -0,0 +1,53 @@ +# Android name search + +Search uses the shared `MobilePasswordPage` search rules in `crates/storage`. +Queries are trimmed, Unicode-lowercased, and matched as literal substrings of the +canonical entry path, including its folders and filename without `.gpg`. +This is lowercase matching, not locale collation or Unicode normalization. `/` +is the canonical separator; backslashes and regex characters remain literal. +Ordinary dot-prefixed entries are searchable; `.extensions` is excluded by the +shared password-store rules. No entry is read or decrypted to search its name. + +The private-runtime API supplies typed rows with canonical folder context and +an object revision. Opening a result validates its identifier and current +revision in Rust under the private-runtime lock, then returns the same directory +navigation and structured viewer model used by Passwords. Deleted/replaced +objects clear selection and expose the existing stale-selection recovery. +No Kotlin matching, traversal, path splitting, or identity reconstruction occurs. + +The Search destination is injected into `ShellViewModel`. Its repository exposes +StateFlow query/loading/results/error state and debounces typing by 250 ms. +Every query, refresh, cancellation, and manual clear invalidates pending work; +uncancellable native completions cannot publish an older query. Keyboard Search +refreshes immediately. Results are lazy, retain canonical location labels, and +expose accessible labelled click actions. Empty, loading, cancelled, error/retry, +and clear states work in compact and expanded windows. + +Only the current query is retained in memory across rotation/window changes and +returning to the destination. It is not written to SavedStateHandle, disk, logs, +analytics, or history. Under this policy names remain available while the store +is locked, but manual lock clears query/results synchronously and a new process +starts empty. Decrypted entry values still follow the stricter background +revocation and native-cover policy documented in presentation-security.md. +Result lists clear when Search stops and reload on resume, incorporating external +repository changes without reusing the old objects. + +Rust fixtures cover Unicode/case behavior, literal separators, duplicate names, +shared-rule equivalence, invalid ciphertext, stale replacement/deletion, +repository refresh, invalid identifiers, and 5,000 entries. Coroutine tests cover +debounce, cancelled/uncancellable work, latest-query wins, retry, empty input, +and metadata clearing. Android UI tests exercise canonical location/action +semantics, keyboard Search, compact/expanded layouts, and empty/error recovery; +native facade instrumentation connects real Rust search to the existing viewer. + +Validation passed for all four required Rust workspace gates and the complete +Android debug/release/AAB, unit, lint, formatting, architecture, package and +dependency gates. App/core/bridge instrumentation, native-load checks and five +cold starts passed on ARM64 API 26/4 KB, API 37/4 KB and strict API 37/16 KB. +Computer use in the existing Android Studio embedded emulator exercised the +public result fixture and the live Rust-backed Search destination. Native +ViewModel instrumentation verifies query survival through actual recreation, +serialized saved-state exclusion, and synchronous manual-lock clearing. +Accessibility semantics/actions were tested; spoken TalkBack output was not +claimed. ARMv7 build/package checks passed; no suitable ARMv7 emulator image is +available. Physical devices are excluded from this milestone's validation scope.