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

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