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
@@ -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")
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+130
-46
@@ -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>
|
||||
Reference in new issue
Block a user