Implement Rust-backed Android name search (#101)
Android ARM verification / rust (push) Failing after 47m11s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 23s

This commit is contained in:
hugo committed 2026-10-09 19:30:15 +02:00
1 parent a06f39af23
commit c7703d8efd
21 files changed
+902 -19

No files matched your search

+3
View File
@@ -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
@@ -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
@@ -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)
@@ -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<MainActivity>()
@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<PasswordRow>(), 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
)
)
)
}
}
}
@@ -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(
@@ -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)
@@ -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
@@ -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 =
internal fun de.rfc1437.ironstorage.bridge.generated.MobileNavigationRow.toFacade(): PasswordRow =
PasswordRow(
value.row.id,
value.row.path,
value.row.title,
value.row.detail,
when (value.row.kind) {
row.id,
row.path,
row.title,
row.detail,
when (row.kind) {
MobilePasswordRowKind.DIRECTORY -> PasswordKind.Folder
MobilePasswordRowKind.ENTRY -> PasswordKind.Entry
},
value.revision,
value.row.icon.toFacade()
revision,
row.icon.toFacade()
)
return PasswordState(
internal fun MobilePasswordNavigation.toFacade(): PasswordState = PasswordState(
path,
title,
parent,
revision,
rows.map(::row),
selected?.let(::row),
rows.map { it.toFacade() },
selected?.toFacade(),
stale = stale
)
}
)
private fun unixSeconds() = (System.currentTimeMillis() / 1000).toULong()
private fun PasswordState.cleared() = copy(
@@ -74,6 +74,7 @@ data class PasswordState(
interface PasswordRepository {
val state: StateFlow<PasswordState>
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)
@@ -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<Long> = mutableGeneration
@@ -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<PasswordRow> = emptyList(),
val error: String? = null,
val cancelled: Boolean = false
) {
override fun toString() = "SearchState(loading=$loading, [REDACTED])"
}
interface SearchRepository {
val state: StateFlow<SearchState>
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<PasswordRow>
) : 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<SearchState> = 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()
}
}
@@ -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<String>()
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)
}
}
@@ -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
@@ -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)
}
)
}
}
}
}
@@ -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 {
@@ -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()
}
}
@@ -102,4 +102,15 @@
<string name="entry_overwrite_enabled">Replacing the existing entry confirmed</string>
<string name="entry_confirm">Confirm</string>
<string name="entry_copy_stored_hint">Selection copies the stored field. Save edits first to copy changes.</string>
<string name="search_title">Search</string>
<string name="search_query">Entry name or path</string>
<string name="search_clear">Clear search</string>
<string name="search_refresh">Refresh results</string>
<string name="search_cancel">Cancel search</string>
<string name="search_loading">Searching names…</string>
<string name="search_prompt">Enter an entry name, folder, or path.</string>
<string name="search_empty">No matching entries.</string>
<string name="search_count">Results: %1$d</string>
<string name="search_open">Open entry</string>
<string name="search_cancelled">Search cancelled. Refresh to try again.</string>
</resources>
@@ -48,6 +48,23 @@ impl From<PasswordNavigation> for MobilePasswordNavigation {
}
#[uniffi::export]
impl MobilePrivateRuntime {
pub fn password_name_search(
&self,
query: String,
) -> Result<Vec<MobileNavigationRow>, 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<MobilePasswordNavigation, MobilePasswordFfiError> {
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,
+1 -1
View File
@@ -121,7 +121,7 @@ impl MobilePasswordPage {
})
}
fn search_repository(
pub(crate) fn search_repository(
repository: &Repository,
query: &str,
) -> Result<Self, MobilePasswordError> {
@@ -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<Vec<NavigationRow>, 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<Vec<NavigationRow>, 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<PasswordNavigation, 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)?;
open_search_repository(&repository, id, revision)
}
fn open_search_repository(
repository: &Repository,
id: &str,
revision: &str,
) -> Result<PasswordNavigation, MobilePasswordError> {
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::<Vec<_>>(),
shared.rows().iter().collect::<Vec<_>>()
);
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::<std::collections::HashSet<_>>()
.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();
+53
View File
@@ -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.