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).
|
[`docs/android/name-search.md`](docs/android/name-search.md).
|
||||||
Adaptive navigation, appearance, safe restoration and shell validation are documented in
|
Adaptive navigation, appearance, safe restoration and shell validation are documented in
|
||||||
[`docs/android/adaptive-shell.md`](docs/android/adaptive-shell.md).
|
[`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
|
## Project layout
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||||
|
import de.rfc1437.ironstorage.core.SetupStatus
|
||||||
import de.rfc1437.ironstorage.core.ShellFixtures
|
import de.rfc1437.ironstorage.core.ShellFixtures
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellSnapshot
|
import de.rfc1437.ironstorage.core.ShellSnapshot
|
||||||
@@ -64,6 +65,7 @@ class AdaptiveShellTest {
|
|||||||
original.home,
|
original.home,
|
||||||
original.search,
|
original.search,
|
||||||
original.authentication,
|
original.authentication,
|
||||||
|
original.editor,
|
||||||
saved
|
saved
|
||||||
)
|
)
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
@@ -138,6 +140,7 @@ class AdaptiveShellTest {
|
|||||||
original.home,
|
original.home,
|
||||||
original.search,
|
original.search,
|
||||||
original.authentication,
|
original.authentication,
|
||||||
|
original.editor,
|
||||||
SavedStateHandle(
|
SavedStateHandle(
|
||||||
saved.keys().associateWith {
|
saved.keys().associateWith {
|
||||||
saved.get<Any>(it)
|
saved.get<Any>(it)
|
||||||
@@ -162,6 +165,76 @@ class AdaptiveShellTest {
|
|||||||
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
|
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 {
|
private class Fixture : ShellRepository {
|
||||||
override val snapshot = MutableStateFlow(
|
override val snapshot = MutableStateFlow(
|
||||||
ShellSnapshot(
|
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.activity.compose.setContent
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import de.rfc1437.ironstorage.core.ProtectedPresentation
|
||||||
import de.rfc1437.ironstorage.features.ProtectedContent
|
import de.rfc1437.ironstorage.features.ProtectedContent
|
||||||
|
|
||||||
/** Keep the production surface and IME protections around substituted test screens. */
|
/** Keep the production surface and IME protections around substituted test screens. */
|
||||||
internal fun MainActivity.setProtectedTestContent(content: @Composable () -> Unit) {
|
internal fun MainActivity.setProtectedTestContent(
|
||||||
setContent { ProtectedContent(authentication.presentation, content) }
|
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.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
import de.rfc1437.ironstorage.core.EditorRepository
|
||||||
import de.rfc1437.ironstorage.core.HomeRepository
|
import de.rfc1437.ironstorage.core.HomeRepository
|
||||||
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
|
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
|
||||||
|
import de.rfc1437.ironstorage.core.NativeEditorRepository
|
||||||
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
||||||
import de.rfc1437.ironstorage.core.NativeHomeRepository
|
import de.rfc1437.ironstorage.core.NativeHomeRepository
|
||||||
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
||||||
@@ -30,6 +32,17 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object ShellModule {
|
object ShellModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun editorRepository(
|
||||||
|
authentication: NativeAuthenticationRepository,
|
||||||
|
passwords: PasswordRepository
|
||||||
|
): EditorRepository = NativeEditorRepository(
|
||||||
|
authentication,
|
||||||
|
passwords,
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun searchRepository(
|
fun searchRepository(
|
||||||
|
|||||||
+134
-3
@@ -143,9 +143,14 @@ class NativeEntryViewerTest {
|
|||||||
assertFalse(authentication.state.value.unlocked)
|
assertFalse(authentication.state.value.unlocked)
|
||||||
val selected = request {
|
val selected = request {
|
||||||
repository.openSearch(result.id, result.revision)
|
repository.openSearch(result.id, result.revision)
|
||||||
assertTrue(repository.state.value.loading)
|
val immediate = repository.state.value
|
||||||
assertEquals("", repository.state.value.revision)
|
assertNull(immediate.sections)
|
||||||
assertNull(repository.state.value.selected)
|
if (immediate.loading) {
|
||||||
|
assertEquals("", immediate.revision)
|
||||||
|
assertNull(immediate.selected)
|
||||||
|
} else {
|
||||||
|
assertEquals(result.id, immediate.selected?.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assertEquals(result.id, selected.selected?.id)
|
assertEquals(result.id, selected.selected?.id)
|
||||||
assertNull(selected.sections)
|
assertNull(selected.sections)
|
||||||
@@ -228,6 +233,7 @@ class NativeEntryViewerTest {
|
|||||||
}
|
}
|
||||||
request { repository.reveal(pin.id) }
|
request { repository.reveal(pin.id) }
|
||||||
assertNull(repository.state.value.sections)
|
assertNull(repository.state.value.sections)
|
||||||
|
withTimeout(10000) { authentication.state.first { !it.unlocked } }
|
||||||
assertFalse(authentication.state.value.unlocked)
|
assertFalse(authentication.state.value.unlocked)
|
||||||
val changed = request { repository.restore("destination", null, null) }
|
val changed = request { repository.restore("destination", null, null) }
|
||||||
request { repository.activate(changed.rows.single().id, changed.revision) }
|
request { repository.activate(changed.rows.single().id, changed.revision) }
|
||||||
@@ -237,11 +243,136 @@ class NativeEntryViewerTest {
|
|||||||
withTimeout(10000) { repository.state.first { it.sections == null } }
|
withTimeout(10000) { repository.state.first { it.sections == null } }
|
||||||
assertNull(repository.state.value.totp)
|
assertNull(repository.state.value.totp)
|
||||||
assertFalse(authentication.state.value.unlocked)
|
assertFalse(authentication.state.value.unlocked)
|
||||||
|
authentication.authenticated { it.setAuthenticationTimeout(300u) }
|
||||||
|
structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
|
||||||
|
authentication,
|
||||||
|
repository,
|
||||||
|
scope,
|
||||||
|
password
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
withContext(Dispatchers.Main.immediate) { repository.lock() }
|
withContext(Dispatchers.Main.immediate) { repository.lock() }
|
||||||
scope.cancel()
|
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 {
|
private class MemoryClipboard : SecretClipboard {
|
||||||
override fun beginCopy(): SecretClipboard = this
|
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 {
|
override fun setAppearance(appearance: Appearance) = run {
|
||||||
it.setMobileAppearance(
|
it.setMobileAppearance(
|
||||||
when (appearance) {
|
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.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -47,6 +48,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
import de.rfc1437.ironstorage.core.EditorRepository
|
||||||
import de.rfc1437.ironstorage.core.PasswordKind
|
import de.rfc1437.ironstorage.core.PasswordKind
|
||||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellIcon
|
import de.rfc1437.ironstorage.core.ShellIcon
|
||||||
@@ -57,7 +59,8 @@ fun PasswordScreen(
|
|||||||
repository: PasswordRepository,
|
repository: PasswordRepository,
|
||||||
authentication: AuthenticationRepository,
|
authentication: AuthenticationRepository,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
leaveOnDispose: Boolean = true
|
leaveOnDispose: Boolean = true,
|
||||||
|
editor: EditorRepository? = null
|
||||||
) {
|
) {
|
||||||
DisposableEffect(repository, authentication) {
|
DisposableEffect(repository, authentication) {
|
||||||
onDispose {
|
onDispose {
|
||||||
@@ -67,6 +70,8 @@ fun PasswordScreen(
|
|||||||
val state by repository.state.collectAsStateWithLifecycle()
|
val state by repository.state.collectAsStateWithLifecycle()
|
||||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||||
var actions by remember { mutableStateOf(false) }
|
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()
|
val lease by authentication.state.collectAsStateWithLifecycle()
|
||||||
var manual by remember { mutableStateOf(false) }
|
var manual by remember { mutableStateOf(false) }
|
||||||
var requested by remember { mutableStateOf(false) }
|
var requested by remember { mutableStateOf(false) }
|
||||||
@@ -110,6 +115,23 @@ fun PasswordScreen(
|
|||||||
Text(stringResource(R.string.password_refresh))
|
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(
|
ListDetailPaneScaffold(
|
||||||
directive = navigator.scaffoldDirective,
|
directive = navigator.scaffoldDirective,
|
||||||
value = navigator.scaffoldValue,
|
value = navigator.scaffoldValue,
|
||||||
@@ -255,6 +277,15 @@ fun PasswordScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (editor != null) {
|
||||||
|
item {
|
||||||
|
TextButton(enabled = !state.loading, onClick = {
|
||||||
|
editor.begin(entry.path)
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.editor_edit_entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
entryViewer(state, repository)
|
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) {
|
LaunchedEffect(lease.failure, state.loading) {
|
||||||
if (requested && !state.loading && !lease.busy) {
|
if (requested && !state.loading && !lease.busy) {
|
||||||
requested = false
|
requested = false
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ private enum class ShellRoute(val tab: ShellTab?) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||||
|
EditorDiscardDialog(viewModel.editor)
|
||||||
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
|
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
|
||||||
val appearancePage by viewModel.appearancePage.collectAsStateWithLifecycle()
|
val appearancePage by viewModel.appearancePage.collectAsStateWithLifecycle()
|
||||||
fun selectTab(tab: ShellTab) {
|
fun selectTab(tab: ShellTab) {
|
||||||
@@ -149,12 +150,20 @@ private fun ShellDestination(
|
|||||||
viewModel: ShellViewModel,
|
viewModel: ShellViewModel,
|
||||||
setupForm: SetupFormViewModel
|
setupForm: SetupFormViewModel
|
||||||
) {
|
) {
|
||||||
|
val setup by viewModel.onboarding.setup.collectAsStateWithLifecycle()
|
||||||
|
val editor by viewModel.editor.state.collectAsStateWithLifecycle()
|
||||||
val ready =
|
val ready =
|
||||||
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
|
!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 {
|
when {
|
||||||
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
|
!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(
|
Column(
|
||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
@@ -176,13 +185,18 @@ private fun ShellDestination(
|
|||||||
}
|
}
|
||||||
ready && route == ShellRoute.Passwords -> {
|
ready && route == ShellRoute.Passwords -> {
|
||||||
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
||||||
|
if (editor.open) {
|
||||||
|
EntryEditorScreen(viewModel.editor, viewModel.authentication)
|
||||||
|
} else {
|
||||||
PasswordScreen(
|
PasswordScreen(
|
||||||
viewModel.passwords,
|
viewModel.passwords,
|
||||||
viewModel.authentication,
|
viewModel.authentication,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
leaveOnDispose = false
|
leaveOnDispose = false,
|
||||||
|
editor = viewModel.editor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ready && route == ShellRoute.Search -> SearchScreen(
|
ready && route == ShellRoute.Search -> SearchScreen(
|
||||||
viewModel.search,
|
viewModel.search,
|
||||||
viewModel::openSearch,
|
viewModel::openSearch,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
import de.rfc1437.ironstorage.core.EditorRepository
|
||||||
import de.rfc1437.ironstorage.core.HomeRepository
|
import de.rfc1437.ironstorage.core.HomeRepository
|
||||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||||
@@ -26,6 +27,7 @@ class ShellViewModel @Inject constructor(
|
|||||||
val home: HomeRepository,
|
val home: HomeRepository,
|
||||||
val search: SearchRepository,
|
val search: SearchRepository,
|
||||||
val authentication: AuthenticationRepository,
|
val authentication: AuthenticationRepository,
|
||||||
|
val editor: EditorRepository,
|
||||||
private val saved: SavedStateHandle
|
private val saved: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val snapshot = repository.snapshot
|
val snapshot = repository.snapshot
|
||||||
@@ -45,6 +47,12 @@ class ShellViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
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 ->
|
val restored = saved.get<String>("shell.tab")?.let { name ->
|
||||||
ShellTab.entries.firstOrNull { it.name == name }
|
ShellTab.entries.firstOrNull { it.name == name }
|
||||||
}
|
}
|
||||||
@@ -84,6 +92,10 @@ class ShellViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
fun refreshShell() = repository.refresh()
|
fun refreshShell() = repository.refresh()
|
||||||
fun selectTab(tab: ShellTab) {
|
fun selectTab(tab: ShellTab) {
|
||||||
|
if (tab != snapshot.value.selectedTab && editor.state.value.open) {
|
||||||
|
editor.requestClose { selectTab(tab) }
|
||||||
|
return
|
||||||
|
}
|
||||||
if (tab != snapshot.value.selectedTab) leaveDestination()
|
if (tab != snapshot.value.selectedTab) leaveDestination()
|
||||||
saved["shell.tab"] = tab.name
|
saved["shell.tab"] = tab.name
|
||||||
repository.selectTab(tab)
|
repository.selectTab(tab)
|
||||||
|
|||||||
@@ -118,4 +118,34 @@
|
|||||||
<string name="shell_appearance_light">Light</string>
|
<string name="shell_appearance_light">Light</string>
|
||||||
<string name="shell_appearance_dark">Dark</string>
|
<string name="shell_appearance_dark">Dark</string>
|
||||||
<string name="shell_refresh">Reload state</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>
|
</resources>
|
||||||
@@ -1408,6 +1408,10 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func cancel() throws
|
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 copyEntryField(path: String, field: UInt64) throws -> MobileEntryCopy
|
||||||
|
|
||||||
func copyPresentedEntryField(path: String, fieldId: UInt64, revision: String) 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 restoreBiometricLease() throws -> MobileAuthenticationState
|
||||||
|
|
||||||
|
func resumeEntryEditor(editor: UInt64, passphrase: String?) throws -> MobileEntryEditorRecovery
|
||||||
|
|
||||||
func revealEntryField(path: String, field: UInt64) throws -> String
|
func revealEntryField(path: String, field: UInt64) throws -> String
|
||||||
|
|
||||||
func revealPresentedEntryField(path: String, fieldId: UInt64, revision: String) 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 {
|
open func copyEntryField(path: String, field: UInt64)throws -> MobileEntryCopy {
|
||||||
return try FfiConverterTypeMobileEntryCopy_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
return try FfiConverterTypeMobileEntryCopy_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
uniffiCallStatus in
|
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 {
|
open func revealEntryField(path: String, field: UInt64)throws -> String {
|
||||||
return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
uniffiCallStatus in
|
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 struct MobileEntryEditorSession: Equatable, Hashable {
|
||||||
public var id: UInt64
|
public var id: UInt64
|
||||||
public var page: MobileEntryEditorPage
|
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)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#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 {
|
public func mobileHomeOperation(authentication: MobileAuthentication?) -> MobileHomeOperation {
|
||||||
return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() {
|
return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -12521,6 +12644,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_mobile_checksum_func_mobile_authentication() != 54684) {
|
if (uniffi_ironstorage_mobile_checksum_func_mobile_authentication() != 54684) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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) {
|
if (uniffi_ironstorage_mobile_checksum_func_mobile_home_operation() != 10726) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -12608,6 +12734,12 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_cancel() != 40449) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_cancel() != 40449) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_copy_entry_field() != 46283) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -12689,6 +12821,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_restore_biometric_lease() != 7408) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_restore_biometric_lease() != 7408) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_reveal_entry_field() != 13272) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -13036,6 +13171,10 @@ extension MobileEntryEditorPage: CustomStringConvertible, CustomDebugStringConve
|
|||||||
public var description: String { "MobileEntryEditorPage([REDACTED])" }
|
public var description: String { "MobileEntryEditorPage([REDACTED])" }
|
||||||
public var debugDescription: String { description }
|
public var debugDescription: String { description }
|
||||||
}
|
}
|
||||||
|
extension MobileEntryEditorRecovery: CustomStringConvertible, CustomDebugStringConvertible {
|
||||||
|
public var description: String { "MobileEntryEditorRecovery([REDACTED])" }
|
||||||
|
public var debugDescription: String { description }
|
||||||
|
}
|
||||||
extension MobileEntryEditorSession: CustomStringConvertible, CustomDebugStringConvertible {
|
extension MobileEntryEditorSession: CustomStringConvertible, CustomDebugStringConvertible {
|
||||||
public var description: String { "MobileEntryEditorSession([REDACTED])" }
|
public var description: String { "MobileEntryEditorSession([REDACTED])" }
|
||||||
public var debugDescription: String { description }
|
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
|
void uniffi_ironstorage_mobile_fn_method_mobileauthentication_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD
|
||||||
#define 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
|
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
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_restore_biometric_lease(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#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
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD
|
||||||
#define 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
|
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
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_AUTHENTICATION
|
||||||
uint64_t uniffi_ironstorage_mobile_fn_func_mobile_authentication(RustCallStatus *_Nonnull out_status
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_FUNC_MOBILE_HOME_OPERATION
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_AUTHENTICATION
|
||||||
uint16_t uniffi_ironstorage_mobile_checksum_func_mobile_authentication(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL
|
||||||
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_cancel(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD
|
#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
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_RESTORE_BIOMETRIC_LEASE
|
||||||
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_restore_biometric_lease(void
|
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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REVEAL_ENTRY_FIELD
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ use ironstorage::{
|
|||||||
MobileAuthenticationError as StorageAuthenticationError,
|
MobileAuthenticationError as StorageAuthenticationError,
|
||||||
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
|
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
|
||||||
MobileAuthenticationState as StorageAuthenticationState,
|
MobileAuthenticationState as StorageAuthenticationState,
|
||||||
MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation,
|
MobileEntryCopy as StorageEntryCopy,
|
||||||
|
MobileEntryEditorRecovery as StorageEntryEditorRecovery,
|
||||||
|
MobileEntryPresentation as StorageEntryPresentation,
|
||||||
MobilePreferences as StorageMobilePreferences,
|
MobilePreferences as StorageMobilePreferences,
|
||||||
},
|
},
|
||||||
mobile_entry::{
|
mobile_entry::{
|
||||||
@@ -88,6 +90,11 @@ pub use android_onboarding::*;
|
|||||||
mod platform;
|
mod platform;
|
||||||
pub use 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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
pub enum MobileIcon {
|
pub enum MobileIcon {
|
||||||
Home,
|
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 {
|
impl From<StorageEntryCopy> for MobileEntryCopy {
|
||||||
fn from(copy: StorageEntryCopy) -> Self {
|
fn from(copy: StorageEntryCopy) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -1954,6 +1984,46 @@ impl MobileAuthentication {
|
|||||||
.map_err(Into::into)
|
.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(
|
pub fn begin_entry_editor(
|
||||||
&self,
|
&self,
|
||||||
path: String,
|
path: String,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use sha2::{Digest as _, Sha256};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
command::EditRequest,
|
command::EditRequest,
|
||||||
crypto::{KeyStore, SecretProvider},
|
crypto::{CryptoError, KeyHandle, KeyStore, SecretProvider},
|
||||||
otp::{OtpAlgorithm, OtpError, OtpKind, OtpUri},
|
otp::{OtpAlgorithm, OtpError, OtpKind, OtpUri},
|
||||||
recipient::SigningPolicy,
|
recipient::SigningPolicy,
|
||||||
repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
|
repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
|
||||||
@@ -240,7 +240,95 @@ pub struct EntryDocument {
|
|||||||
modified: bool,
|
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 {
|
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 {
|
fn from_session(session: EditSession) -> Self {
|
||||||
let path = session.path().clone();
|
let path = session.path().clone();
|
||||||
let conflict_token = conflict_token(&session);
|
let conflict_token = conflict_token(&session);
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ use crate::{
|
|||||||
},
|
},
|
||||||
config::{Config, ConfigError, GitRemote, MobileAppearance},
|
config::{Config, ConfigError, GitRemote, MobileAppearance},
|
||||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||||
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
|
document::{
|
||||||
|
DocumentError, EntryDocument, EntryDocumentService, EntryFieldId, SealedEntryDocument,
|
||||||
|
},
|
||||||
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
|
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
|
||||||
mobile_entry::{
|
mobile_entry::{
|
||||||
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
|
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
|
||||||
@@ -306,12 +308,37 @@ struct MobileAuthenticationStatus {
|
|||||||
active: Option<ActiveMobileLease>,
|
active: Option<ActiveMobileLease>,
|
||||||
next_editor_id: u64,
|
next_editor_id: u64,
|
||||||
editors: BTreeMap<u64, MobileEntryDraft>,
|
editors: BTreeMap<u64, MobileEntryDraft>,
|
||||||
|
editor_checkpoints: BTreeMap<u64, EditorCheckpoint>,
|
||||||
mutation_active: bool,
|
mutation_active: bool,
|
||||||
repository_operation_active: bool,
|
repository_operation_active: bool,
|
||||||
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
|
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
|
||||||
watch_snapshot: WatchSnapshotSender,
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub(crate) enum MobileRepositoryOperation {
|
pub(crate) enum MobileRepositoryOperation {
|
||||||
Commit,
|
Commit,
|
||||||
@@ -424,6 +451,7 @@ impl MobileAuthentication {
|
|||||||
active: None,
|
active: None,
|
||||||
next_editor_id: 0,
|
next_editor_id: 0,
|
||||||
editors: BTreeMap::new(),
|
editors: BTreeMap::new(),
|
||||||
|
editor_checkpoints: BTreeMap::new(),
|
||||||
mutation_active: false,
|
mutation_active: false,
|
||||||
repository_operation_active: false,
|
repository_operation_active: false,
|
||||||
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
|
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
|
||||||
@@ -1584,11 +1612,173 @@ impl MobileAuthentication {
|
|||||||
return Err(document_error(error));
|
return Err(document_error(error));
|
||||||
}
|
}
|
||||||
self.reconcile_saved_document(draft.document());
|
self.reconcile_saved_document(draft.document());
|
||||||
|
self.status()?.editor_checkpoints.remove(&editor);
|
||||||
Ok(MobileEntryPage::from_document(draft.document()))
|
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> {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1611,8 +1801,11 @@ impl MobileAuthentication {
|
|||||||
MobileLifecycleEvent::Foreground => {}
|
MobileLifecycleEvent::Foreground => {}
|
||||||
MobileLifecycleEvent::Background
|
MobileLifecycleEvent::Background
|
||||||
| MobileLifecycleEvent::DeviceLocked
|
| MobileLifecycleEvent::DeviceLocked
|
||||||
| MobileLifecycleEvent::MemoryPressure
|
| MobileLifecycleEvent::MemoryPressure => self.manual_lock()?,
|
||||||
| MobileLifecycleEvent::ProcessRecreated => self.manual_lock()?,
|
MobileLifecycleEvent::ProcessRecreated => {
|
||||||
|
self.manual_lock()?;
|
||||||
|
self.status()?.editor_checkpoints.clear();
|
||||||
|
}
|
||||||
MobileLifecycleEvent::UserActivity => {
|
MobileLifecycleEvent::UserActivity => {
|
||||||
if self.state()?.unlocked() {
|
if self.state()?.unlocked() {
|
||||||
self.touch_user_activity()?;
|
self.touch_user_activity()?;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
use crate::mobile::MobileIcon;
|
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 std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize};
|
||||||
|
|
||||||
use crate::document::{
|
use crate::document::{
|
||||||
@@ -258,6 +262,9 @@ pub struct MobileEntryEditorInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn new(id: u64, name: Option<String>, value: Option<String>) -> Self {
|
||||||
Self { id, name, value }
|
Self { id, name, value }
|
||||||
}
|
}
|
||||||
@@ -273,6 +280,10 @@ impl MobileEntryDraft {
|
|||||||
Self { document, creating }
|
Self { document, creating }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn creating(&self) -> bool {
|
||||||
|
self.creating
|
||||||
|
}
|
||||||
|
|
||||||
pub fn page(&self) -> MobileEntryEditorPage {
|
pub fn page(&self) -> MobileEntryEditorPage {
|
||||||
let fields = self
|
let fields = self
|
||||||
.document
|
.document
|
||||||
@@ -640,7 +651,8 @@ fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField {
|
|||||||
sensitive,
|
sensitive,
|
||||||
multiline: matches!(kind, MobileEntryEditorFieldKind::Note)
|
multiline: matches!(kind, MobileEntryEditorFieldKind::Note)
|
||||||
|| field.value().contains(&b'\n'),
|
|| 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,
|
removable: index > 0,
|
||||||
reorderable: index > 0,
|
reorderable: index > 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,17 @@ pub struct EditSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn path(&self) -> &EntryPath {
|
||||||
&self.path
|
&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);
|
assert_eq!(session.remaining_time()?, None);
|
||||||
Ok(())
|
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