Implement Rust-backed Android name search (#101)
This commit is contained in:
1 parent
a06f39af23
commit
c7703d8efd
21 files changed
+902
-19
No files matched your search
@@ -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(
|
||||
|
||||
+30
@@ -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)
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+17
-14
@@ -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()
|
||||
}
|
||||
}
|
||||
+88
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user