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