feat(android): add structured entry editor and encrypted draft recovery (#103)
Android ARM verification / rust (push) Failing after 46m30s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-09 22:11:37 +02:00
1 parent d22d71eb5e
commit 06575363a2
23 files changed
+2330 -21

No files matched your search

@@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelProvider
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.SetupStatus
import de.rfc1437.ironstorage.core.ShellFixtures
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellSnapshot
@@ -64,6 +65,7 @@ class AdaptiveShellTest {
original.home,
original.search,
original.authentication,
original.editor,
saved
)
compose.activity.runOnUiThread {
@@ -138,6 +140,7 @@ class AdaptiveShellTest {
original.home,
original.search,
original.authentication,
original.editor,
SavedStateHandle(
saved.keys().associateWith {
saved.get<Any>(it)
@@ -162,6 +165,76 @@ class AdaptiveShellTest {
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
}
@Test fun processRecreationExplainsDiscardedDraftWithoutRestoringSecretState() {
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val saved = SavedStateHandle(mapOf("shell.tab" to "Passwords", "editor.wasOpen" to true))
val model = ShellViewModel(
Fixture(),
original.onboarding,
original.passwords,
original.home,
original.search,
original.authentication,
original.editor,
saved
)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
ApplicationTheme(original.authentication) { ShellScreen(model, setup) }
}
}
compose.waitUntil(10000) { original.editor.state.value.error != null }
compose.onNodeWithText(
"An unsaved draft was discarded after the application process restarted. " +
"Unlock the entry to edit it again."
).assertIsDisplayed()
compose.runOnIdle {
assertFalse(original.editor.state.value.open)
assertTrue(original.editor.state.value.fields.isEmpty())
assertFalse(original.authentication.state.value.unlocked)
assertEquals(false, saved.get<Boolean>("editor.wasOpen"))
assertTrue(saved.keys().none { it.contains("draft") || it.contains("secret") })
}
}
@Test fun configuredEmptyStoreOffersEntryAndFolderCreation() {
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val repository = Fixture().apply {
snapshot.value = ShellSnapshot(
ShellState.Empty,
ShellFixtures.all.getValue(ShellState.Empty),
selectedTab = ShellTab.Passwords
)
}
val onboarding = object : OnboardingRepository by original.onboarding {
override val setup = MutableStateFlow(SetupStatus(configured = true))
}
val model = ShellViewModel(
repository,
onboarding,
original.passwords,
original.home,
original.search,
original.authentication,
original.editor,
SavedStateHandle(mapOf("shell.tab" to "Passwords"))
)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
ApplicationTheme(original.authentication) { ShellScreen(model, setup) }
}
}
compose.onNodeWithText("New entry").assertIsDisplayed().performClick()
compose.onNodeWithText("Create").assertIsDisplayed()
compose.onNodeWithText("Cancel").performClick()
compose.onNodeWithText("New folder").assertIsDisplayed().performClick()
compose.onNodeWithText("Create").assertIsDisplayed()
compose.onNodeWithText("Cancel").performClick()
compose.runOnIdle { assertFalse(original.editor.state.value.open) }
}
private class Fixture : ShellRepository {
override val snapshot = MutableStateFlow(
ShellSnapshot(
@@ -0,0 +1,236 @@
package de.rfc1437.ironstorage
import android.os.Bundle
import android.os.Parcel
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.assertIsDisplayed
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.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.Density
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.EditorField
import de.rfc1437.ironstorage.core.EditorFieldKind
import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.EditorState
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.EditorDiscardDialog
import de.rfc1437.ironstorage.features.EntryEditorScreen
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class EntryEditorScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun structuredInputsGenerationAndAccessibleReorderForwardTypedIds() {
val repository = Fixture()
val lease = MutableStateFlow(AuthenticationState(unlocked = true))
val presentation = ProtectedPresentation()
val authentication = object : AuthenticationRepository by compose.activity.authentication {
override val state = lease
override val presentation = presentation
override fun lock() {
lease.value = AuthenticationState()
presentation.clear()
}
}
lateinit var inputMode: InputModeManager
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent(presentation) {
inputMode = LocalInputModeManager.current
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
MaterialTheme {
EntryEditorScreen(repository, authentication)
EditorDiscardDialog(repository)
}
}
}
}
fun field(tag: String) = compose.onNodeWithTag("entry-editor")
.performScrollToNode(hasTestTag(tag)).let { compose.onNodeWithTag(tag) }
fun control(text: String) = compose.onNodeWithTag("entry-editor")
.performScrollToNode(hasText(text)).let { compose.onNodeWithText(text) }
val password = field("editor-value-41")
assertTrue(password.fetchSemanticsNode().config.contains(SemanticsProperties.Password))
password.performTextReplacement("public replacement secret")
assertEquals(41uL, repository.changedId)
assertEquals("public replacement secret", repository.state.value.fields.first().value)
field("editor-value-87").performTextReplacement("first\nsecond\n日本語")
assertEquals(87uL, repository.changedId)
field("editor-down-73").performClick()
assertEquals(73uL to 2u, repository.moved)
field("editor-remove-87").performClick()
assertEquals(87uL, repository.removed)
control("Generate password").performClick()
assertEquals(0, repository.generations)
compose.onNodeWithText("Password length").performTextReplacement("24")
compose.onNodeWithText("Generate replacement").performClick()
assertEquals(1, repository.generations)
assertEquals(24u, repository.length)
control("Add named field").performClick()
compose.onNodeWithTag("editor-add-name").performTextReplacement("custom")
compose.onNodeWithTag("editor-add-value").performTextReplacement("public value")
compose.onNodeWithText("Add field").performClick()
assertEquals(Triple(EditorFieldKind.Field, "custom", "public value"), repository.added)
control("Cancel").performClick()
compose.onNodeWithText("Discard unsaved changes?").assertIsDisplayed()
compose.onNodeWithText("Keep editing").performClick()
assertFalse(repository.state.value.discardRequested)
assertEquals("public replacement secret", repository.state.value.fields.first().value)
compose.activity.runOnUiThread {
compose.activity.onBackPressedDispatcher.onBackPressed()
}
compose.onNodeWithText("Discard unsaved changes?").assertIsDisplayed()
compose.onNodeWithText("Keep editing").performClick()
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
control("Save entry")
.performSemanticsAction(SemanticsActions.RequestFocus) { assertTrue(it()) }
.performKeyInput { pressKey(Key.Enter) }
assertEquals(1, repository.saves)
val draft = repository.state.value
compose.runOnIdle {
repository.state.value =
repository.state.value.copy(locked = true, fields = emptyList())
presentation.clear()
}
compose.onNodeWithTag("editor-value-41").assertDoesNotExist()
compose.onNodeWithText("Unlock").assertIsDisplayed()
compose.runOnIdle { repository.state.value = draft }
field("editor-value-41").assertIsDisplayed()
compose.runOnIdle {
val saved = Bundle()
InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(
compose.activity,
saved
)
val parcel = Parcel.obtain()
try {
parcel.writeBundle(saved)
val bytes = parcel.marshall()
for (marker in repository.state.value.fields.mapNotNull { it.value }) {
assertFalse(bytes.toString(Charsets.UTF_8).contains(marker))
assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker))
}
} finally {
parcel.recycle()
}
}
}
private class Fixture : EditorRepository {
override val state = MutableStateFlow(
EditorState(
open = true,
dirty = true,
defaultLength = 25u,
maximumLength = 4096u,
fields = listOf(
field(
41uL,
EditorFieldKind.Password,
null,
"Password",
"public fixture secret",
false
),
field(73uL, EditorFieldKind.Field, "custom", "Custom", "public field", true),
field(
87uL,
EditorFieldKind.Note,
null,
"Notes",
"first note\nsecond note",
true
)
)
)
)
var changedId: ULong? = null
var moved: Pair<ULong, UInt>? = null
var removed: ULong? = null
var added: Triple<EditorFieldKind, String?, String>? = null
var generations = 0
var length: UInt? = null
var saves = 0
override fun processRecreated() = Unit
override fun begin(path: String) = Unit
override fun create(parent: String, name: String, passphrase: String) = Unit
override fun createFolder(parent: String, name: String) = Unit
override fun change(id: ULong, name: String?, value: String?) {
changedId = id
state.value = state.value.copy(
fields = state.value.fields.map {
if (it.id == id) it.copy(name = name, value = value) else it
}
)
}
override fun add(kind: EditorFieldKind, name: String?, value: String) {
added = Triple(kind, name, value)
state.value =
state.value.copy(completedOperations = state.value.completedOperations + 1)
}
override fun remove(id: ULong) {
removed = id
}
override fun reorder(id: ULong, index: UInt) {
moved = id to index
}
override fun generate(length: UInt?, noSymbols: Boolean) {
generations += 1
this.length =
length
}
override fun save() {
saves += 1
}
override fun resume(passphrase: String) = Unit
override fun requestClose(after: () -> Unit) {
state.value =
state.value.copy(discardRequested = true)
}
override fun keepEditing() {
state.value = state.value.copy(discardRequested = false)
}
override fun confirmDiscard() {
state.value = EditorState()
}
companion object {
private fun field(
id: ULong,
kind: EditorFieldKind,
name: String?,
label: String,
value: String,
movable: Boolean
) = EditorField(
id, kind, name, label, value, "••••••", null,
sensitive = true, multiline = kind == EditorFieldKind.Note,
nameEditable = kind == EditorFieldKind.Field,
removable = movable, reorderable = movable
)
}
}
}
@@ -2,9 +2,13 @@ package de.rfc1437.ironstorage
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.ProtectedContent
/** Keep the production surface and IME protections around substituted test screens. */
internal fun MainActivity.setProtectedTestContent(content: @Composable () -> Unit) {
setContent { ProtectedContent(authentication.presentation, content) }
internal fun MainActivity.setProtectedTestContent(
presentation: ProtectedPresentation = authentication.presentation,
content: @Composable () -> Unit
) {
setContent { ProtectedContent(presentation, content) }
}
@@ -7,8 +7,10 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
import de.rfc1437.ironstorage.core.NativeEditorRepository
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
import de.rfc1437.ironstorage.core.NativeHomeRepository
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
@@ -30,6 +32,17 @@ import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
object ShellModule {
@Provides
@Singleton
fun editorRepository(
authentication: NativeAuthenticationRepository,
passwords: PasswordRepository
): EditorRepository = NativeEditorRepository(
authentication,
passwords,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
@Provides
@Singleton
fun searchRepository(
@@ -143,9 +143,14 @@ class NativeEntryViewerTest {
assertFalse(authentication.state.value.unlocked)
val selected = request {
repository.openSearch(result.id, result.revision)
assertTrue(repository.state.value.loading)
assertEquals("", repository.state.value.revision)
assertNull(repository.state.value.selected)
val immediate = repository.state.value
assertNull(immediate.sections)
if (immediate.loading) {
assertEquals("", immediate.revision)
assertNull(immediate.selected)
} else {
assertEquals(result.id, immediate.selected?.id)
}
}
assertEquals(result.id, selected.selected?.id)
assertNull(selected.sections)
@@ -228,6 +233,7 @@ class NativeEntryViewerTest {
}
request { repository.reveal(pin.id) }
assertNull(repository.state.value.sections)
withTimeout(10000) { authentication.state.first { !it.unlocked } }
assertFalse(authentication.state.value.unlocked)
val changed = request { repository.restore("destination", null, null) }
request { repository.activate(changed.rows.single().id, changed.revision) }
@@ -237,11 +243,136 @@ class NativeEntryViewerTest {
withTimeout(10000) { repository.state.first { it.sections == null } }
assertNull(repository.state.value.totp)
assertFalse(authentication.state.value.unlocked)
authentication.authenticated { it.setAuthenticationTimeout(300u) }
structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
authentication,
repository,
scope,
password
)
} finally {
withContext(Dispatchers.Main.immediate) { repository.lock() }
scope.cancel()
}
}
private suspend fun structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
authentication: NativeAuthenticationRepository,
passwords: NativePasswordRepository,
scope: CoroutineScope,
passphrase: String
) {
// The native private root is intentionally immutable for the life of this test process.
val editor = NativeEditorRepository(authentication, passwords, scope)
suspend fun request(action: () -> Unit): EditorState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { editor.state.first { !it.busy } }
}
try {
assertNull(request { editor.createFolder("", "editor") }.error)
assertNull(request { editor.create("editor", "new", passphrase) }.error)
val first = editor.state.value.fields.first().id
request { editor.change(first, null, "initial password") }
request { editor.add(EditorFieldKind.Field, "custom", "Unicode 日本語") }
request { editor.add(EditorFieldKind.Field, "custom", "repeated") }
request { editor.add(EditorFieldKind.Field, "PIN", "123493") }
request { editor.add(EditorFieldKind.Note, null, "first note\nsecond note") }
request {
editor.add(
EditorFieldKind.OtpUri,
null,
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example"
)
}
assertNull(editor.state.value.error)
assertTrue(editor.state.value.fields.single { it.name == "PIN" }.sensitive)
val custom = editor.state.value.fields.first { it.name == "custom" }.id
request { editor.reorder(custom, 2u) }
assertEquals(custom, editor.state.value.fields[2].id)
request { editor.add(EditorFieldKind.Field, "temporary", "remove me") }
val temporary = editor.state.value.fields.single { it.name == "temporary" }.id
request { editor.remove(temporary) }
assertTrue(editor.state.value.fields.none { it.id == temporary })
request { editor.generate(24u, true) }
val generated = requireNotNull(editor.state.value.fields.first().value)
assertEquals(24, generated.length)
assertTrue(generated.all { it.isLetterOrDigit() && it.code < 128 })
assertNull(request { editor.save() }.error)
assertFalse(editor.state.value.open)
withContext(Dispatchers.Main.immediate) { passwords.restore("editor", null, null) }
val navigation = withTimeout(30000) { passwords.state.first { !it.loading } }
val row = navigation.rows.single { it.title == "new" }
withContext(Dispatchers.Main.immediate) {
passwords.activate(row.id, navigation.revision)
}
withTimeout(30000) { passwords.state.first { !it.loading } }
withContext(Dispatchers.Main.immediate) { passwords.unlock(passphrase) }
withTimeout(30000) { passwords.state.first { !it.loading } }
assertNotNull(passwords.state.value.sections)
assertNull(request { editor.begin("editor/new") }.error)
assertNull(passwords.state.value.sections)
assertEquals(generated, editor.state.value.fields.first().value)
assertEquals(2, editor.state.value.fields.count { it.name == "custom" })
assertEquals(
"first note\nsecond note",
editor.state.value.fields.single {
it.kind ==
EditorFieldKind.Note
}.value
)
assertEquals("123493", editor.state.value.fields.single { it.name == "PIN" }.value)
val field = editor.state.value.fields.first { it.name == "custom" }
request { editor.change(field.id, "bad\nname", "retained after validation") }
assertNotNull(request { editor.save() }.error)
assertEquals(
"retained after validation",
editor.state.value.fields.single {
it.id ==
field.id
}.value
)
request { editor.change(field.id, "custom", "retained after validation") }
assertNull(request { editor.save() }.error)
request { editor.begin("editor/new") }
val passwordId = editor.state.value.fields.first().id
request { editor.change(passwordId, null, "in-memory recovered secret") }
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(AuthenticationLifecycle.Background)
}
assertTrue(editor.state.value.locked)
assertTrue(editor.state.value.fields.isEmpty())
assertFalse(authentication.state.value.unlocked)
assertNull(request { editor.resume(passphrase) }.error)
assertEquals("in-memory recovered secret", editor.state.value.fields.first().value)
assertTrue(editor.state.value.dirty)
var navigated = false
request { editor.requestClose { navigated = true } }
assertTrue(editor.state.value.discardRequested)
assertFalse(navigated)
request { editor.keepEditing() }
assertTrue(editor.state.value.open)
assertFalse(navigated)
request { editor.requestClose { navigated = true } }
request { editor.confirmDiscard() }
withContext(Dispatchers.Main.immediate) { assertTrue(navigated) }
request { editor.begin("editor/new") }
assertEquals(
"discard cannot change the stored entry",
generated,
editor.state.value.fields.first().value
)
authentication.authenticated { it.setAuthenticationTimeout(2u) }
withTimeout(10000) { editor.state.first { it.locked } }
assertTrue(editor.state.value.fields.isEmpty())
assertNull(request { editor.resume(passphrase) }.error)
authentication.authenticated { it.setAuthenticationTimeout(300u) }
request { editor.requestClose() }
if (editor.state.value.discardRequested) request { editor.confirmDiscard() }
assertFalse(editor.state.value.open)
} finally {
withContext(Dispatchers.Main.immediate) { authentication.lock() }
}
}
private class MemoryClipboard : SecretClipboard {
override fun beginCopy(): SecretClipboard = this
@@ -0,0 +1,54 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class EditorFieldKind { Password, Field, Note, OtpUri }
data class EditorField(
val id: ULong,
val kind: EditorFieldKind,
val name: String?,
val label: String,
val value: String?,
val maskedValue: String,
val diagnostic: String?,
val sensitive: Boolean,
val multiline: Boolean,
val nameEditable: Boolean,
val removable: Boolean,
val reorderable: Boolean
) {
override fun toString() = "EditorField(id=$id, [REDACTED])"
}
data class EditorState(
val open: Boolean = false,
val locked: Boolean = false,
val busy: Boolean = false,
val title: String = "Entry editor",
val path: String = "",
val fields: List<EditorField> = emptyList(),
val dirty: Boolean = false,
val defaultLength: UInt = 0u,
val maximumLength: UInt = 0u,
val error: String? = null,
val discardRequested: Boolean = false,
val completedOperations: Long = 0
) {
override fun toString() = "EditorState(open=$open, locked=$locked, [REDACTED])"
}
interface EditorRepository {
val state: StateFlow<EditorState>
fun processRecreated()
fun begin(path: String)
fun create(parent: String, name: String, passphrase: String = "")
fun createFolder(parent: String, name: String)
fun change(id: ULong, name: String?, value: String?)
fun add(kind: EditorFieldKind, name: String?, value: String)
fun remove(id: ULong)
fun reorder(id: ULong, index: UInt)
fun generate(length: UInt?, noSymbols: Boolean)
fun save()
fun resume(passphrase: String)
fun requestClose(after: () -> Unit = {})
fun keepEditing()
fun confirmDiscard()
}
@@ -116,6 +116,19 @@ class NativeAuthenticationRepository(
}
}
}
internal suspend fun checkpointEditor(
editor: ULong,
version: ULong,
fields: List<de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput>
) = withContext(io) {
(current ?: throw cancelled()).checkpointEntryEditorInputs(editor, version, fields)
}
internal suspend fun discardEditor(editor: ULong) = withContext(io) {
current?.discardEntryEditor(editor)
Unit
}
override fun setAppearance(appearance: Appearance) = run {
it.setMobileAppearance(
when (appearance) {
@@ -0,0 +1,332 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorPage
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorSession
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** Frontend operation/input state only; Rust owns documents and encrypted recovery checkpoints. */
class NativeEditorRepository(
private val authentication: NativeAuthenticationRepository,
private val passwords: PasswordRepository,
private val scope: CoroutineScope
) : EditorRepository {
private val mutable = MutableStateFlow(EditorState())
override val state: StateFlow<EditorState> = mutable
private val operation = Mutex()
@Volatile private var epoch = 0L
@Volatile private var version = 0uL
@Volatile private var checkpoint: ULong? = null
private var pending: Job? = null
private var afterClose: () -> Unit = {}
private var retry: (suspend (String) -> MobileEntryEditorSession)? = null
init {
val registration = authentication.presentation.register {
if (mutable.value.open) {
mutable.value = mutable.value.copy(locked = true, fields = emptyList())
}
}
scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() }
}
override fun processRecreated() {
scope.launch {
val notice = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
de.rfc1437.ironstorage.bridge.generated.mobileEditorProcessRecreatedNotice()
}
if (!mutable.value.open) mutable.value = mutable.value.copy(error = notice)
}
}
override fun begin(path: String) {
if (mutable.value.open) return
epoch += 1
authentication.presentation.clear()
mutable.value = EditorState(open = true, path = path)
retry = { passphrase ->
authentication.authenticated {
it.unlockEntry(path, passphrase.ifEmpty { null })
it.beginEntryEditor(path).also { session ->
checkpoint = session.id
it.checkpointEntryEditor(session.id, ++version)
}
}
}
resume("")
}
override fun create(parent: String, name: String, passphrase: String) {
if (mutable.value.open) return
epoch += 1
authentication.presentation.clear()
mutable.value = EditorState(open = true)
retry = { secret ->
authentication.authenticated {
it.beginCreateEntry(parent, name, secret.ifEmpty { null }).also { session ->
checkpoint = session.id
it.checkpointEntryEditor(session.id, ++version)
}
}
}
resume(passphrase)
}
override fun createFolder(parent: String, name: String) = run(protectedResult = false) {
authentication.authenticated { it.createDirectory(parent, name) }
passwords.restore(passwords.state.value.path, null, null)
passwords.state.first { !it.loading }
EditorState()
}
override fun resume(passphrase: String) = run {
pending?.join()
val id = checkpoint
if (id == null) {
requireNotNull(retry)(passphrase).page.facade()
} else {
val recovered = authentication.authenticated {
it.resumeEntryEditor(id, passphrase.ifEmpty { null })
}
val inputs = recovered.inputs?.associateBy { it.id }
recovered.session.page.facade().let { page ->
page.copy(
fields = page.fields.map { field ->
val input = inputs?.get(field.id)
if (input ==
null
) {
field
} else {
field.copy(name = input.name, value = input.value)
}
},
dirty = page.dirty || inputs != null
)
}
}
}
override fun change(id: ULong, name: String?, value: String?) {
val current = mutable.value
if (!current.open || current.locked || current.busy) return
if (current.fields.none { it.id == id }) return
mutable.value = current.copy(
fields = current.fields.map {
if (it.id ==
id
) {
it.copy(name = name, value = value)
} else {
it
}
},
dirty = true,
error = null
)
val editor = checkpoint ?: return
val fields = inputs()
val ticket = epoch
val inputVersion = ++version
pending?.cancel()
pending = scope.launch {
try {
authentication.checkpointEditor(editor, inputVersion, fields)
} catch (error: CancellationException) {
throw error
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket == epoch &&
inputVersion == version
) {
mutable.value =
mutable.value.copy(error = error.detail)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = "Update the application and retry.")
}
}
}
}
private fun inputs() = mutable.value.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value)
}
private fun modify(
action: (
de.rfc1437.ironstorage.bridge.generated.MobileAuthentication,
ULong
) -> MobileEntryEditorPage
) = run {
pending?.join()
val id = requireNotNull(checkpoint)
val fields = inputs()
authentication.touch()
authentication.authenticated {
it.updateEntryEditor(id, fields)
action(it, id).also { _ -> it.checkpointEntryEditor(id, ++version) }
}.facade()
}
override fun add(kind: EditorFieldKind, name: String?, value: String) = modify { auth, id ->
auth.addEntryEditorField(id, kind.native(), name, value)
}
override fun remove(id: ULong) =
modify { auth, editor -> auth.removeEntryEditorField(editor, id) }
override fun reorder(id: ULong, index: UInt) =
modify { auth, editor -> auth.reorderEntryEditorField(editor, id, index) }
override fun generate(length: UInt?, noSymbols: Boolean) =
modify { auth, editor -> auth.generateEntryEditorPassword(editor, length, noSymbols) }
override fun save() = run(protectedResult = false) {
pending?.join()
val id = requireNotNull(checkpoint)
val fields = inputs()
authentication.touch()
authentication.authenticated { it.saveEntryEditor(id, fields) }
checkpoint = null
retry = null
passwords.restore(passwords.state.value.path, null, null)
passwords.state.first { !it.loading }
EditorState()
}
override fun requestClose(after: () -> Unit) {
afterClose = after
if (mutable.value.dirty) {
mutable.value = mutable.value.copy(discardRequested = true)
} else {
confirmDiscard()
}
}
override fun keepEditing() {
afterClose = {}
mutable.value = mutable.value.copy(discardRequested = false)
}
override fun confirmDiscard() {
val after = afterClose
afterClose = {}
epoch += 1
val ticket = epoch
mutable.value = mutable.value.copy(busy = true, discardRequested = false)
scope.launch {
operation.withLock {
try {
pending?.join()
checkpoint?.let { authentication.discardEditor(it) }
if (ticket != epoch) return@withLock
checkpoint = null
retry = null
mutable.value = EditorState()
after()
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(busy = false, error = error.detail)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
busy = false,
error = "Update the application and retry."
)
}
}
}
}
}
private fun run(protectedResult: Boolean = true, action: suspend () -> EditorState) {
if (mutable.value.busy) return
val ticket = epoch
mutable.value = mutable.value.copy(busy = true, error = null)
scope.launch {
operation.withLock {
if (ticket != epoch) return@withLock
try {
val result = action()
if (ticket == epoch &&
(!protectedResult || authentication.state.value.unlocked)
) {
mutable.value = result.copy(
completedOperations = mutable.value.completedOperations + 1
)
}
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = error.title + "\n" + error.detail)
}
} catch (_: MobilePrivateStorageFfiException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
error = "Unlock the device and retry."
)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = "Update the application and retry.")
}
} catch (_: java.io.IOException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
error = "Storage unavailable. Keep the draft and retry."
)
}
} finally {
if (ticket == epoch) mutable.value = mutable.value.copy(busy = false)
}
}
}
}
}
private fun EditorFieldKind.native() = when (this) {
EditorFieldKind.Password -> MobileEntryEditorFieldKind.PASSWORD
EditorFieldKind.Field -> MobileEntryEditorFieldKind.FIELD
EditorFieldKind.Note -> MobileEntryEditorFieldKind.NOTE
EditorFieldKind.OtpUri -> MobileEntryEditorFieldKind.OTP_URI
}
private fun MobileEntryEditorPage.facade() = EditorState(
open = true,
title = title,
path = path,
fields = fields.map { field ->
EditorField(
field.id,
when (field.kind) {
MobileEntryEditorFieldKind.PASSWORD -> EditorFieldKind.Password
MobileEntryEditorFieldKind.FIELD -> EditorFieldKind.Field
MobileEntryEditorFieldKind.NOTE -> EditorFieldKind.Note
MobileEntryEditorFieldKind.OTP_URI -> EditorFieldKind.OtpUri
},
field.name, field.label, field.value, field.maskedValue, field.diagnostic,
field.sensitive, field.multiline, field.nameEditable, field.removable, field.reorderable
)
},
dirty = dirty,
defaultLength = defaultPasswordLength,
maximumLength = maximumPasswordLength
)
@@ -0,0 +1,457 @@
package de.rfc1437.ironstorage.features
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
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.AuthenticationRepository
import de.rfc1437.ironstorage.core.EditorFieldKind
import de.rfc1437.ironstorage.core.EditorRepository
@Composable
fun EntryEditorScreen(repository: EditorRepository, authentication: AuthenticationRepository) {
val state by repository.state.collectAsStateWithLifecycle()
val lease by authentication.state.collectAsStateWithLifecycle()
var manual by remember { mutableStateOf(false) }
var adding by remember { mutableStateOf<EditorFieldKind?>(null) }
var generating by remember { mutableStateOf(false) }
var revealed by remember { mutableStateOf(emptySet<ULong>()) }
val focus = LocalFocusManager.current
ProtectedInputSurface(authentication.presentation)
BackHandler { repository.requestClose() }
LazyColumn(
Modifier.fillMaxSize().imePadding().testTag("entry-editor"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
state.title,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics {
heading()
}
)
Text(state.path)
FlowRow {
TextButton(onClick = {
repository.requestClose()
}) { Text(stringResource(R.string.editor_cancel)) }
Button(
enabled =
!state.busy && !state.locked && state.dirty && state.fields.isNotEmpty() &&
!lease.busy,
onClick = {
focus.clearFocus()
repository.save()
}
) { Text(stringResource(R.string.editor_save)) }
TextButton(enabled = !state.busy, onClick = {
repository.requestClose(authentication::lock)
}) {
Text(stringResource(R.string.password_lock))
}
}
if (state.busy || lease.busy) CircularProgressIndicator()
state.error?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("editor-error")
)
}
if (state.locked || state.fields.isEmpty()) {
Text(stringResource(R.string.editor_locked))
Button(enabled = !state.busy && !lease.busy, onClick = { repository.resume("") }) {
Text(stringResource(R.string.password_unlock))
}
TextButton(enabled = !state.busy && !lease.busy, onClick = { manual = true }) {
Text(stringResource(R.string.auth_manual))
}
} else {
FlowRow {
TextButton(enabled = !state.busy, onClick = {
adding = EditorFieldKind.Field
}) { Text(stringResource(R.string.editor_add_field)) }
TextButton(enabled = !state.busy, onClick = {
repository.add(EditorFieldKind.Note, null, "")
}) { Text(stringResource(R.string.editor_add_notes)) }
TextButton(enabled = !state.busy, onClick = {
adding = EditorFieldKind.OtpUri
}) { Text(stringResource(R.string.editor_add_otp)) }
TextButton(enabled = !state.busy, onClick = {
generating = true
}) { Text(stringResource(R.string.editor_generate)) }
}
}
}
itemsIndexed(state.fields, key = { _, field -> field.id.toString() }) { index, field ->
Column(
Modifier.fillMaxWidth().widthIn(max = 800.dp).padding(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (field.nameEditable) {
OutlinedTextField(
value = field.name.orEmpty(),
onValueChange = { repository.change(field.id, it, field.value) },
enabled = !state.busy && !lease.busy,
label = { Text(stringResource(R.string.editor_field_name)) },
modifier = Modifier.fillMaxWidth().testTag("editor-name-${field.id}"),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = {
focus.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
})
)
}
val shown = field.id in revealed
OutlinedTextField(
value = field.value.orEmpty(),
onValueChange = { repository.change(field.id, field.name, it) },
enabled = !state.busy && !lease.busy && field.value != null,
label = { Text(field.label) },
modifier = Modifier.fillMaxWidth().testTag("editor-value-${field.id}"),
visualTransformation = if (shown) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
singleLine = !field.multiline,
minLines = if (field.multiline) 3 else 1,
keyboardOptions = KeyboardOptions(
keyboardType = if (shown) KeyboardType.Text else KeyboardType.Password,
autoCorrectEnabled = false,
imeAction = if (field.multiline) ImeAction.Default else ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = {
focus.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
})
)
field.diagnostic?.let { Text(it) }
FlowRow {
TextButton(onClick = {
revealed =
if (shown) revealed - field.id else revealed + field.id
}) {
Text(
stringResource(
if (shown) R.string.editor_hide else R.string.editor_reveal
)
)
}
if (field.removable) {
TextButton(enabled = !state.busy, onClick = {
repository.remove(field.id)
}, modifier = Modifier.testTag("editor-remove-${field.id}")) {
Text(stringResource(R.string.editor_remove))
}
}
if (field.reorderable) {
TextButton(
enabled =
!state.busy && index > 0 && state.fields[index - 1].reorderable,
onClick = {
repository.reorder(
field.id,
(
index -
1
).toUInt()
)
},
modifier = Modifier.testTag("editor-up-${field.id}")
) {
Text(stringResource(R.string.editor_up))
}
val canMoveDown = !state.busy && index < state.fields.lastIndex
TextButton(enabled = canMoveDown, onClick = {
repository.reorder(
field.id,
(
index +
1
).toUInt()
)
}, modifier = Modifier.testTag("editor-down-${field.id}")) {
Text(stringResource(R.string.editor_down))
}
}
}
}
}
}
if (manual) {
PassphraseDialog(
stringResource(R.string.auth_manual),
stringResource(R.string.editor_unlock_detail),
authentication.presentation,
onCancel = { manual = false },
onSubmit = {
manual = false
repository.resume(it)
}
)
}
adding?.let { kind ->
EditorAddDialog(repository, authentication, kind) { adding = null }
}
if (generating) {
EditorGenerateDialog(repository, authentication) { generating = false }
}
}
@Composable
fun EditorDiscardDialog(repository: EditorRepository) {
val state by repository.state.collectAsStateWithLifecycle()
if (!state.discardRequested) return
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = repository::keepEditing,
title = { Text(stringResource(R.string.editor_discard_title)) },
text = { Text(stringResource(R.string.editor_discard_detail)) },
confirmButton = {
TextButton(onClick = repository::confirmDiscard) {
Text(stringResource(R.string.editor_discard))
}
},
dismissButton = {
TextButton(onClick = repository::keepEditing) {
Text(stringResource(R.string.editor_keep))
}
}
)
}
@Composable
private fun EditorAddDialog(
repository: EditorRepository,
authentication: AuthenticationRepository,
kind: EditorFieldKind,
close: () -> Unit
) {
val state by repository.state.collectAsStateWithLifecycle()
var submitted by remember { mutableStateOf<Long?>(null) }
LaunchedEffect(state.busy, state.completedOperations) {
if (submitted != null && !state.busy && state.completedOperations != submitted) close()
}
var name by rememberProtectedInput(authentication.presentation)
var value by rememberProtectedInput(authentication.presentation)
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = {
Text(
stringResource(
if (kind ==
EditorFieldKind.OtpUri
) {
R.string.editor_add_otp
} else {
R.string.editor_add_field
}
)
)
},
text = {
ProtectedInputSurface(authentication.presentation)
Column {
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (kind ==
EditorFieldKind.Field
) {
OutlinedTextField(name, {
name = it
}, label = {
Text(stringResource(R.string.editor_field_name))
}, modifier = Modifier.testTag("editor-add-name"), singleLine = true)
}
OutlinedTextField(
value,
{
value = it
},
label = {
Text(stringResource(R.string.editor_field_value))
},
modifier = Modifier.testTag("editor-add-value"),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
autoCorrectEnabled = false
)
)
}
},
confirmButton = {
TextButton(onClick = {
submitted = state.completedOperations
repository.add(
kind,
if (kind ==
EditorFieldKind.Field
) {
name
} else {
null
},
value
)
}) { Text(stringResource(R.string.editor_add)) }
},
dismissButton = {
TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) }
}
)
}
@Composable
private fun EditorGenerateDialog(
repository: EditorRepository,
authentication: AuthenticationRepository,
close: () -> Unit
) {
val state by repository.state.collectAsStateWithLifecycle()
var length by remember { mutableStateOf(state.defaultLength.toString()) }
var noSymbols by remember { mutableStateOf(false) }
val noSymbolsLabel = stringResource(R.string.editor_no_symbols)
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = { Text(stringResource(R.string.editor_generate)) },
text = {
Column {
Text(stringResource(R.string.editor_generate_detail))
OutlinedTextField(
length,
{
length = it
},
label = {
Text(stringResource(R.string.editor_length))
},
supportingText = {
Text(
stringResource(R.string.editor_length_help, state.maximumLength.toInt())
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
)
)
FlowRow {
Checkbox(
noSymbols,
{ noSymbols = it },
modifier = Modifier.semantics {
contentDescription =
noSymbolsLabel
}
)
Text(stringResource(R.string.editor_no_symbols))
}
}
},
confirmButton = {
TextButton(onClick = {
repository.generate(
if (length.isBlank()) {
null
} else {
length.toUIntOrNull()
?: 0u
},
noSymbols
)
close()
}) { Text(stringResource(R.string.editor_generate_confirm)) }
},
dismissButton = {
TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) }
}
)
}
@Composable
internal fun EditorCreationDialog(
authentication: AuthenticationRepository,
folder: Boolean,
close: () -> Unit,
create: (String) -> Unit
) {
var name by rememberProtectedInput(authentication.presentation)
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = {
Text(
stringResource(
if (folder) R.string.editor_new_folder else R.string.editor_new_entry
)
)
},
text = {
ProtectedInputSurface(authentication.presentation)
OutlinedTextField(
name,
{
name = it
},
label = { Text(stringResource(R.string.editor_name)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(onDone = { create(name) })
)
},
confirmButton = {
TextButton(onClick = { create(name) }) { Text(stringResource(R.string.editor_create)) }
},
dismissButton = {
TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) }
}
)
}
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -47,6 +48,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.PasswordRepository
import de.rfc1437.ironstorage.core.ShellIcon
@@ -57,7 +59,8 @@ fun PasswordScreen(
repository: PasswordRepository,
authentication: AuthenticationRepository,
modifier: Modifier = Modifier,
leaveOnDispose: Boolean = true
leaveOnDispose: Boolean = true,
editor: EditorRepository? = null
) {
DisposableEffect(repository, authentication) {
onDispose {
@@ -67,6 +70,8 @@ fun PasswordScreen(
val state by repository.state.collectAsStateWithLifecycle()
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
var actions by remember { mutableStateOf(false) }
var createFolder by remember { mutableStateOf<Boolean?>(null) }
val editorState = editor?.state?.collectAsStateWithLifecycle()?.value
val lease by authentication.state.collectAsStateWithLifecycle()
var manual by remember { mutableStateOf(false) }
var requested by remember { mutableStateOf(false) }
@@ -110,6 +115,23 @@ fun PasswordScreen(
Text(stringResource(R.string.password_refresh))
}
}
if (editor != null) {
FlowRow {
TextButton(enabled = !state.loading && editorState?.busy != true, onClick = {
createFolder =
false
}) {
Text(stringResource(R.string.editor_new_entry))
}
TextButton(enabled = !state.loading && editorState?.busy != true, onClick = {
createFolder =
true
}) {
Text(stringResource(R.string.editor_new_folder))
}
}
editorState?.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
@@ -255,6 +277,15 @@ fun PasswordScreen(
}
}
} else {
if (editor != null) {
item {
TextButton(enabled = !state.loading, onClick = {
editor.begin(entry.path)
}) {
Text(stringResource(R.string.editor_edit_entry))
}
}
}
entryViewer(state, repository)
}
}
@@ -263,6 +294,26 @@ fun PasswordScreen(
}
)
}
createFolder?.let { folder ->
if (editor != null) {
EditorCreationDialog(
authentication,
folder,
close = { createFolder = null },
create = { name ->
createFolder = null
if (folder) {
editor.createFolder(
state.path,
name
)
} else {
editor.create(state.path, name)
}
}
)
}
}
LaunchedEffect(lease.failure, state.loading) {
if (requested && !state.loading && !lease.busy) {
requested = false
@@ -57,6 +57,7 @@ private enum class ShellRoute(val tab: ShellTab?) {
@Composable
fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
EditorDiscardDialog(viewModel.editor)
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
val appearancePage by viewModel.appearancePage.collectAsStateWithLifecycle()
fun selectTab(tab: ShellTab) {
@@ -149,12 +150,20 @@ private fun ShellDestination(
viewModel: ShellViewModel,
setupForm: SetupFormViewModel
) {
val setup by viewModel.onboarding.setup.collectAsStateWithLifecycle()
val editor by viewModel.editor.state.collectAsStateWithLifecycle()
val ready =
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked)
(
snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked ||
(snapshot.state == ShellState.Empty && setup.configured)
)
when {
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Empty || route == ShellRoute.Preferences) -> {
(
(snapshot.state == ShellState.Empty && !setup.configured) ||
route == ShellRoute.Preferences
) -> {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
@@ -176,12 +185,17 @@ private fun ShellDestination(
}
ready && route == ShellRoute.Passwords -> {
LaunchedEffect(viewModel) { viewModel.openPasswords() }
PasswordScreen(
viewModel.passwords,
viewModel.authentication,
Modifier.fillMaxSize(),
leaveOnDispose = false
)
if (editor.open) {
EntryEditorScreen(viewModel.editor, viewModel.authentication)
} else {
PasswordScreen(
viewModel.passwords,
viewModel.authentication,
Modifier.fillMaxSize(),
leaveOnDispose = false,
editor = viewModel.editor
)
}
}
ready && route == ShellRoute.Search -> SearchScreen(
viewModel.search,
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.PasswordRepository
@@ -26,6 +27,7 @@ class ShellViewModel @Inject constructor(
val home: HomeRepository,
val search: SearchRepository,
val authentication: AuthenticationRepository,
val editor: EditorRepository,
private val saved: SavedStateHandle
) : ViewModel() {
val snapshot = repository.snapshot
@@ -45,6 +47,12 @@ class ShellViewModel @Inject constructor(
}
init {
if (saved.get<Boolean>("editor.wasOpen") == true && !editor.state.value.open) {
editor.processRecreated()
}
viewModelScope.launch {
editor.state.collect { saved["editor.wasOpen"] = it.open }
}
val restored = saved.get<String>("shell.tab")?.let { name ->
ShellTab.entries.firstOrNull { it.name == name }
}
@@ -84,6 +92,10 @@ class ShellViewModel @Inject constructor(
}
fun refreshShell() = repository.refresh()
fun selectTab(tab: ShellTab) {
if (tab != snapshot.value.selectedTab && editor.state.value.open) {
editor.requestClose { selectTab(tab) }
return
}
if (tab != snapshot.value.selectedTab) leaveDestination()
saved["shell.tab"] = tab.name
repository.selectTab(tab)
@@ -118,4 +118,34 @@
<string name="shell_appearance_light">Light</string>
<string name="shell_appearance_dark">Dark</string>
<string name="shell_refresh">Reload state</string>
<string name="editor_cancel">Cancel</string>
<string name="editor_save">Save entry</string>
<string name="editor_locked">The draft is protected. Unlock to resume editing. No plaintext draft is saved on disk.</string>
<string name="editor_unlock_detail">Unlock the GPG key to resume this in-memory draft.</string>
<string name="editor_add_field">Add named field</string>
<string name="editor_add_notes">Add notes</string>
<string name="editor_add_otp">Add OTP URI</string>
<string name="editor_field_name">Field name</string>
<string name="editor_field_value">Field value</string>
<string name="editor_generate">Generate password</string>
<string name="editor_generate_detail">Generate a replacement password in the draft. Save the entry to apply it.</string>
<string name="editor_length">Password length</string>
<string name="editor_no_symbols">Use letters and numbers only</string>
<string name="editor_generate_confirm">Generate replacement</string>
<string name="editor_reveal">Reveal value</string>
<string name="editor_hide">Hide value</string>
<string name="editor_remove">Remove field</string>
<string name="editor_up">Move up</string>
<string name="editor_down">Move down</string>
<string name="editor_add">Add field</string>
<string name="editor_discard_title">Discard unsaved changes?</string>
<string name="editor_discard_detail">The entry draft will be discarded without changing the password store.</string>
<string name="editor_discard">Discard changes</string>
<string name="editor_keep">Keep editing</string>
<string name="editor_new_entry">New entry</string>
<string name="editor_new_folder">New folder</string>
<string name="editor_edit_entry">Edit entry</string>
<string name="editor_name">Name</string>
<string name="editor_create">Create</string>
<string name="editor_length_help">Leave blank for the default. Maximum: %1$d.</string>
</resources>