feat(android): add structured entry editor and encrypted draft recovery (#103)
Android ARM verification / rust (push) Failing after 46m30s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-09 22:11:37 +02:00
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(