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

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