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

@@ -331,6 +331,11 @@ class EntryViewerTest {
saved = value
dismissEdit()
}
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
inspect(id, revision)
prepareMutation(action)
}
override fun unlockMutation(passphrase: String) = unlock(passphrase)
override fun prepareMutation(action: PasswordMutation) {
this.action = action
state.value =
@@ -354,7 +359,7 @@ class EntryViewerTest {
override fun dismissMutation() {
state.value = state.value.copy(mutation = null)
}
override fun mutate(destination: String?, overwrite: Boolean) {
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) {
this.destination =
destination
dismissMutation()
@@ -0,0 +1,228 @@
package de.rfc1437.ironstorage
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.PasswordDestination
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordMutationPlan
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.ShellIcon
import de.rfc1437.ironstorage.features.PasswordScreen
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class MutationWorkflowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private fun display(fixture: Fixture) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { PasswordScreen(fixture, fixture.base.authentication) }
}
}
}
@Test fun fullSwipeOnlySelectsDeleteAndCancelNeverMutates() {
val fixture = Fixture()
display(fixture)
compose.onNodeWithText("Source").performTouchInput { swipeLeft() }
compose.waitUntil(5000) { fixture.state.value.mutation != null }
assertEquals(PasswordMutation.Delete, fixture.state.value.mutation!!.action)
assertEquals(0, fixture.committed)
compose.onNodeWithText("Cancel").performClick()
assertEquals(0, fixture.committed)
compose.onNodeWithContentDescription("Actions for Source").performClick()
compose.onNodeWithText("Delete entry").performClick()
compose.onNodeWithText("Confirm").assertIsNotEnabled()
compose.onNode(
hasText("Unlock") and hasAnyAncestor(hasTestTag("mutation-destinations"))
).performClick()
compose.onNodeWithText("Confirm").performClick()
assertEquals(1, fixture.committed)
assertEquals("entry:source", fixture.lastId)
}
@Test fun overwriteAndDirtyEditorDiscardRequireSeparateConsent() {
val fixture = Fixture(dirty = true, collision = true)
fixture.base.lease.value = AuthenticationState(unlocked = true)
display(fixture)
compose.onNodeWithContentDescription("Actions for Source").performClick()
compose.onNodeWithText("Copy entry").performClick()
compose.onNodeWithTag(
"mutation-destinations"
).performScrollToNode(hasText("Target\nFolder"))
compose.onNodeWithText("Target\nFolder").performClick()
compose.onNodeWithText("Confirm").assertIsNotEnabled()
compose.onNodeWithTag(
"mutation-destinations"
).performScrollToNode(hasText("Discard its open editors and unsaved edits"))
compose.onNodeWithText("Discard its open editors and unsaved edits").performClick()
compose.onNodeWithText("Confirm").assertIsNotEnabled()
compose.onNodeWithTag(
"mutation-destinations"
).performScrollToNode(hasText("Allow replacing the existing entry"))
compose.onNodeWithText("Allow replacing the existing entry").performClick()
compose.onNodeWithText("Confirm").performClick()
assertEquals(1, fixture.committed)
assertTrue(fixture.overwrite)
assertTrue(fixture.discard)
assertEquals("target", fixture.destination)
}
@Test fun foldersHaveOverflowAndTalkBackActionsWithoutGestures() {
val fixture = Fixture()
fixture.base.lease.value = AuthenticationState(unlocked = true)
display(fixture)
val node = compose.onNodeWithText("Group").fetchSemanticsNode()
val actions = node.config[SemanticsActions.CustomActions]
assertTrue(actions.any { it.label == "Move folder Group" })
assertTrue(actions.any { it.label == "Copy folder Group" })
compose.runOnIdle {
assertTrue(actions.single { it.label == "Delete folder Group" }.action())
}
assertEquals("group/", fixture.state.value.mutation!!.source)
assertEquals(0, fixture.committed)
compose.onNodeWithText("Confirm").performClick()
assertEquals(1, fixture.committed)
assertEquals("directory:group", fixture.lastId)
}
@Test fun inspectDisposableMutationWithComputerUse() {
assumeTrue(InstrumentationRegistry.getArguments().getString("mutationInspect") == "true")
val fixture = Fixture(dirty = true, collision = true)
val done = CountDownLatch(1)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme {
Column(Modifier.fillMaxSize()) {
TextButton(onClick = {
done.countDown()
}) { Text("Finish mutation inspection") }
Text("Confirmed actions: ${fixture.committed}")
PasswordScreen(fixture, fixture.base.authentication, Modifier.weight(1f))
}
}
}
}
compose.waitForIdle()
while (!done.await(100, TimeUnit.MILLISECONDS)) {
compose.mainClock.advanceTimeBy(100)
compose.waitForIdle()
}
}
private class Fixture(
private val dirty: Boolean = false,
private val collision: Boolean = false,
val base: EntryViewerTest.Fixture = EntryViewerTest.Fixture()
) : PasswordRepository by base {
override val state = base.state
var committed by mutableStateOf(0)
var lastId: String? = null
var destination: String? = null
var overwrite = false
var discard = false
init {
state.value = state.value.copy(
selected = null,
rows = listOf(
PasswordRow(
"entry:source",
"source",
"Source",
"Locked entry",
PasswordKind.Entry,
"entry-r",
ShellIcon.Key
),
PasswordRow(
"directory:group",
"group",
"Group",
"Folder",
PasswordKind.Folder,
"folder-r",
ShellIcon.Folder
)
)
)
}
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
lastId = id
val folder = id == "directory:group"
state.value = state.value.copy(
mutation = PasswordMutationPlan(
action, if (folder) "group/" else "source", "plan-r",
listOf(
PasswordDestination(
"target",
"Target",
"Folder",
collision,
collisionTargets = if (collision) {
listOf(
"target/source"
)
} else {
emptyList()
}
)
),
dirty, dirty, if (folder) PasswordKind.Folder else PasswordKind.Entry,
if (folder) "Group" else "Source", if (folder) 2u else 1u
)
)
}
override fun prepareMutation(action: PasswordMutation) {
state.value.selected?.let { prepareRowMutation(it.id, it.revision, action) }
}
override fun unlockMutation(passphrase: String) {
base.lease.value =
AuthenticationState(unlocked = true)
}
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) {
this.destination = destination
this.overwrite = overwrite
discard = discardEditor
committed += 1
dismissMutation()
}
override fun dismissMutation() {
state.value = state.value.copy(mutation = null)
}
override fun inspect(id: String, revision: String) {
state.value =
state.value.copy(selected = state.value.rows.single { it.id == id })
}
override fun activate(id: String, revision: String) = inspect(id, revision)
}
}
@@ -29,6 +29,7 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.PasswordField
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.PasswordSection
@@ -215,9 +216,14 @@ class PasswordScreenTest {
override fun edit(id: ULong) = Unit
override fun dismissEdit() = Unit
override fun saveEdit(value: String) = Unit
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
inspect(id, revision)
prepareMutation(action)
}
override fun unlockMutation(passphrase: String) = unlock(passphrase)
override fun prepareMutation(action: de.rfc1437.ironstorage.core.PasswordMutation) = Unit
override fun dismissMutation() = Unit
override fun mutate(destination: String?, overwrite: Boolean) = Unit
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) = Unit
override fun openSearch(id: String, revision: String) {
error("Search is not used by this fixture")
}