Implement Rust-backed Android name search (#101)
This commit is contained in:
1 parent
a06f39af23
commit
c7703d8efd
21 files changed
+910
-27
No files matched your search
+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
|
||||
|
||||
+25
-22
@@ -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()
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user