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")
}
@@ -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
@@ -281,66 +281,29 @@ fun EntryDialogs(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
)
}
state.mutation?.let { plan ->
var destination by remember(plan) { mutableStateOf<String?>(null) }
var overwrite by remember(plan, destination) { mutableStateOf(false) }
val selected = plan.destinations.find { it.path == destination }
ProtectedWindow()
AlertDialog(
onDismissRequest = repository::dismissMutation,
title = { Text(stringResource(plan.action.label())) },
text = {
ProtectedInputSurface()
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(plan.source)
if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor))
if (plan.action == PasswordMutation.Delete) {
Text(stringResource(R.string.entry_delete_confirm))
} else {
plan.destinations.forEach { candidate ->
TextButton(onClick = { destination = candidate.path }) {
Text(candidate.title + "\n" + candidate.detail)
}
}
selected?.let { Text(stringResource(R.string.entry_destination, it.path)) }
if (selected?.overwrite == true) {
TextButton(onClick = { overwrite = !overwrite }) {
Text(
stringResource(
if (overwrite) {
R.string.entry_overwrite_enabled
} else {
R.string.entry_overwrite
}
)
)
}
}
}
}
},
confirmButton = {
TextButton(
enabled = !state.loading && (
plan.action == PasswordMutation.Delete ||
selected != null && (!selected.overwrite || overwrite)
),
onClick = { repository.mutate(destination, overwrite) }
) {
Text(stringResource(R.string.entry_confirm))
}
},
dismissButton = {
TextButton(onClick = repository::dismissMutation) {
Text(stringResource(R.string.auth_cancel))
}
},
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
)
}
internal fun PasswordMutation.label(
kind: de.rfc1437.ironstorage.core.PasswordKind = de.rfc1437.ironstorage.core.PasswordKind.Entry
) = when (this) {
PasswordMutation.Move -> if (kind ==
de.rfc1437.ironstorage.core.PasswordKind.Folder
) {
R.string.mutation_move_folder
} else {
R.string.entry_move
}
PasswordMutation.Copy -> if (kind ==
de.rfc1437.ironstorage.core.PasswordKind.Folder
) {
R.string.mutation_copy_folder
} else {
R.string.entry_copy_entry
}
PasswordMutation.Delete -> if (kind ==
de.rfc1437.ironstorage.core.PasswordKind.Folder
) {
R.string.mutation_delete_folder
} else {
R.string.entry_delete
}
}
private fun PasswordMutation.label() = when (this) {
PasswordMutation.Move -> R.string.entry_move
PasswordMutation.Copy -> R.string.entry_copy_entry
PasswordMutation.Delete -> R.string.entry_delete
}
@@ -0,0 +1,215 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
import de.rfc1437.ironstorage.core.AuthenticationRepository
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.PasswordState
@Composable
internal fun MutationDialog(
plan: PasswordMutationPlan,
state: PasswordState,
repository: PasswordRepository,
authentication: AuthenticationRepository
) {
val lease by authentication.state.collectAsStateWithLifecycle()
var destination by remember(plan) { mutableStateOf<String?>(null) }
var overwrite by remember(plan, destination) { mutableStateOf(false) }
var discard by remember(plan) { mutableStateOf(false) }
var manual by remember(plan) { mutableStateOf(false) }
var requested by remember(plan) { mutableStateOf(false) }
val selected = plan.destinations.find { it.path == destination }
LaunchedEffect(lease.failure, lease.busy, state.loading) {
if (requested && !lease.busy && !state.loading) {
requested = false
if (lease.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
lease.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
) {
manual = true
}
}
}
val cancel = {
repository.dismissMutation()
if (lease.busy) authentication.cancel()
}
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = cancel,
title = { Text(stringResource(plan.action.label(plan.kind))) },
text = {
ProtectedInputSurface(authentication.presentation)
LazyColumn(
Modifier.fillMaxWidth().heightIn(max = 400.dp).testTag("mutation-destinations")
) {
item {
Text(plan.sourceTitle, style = MaterialTheme.typography.titleLarge)
Text(plan.source)
if (plan.kind == PasswordKind.Folder) {
Text(
pluralStringResource(
R.plurals.mutation_entries,
plan.entries.coerceAtMost(Int.MAX_VALUE.toUInt()).toInt(),
plan.entries.toString()
)
)
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
lease.failure?.let {
Text(it.title + "\n" + it.detail, color = MaterialTheme.colorScheme.error)
}
if (!lease.unlocked) {
TextButton(enabled = !lease.busy && !state.loading, onClick = {
requested = true
repository.unlockMutation("")
}) { Text(stringResource(R.string.password_unlock)) }
TextButton(enabled = !lease.busy, onClick = { manual = true }) {
Text(stringResource(R.string.auth_manual))
}
}
if (plan.hasOpenEditor) {
Text(
stringResource(
if (plan.hasDirtyEditor) {
R.string.mutation_dirty_editor
} else {
R.string.mutation_open_editor
}
)
)
Row(
Modifier.fillMaxWidth().toggleable(discard, role = Role.Checkbox) {
discard = it
}
) {
Checkbox(discard, onCheckedChange = null)
Text(stringResource(R.string.mutation_discard_editor))
}
}
if (plan.action == PasswordMutation.Delete) {
Text(stringResource(R.string.mutation_delete_target, plan.source))
}
}
if (plan.action != PasswordMutation.Delete) {
items(plan.destinations, key = { it.path }) { candidate ->
Row(
Modifier.fillMaxWidth().padding(
start = (
candidate.depth.coerceAtMost(12u).toInt() *
12
).dp
)
.selectable(
selected = destination == candidate.path,
enabled =
candidate.unavailable == null,
role = Role.RadioButton,
onClick = { destination = candidate.path }
)
) {
RadioButton(
selected = destination == candidate.path,
onClick = null,
enabled =
candidate.unavailable == null
)
Column {
Text(candidate.title + "\n" + candidate.detail)
candidate.unavailable?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
}
}
}
selected?.let { target ->
item {
Text(stringResource(R.string.entry_destination, target.path))
if (target.overwrite) {
Text(stringResource(R.string.mutation_collision))
target.collisionTargets.forEach { Text(it) }
Row(
Modifier.fillMaxWidth().toggleable(
overwrite,
role = Role.Checkbox
) {
overwrite = it
}
) {
Checkbox(overwrite, onCheckedChange = null)
Text(stringResource(R.string.entry_overwrite))
}
}
}
}
}
}
},
confirmButton = {
TextButton(
enabled = lease.unlocked && !state.loading && !lease.busy &&
(!plan.hasOpenEditor || discard) &&
(
plan.action == PasswordMutation.Delete ||
selected != null && selected.unavailable == null &&
(!selected.overwrite || overwrite)
),
onClick = { repository.mutate(destination, overwrite, discard) }
) {
Text(stringResource(R.string.entry_confirm))
}
},
dismissButton = {
TextButton(onClick = cancel) { Text(stringResource(R.string.auth_cancel)) }
}
)
if (manual) {
PassphraseDialog(
stringResource(R.string.auth_manual),
stringResource(R.string.auth_manual_detail),
presentation = authentication.presentation,
onCancel = {
manual = false
authentication.cancel()
},
onSubmit = {
manual = false
requested = true
repository.unlockMutation(it)
}
)
}
}
@@ -1,8 +1,10 @@
package de.rfc1437.ironstorage.features
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -18,6 +20,8 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
@@ -25,13 +29,17 @@ import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@@ -50,8 +58,10 @@ import de.rfc1437.ironstorage.core.AuthenticationFailureKind
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.ShellIcon
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
@@ -150,6 +160,7 @@ fun PasswordScreen(
)
Text(state.path.ifEmpty { stringResource(R.string.password_root) })
if (state.loading) CircularProgressIndicator()
state.notice?.let { Text(it) }
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
@@ -182,57 +193,121 @@ fun PasswordScreen(
R.string.password_entry
}
)
ListItem(
headlineContent = { Text(row.title) },
supportingContent = { Text(row.detail) },
leadingContent = {
Icon(
painterResource(
when (row.icon) {
ShellIcon.Folder -> R.drawable.ic_folder
ShellIcon.Key -> R.drawable.ic_key
else -> error("Invalid Rust navigation icon")
}
),
null
)
},
trailingContent = {
TextButton(
modifier = Modifier.semantics {
contentDescription =
rowActions
},
enabled = !state.loading,
onClick = {
actions = true
repository.inspect(row.id, state.revision)
val mutationLabels = PasswordMutation.entries.associateWith { action ->
stringResource(
R.string.mutation_action_for,
stringResource(action.label(row.kind)),
row.title
)
}
key(row.id, row.revision) {
val swipe = rememberSwipeToDismissBoxState()
val swipeScope = rememberCoroutineScope()
SwipeToDismissBox(
state = swipe,
gesturesEnabled = !state.loading,
onDismiss = { direction ->
val action = if (direction ==
SwipeToDismissBoxValue.EndToStart
) {
PasswordMutation.Delete
} else {
PasswordMutation.Move
}
) { Text(actionLabel) }
},
modifier = Modifier.combinedClickable(
enabled = !state.loading,
onClickLabel = stringResource(R.string.password_open),
onLongClickLabel = rowActions,
onClick = {
actions = false
repository.activate(row.id, state.revision)
repository.prepareRowMutation(row.id, row.revision, action)
swipeScope.launch { swipe.reset() }
},
onLongClick = {
actions = true
repository.inspect(row.id, state.revision)
backgroundContent = {
val action = if (swipe.dismissDirection ==
SwipeToDismissBoxValue.EndToStart
) {
PasswordMutation.Delete
} else {
PasswordMutation.Move
}
Box(
Modifier.fillMaxSize().background(
MaterialTheme.colorScheme.surfaceContainerHigh
).padding(16.dp),
contentAlignment = if (action ==
PasswordMutation.Delete
) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
}
) {
Text(mutationLabels.getValue(action))
}
}
).semantics {
stateDescription = kindLabel
customActions = listOf(
CustomAccessibilityAction(rowActions) {
actions = true
repository.inspect(row.id, state.revision)
true
) {
ListItem(
headlineContent = { Text(row.title) },
supportingContent = { Text(row.detail) },
leadingContent = {
Icon(
painterResource(
when (row.icon) {
ShellIcon.Folder -> R.drawable.ic_folder
ShellIcon.Key -> R.drawable.ic_key
else -> error(
"Invalid Rust navigation icon"
)
}
),
null
)
},
trailingContent = {
TextButton(
modifier = Modifier.semantics {
contentDescription =
rowActions
},
enabled = !state.loading,
onClick = {
actions = true
repository.inspect(row.id, state.revision)
}
) { Text(actionLabel) }
},
modifier = Modifier.combinedClickable(
enabled = !state.loading,
onClickLabel = stringResource(R.string.password_open),
onLongClickLabel = rowActions,
onClick = {
actions = false
repository.activate(row.id, state.revision)
},
onLongClick = {
actions = true
repository.inspect(row.id, state.revision)
}
).semantics {
stateDescription = kindLabel
customActions = listOf(
CustomAccessibilityAction(rowActions) {
actions = true
repository.inspect(row.id, state.revision)
true
}
) + PasswordMutation.entries.map { action ->
CustomAccessibilityAction(
mutationLabels.getValue(action)
) {
actions = false
repository.prepareRowMutation(
row.id,
row.revision,
action
)
true
}
}
}
)
}
)
}
HorizontalDivider()
}
}
@@ -331,6 +406,7 @@ fun PasswordScreen(
) {
EntryDialogs(state, repository, authentication)
}
state.mutation?.let { MutationDialog(it, state, repository, authentication) }
if (manual && entry != null) {
PassphraseDialog(
stringResource(R.string.auth_manual),
@@ -363,7 +439,15 @@ fun PasswordScreen(
},
text = {
ProtectedInputSurface()
Text(selected.detail + "\n" + selected.path)
Column {
Text(selected.detail + "\n" + selected.path)
PasswordMutation.entries.forEach { action ->
TextButton(onClick = {
actions = false
repository.prepareRowMutation(selected.id, selected.revision, action)
}) { Text(stringResource(action.label(selected.kind))) }
}
}
},
confirmButton = {
TextButton(onClick = {
@@ -253,4 +253,17 @@
<string name="totp_unavailable">This entry no longer has a TOTP code.</string>
<string name="totp_stale">This entry changed. Refresh the catalog and select it again.</string>
<string name="totp_back">Back to TOTP entries</string>
<string name="mutation_action_for">%1$s %2$s</string>
<string name="mutation_move_folder">Move folder</string>
<string name="mutation_copy_folder">Copy folder</string>
<string name="mutation_delete_folder">Delete folder</string>
<string name="mutation_delete_target">Delete %1$s and its contents? This requires confirmation.</string>
<string name="mutation_open_editor">This target has an open editor.</string>
<string name="mutation_dirty_editor">This target has unsaved edits.</string>
<string name="mutation_discard_editor">Discard its open editors and unsaved edits</string>
<string name="mutation_collision">These existing entries will be replaced:</string>
<plurals name="mutation_entries">
<item quantity="one">%1$s password entry</item>
<item quantity="other">%1$s password entries</item>
</plurals>
</resources>