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(
|
||||
|
||||
Reference in new issue
Block a user