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
+2325 -16

No files matched your search

+2
View File
@@ -88,6 +88,8 @@ Name-only Android search and its state/security policy are documented in
[`docs/android/name-search.md`](docs/android/name-search.md).
Adaptive navigation, appearance, safe restoration and shell validation are documented in
[`docs/android/adaptive-shell.md`](docs/android/adaptive-shell.md).
Native entry creation, editing and encrypted draft recovery are documented in
[`docs/android/entry-editor.md`](docs/android/entry-editor.md).
## Project layout
@@ -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,13 +185,18 @@ private fun ShellDestination(
}
ready && route == ShellRoute.Passwords -> {
LaunchedEffect(viewModel) { viewModel.openPasswords() }
if (editor.open) {
EntryEditorScreen(viewModel.editor, viewModel.authentication)
} else {
PasswordScreen(
viewModel.passwords,
viewModel.authentication,
Modifier.fillMaxSize(),
leaveOnDispose = false
leaveOnDispose = false,
editor = viewModel.editor
)
}
}
ready && route == ShellRoute.Search -> SearchScreen(
viewModel.search,
viewModel::openSearch,
@@ -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>
+139
View File
@@ -1408,6 +1408,10 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
func cancel() throws
func checkpointEntryEditor(editor: UInt64, version: UInt64) throws
func checkpointEntryEditorInputs(editor: UInt64, version: UInt64, fields: [MobileEntryEditorInput]) throws
func copyEntryField(path: String, field: UInt64) throws -> MobileEntryCopy
func copyPresentedEntryField(path: String, fieldId: UInt64, revision: String) throws -> MobileEntryCopy
@@ -1462,6 +1466,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
func restoreBiometricLease() throws -> MobileAuthenticationState
func resumeEntryEditor(editor: UInt64, passphrase: String?) throws -> MobileEntryEditorRecovery
func revealEntryField(path: String, field: UInt64) throws -> String
func revealPresentedEntryField(path: String, fieldId: UInt64, revision: String) throws -> String
@@ -1627,6 +1633,27 @@ open func cancel()throws {try rustCallWithError(FfiConverterTypeMobileAuthenti
}
}
open func checkpointEntryEditor(editor: UInt64, version: UInt64)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_mobile_fn_method_mobileauthentication_checkpoint_entry_editor(
self.uniffiCloneHandle(),
FfiConverterUInt64.lower(editor),
FfiConverterUInt64.lower(version),uniffiCallStatus
)
}
}
open func checkpointEntryEditorInputs(editor: UInt64, version: UInt64, fields: [MobileEntryEditorInput])throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_mobile_fn_method_mobileauthentication_checkpoint_entry_editor_inputs(
self.uniffiCloneHandle(),
FfiConverterUInt64.lower(editor),
FfiConverterUInt64.lower(version),
FfiConverterSequenceTypeMobileEntryEditorInput.lower(fields),uniffiCallStatus
)
}
}
open func copyEntryField(path: String, field: UInt64)throws -> MobileEntryCopy {
return try FfiConverterTypeMobileEntryCopy_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
@@ -1918,6 +1945,17 @@ open func restoreBiometricLease()throws -> MobileAuthenticationState {
})
}
open func resumeEntryEditor(editor: UInt64, passphrase: String?)throws -> MobileEntryEditorRecovery {
return try FfiConverterTypeMobileEntryEditorRecovery_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_mobile_fn_method_mobileauthentication_resume_entry_editor(
self.uniffiCloneHandle(),
FfiConverterUInt64.lower(editor),
FfiConverterOptionString.lower(passphrase),uniffiCallStatus
)
})
}
open func revealEntryField(path: String, field: UInt64)throws -> String {
return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
uniffiCallStatus in
@@ -5032,6 +5070,60 @@ public func FfiConverterTypeMobileEntryEditorPage_lower(_ value: MobileEntryEdit
}
public struct MobileEntryEditorRecovery: Equatable, Hashable {
public var session: MobileEntryEditorSession
public var inputs: [MobileEntryEditorInput]?
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(session: MobileEntryEditorSession, inputs: [MobileEntryEditorInput]?) {
self.session = session
self.inputs = inputs
}
}
#if compiler(>=6)
extension MobileEntryEditorRecovery: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeMobileEntryEditorRecovery: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryEditorRecovery {
return
try MobileEntryEditorRecovery(
session: FfiConverterTypeMobileEntryEditorSession.read(from: &buf),
inputs: FfiConverterOptionSequenceTypeMobileEntryEditorInput.read(from: &buf)
)
}
public static func write(_ value: MobileEntryEditorRecovery, into buf: inout [UInt8]) {
FfiConverterTypeMobileEntryEditorSession.write(value.session, into: &buf)
FfiConverterOptionSequenceTypeMobileEntryEditorInput.write(value.inputs, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileEntryEditorRecovery_lift(_ buf: RustBuffer) throws -> MobileEntryEditorRecovery {
return try FfiConverterTypeMobileEntryEditorRecovery.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeMobileEntryEditorRecovery_lower(_ value: MobileEntryEditorRecovery) -> RustBuffer {
return FfiConverterTypeMobileEntryEditorRecovery.lower(value)
}
public struct MobileEntryEditorSession: Equatable, Hashable {
public var id: UInt64
public var page: MobileEntryEditorPage
@@ -11889,6 +11981,30 @@ fileprivate struct FfiConverterOptionTypeMobilePrivateStorageErrorKind: FfiConve
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterOptionSequenceTypeMobileEntryEditorInput: FfiConverterRustBuffer {
typealias SwiftType = [MobileEntryEditorInput]?
public static func write(_ value: SwiftType, into buf: inout [UInt8]) {
guard let value = value else {
writeInt(&buf, Int8(0))
return
}
writeInt(&buf, Int8(1))
FfiConverterSequenceTypeMobileEntryEditorInput.write(value, into: &buf)
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType {
switch try readInt(&buf) as Int8 {
case 0: return nil
case 1: return try FfiConverterSequenceTypeMobileEntryEditorInput.read(from: &buf)
default: throw UniffiInternalError.unexpectedOptionalTag
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -12320,6 +12436,13 @@ public func mobileAuthentication()throws -> MobileAuthentication {
)
})
}
public func mobileEditorProcessRecreatedNotice() -> String {
return try! FfiConverterString.lift(try! rustCall() {
uniffiCallStatus in
uniffi_ironstorage_mobile_fn_func_mobile_editor_process_recreated_notice(uniffiCallStatus
)
})
}
public func mobileHomeOperation(authentication: MobileAuthentication?) -> MobileHomeOperation {
return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() {
uniffiCallStatus in
@@ -12521,6 +12644,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_mobile_checksum_func_mobile_authentication() != 54684) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_mobile_checksum_func_mobile_editor_process_recreated_notice() != 20887) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_mobile_checksum_func_mobile_home_operation() != 10726) {
return InitializationResult.apiChecksumMismatch
}
@@ -12608,6 +12734,12 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_cancel() != 40449) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_checkpoint_entry_editor() != 51032) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_checkpoint_entry_editor_inputs() != 62194) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_copy_entry_field() != 46283) {
return InitializationResult.apiChecksumMismatch
}
@@ -12689,6 +12821,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_restore_biometric_lease() != 7408) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_resume_entry_editor() != 56859) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_reveal_entry_field() != 13272) {
return InitializationResult.apiChecksumMismatch
}
@@ -13036,6 +13171,10 @@ extension MobileEntryEditorPage: CustomStringConvertible, CustomDebugStringConve
public var description: String { "MobileEntryEditorPage([REDACTED])" }
public var debugDescription: String { description }
}
extension MobileEntryEditorRecovery: CustomStringConvertible, CustomDebugStringConvertible {
public var description: String { "MobileEntryEditorRecovery([REDACTED])" }
public var debugDescription: String { description }
}
extension MobileEntryEditorSession: CustomStringConvertible, CustomDebugStringConvertible {
public var description: String { "MobileEntryEditorSession([REDACTED])" }
public var debugDescription: String { description }
+45
View File
@@ -464,6 +464,16 @@ RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_cached_totp_
void uniffi_ironstorage_mobile_fn_method_mobileauthentication_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR
void uniffi_ironstorage_mobile_fn_method_mobileauthentication_checkpoint_entry_editor(uint64_t ptr, uint64_t editor, uint64_t version, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR_INPUTS
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR_INPUTS
void uniffi_ironstorage_mobile_fn_method_mobileauthentication_checkpoint_entry_editor_inputs(uint64_t ptr, uint64_t editor, uint64_t version, RustBuffer fields, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_copy_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustCallStatus *_Nonnull out_status
@@ -599,6 +609,11 @@ RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_replace_pres
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_restore_biometric_lease(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_RESUME_ENTRY_EDITOR
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_RESUME_ENTRY_EDITOR
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_resume_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer passphrase, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_reveal_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustCallStatus *_Nonnull out_status
@@ -1193,6 +1208,12 @@ void uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_verify_secure_stor
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_AUTHENTICATION
uint64_t uniffi_ironstorage_mobile_fn_func_mobile_authentication(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_EDITOR_PROCESS_RECREATED_NOTICE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_EDITOR_PROCESS_RECREATED_NOTICE
RustBuffer uniffi_ironstorage_mobile_fn_func_mobile_editor_process_recreated_notice(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_HOME_OPERATION
@@ -1575,6 +1596,12 @@ void ffi_ironstorage_mobile_rust_future_complete_void(uint64_t handle, RustCallS
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_AUTHENTICATION
uint16_t uniffi_ironstorage_mobile_checksum_func_mobile_authentication(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_EDITOR_PROCESS_RECREATED_NOTICE
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_EDITOR_PROCESS_RECREATED_NOTICE
uint16_t uniffi_ironstorage_mobile_checksum_func_mobile_editor_process_recreated_notice(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION
@@ -1749,6 +1776,18 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_cached_t
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_cancel(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_checkpoint_entry_editor(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR_INPUTS
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CHECKPOINT_ENTRY_EDITOR_INPUTS
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_checkpoint_entry_editor_inputs(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD
@@ -1911,6 +1950,12 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_replace_
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_RESTORE_BIOMETRIC_LEASE
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_restore_biometric_lease(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_RESUME_ENTRY_EDITOR
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_RESUME_ENTRY_EDITOR
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_resume_entry_editor(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD
+71 -1
View File
@@ -24,7 +24,9 @@ use ironstorage::{
MobileAuthenticationError as StorageAuthenticationError,
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
MobileAuthenticationState as StorageAuthenticationState,
MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation,
MobileEntryCopy as StorageEntryCopy,
MobileEntryEditorRecovery as StorageEntryEditorRecovery,
MobileEntryPresentation as StorageEntryPresentation,
MobilePreferences as StorageMobilePreferences,
},
mobile_entry::{
@@ -88,6 +90,11 @@ pub use android_onboarding::*;
mod platform;
pub use platform::*;
#[uniffi::export]
pub fn mobile_editor_process_recreated_notice() -> String {
ironstorage::mobile_entry::editor_process_recreated_notice().to_owned()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileIcon {
Home,
@@ -1389,6 +1396,29 @@ impl From<MobileEntryEditorInput> for StorageEntryEditorInput {
}
}
#[derive(Clone, uniffi::Record)]
pub struct MobileEntryEditorRecovery {
pub session: MobileEntryEditorSession,
pub inputs: Option<Vec<MobileEntryEditorInput>>,
}
impl From<StorageEntryEditorRecovery> for MobileEntryEditorRecovery {
fn from(recovery: StorageEntryEditorRecovery) -> Self {
Self {
session: recovery.session.into(),
inputs: recovery.inputs.map(|inputs| {
inputs
.into_iter()
.map(|input| {
let (id, name, value) = input.into_parts();
MobileEntryEditorInput { id, name, value }
})
.collect()
}),
}
}
}
impl From<StorageEntryCopy> for MobileEntryCopy {
fn from(copy: StorageEntryCopy) -> Self {
Self {
@@ -1954,6 +1984,46 @@ impl MobileAuthentication {
.map_err(Into::into)
}
pub fn checkpoint_entry_editor(
&self,
editor: u64,
version: u64,
) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.checkpoint_entry_editor(editor, version)
.map_err(Into::into)
}
pub fn checkpoint_entry_editor_inputs(
&self,
editor: u64,
version: u64,
fields: Vec<MobileEntryEditorInput>,
) -> Result<(), MobileAuthenticationFfiError> {
self.authentication
.checkpoint_entry_editor_inputs(
editor,
version,
fields.into_iter().map(Into::into).collect(),
)
.map_err(Into::into)
}
pub fn resume_entry_editor(
&self,
editor: u64,
passphrase: Option<String>,
) -> Result<MobileEntryEditorRecovery, MobileAuthenticationFfiError> {
self.authentication
.resume_entry_editor(
editor,
passphrase
.map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
)
.map(Into::into)
.map_err(Into::into)
}
pub fn begin_entry_editor(
&self,
path: String,
+89 -1
View File
@@ -6,7 +6,7 @@ use sha2::{Digest as _, Sha256};
use crate::{
command::EditRequest,
crypto::{KeyStore, SecretProvider},
crypto::{CryptoError, KeyHandle, KeyStore, SecretProvider},
otp::{OtpAlgorithm, OtpError, OtpKind, OtpUri},
recipient::SigningPolicy,
repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
@@ -240,7 +240,95 @@ pub struct EntryDocument {
modified: bool,
}
/// An editor checkpoint contains ciphertext only and is never written to a repository.
pub(crate) struct SealedEntryDocument {
path: EntryPath,
fields: Vec<(EntryFieldId, Range<usize>, Vec<u8>)>,
next_id: u64,
conflict_token: DocumentConflictToken,
original_ciphertext: Option<EncryptedEntry>,
original_plaintext: Range<usize>,
ciphertext: EncryptedEntry,
modified: bool,
}
impl SealedEntryDocument {
pub(crate) fn probe(&self) -> &EncryptedEntry {
&self.ciphertext
}
pub(crate) fn restore(
&self,
keys: &KeyStore,
provider: &mut impl SecretProvider,
) -> Result<EntryDocument, CryptoError> {
let decrypted = keys.decrypt(&self.ciphertext, provider)?;
let read = |range: &Range<usize>| -> Result<SecretBytes, CryptoError> {
decrypted
.expose()
.get(range.clone())
.map(|bytes| SecretBytes::new(bytes.to_vec()))
.ok_or(CryptoError::CorruptMessage)
};
let plaintext = read(&self.original_plaintext)?;
let mut fields = self
.fields
.iter()
.map(|(id, contents, ending)| {
Ok(EntryField {
id: *id,
contents: read(contents)?,
ending: ending.clone(),
metadata: empty_note_metadata(),
})
})
.collect::<Result<Vec<_>, CryptoError>>()?;
classify_all(&mut fields);
Ok(EntryDocument {
path: self.path.clone(),
fields,
next_id: self.next_id,
conflict_token: self.conflict_token,
session: EditSession::restored(
self.path.clone(),
self.original_ciphertext.clone(),
plaintext,
),
modified: self.modified,
})
}
}
impl EntryDocument {
pub(crate) fn seal(
&self,
keys: &KeyStore,
recipient: &KeyHandle,
) -> Result<SealedEntryDocument, CryptoError> {
let mut body = self.session.plaintext().expose().to_vec();
let original_plaintext = 0..body.len();
let fields = self
.fields
.iter()
.map(|field| {
let start = body.len();
body.extend_from_slice(field.contents.expose());
(field.id, start..body.len(), field.ending.clone())
})
.collect();
let ciphertext = keys.encrypt(SecretBytes::new(body), std::slice::from_ref(recipient))?;
Ok(SealedEntryDocument {
path: self.path.clone(),
fields,
next_id: self.next_id,
conflict_token: self.conflict_token,
original_ciphertext: self.session.original_ciphertext().cloned(),
original_plaintext,
ciphertext,
modified: self.modified,
})
}
fn from_session(session: EditSession) -> Self {
let path = session.path().clone();
let conflict_token = conflict_token(&session);
+197 -4
View File
@@ -16,7 +16,9 @@ use crate::{
},
config::{Config, ConfigError, GitRemote, MobileAppearance},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
document::{
DocumentError, EntryDocument, EntryDocumentService, EntryFieldId, SealedEntryDocument,
},
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
mobile_entry::{
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
@@ -306,12 +308,37 @@ struct MobileAuthenticationStatus {
active: Option<ActiveMobileLease>,
next_editor_id: u64,
editors: BTreeMap<u64, MobileEntryDraft>,
editor_checkpoints: BTreeMap<u64, EditorCheckpoint>,
mutation_active: bool,
repository_operation_active: bool,
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
watch_snapshot: WatchSnapshotSender,
}
struct SealedEditorInput {
id: u64,
name: Option<std::ops::Range<usize>>,
value: Option<std::ops::Range<usize>>,
}
struct SealedEditorInputs {
fields: Vec<SealedEditorInput>,
ciphertext: EncryptedEntry,
}
struct EditorCheckpoint {
document: SealedEntryDocument,
creating: bool,
recipient: crate::crypto::KeyHandle,
field_ids: std::collections::BTreeSet<u64>,
version: u64,
inputs: Option<SealedEditorInputs>,
}
/// Only released after the checkpoint's encryption key has an active authentication lease.
pub struct MobileEntryEditorRecovery {
pub session: MobileEntryEditorSession,
pub inputs: Option<Vec<MobileEntryEditorInput>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum MobileRepositoryOperation {
Commit,
@@ -424,6 +451,7 @@ impl MobileAuthentication {
active: None,
next_editor_id: 0,
editors: BTreeMap::new(),
editor_checkpoints: BTreeMap::new(),
mutation_active: false,
repository_operation_active: false,
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
@@ -1584,11 +1612,173 @@ impl MobileAuthentication {
return Err(document_error(error));
}
self.reconcile_saved_document(draft.document());
self.status()?.editor_checkpoints.remove(&editor);
Ok(MobileEntryPage::from_document(draft.document()))
}
/// Seal the lossless document, original revision and stable IDs in memory only.
pub fn checkpoint_entry_editor(
&self,
editor: u64,
version: u64,
) -> Result<(), MobileAuthenticationError> {
self.ensure_active()?;
let mut status = self.status()?;
let key = &status.active.as_ref().ok_or_else(locked_error)?.key;
let recipient = self
.keys
.resolve(key.fingerprint().as_str())
.map_err(key_error)?;
let draft = status.editors.get(&editor).ok_or_else(editor_missing)?;
let checkpoint = EditorCheckpoint {
document: draft
.document()
.seal(&self.keys, &recipient)
.map_err(key_error)?,
creating: draft.creating(),
recipient,
field_ids: draft
.document()
.fields()
.iter()
.map(|field| field.id().value())
.collect(),
version,
inputs: None,
};
status.editor_checkpoints.insert(editor, checkpoint);
Ok(())
}
/// Accept an already collected input even if background revocation won the race.
/// This only encrypts caller-supplied values; it cannot read or publish protected data.
pub fn checkpoint_entry_editor_inputs(
&self,
editor: u64,
version: u64,
inputs: Vec<MobileEntryEditorInput>,
) -> Result<(), MobileAuthenticationError> {
let mut status = self.status()?;
let checkpoint = status
.editor_checkpoints
.get_mut(&editor)
.ok_or_else(editor_missing)?;
if version <= checkpoint.version {
return Ok(());
}
let inputs = inputs
.into_iter()
.map(MobileEntryEditorInput::into_parts)
.collect::<Vec<_>>();
let ids = inputs
.iter()
.map(|input| input.0)
.collect::<std::collections::BTreeSet<_>>();
if ids != checkpoint.field_ids || ids.len() != inputs.len() {
return Err(editor_error(MobileEntryEditorError::FieldSetChanged));
}
let mut body = Vec::new();
let mut append = |text: Option<String>| {
text.map(|text| {
let start = body.len();
let bytes = SecretBytes::new(text.into_bytes());
body.extend_from_slice(bytes.expose());
start..body.len()
})
};
let fields = inputs
.into_iter()
.map(|(id, name, value)| SealedEditorInput {
id,
name: append(name),
value: append(value),
})
.collect();
let ciphertext = self
.keys
.encrypt(
SecretBytes::new(body),
std::slice::from_ref(&checkpoint.recipient),
)
.map_err(key_error)?;
checkpoint.inputs = Some(SealedEditorInputs { fields, ciphertext });
checkpoint.version = version;
Ok(())
}
pub fn resume_entry_editor(
&self,
editor: u64,
passphrase: Option<SecretBytes>,
) -> Result<MobileEntryEditorRecovery, MobileAuthenticationError> {
let probe = self
.status()?
.editor_checkpoints
.get(&editor)
.ok_or_else(editor_missing)?
.document
.probe()
.clone();
self.unlock_ciphertext(&probe, passphrase)?;
self.ensure_active()?;
let mut status = self.status()?;
let active = status.active.as_ref().ok_or_else(locked_error)?;
let handle = active.handle.clone();
let mut provider = KeyOnlyProvider::new(handle.clone(), &active.key);
let checkpoint = status
.editor_checkpoints
.get(&editor)
.ok_or_else(editor_missing)?;
let document = checkpoint
.document
.restore(&self.keys, &mut provider)
.map_err(key_error)?;
let inputs = checkpoint
.inputs
.as_ref()
.map(|inputs| {
let plaintext = self
.keys
.decrypt(&inputs.ciphertext, &mut provider)
.map_err(key_error)?;
let read =
|range: &std::ops::Range<usize>| -> Result<String, MobileAuthenticationError> {
let bytes = plaintext
.expose()
.get(range.clone())
.ok_or_else(|| key_error(CryptoError::CorruptMessage))?;
String::from_utf8(bytes.to_vec())
.map_err(|_| editor_error(MobileEntryEditorError::NonUtf8))
};
inputs
.fields
.iter()
.map(|input| {
Ok(MobileEntryEditorInput::new(
input.id,
input.name.as_ref().map(&read).transpose()?,
input.value.as_ref().map(&read).transpose()?,
))
})
.collect::<Result<Vec<_>, MobileAuthenticationError>>()
})
.transpose()?;
handle
.ensure_active()
.map_err(MobileAuthenticationError::authentication)?;
let draft = MobileEntryDraft::new(document, checkpoint.creating);
let page = draft.page();
status.editors.insert(editor, draft);
Ok(MobileEntryEditorRecovery {
session: MobileEntryEditorSession::new(editor, page),
inputs,
})
}
pub fn discard_entry_editor(&self, editor: u64) -> Result<(), MobileAuthenticationError> {
self.status()?.editors.remove(&editor);
let mut status = self.status()?;
status.editors.remove(&editor);
status.editor_checkpoints.remove(&editor);
Ok(())
}
@@ -1611,8 +1801,11 @@ impl MobileAuthentication {
MobileLifecycleEvent::Foreground => {}
MobileLifecycleEvent::Background
| MobileLifecycleEvent::DeviceLocked
| MobileLifecycleEvent::MemoryPressure
| MobileLifecycleEvent::ProcessRecreated => self.manual_lock()?,
| MobileLifecycleEvent::MemoryPressure => self.manual_lock()?,
MobileLifecycleEvent::ProcessRecreated => {
self.manual_lock()?;
self.status()?.editor_checkpoints.clear();
}
MobileLifecycleEvent::UserActivity => {
if self.state()?.unlocked() {
self.touch_user_activity()?;
+13 -1
View File
@@ -2,6 +2,10 @@
use crate::mobile::MobileIcon;
pub fn editor_process_recreated_notice() -> &'static str {
"An unsaved draft was discarded after the application process restarted. Unlock the entry to edit it again."
}
use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize};
use crate::document::{
@@ -258,6 +262,9 @@ pub struct MobileEntryEditorInput {
}
impl MobileEntryEditorInput {
pub fn into_parts(self) -> (u64, Option<String>, Option<String>) {
(self.id, self.name, self.value)
}
pub fn new(id: u64, name: Option<String>, value: Option<String>) -> Self {
Self { id, name, value }
}
@@ -273,6 +280,10 @@ impl MobileEntryDraft {
Self { document, creating }
}
pub(crate) fn creating(&self) -> bool {
self.creating
}
pub fn page(&self) -> MobileEntryEditorPage {
let fields = self
.document
@@ -640,7 +651,8 @@ fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField {
sensitive,
multiline: matches!(kind, MobileEntryEditorFieldKind::Note)
|| field.value().contains(&b'\n'),
name_editable: matches!(kind, MobileEntryEditorFieldKind::Field),
name_editable: matches!(kind, MobileEntryEditorFieldKind::Field)
&& metadata.diagnostic() != Some(EntryFieldDiagnostic::NonUtf8Value),
removable: index > 0,
reorderable: index > 0,
}
+11
View File
@@ -173,6 +173,17 @@ pub struct EditSession {
}
impl EditSession {
pub(crate) fn restored(
path: EntryPath,
original_ciphertext: Option<EncryptedEntry>,
plaintext: SecretBytes,
) -> Self {
Self {
path,
original_ciphertext,
plaintext,
}
}
pub fn path(&self) -> &EntryPath {
&self.path
}
@@ -919,3 +919,271 @@ fn locking_interrupts_an_os_prompt_before_waiting_for_the_lease_mutex() -> TestR
assert_eq!(session.remaining_time()?, None);
Ok(())
}
#[test]
fn encrypted_editor_checkpoint_retains_inputs_ids_and_conflict_base_across_lock() -> TestResult {
use ironstorage::{
config::Config,
mobile_authentication::MobileAuthentication,
mobile_entry::{MobileEntryEditorFieldKind, MobileEntryEditorInput},
mobile_platform::MobileLifecycleEvent,
};
let fixture = FixtureSet::load()?;
let vault = fixture.materialize_store("basic")?;
let root = tempfile::tempdir()?;
let source = root.path().join("config.toml");
std::fs::write(
&source,
format!(
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n",
vault.path().to_str().unwrap(),
fixture.path("keys").to_str().unwrap(),
fixture.key("alice")?.primary_fingerprint,
),
)?;
let clock = ManualClock::default();
let backend = MemoryBackend::default();
let make = || {
MobileAuthentication::with_platform(
Config::load(Some(&source)).unwrap(),
Arc::new(backend.clone()),
Arc::new(clock.clone()),
)
};
let authentication = make()?;
let password =
|| SecretBytes::new(fixture.key("alice").unwrap().passphrase.as_bytes().to_vec());
authentication.create_directory("", "checkpoint")?;
let session = authentication.begin_create_entry("checkpoint", "entry", Some(password()))?;
let id = session.id();
for (kind, name, value) in [
(
MobileEntryEditorFieldKind::Field,
Some("custom".to_owned()),
"first",
),
(
MobileEntryEditorFieldKind::Field,
Some("custom".to_owned()),
"second",
),
(
MobileEntryEditorFieldKind::Field,
Some("pin".to_owned()),
"123493",
),
(
MobileEntryEditorFieldKind::Note,
None,
"first note\nsecond note",
),
(
MobileEntryEditorFieldKind::OtpUri,
None,
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
),
] {
authentication.add_entry_editor_field(id, kind, name, value.to_owned())?;
}
let page = authentication.entry_editor(id)?;
let ids = page
.fields()
.iter()
.map(|field| field.id())
.collect::<Vec<_>>();
let inputs = |text: &str| {
page.fields()
.iter()
.map(|field| {
MobileEntryEditorInput::new(
field.id(),
field.name().map(str::to_owned),
if field.kind() == MobileEntryEditorFieldKind::Password {
Some(text.to_owned())
} else {
field.value().map(str::to_owned)
},
)
})
.collect::<Vec<_>>()
};
authentication.checkpoint_entry_editor(id, 1)?;
authentication.checkpoint_entry_editor_inputs(id, 3, inputs("latest draft"))?;
authentication.checkpoint_entry_editor_inputs(id, 2, inputs("stale input"))?;
authentication.lifecycle(MobileLifecycleEvent::Background)?;
assert!(!authentication.state()?.unlocked());
assert!(authentication.entry_editor(id).is_err());
// A queued input may finish after the cover/lease revocation; it only encrypts.
authentication.checkpoint_entry_editor_inputs(id, 4, inputs("background-safe draft"))?;
assert!(
authentication
.resume_entry_editor(id, Some(SecretBytes::new(b"incorrect".to_vec())))
.is_err()
);
let recovered = authentication.resume_entry_editor(id, Some(password()))?;
assert_eq!(recovered.session.id(), id);
assert_eq!(
recovered
.session
.page()
.fields()
.iter()
.map(|field| field.id())
.collect::<Vec<_>>(),
ids
);
let recovered_inputs = recovered.inputs.unwrap();
assert_eq!(
recovered_inputs
.first()
.unwrap()
.clone()
.into_parts()
.2
.as_deref(),
Some("background-safe draft")
);
// Validation failures leave both the document and encrypted checkpoint available.
let invalid = page
.fields()
.iter()
.map(|field| {
MobileEntryEditorInput::new(
field.id(),
if field.name_editable() {
Some("bad\nname".to_owned())
} else {
field.name().map(str::to_owned)
},
field.value().map(str::to_owned),
)
})
.collect();
authentication.checkpoint_entry_editor_inputs(id, 5, invalid)?;
let invalid = authentication
.resume_entry_editor(id, None)?
.inputs
.unwrap();
assert!(authentication.save_entry_editor(id, invalid).is_err());
assert!(authentication.entry_editor(id).is_ok());
authentication.save_entry_editor(id, recovered_inputs)?;
assert!(
authentication.resume_entry_editor(id, None).is_err(),
"successful save deletes the checkpoint"
);
let opened = authentication.begin_entry_editor("checkpoint/entry")?;
authentication.checkpoint_entry_editor(opened.id(), 6)?;
let other = make()?;
other.unlock_entry("checkpoint/entry", Some(password()))?;
other.replace_entry_field("checkpoint/entry", ids[0], "concurrent change".to_owned())?;
authentication.manual_lock()?;
let recovered = authentication.resume_entry_editor(opened.id(), Some(password()))?;
let fields = recovered
.session
.page()
.fields()
.iter()
.map(|field| {
MobileEntryEditorInput::new(
field.id(),
field.name().map(str::to_owned),
field.value().map(str::to_owned),
)
})
.collect();
assert!(
authentication
.save_entry_editor(opened.id(), fields)
.is_err(),
"checkpoint must preserve its original conflict base"
);
authentication.discard_entry_editor(opened.id())?;
assert!(
authentication
.resume_entry_editor(opened.id(), None)
.is_err()
);
let opened = authentication.begin_entry_editor("checkpoint/entry")?;
authentication.checkpoint_entry_editor(opened.id(), 7)?;
authentication.set_authentication_timeout(1)?;
clock.advance(Duration::from_secs(2));
assert!(!authentication.state()?.unlocked());
assert!(
authentication
.resume_entry_editor(opened.id(), Some(password()))
.is_ok()
);
authentication.lifecycle(MobileLifecycleEvent::ProcessRecreated)?;
assert!(!authentication.state()?.unlocked());
assert!(
authentication
.resume_entry_editor(opened.id(), Some(password()))
.is_err()
);
let repository = Repository::open(vault.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let path = ironstorage::repository::EntryPath::parse("checkpoint/binary")?;
let recipients = ironstorage::recipient::RecipientPolicyManager::new(&repository, &keys)
.resolve_for_entry(&path, None)?;
let bytes = b"opaque password\nunknown: \xff\nPIN: 123493\n";
let encrypted = keys.encrypt(SecretBytes::new(bytes.to_vec()), recipients.recipients())?;
repository.write_entry(&path, &encrypted)?;
authentication.unlock_entry("checkpoint/binary", Some(password()))?;
let editor = authentication.begin_entry_editor("checkpoint/binary")?;
let opaque = editor
.page()
.fields()
.iter()
.find(|field| field.value().is_none())
.unwrap();
assert!(!opaque.name_editable());
authentication.checkpoint_entry_editor(editor.id(), 8)?;
authentication.manual_lock()?;
let recovered = authentication.resume_entry_editor(editor.id(), Some(password()))?;
let fields = recovered
.session
.page()
.fields()
.iter()
.map(|field| {
MobileEntryEditorInput::new(
field.id(),
field.name().map(str::to_owned),
if field.name() == Some("PIN") {
Some("123494".to_owned())
} else {
field.value().map(str::to_owned)
},
)
})
.collect();
authentication.save_entry_editor(editor.id(), fields)?;
struct FixtureSecret {
fingerprint: String,
password: Vec<u8>,
}
impl ironstorage::crypto::SecretProvider for FixtureSecret {
fn secret_for(
&mut self,
key: &KeyInfo,
) -> Result<SecretBytes, ironstorage::crypto::SecretProviderError> {
if key.fingerprint().as_str() != self.fingerprint {
return Err(ironstorage::crypto::SecretProviderError::Unavailable);
}
Ok(SecretBytes::new(self.password.clone()))
}
}
let decrypted = keys.decrypt(
&repository.read_entry(&path)?,
&mut FixtureSecret {
fingerprint: fixture.key("alice")?.primary_fingerprint.clone(),
password: fixture.key("alice")?.passphrase.as_bytes().to_vec(),
},
)?;
assert_eq!(
decrypted.expose(),
b"opaque password\nunknown: \xff\nPIN: 123494\n",
"opaque non-UTF8 fields must round-trip losslessly"
);
Ok(())
}
+61
View File
@@ -0,0 +1,61 @@
# Android entry editor
Passwords offers New folder and New entry in configured stores, including an
empty store. An unlocked entry offers Edit entry. The native form presents
Rust field IDs, kinds, names, sensitivity, capabilities and generation options.
Values are hidden until explicitly revealed. Named/repeated fields, multiline
notes and OTP URIs can be added; accessible Up/Down and Remove controls obey
Rust capabilities. Opaque non-UTF8 fields remain intact and cannot be edited.
Generation requires confirmation before replacing the password.
Rust owns validation, lossless entry serialization, recipients, generation,
revision checks, atomic encryption/write and Git commits. Kotlin collects input
and presents typed results. Failed validation and stale saves keep the draft;
an explicit discard is required before Back, another tab or manual Lock can
abandon dirty changes. Background/expiry clears plaintext presentation at once.
The Rust authentication owner retains a ciphertext-only checkpoint in memory,
including stable IDs, exact bytes and the original conflict base. Collected
inputs are encrypted with monotonic versions; late completion cannot replace
newer input. Unlock restores that draft without silently adopting a newer
repository revision. The checkpoint is neither a repository format nor a disk
draft. Save and confirmed discard remove it; process recreation removes all
checkpoints and starts locked. SavedStateHandle contains only an editor-open
boolean for the Rust-provided discarded-draft explanation, never fields or
native editor handles.
The existing protected Compose surface disables autofill/content capture,
personalized IME learning and extraction. Activity/dialog secure flags protect
screenshots; window focus loss and memory pressure clear protected presentation.
Editor records redact their string representations. Long fields use a lazy
scrolling form with IME padding, focus traversal and multiline input; keyboard
activation and 200% text are exercised by instrumentation.
Regression coverage uses public encrypted fixtures for folder/entry creation,
dynamic repeated fields, PINs, multiline Unicode, OTP, add/remove/reorder,
generation, atomic save, invalid input, stale conflict, explicit discard,
background and two-second authentication expiry. A Rust fixture preserves an
opaque non-UTF8 value through checkpoint recovery and a neighboring field edit.
Compose tests cover typed callbacks, masking, keyboard save, confirmation,
configured-empty creation and process-restart saved-state boundaries.
Validation uses ARM64 emulators on API 26 (4 KB), API 37 (4 KB) and API 37
(strict 16 KB), with app/core/bridge instrumentation and five cold starts on
each. Debug/release builds, both ARM native ABIs, packaging, lint, architecture,
dependency/license and SBOM gates accompany all four Rust workspace checks.
ARMv7 is built and packaged; no live ARMv7 image was available. Physical devices
and x86_64 are outside this milestone's support scope.
Computer use in Android Studio's embedded emulator exercises a freshly created
public fixture store: folder/entry creation, confirmed generation, dynamic PIN
fields, save and reopening, dirty tab cancellation, background/expiry recovery
and confirmed discard. The fixture uses a known public passphrase, with no user
secret required. Editor entry clears viewer presentation without starting a
competing storage refresh; completed mutations wait for directory refresh before
accepting another operation. The core regression keeps the native process's
single immutable private root throughout viewer and editor checks.
The additive shared contract regenerates hardened Swift bindings. The iPhone
and dependent watch simulator targets build after XcodeGen generation; the
iPhone update preserves its app container, and computer use verifies locked
folder navigation with existing data and biometric enrollment retained.