Implement confirmed Android tree mutation workflows (#108)
This commit is contained in:
1 parent
6a864a8d7f
commit
316a875d63
22 files changed
+2170
-220
No files matched your search
+187
@@ -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 {
|
||||
|
||||
+90
-43
@@ -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
|
||||
|
||||
Reference in new issue
Block a user