Implement confirmed Android tree mutation workflows (#108)
Android ARM verification / rust (push) Failing after 49m2s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 2m40s

This commit is contained in:
hugo committed 2026-10-12 13:06:22 +02:00
1 parent 6a864a8d7f
commit 316a875d63
22 files changed
+2170 -220

No files matched your search

@@ -0,0 +1,187 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeTreeMutationTest {
@Test fun nativePlansDirtyEditorsNavigationAndInterruptionRemainCoherent(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, NativeEntryViewerTest.rootName).apply { mkdirs() }.canonicalPath
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val session = NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, NativeEntryViewerTest.MemoryKeystore())
val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val passwords = NativePasswordRepository(
session,
scope,
authentication,
Dispatchers.IO,
NativeEntryViewerTest.MemoryClipboard()
)
val editor =
NativeEditorRepository(authentication, passwords, scope)
val passphrase = "public-mutation-passphrase"
suspend fun request(action: () -> Unit): PasswordState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { passwords.state.first { !it.loading } }
}
try {
withContext(Dispatchers.IO) {
session.initialize()
val setup = session.storage().androidOnboarding(session.backend())
val key = requireNotNull(setup.generate("Mutation fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
authentication.authenticated { auth ->
auth.createDirectory("", "source")
auth.createDirectory("", "destination")
val draft = auth.beginCreateEntry("source", "entry", passphrase)
auth.saveEntryEditor(
draft.id,
draft.page.fields.map {
MobileEntryEditorInput(it.id, it.name, "fixture-secret")
}
)
}
withContext(Dispatchers.Main.immediate) { authentication.lock() }
val root = request { passwords.restore("", null, null) }
val source = root.rows.single { it.path == "source" }
val prepared =
request {
passwords.prepareRowMutation(source.id, source.revision, PasswordMutation.Move)
}
assertFalse(authentication.state.value.unlocked)
assertNull(prepared.sections)
assertEquals(PasswordKind.Folder, requireNotNull(prepared.mutation).kind)
assertEquals(1u, requireNotNull(prepared.mutation).entries)
assertEquals("source/", requireNotNull(prepared.mutation).source)
request { passwords.dismissMutation() }
val unchanged = request { passwords.refresh() }
assertTrue(unchanged.rows.any { it.path == "source" })
request {
passwords.prepareRowMutation(source.id, source.revision, PasswordMutation.Move)
}
assertNull(request { passwords.unlockMutation(passphrase) }.error)
withContext(Dispatchers.Main.immediate) { editor.begin("source/entry") }
val opened = withTimeout(30000) {
editor.state.first {
!it.busy &&
it.fields.isNotEmpty()
}
}
val field = opened.fields.first().id
withContext(Dispatchers.Main.immediate) { editor.change(field, null, "unsaved edit") }
withTimeout(30000) { editor.state.first { !it.busy && it.dirty } }
val dirty = withTimeout(30000) {
var page =
request {
passwords.prepareRowMutation(
source.id,
source.revision,
PasswordMutation.Move
)
}
while (page.mutation?.hasDirtyEditor != true) {
delay(20)
page =
request {
passwords.prepareRowMutation(
source.id,
source.revision,
PasswordMutation.Move
)
}
}
page
}
assertTrue(requireNotNull(dirty.mutation).hasOpenEditor)
assertTrue(requireNotNull(dirty.mutation).hasDirtyEditor)
val rejected = request { passwords.mutate("destination", false, false) }
assertNotNull(rejected.error)
assertTrue(editor.state.value.open)
assertEquals("unsaved edit", editor.state.value.fields.first().value)
val refreshedSource = rejected.rows.single { it.path == "source" }
request {
passwords.prepareRowMutation(
refreshedSource.id,
refreshedSource.revision,
PasswordMutation.Move
)
}
val moved = request { passwords.mutate("destination", false, true) }
assertNull(moved.error)
assertEquals("destination/source", moved.path)
assertFalse(editor.state.value.open)
assertTrue(editor.state.value.fields.isEmpty())
val entry = moved.rows.single()
request { passwords.activate(entry.id, moved.revision) }
request { passwords.unlock(passphrase) }
val password = passwords.state.value.sections!!.first().fields.first().id
val revealed = request { passwords.reveal(password) }
assertEquals("fixture-secret", revealed.sections!!.first().fields.first().value)
request { passwords.prepareMutation(PasswordMutation.Copy) }
val copied = request { passwords.mutate("destination", false, false) }
assertNull(copied.error)
assertEquals("destination", copied.path)
val selected = requireNotNull(copied.selected)
authentication.authenticated { auth ->
auth.replaceEntryField("destination/entry", 0u, "replacement-secret")
}
val stale =
request {
passwords.prepareRowMutation(
selected.id,
selected.revision,
PasswordMutation.Delete
)
}
assertNotNull(stale.error)
assertNull(stale.mutation)
request { passwords.refresh() }
val current = passwords.state.value.rows.single { it.path == "destination/entry" }
request {
passwords.prepareRowMutation(current.id, current.revision, PasswordMutation.Delete)
}
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(AuthenticationLifecycle.Background)
assertNull(passwords.state.value.mutation)
assertNull(passwords.state.value.sections)
passwords.mutate(null, false, false)
}
val retained = request { passwords.refresh() }
assertTrue(retained.rows.any { it.id == current.id })
assertFalse(authentication.state.value.unlocked)
request {
passwords.prepareRowMutation(current.id, current.revision, PasswordMutation.Delete)
}
request { passwords.unlockMutation(passphrase) }
val deleted = request { passwords.mutate(null, false, false) }
assertNull(deleted.error)
assertEquals("destination", deleted.path)
assertTrue(deleted.rows.none { it.id == current.id })
assertNotNull(deleted.notice)
} finally {
withContext(Dispatchers.Main.immediate) { scope.cancel() }
roots.forEach { assertTrue(File(it).deleteRecursively()) }
}
}
}
@@ -43,7 +43,20 @@ class NativeEditorRepository(
mutable.value = mutable.value.copy(locked = true, fields = emptyList())
}
}
scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() }
val discarded = authentication.presentation.registerEditorDiscard { ids ->
if (checkpoint in ids) {
epoch += 1
pending?.cancel()
checkpoint = null
retry = null
afterClose = {}
mutable.value = EditorState()
}
}
scope.coroutineContext[Job]?.invokeOnCompletion {
registration.close()
discarded.close()
}
}
override fun processRecreated() {
scope.launch {
@@ -50,12 +50,8 @@ class NativePasswordRepository(
private val mutex = Mutex()
init {
val registration = authentication.presentation.register {
if (mutable.value.sections != null) {
sequence.incrementAndGet()
mutable.value = mutable.value.cleared().copy(loading = false)
} else {
mutable.value = mutable.value.cleared()
}
sequence.incrementAndGet()
mutable.value = mutable.value.cleared().copy(loading = false)
}
scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() }
// Hidden codes still expire; only the composed UI requests decryption/countdown refresh.
@@ -394,46 +390,89 @@ class NativePasswordRepository(
current.copy(error = session.text(R.string.core_copy_unavailable), notice = null)
}
}
override fun prepareMutation(action: PasswordMutation) = content { current ->
val plan = authentication.authenticated {
it.preparePresentedEntryMutation(
current.entryPath(),
requireNotNull(current.documentRevision),
action.native()
)
}
current.copy(
mutation = PasswordMutationPlan(
action,
plan.source,
plan.revision,
plan.destinations.map {
PasswordDestination(it.path, it.title, it.detail, it.requiresOverwrite)
},
plan.hasOpenEditor
)
)
override fun prepareMutation(action: PasswordMutation) {
val row = mutable.value.selected ?: return
prepareRowMutation(row.id, row.revision, action)
}
override fun dismissMutation() {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(mutation = null, loading = false)
}
override fun mutate(destination: String?, overwrite: Boolean) = content { current ->
val plan = current.mutation ?: return@content current
authentication.authenticated {
it.performEntryMutation(
MobileMutationRequest(
plan.action.native(),
plan.source,
plan.revision,
destination,
true,
overwrite,
plan.discardEditor
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
run {
val plan = authentication.authenticated {
it.prepareNavigationMutation(id, revision, action.native())
}
mutable.value.copy(
mutation = PasswordMutationPlan(
action, plan.source, plan.revision,
plan.destinations.map {
PasswordDestination(
it.path,
it.title,
it.detail,
it.requiresOverwrite,
it.depth,
it.unavailable,
it.collisionTargets
)
},
plan.hasOpenEditor, plan.hasDirtyEditor,
if (plan.kind ==
MobilePasswordRowKind.DIRECTORY
) {
PasswordKind.Folder
} else {
PasswordKind.Entry
},
plan.sourceTitle, plan.entries
)
)
}
session.storage().passwordNavigation(current.path, null, null, false).toFacade()
}
override fun unlockMutation(passphrase: String) {
val current = mutable.value
val plan = current.mutation ?: return
run(false) {
authentication.authenticated {
it.unlockMutation(plan.source, plan.revision, passphrase.ifEmpty { null })
}
current
}
}
override fun dismissMutation() {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(mutation = null, loading = false, error = null)
}
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) {
val current = mutable.value
val plan = current.mutation ?: return
if (current.loading || !authentication.state.value.unlocked) return
authentication.touch()
run {
val outcome = try {
authentication.authenticated {
it.performNavigationMutation(
MobileMutationRequest(
plan.action.native(),
plan.source,
plan.revision,
destination,
true,
overwrite,
discardEditor
),
current.path
)
}
} catch (error: MobileAuthenticationFfiException.Failed) {
return@run session.storage().passwordNavigation(current.path, null, null, false)
.toFacade().copy(error = error.title + "\n" + error.detail)
}
withContext(Dispatchers.Main.immediate) {
authentication.presentation.editorsDiscarded(outcome.discardedEditors.toSet())
}
outcome.navigation.toFacade().copy(
notice =
outcome.result.title + "\n" + outcome.result.detail
)
}
}
private fun content(
userActivity: Boolean = true,
@@ -494,7 +533,15 @@ class NativePasswordRepository(
}
if (sequence.get() == ticket) {
mutable.value =
(if (authentication.state.value.unlocked) result else result.cleared())
(
if (authentication.state.value.unlocked) {
result
} else {
result.cleared().copy(
mutation = result.mutation
)
}
)
.copy(loading = false)
}
}
@@ -57,14 +57,21 @@ data class PasswordDestination(
val path: String,
val title: String,
val detail: String,
val overwrite: Boolean
val overwrite: Boolean,
val depth: UInt = 0u,
val unavailable: String? = null,
val collisionTargets: List<String> = emptyList()
)
data class PasswordMutationPlan(
val action: PasswordMutation,
val source: String,
val revision: String,
val destinations: List<PasswordDestination>,
val discardEditor: Boolean
val hasOpenEditor: Boolean,
val hasDirtyEditor: Boolean = false,
val kind: PasswordKind = PasswordKind.Entry,
val sourceTitle: String = source,
val entries: UInt = 1u
)
data class PasswordState(
val path: String = "",
@@ -105,6 +112,8 @@ interface PasswordRepository {
fun dismissEdit()
fun saveEdit(value: String)
fun prepareMutation(action: PasswordMutation)
fun prepareRowMutation(id: String, revision: String, action: PasswordMutation)
fun unlockMutation(passphrase: String)
fun dismissMutation()
fun mutate(destination: String?, overwrite: Boolean)
fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean = false)
}
@@ -21,6 +21,17 @@ class ProtectedPresentation {
fun clearMetadata() {
synchronized(metadata) { metadata.values.toList() }.forEach { it() }
}
private val editorListeners = linkedMapOf<Any, (Set<ULong>) -> Unit>()
fun registerEditorDiscard(discard: (Set<ULong>) -> Unit): AutoCloseable {
val registration = Any()
synchronized(editorListeners) { editorListeners[registration] = discard }
return AutoCloseable {
synchronized(editorListeners) { editorListeners.remove(registration) }
}
}
fun editorsDiscarded(ids: Set<ULong>) {
synchronized(editorListeners) { editorListeners.values.toList() }.forEach { it(ids) }
}
private val listeners = linkedMapOf<Any, () -> Unit>()
private val mutableGeneration = MutableStateFlow(0L)
val generation: StateFlow<Long> = mutableGeneration