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
+910 -27

No files matched your search

@@ -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 =
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(
@@ -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>