feat(android): add structured entry editor and encrypted draft recovery (#103)
This commit is contained in:
1 parent
d22d71eb5e
commit
06575363a2
23 files changed
+2330
-21
No files matched your search
+134
-3
@@ -143,9 +143,14 @@ class NativeEntryViewerTest {
|
||||
assertFalse(authentication.state.value.unlocked)
|
||||
val selected = request {
|
||||
repository.openSearch(result.id, result.revision)
|
||||
assertTrue(repository.state.value.loading)
|
||||
assertEquals("", repository.state.value.revision)
|
||||
assertNull(repository.state.value.selected)
|
||||
val immediate = repository.state.value
|
||||
assertNull(immediate.sections)
|
||||
if (immediate.loading) {
|
||||
assertEquals("", immediate.revision)
|
||||
assertNull(immediate.selected)
|
||||
} else {
|
||||
assertEquals(result.id, immediate.selected?.id)
|
||||
}
|
||||
}
|
||||
assertEquals(result.id, selected.selected?.id)
|
||||
assertNull(selected.sections)
|
||||
@@ -228,6 +233,7 @@ class NativeEntryViewerTest {
|
||||
}
|
||||
request { repository.reveal(pin.id) }
|
||||
assertNull(repository.state.value.sections)
|
||||
withTimeout(10000) { authentication.state.first { !it.unlocked } }
|
||||
assertFalse(authentication.state.value.unlocked)
|
||||
val changed = request { repository.restore("destination", null, null) }
|
||||
request { repository.activate(changed.rows.single().id, changed.revision) }
|
||||
@@ -237,11 +243,136 @@ class NativeEntryViewerTest {
|
||||
withTimeout(10000) { repository.state.first { it.sections == null } }
|
||||
assertNull(repository.state.value.totp)
|
||||
assertFalse(authentication.state.value.unlocked)
|
||||
authentication.authenticated { it.setAuthenticationTimeout(300u) }
|
||||
structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
|
||||
authentication,
|
||||
repository,
|
||||
scope,
|
||||
password
|
||||
)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { repository.lock() }
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
|
||||
authentication: NativeAuthenticationRepository,
|
||||
passwords: NativePasswordRepository,
|
||||
scope: CoroutineScope,
|
||||
passphrase: String
|
||||
) {
|
||||
// The native private root is intentionally immutable for the life of this test process.
|
||||
val editor = NativeEditorRepository(authentication, passwords, scope)
|
||||
suspend fun request(action: () -> Unit): EditorState {
|
||||
withContext(Dispatchers.Main.immediate) { action() }
|
||||
return withTimeout(30000) { editor.state.first { !it.busy } }
|
||||
}
|
||||
try {
|
||||
assertNull(request { editor.createFolder("", "editor") }.error)
|
||||
assertNull(request { editor.create("editor", "new", passphrase) }.error)
|
||||
val first = editor.state.value.fields.first().id
|
||||
request { editor.change(first, null, "initial password") }
|
||||
request { editor.add(EditorFieldKind.Field, "custom", "Unicode 日本語") }
|
||||
request { editor.add(EditorFieldKind.Field, "custom", "repeated") }
|
||||
request { editor.add(EditorFieldKind.Field, "PIN", "123493") }
|
||||
request { editor.add(EditorFieldKind.Note, null, "first note\nsecond note") }
|
||||
request {
|
||||
editor.add(
|
||||
EditorFieldKind.OtpUri,
|
||||
null,
|
||||
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example"
|
||||
)
|
||||
}
|
||||
assertNull(editor.state.value.error)
|
||||
assertTrue(editor.state.value.fields.single { it.name == "PIN" }.sensitive)
|
||||
val custom = editor.state.value.fields.first { it.name == "custom" }.id
|
||||
request { editor.reorder(custom, 2u) }
|
||||
assertEquals(custom, editor.state.value.fields[2].id)
|
||||
request { editor.add(EditorFieldKind.Field, "temporary", "remove me") }
|
||||
val temporary = editor.state.value.fields.single { it.name == "temporary" }.id
|
||||
request { editor.remove(temporary) }
|
||||
assertTrue(editor.state.value.fields.none { it.id == temporary })
|
||||
request { editor.generate(24u, true) }
|
||||
val generated = requireNotNull(editor.state.value.fields.first().value)
|
||||
assertEquals(24, generated.length)
|
||||
assertTrue(generated.all { it.isLetterOrDigit() && it.code < 128 })
|
||||
assertNull(request { editor.save() }.error)
|
||||
assertFalse(editor.state.value.open)
|
||||
withContext(Dispatchers.Main.immediate) { passwords.restore("editor", null, null) }
|
||||
val navigation = withTimeout(30000) { passwords.state.first { !it.loading } }
|
||||
val row = navigation.rows.single { it.title == "new" }
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
passwords.activate(row.id, navigation.revision)
|
||||
}
|
||||
withTimeout(30000) { passwords.state.first { !it.loading } }
|
||||
withContext(Dispatchers.Main.immediate) { passwords.unlock(passphrase) }
|
||||
withTimeout(30000) { passwords.state.first { !it.loading } }
|
||||
assertNotNull(passwords.state.value.sections)
|
||||
assertNull(request { editor.begin("editor/new") }.error)
|
||||
assertNull(passwords.state.value.sections)
|
||||
assertEquals(generated, editor.state.value.fields.first().value)
|
||||
assertEquals(2, editor.state.value.fields.count { it.name == "custom" })
|
||||
assertEquals(
|
||||
"first note\nsecond note",
|
||||
editor.state.value.fields.single {
|
||||
it.kind ==
|
||||
EditorFieldKind.Note
|
||||
}.value
|
||||
)
|
||||
assertEquals("123493", editor.state.value.fields.single { it.name == "PIN" }.value)
|
||||
val field = editor.state.value.fields.first { it.name == "custom" }
|
||||
request { editor.change(field.id, "bad\nname", "retained after validation") }
|
||||
assertNotNull(request { editor.save() }.error)
|
||||
assertEquals(
|
||||
"retained after validation",
|
||||
editor.state.value.fields.single {
|
||||
it.id ==
|
||||
field.id
|
||||
}.value
|
||||
)
|
||||
request { editor.change(field.id, "custom", "retained after validation") }
|
||||
assertNull(request { editor.save() }.error)
|
||||
request { editor.begin("editor/new") }
|
||||
val passwordId = editor.state.value.fields.first().id
|
||||
request { editor.change(passwordId, null, "in-memory recovered secret") }
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
authentication.lifecycle(AuthenticationLifecycle.Background)
|
||||
}
|
||||
assertTrue(editor.state.value.locked)
|
||||
assertTrue(editor.state.value.fields.isEmpty())
|
||||
assertFalse(authentication.state.value.unlocked)
|
||||
assertNull(request { editor.resume(passphrase) }.error)
|
||||
assertEquals("in-memory recovered secret", editor.state.value.fields.first().value)
|
||||
assertTrue(editor.state.value.dirty)
|
||||
var navigated = false
|
||||
request { editor.requestClose { navigated = true } }
|
||||
assertTrue(editor.state.value.discardRequested)
|
||||
assertFalse(navigated)
|
||||
request { editor.keepEditing() }
|
||||
assertTrue(editor.state.value.open)
|
||||
assertFalse(navigated)
|
||||
request { editor.requestClose { navigated = true } }
|
||||
request { editor.confirmDiscard() }
|
||||
withContext(Dispatchers.Main.immediate) { assertTrue(navigated) }
|
||||
request { editor.begin("editor/new") }
|
||||
assertEquals(
|
||||
"discard cannot change the stored entry",
|
||||
generated,
|
||||
editor.state.value.fields.first().value
|
||||
)
|
||||
authentication.authenticated { it.setAuthenticationTimeout(2u) }
|
||||
withTimeout(10000) { editor.state.first { it.locked } }
|
||||
assertTrue(editor.state.value.fields.isEmpty())
|
||||
assertNull(request { editor.resume(passphrase) }.error)
|
||||
authentication.authenticated { it.setAuthenticationTimeout(300u) }
|
||||
request { editor.requestClose() }
|
||||
if (editor.state.value.discardRequested) request { editor.confirmDiscard() }
|
||||
assertFalse(editor.state.value.open)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { authentication.lock() }
|
||||
}
|
||||
}
|
||||
private class MemoryClipboard : SecretClipboard {
|
||||
override fun beginCopy(): SecretClipboard = this
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class EditorFieldKind { Password, Field, Note, OtpUri }
|
||||
data class EditorField(
|
||||
val id: ULong,
|
||||
val kind: EditorFieldKind,
|
||||
val name: String?,
|
||||
val label: String,
|
||||
val value: String?,
|
||||
val maskedValue: String,
|
||||
val diagnostic: String?,
|
||||
val sensitive: Boolean,
|
||||
val multiline: Boolean,
|
||||
val nameEditable: Boolean,
|
||||
val removable: Boolean,
|
||||
val reorderable: Boolean
|
||||
) {
|
||||
override fun toString() = "EditorField(id=$id, [REDACTED])"
|
||||
}
|
||||
data class EditorState(
|
||||
val open: Boolean = false,
|
||||
val locked: Boolean = false,
|
||||
val busy: Boolean = false,
|
||||
val title: String = "Entry editor",
|
||||
val path: String = "",
|
||||
val fields: List<EditorField> = emptyList(),
|
||||
val dirty: Boolean = false,
|
||||
val defaultLength: UInt = 0u,
|
||||
val maximumLength: UInt = 0u,
|
||||
val error: String? = null,
|
||||
val discardRequested: Boolean = false,
|
||||
val completedOperations: Long = 0
|
||||
) {
|
||||
override fun toString() = "EditorState(open=$open, locked=$locked, [REDACTED])"
|
||||
}
|
||||
interface EditorRepository {
|
||||
val state: StateFlow<EditorState>
|
||||
fun processRecreated()
|
||||
fun begin(path: String)
|
||||
fun create(parent: String, name: String, passphrase: String = "")
|
||||
fun createFolder(parent: String, name: String)
|
||||
fun change(id: ULong, name: String?, value: String?)
|
||||
fun add(kind: EditorFieldKind, name: String?, value: String)
|
||||
fun remove(id: ULong)
|
||||
fun reorder(id: ULong, index: UInt)
|
||||
fun generate(length: UInt?, noSymbols: Boolean)
|
||||
fun save()
|
||||
fun resume(passphrase: String)
|
||||
fun requestClose(after: () -> Unit = {})
|
||||
fun keepEditing()
|
||||
fun confirmDiscard()
|
||||
}
|
||||
+13
@@ -116,6 +116,19 @@ class NativeAuthenticationRepository(
|
||||
}
|
||||
}
|
||||
}
|
||||
internal suspend fun checkpointEditor(
|
||||
editor: ULong,
|
||||
version: ULong,
|
||||
fields: List<de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput>
|
||||
) = withContext(io) {
|
||||
(current ?: throw cancelled()).checkpointEntryEditorInputs(editor, version, fields)
|
||||
}
|
||||
|
||||
internal suspend fun discardEditor(editor: ULong) = withContext(io) {
|
||||
current?.discardEntryEditor(editor)
|
||||
Unit
|
||||
}
|
||||
|
||||
override fun setAppearance(appearance: Appearance) = run {
|
||||
it.setMobileAppearance(
|
||||
when (appearance) {
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorPage
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorSession
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/** Frontend operation/input state only; Rust owns documents and encrypted recovery checkpoints. */
|
||||
class NativeEditorRepository(
|
||||
private val authentication: NativeAuthenticationRepository,
|
||||
private val passwords: PasswordRepository,
|
||||
private val scope: CoroutineScope
|
||||
) : EditorRepository {
|
||||
private val mutable = MutableStateFlow(EditorState())
|
||||
override val state: StateFlow<EditorState> = mutable
|
||||
private val operation = Mutex()
|
||||
|
||||
@Volatile private var epoch = 0L
|
||||
|
||||
@Volatile private var version = 0uL
|
||||
|
||||
@Volatile private var checkpoint: ULong? = null
|
||||
private var pending: Job? = null
|
||||
private var afterClose: () -> Unit = {}
|
||||
private var retry: (suspend (String) -> MobileEntryEditorSession)? = null
|
||||
|
||||
init {
|
||||
val registration = authentication.presentation.register {
|
||||
if (mutable.value.open) {
|
||||
mutable.value = mutable.value.copy(locked = true, fields = emptyList())
|
||||
}
|
||||
}
|
||||
scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() }
|
||||
}
|
||||
override fun processRecreated() {
|
||||
scope.launch {
|
||||
val notice = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
de.rfc1437.ironstorage.bridge.generated.mobileEditorProcessRecreatedNotice()
|
||||
}
|
||||
if (!mutable.value.open) mutable.value = mutable.value.copy(error = notice)
|
||||
}
|
||||
}
|
||||
override fun begin(path: String) {
|
||||
if (mutable.value.open) return
|
||||
epoch += 1
|
||||
authentication.presentation.clear()
|
||||
mutable.value = EditorState(open = true, path = path)
|
||||
retry = { passphrase ->
|
||||
authentication.authenticated {
|
||||
it.unlockEntry(path, passphrase.ifEmpty { null })
|
||||
it.beginEntryEditor(path).also { session ->
|
||||
checkpoint = session.id
|
||||
it.checkpointEntryEditor(session.id, ++version)
|
||||
}
|
||||
}
|
||||
}
|
||||
resume("")
|
||||
}
|
||||
override fun create(parent: String, name: String, passphrase: String) {
|
||||
if (mutable.value.open) return
|
||||
epoch += 1
|
||||
authentication.presentation.clear()
|
||||
mutable.value = EditorState(open = true)
|
||||
retry = { secret ->
|
||||
authentication.authenticated {
|
||||
it.beginCreateEntry(parent, name, secret.ifEmpty { null }).also { session ->
|
||||
checkpoint = session.id
|
||||
it.checkpointEntryEditor(session.id, ++version)
|
||||
}
|
||||
}
|
||||
}
|
||||
resume(passphrase)
|
||||
}
|
||||
override fun createFolder(parent: String, name: String) = run(protectedResult = false) {
|
||||
authentication.authenticated { it.createDirectory(parent, name) }
|
||||
passwords.restore(passwords.state.value.path, null, null)
|
||||
passwords.state.first { !it.loading }
|
||||
EditorState()
|
||||
}
|
||||
override fun resume(passphrase: String) = run {
|
||||
pending?.join()
|
||||
val id = checkpoint
|
||||
if (id == null) {
|
||||
requireNotNull(retry)(passphrase).page.facade()
|
||||
} else {
|
||||
val recovered = authentication.authenticated {
|
||||
it.resumeEntryEditor(id, passphrase.ifEmpty { null })
|
||||
}
|
||||
val inputs = recovered.inputs?.associateBy { it.id }
|
||||
recovered.session.page.facade().let { page ->
|
||||
page.copy(
|
||||
fields = page.fields.map { field ->
|
||||
val input = inputs?.get(field.id)
|
||||
if (input ==
|
||||
null
|
||||
) {
|
||||
field
|
||||
} else {
|
||||
field.copy(name = input.name, value = input.value)
|
||||
}
|
||||
},
|
||||
dirty = page.dirty || inputs != null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun change(id: ULong, name: String?, value: String?) {
|
||||
val current = mutable.value
|
||||
if (!current.open || current.locked || current.busy) return
|
||||
if (current.fields.none { it.id == id }) return
|
||||
mutable.value = current.copy(
|
||||
fields = current.fields.map {
|
||||
if (it.id ==
|
||||
id
|
||||
) {
|
||||
it.copy(name = name, value = value)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
dirty = true,
|
||||
error = null
|
||||
)
|
||||
val editor = checkpoint ?: return
|
||||
val fields = inputs()
|
||||
val ticket = epoch
|
||||
val inputVersion = ++version
|
||||
pending?.cancel()
|
||||
pending = scope.launch {
|
||||
try {
|
||||
authentication.checkpointEditor(editor, inputVersion, fields)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||
if (ticket == epoch &&
|
||||
inputVersion == version
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = error.detail)
|
||||
}
|
||||
} catch (_: NativeBridgeException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "Update the application and retry.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun inputs() = mutable.value.fields.map {
|
||||
MobileEntryEditorInput(it.id, it.name, it.value)
|
||||
}
|
||||
private fun modify(
|
||||
action: (
|
||||
de.rfc1437.ironstorage.bridge.generated.MobileAuthentication,
|
||||
ULong
|
||||
) -> MobileEntryEditorPage
|
||||
) = run {
|
||||
pending?.join()
|
||||
val id = requireNotNull(checkpoint)
|
||||
val fields = inputs()
|
||||
authentication.touch()
|
||||
authentication.authenticated {
|
||||
it.updateEntryEditor(id, fields)
|
||||
action(it, id).also { _ -> it.checkpointEntryEditor(id, ++version) }
|
||||
}.facade()
|
||||
}
|
||||
override fun add(kind: EditorFieldKind, name: String?, value: String) = modify { auth, id ->
|
||||
auth.addEntryEditorField(id, kind.native(), name, value)
|
||||
}
|
||||
override fun remove(id: ULong) =
|
||||
modify { auth, editor -> auth.removeEntryEditorField(editor, id) }
|
||||
override fun reorder(id: ULong, index: UInt) =
|
||||
modify { auth, editor -> auth.reorderEntryEditorField(editor, id, index) }
|
||||
override fun generate(length: UInt?, noSymbols: Boolean) =
|
||||
modify { auth, editor -> auth.generateEntryEditorPassword(editor, length, noSymbols) }
|
||||
override fun save() = run(protectedResult = false) {
|
||||
pending?.join()
|
||||
val id = requireNotNull(checkpoint)
|
||||
val fields = inputs()
|
||||
authentication.touch()
|
||||
authentication.authenticated { it.saveEntryEditor(id, fields) }
|
||||
checkpoint = null
|
||||
retry = null
|
||||
passwords.restore(passwords.state.value.path, null, null)
|
||||
passwords.state.first { !it.loading }
|
||||
EditorState()
|
||||
}
|
||||
override fun requestClose(after: () -> Unit) {
|
||||
afterClose = after
|
||||
if (mutable.value.dirty) {
|
||||
mutable.value = mutable.value.copy(discardRequested = true)
|
||||
} else {
|
||||
confirmDiscard()
|
||||
}
|
||||
}
|
||||
override fun keepEditing() {
|
||||
afterClose = {}
|
||||
mutable.value = mutable.value.copy(discardRequested = false)
|
||||
}
|
||||
override fun confirmDiscard() {
|
||||
val after = afterClose
|
||||
afterClose = {}
|
||||
epoch += 1
|
||||
val ticket = epoch
|
||||
mutable.value = mutable.value.copy(busy = true, discardRequested = false)
|
||||
scope.launch {
|
||||
operation.withLock {
|
||||
try {
|
||||
pending?.join()
|
||||
checkpoint?.let { authentication.discardEditor(it) }
|
||||
if (ticket != epoch) return@withLock
|
||||
checkpoint = null
|
||||
retry = null
|
||||
mutable.value = EditorState()
|
||||
after()
|
||||
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(busy = false, error = error.detail)
|
||||
}
|
||||
} catch (_: NativeBridgeException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
busy = false,
|
||||
error = "Update the application and retry."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun run(protectedResult: Boolean = true, action: suspend () -> EditorState) {
|
||||
if (mutable.value.busy) return
|
||||
val ticket = epoch
|
||||
mutable.value = mutable.value.copy(busy = true, error = null)
|
||||
scope.launch {
|
||||
operation.withLock {
|
||||
if (ticket != epoch) return@withLock
|
||||
try {
|
||||
val result = action()
|
||||
if (ticket == epoch &&
|
||||
(!protectedResult || authentication.state.value.unlocked)
|
||||
) {
|
||||
mutable.value = result.copy(
|
||||
completedOperations = mutable.value.completedOperations + 1
|
||||
)
|
||||
}
|
||||
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = error.title + "\n" + error.detail)
|
||||
}
|
||||
} catch (_: MobilePrivateStorageFfiException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
error = "Unlock the device and retry."
|
||||
)
|
||||
}
|
||||
} catch (_: NativeBridgeException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "Update the application and retry.")
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
error = "Storage unavailable. Keep the draft and retry."
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (ticket == epoch) mutable.value = mutable.value.copy(busy = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun EditorFieldKind.native() = when (this) {
|
||||
EditorFieldKind.Password -> MobileEntryEditorFieldKind.PASSWORD
|
||||
EditorFieldKind.Field -> MobileEntryEditorFieldKind.FIELD
|
||||
EditorFieldKind.Note -> MobileEntryEditorFieldKind.NOTE
|
||||
EditorFieldKind.OtpUri -> MobileEntryEditorFieldKind.OTP_URI
|
||||
}
|
||||
private fun MobileEntryEditorPage.facade() = EditorState(
|
||||
open = true,
|
||||
title = title,
|
||||
path = path,
|
||||
fields = fields.map { field ->
|
||||
EditorField(
|
||||
field.id,
|
||||
when (field.kind) {
|
||||
MobileEntryEditorFieldKind.PASSWORD -> EditorFieldKind.Password
|
||||
MobileEntryEditorFieldKind.FIELD -> EditorFieldKind.Field
|
||||
MobileEntryEditorFieldKind.NOTE -> EditorFieldKind.Note
|
||||
MobileEntryEditorFieldKind.OTP_URI -> EditorFieldKind.OtpUri
|
||||
},
|
||||
field.name, field.label, field.value, field.maskedValue, field.diagnostic,
|
||||
field.sensitive, field.multiline, field.nameEditable, field.removable, field.reorderable
|
||||
)
|
||||
},
|
||||
dirty = dirty,
|
||||
defaultLength = defaultPasswordLength,
|
||||
maximumLength = maximumPasswordLength
|
||||
)
|
||||
Reference in new issue
Block a user