Implement structured Android entry viewer (#99)
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user