feat(android): add structured entry editor and encrypted draft recovery (#103)
This commit is contained in:
1 parent
d22d71eb5e
commit
06575363a2
23 files changed
+2325
-16
No files matched your search
@@ -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(
|
||||
|
||||
+134
-3
@@ -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()
|
||||
}
|
||||
+13
@@ -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
|
||||
)
|
||||
+457
@@ -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>
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user