Implement structured Android entry viewer (#99)
Android ARM verification / rust (push) Failing after 47m18s
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 17:36:38 +02:00
1 parent 647dc1bb4b
commit c0ba6d43d8
29 files changed
+1768 -78

No files matched your search

@@ -0,0 +1,335 @@
package de.rfc1437.ironstorage
import android.content.ClipboardManager
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement
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.PasswordDestination
import de.rfc1437.ironstorage.core.PasswordEdit
import de.rfc1437.ironstorage.core.PasswordField
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordMutationPlan
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.PasswordSection
import de.rfc1437.ironstorage.core.PasswordState
import de.rfc1437.ironstorage.core.PasswordTotp
import de.rfc1437.ironstorage.core.ShellIcon
import de.rfc1437.ironstorage.features.PasswordScreen
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class EntryViewerTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun maskedFieldsExplicitActionsEditorDiagnosticsAndRevocation() {
val repository = Fixture()
compose.activity.runOnUiThread {
compose.activity.setContent {
MaterialTheme { PasswordScreen(repository, repository.authentication) }
}
}
compose.onNodeWithText("654321").assertDoesNotExist()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithText("alice 日本語").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("123456").performScrollTo().assertIsDisplayed()
compose.onNodeWithContentDescription("Reveal PIN").performScrollTo()
.performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) }
assertEquals(2uL, repository.lastId)
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("654321"))
compose.onNodeWithText("654321").assertIsDisplayed()
compose.onNodeWithContentDescription("Hide PIN").performScrollTo().performClick()
compose.onNodeWithText("654321").assertDoesNotExist()
compose.onNodeWithContentDescription("Copy PIN").performScrollTo()
.performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) }
assertEquals(2uL, repository.lastId)
compose.onNodeWithContentDescription("Edit PIN").performScrollTo().performClick()
compose.onNode(hasSetTextAction()).performTextReplacement("987654")
compose.onNodeWithText("Save").performClick()
assertEquals("987654", repository.saved)
compose.onNodeWithTag(
"entry-detail"
).performScrollToNode(hasText("First line\nSecond line 🌻"))
compose.onNodeWithText("First line\nSecond line 🌻").assertIsDisplayed()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("Invalid OTP URI"))
compose.onNodeWithText("Invalid OTP URI").assertIsDisplayed()
compose.runOnIdle {
repository.state.value =
repository.state.value.copy(edit = PasswordEdit(2u, "PIN", "987654", false, true))
}
compose.runOnIdle { repository.lease.value = AuthenticationState() }
compose.onNode(hasSetTextAction()).assertDoesNotExist()
compose.onNodeWithText("123456").assertDoesNotExist()
compose.onNodeWithText("First line\nSecond line 🌻").assertDoesNotExist()
compose.onNodeWithText("Unlock").assertIsDisplayed()
}
@Test fun overflowRequiresDestinationAndExplicitMutationConfirmation() {
val repository = Fixture()
repository.unlock("")
compose.activity.runOnUiThread {
compose.activity.setContent {
MaterialTheme { PasswordScreen(repository, repository.authentication) }
}
}
compose.onNodeWithText("More").performScrollTo().performClick()
compose.onNodeWithText("Move entry").performClick()
compose.onNodeWithText("destination\nFolder").performClick()
compose.onNodeWithText("Confirm").performClick()
assertEquals("destination", repository.destination)
assertEquals(PasswordMutation.Move, repository.action)
}
@Test fun clipboardMarksExplicitCopiesSensitiveAndViewerPreventsScreenCapture() {
compose.runOnIdle {
val clipboard = AndroidSecretClipboard(compose.activity)
clipboard.write("public-copy-fixture".toByteArray())
val manager = compose.activity.getSystemService(ClipboardManager::class.java)
org.junit.Assert.assertTrue(
manager.primaryClipDescription!!.extras!!
.getBoolean("android.content.extra.IS_SENSITIVE")
)
assertEquals("public-copy-fixture", clipboard.read()!!.toString(Charsets.UTF_8))
clipboard.clear()
org.junit.Assert.assertTrue(
compose.activity.window.attributes.flags and
WindowManager.LayoutParams.FLAG_SECURE != 0
)
}
}
@Test fun inspectDisposableViewerWithComputerUse() {
val arguments = InstrumentationRegistry.getArguments()
assumeTrue(arguments.getString("viewerInspect") == "true")
val repository = Fixture()
val done = CountDownLatch(1)
compose.activity.runOnUiThread {
compose.activity.setContent {
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(
density.density,
arguments.getString("viewerFont")?.toFloat() ?: 1f
)
) {
MaterialTheme {
Column(Modifier.fillMaxSize()) {
TextButton(onClick = {
done.countDown()
}) { Text("Finish viewer inspection") }
PasswordScreen(
repository,
repository.authentication,
Modifier.weight(1f)
)
}
}
}
}
}
compose.waitForIdle()
android.util.Log.i("IronStorage99", "viewer-inspection-ready")
val started = android.os.SystemClock.elapsedRealtime()
while (!done.await(100, TimeUnit.MILLISECONDS)) {
compose.mainClock.advanceTimeBy(100)
compose.waitForIdle()
org.junit.Assert.assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000)
}
}
private class Fixture : PasswordRepository {
private val row =
PasswordRow(
"row",
"viewer",
"Viewer",
"Locked entry",
PasswordKind.Entry,
"r",
ShellIcon.Key
)
override val state =
MutableStateFlow(PasswordState(rows = listOf(row), selected = row, revision = "r"))
val lease = MutableStateFlow(AuthenticationState())
val authentication = object : AuthenticationRepository {
override val state = lease
override fun reload() = Unit
override fun unlock() = Unit
override fun enroll(passphrase: String) = Unit
override fun disable() = Unit
override fun lock() {
lease.value = AuthenticationState()
}
override fun cancel() = lock()
override fun touch() = Unit
}
var lastId: ULong? = null
var saved: String? = null
var destination: String? = null
var action: PasswordMutation? = null
private val sections = listOf(
PasswordSection(
"Password",
listOf(
PasswordField(0u, "Password", "Hidden", null, sensitive = true, editable = true)
)
),
PasswordSection(
"Details",
listOf(
PasswordField(
1u,
"Username",
"alice 日本語",
null,
selectable = true,
editable = true
),
PasswordField(2u, "PIN", "Hidden", null, sensitive = true, editable = true),
PasswordField(3u, "custom", "Repeated one", null, selectable = true),
PasswordField(4u, "custom", "Repeated two", null, selectable = true)
)
),
PasswordSection(
"OTP",
listOf(
PasswordField(
5u,
"OTP URI",
"Hidden",
"Invalid OTP URI",
sensitive = true
)
)
),
PasswordSection(
"Notes",
listOf(
PasswordField(
6u,
"Notes",
"First line\nSecond line 🌻",
null,
selectable = true,
multiline = true
)
)
)
)
override fun restore(path: String, selected: String?, revision: String?) = Unit
override fun refresh() = Unit
override fun activate(id: String, revision: String) = Unit
override fun inspect(id: String, revision: String) = Unit
override fun back() = lock()
override fun unlock(passphrase: String) {
lease.value = AuthenticationState(unlocked = true)
state.value =
state.value.copy(sections = sections, totp = PasswordTotp("123456", "fixture", 17u))
}
override fun lock() {
authentication.lock()
state.value =
state.value.copy(sections = null, totp = null, edit = null)
}
private fun field(id: ULong, reveal: Boolean) {
lastId = id
state.value = state.value.copy(
sections = state.value.sections?.map { section ->
section.copy(
fields = section.fields.map {
if (it.id ==
id
) {
it.copy(
value = if (reveal) "654321" else "Hidden",
revealed = reveal
)
} else {
it
}
}
)
}
)
}
override fun reveal(id: ULong) = field(id, true)
override fun hide(id: ULong) = field(id, false)
override fun copy(id: ULong) {
lastId = id
android.util.Log.i("IronStorage99", "copy-field-$id")
}
override fun copyTotp() = Unit
override fun edit(id: ULong) {
lastId = id
state.value =
state.value.copy(edit = PasswordEdit(id, "PIN", "654321", false, true))
}
override fun dismissEdit() {
state.value = state.value.copy(edit = null)
}
override fun saveEdit(value: String) {
saved = value
dismissEdit()
}
override fun prepareMutation(action: PasswordMutation) {
this.action = action
state.value =
state.value.copy(
mutation = PasswordMutationPlan(
action,
"viewer",
"r",
listOf(
PasswordDestination(
"destination",
"destination",
"Folder",
false
)
),
false
)
)
}
override fun dismissMutation() {
state.value = state.value.copy(mutation = null)
}
override fun mutate(destination: String?, overwrite: Boolean) {
this.destination =
destination
dismissMutation()
}
}
}
@@ -177,6 +177,16 @@ class PasswordScreenTest {
}
var lastId: String? = null
var replaceOnRefresh = false
override fun reveal(id: ULong) = Unit
override fun hide(id: ULong) = Unit
override fun copy(id: ULong) = Unit
override fun copyTotp() = Unit
override fun edit(id: ULong) = Unit
override fun dismissEdit() = Unit
override fun saveEdit(value: String) = Unit
override fun prepareMutation(action: de.rfc1437.ironstorage.core.PasswordMutation) = Unit
override fun dismissMutation() = Unit
override fun mutate(destination: String?, overwrite: Boolean) = Unit
override fun restore(path: String, selected: String?, revision: String?) = Unit
override fun refresh() {
if (!replaceOnRefresh) return
@@ -1,6 +1,7 @@
package de.rfc1437.ironstorage
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
@@ -46,6 +47,7 @@ class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
keystore.attach(this)
enableEdgeToEdge()
setContent {
@@ -18,6 +18,7 @@ import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
import de.rfc1437.ironstorage.platform.privateDirectories
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
@@ -43,12 +44,14 @@ object ShellModule {
@Singleton
fun passwordRepository(
session: NativePrivateSession,
authentication: NativeAuthenticationRepository
authentication: NativeAuthenticationRepository,
@ApplicationContext context: Context
): PasswordRepository = NativePasswordRepository(
session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
authentication,
Dispatchers.IO
Dispatchers.IO,
AndroidSecretClipboard(context)
)
@Provides