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(