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
+1 -1
View File
@@ -135,5 +135,5 @@ tasks.register("androidVerification") {
dependsOn(subprojects.map { "${it.path}:ktlintCheck" })
dependsOn(subprojects.map { "${it.path}:lintDebug" })
dependsOn(subprojects.map { "${it.path}:testDebugUnitTest" })
dependsOn(":app:assembleDebugAndroidTest", ":rust-bridge:assembleDebugAndroidTest", ":platform:assembleDebugAndroidTest")
dependsOn(":core:assembleDebugAndroidTest", ":app:assembleDebugAndroidTest", ":rust-bridge:assembleDebugAndroidTest", ":platform:assembleDebugAndroidTest")
}
+4
View File
@@ -11,6 +11,7 @@ android {
ndkVersion = libs.versions.ndk.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
@@ -36,6 +37,9 @@ dependencies {
implementation(libs.coroutines.android)
testImplementation(libs.coroutines.test)
testImplementation(libs.junit)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.android.test.runner)
androidTestImplementation(libs.android.test.core)
}
val fixtureOutput = layout.buildDirectory.file(
+13 -2
View File
@@ -3,7 +3,17 @@
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew :core:dependencies --write-locks
androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-jvm:1.7.0=debugAndroidTestCompileClasspath
androidx.annotation:annotation:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation:1.7.0=debugAndroidTestCompileClasspath
androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.lifecycle:lifecycle-common:2.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.services:storage:1.6.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:core:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
ch.qos.logback:logback-classic:1.3.15=ktlint
ch.qos.logback:logback-core:1.3.15=ktlint
com.android.tools.analytics-library:protos:32.4.1=androidLintTool
@@ -42,6 +52,7 @@ com.google.code.gson:gson:2.11.0=androidLintTool
com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool
com.google.guava:failureaccess:1.0.2=androidLintTool
com.google.guava:guava:33.4.0-jre=androidLintTool
com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool
com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool
com.google.jimfs:jimfs:1.1=androidLintTool
@@ -75,7 +86,7 @@ io.github.oshai:kotlin-logging:5.1.0=ktlint,ktlintBaselineReporter,ktlintReporte
jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool
javax.inject:javax.inject:1=androidLintTool
junit:junit:4.13.2=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
net.java.dev.jna:jna-platform:5.6.0=androidLintTool
net.java.dev.jna:jna:5.19.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
net.java.dev.jna:jna:5.6.0=androidLintTool
@@ -94,7 +105,7 @@ org.checkerframework:checker-qual:3.43.0=androidLintTool
org.ec4j.core:ec4j-core:1.1.1=ktlint,ktlintBaselineReporter,ktlintRuleset
org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool
org.glassfish.jaxb:txw2:2.3.2=androidLintTool
org.hamcrest:hamcrest-core:1.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
@@ -0,0 +1,228 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Actual encrypted Rust documents and Kotlin facade, with deterministic OS capabilities. */
class NativeEntryViewerTest {
@Test fun fieldsEditingCopyMutationsAndTotpExpiryUseTheNativeOwner() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = "viewer-${UUID.randomUUID()}"
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }.canonicalPath
}
val session = NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, MemoryKeystore())
withContext(Dispatchers.IO) { session.initialize() }
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val clipboard = MemoryClipboard()
val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val repository =
NativePasswordRepository(session, scope, authentication, Dispatchers.IO, clipboard)
val password = "public-viewer-passphrase"
suspend fun request(action: () -> Unit): PasswordState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { repository.state.first { !it.loading } }
}
fun fields() = requireNotNull(repository.state.value.sections).flatMap { it.fields }
suspend fun open() {
val navigation = request { repository.restore("", null, null) }
val row = navigation.rows.single { it.title == "viewer" }
request { repository.activate(row.id, navigation.revision) }
assertNull(repository.state.value.sections)
val page = request { repository.unlock(password) }
assertNull(page.error)
assertNotNull(page.sections)
}
try {
withContext(Dispatchers.IO) {
val setup = session.storage().androidOnboarding(session.backend())
val key = setup.generate("Viewer fixture", password).selectedKey
setup.setupLocal(requireNotNull(key), password)
}
authentication.authenticated { auth ->
val draft = auth.beginCreateEntry("", "viewer", password)
val first = draft.page.fields.first()
auth.updateEntryEditor(
draft.id,
listOf(MobileEntryEditorInput(first.id, first.name, "fixture-secret"))
)
auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.FIELD,
"PIN",
"123493"
)
auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.FIELD,
"custom",
"Unicode Ελληνικά 日本語"
)
auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.FIELD,
"custom",
"Repeated field"
)
auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.NOTE,
null,
"First line\nSecond line"
)
val page = auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.OTP_URI,
null,
"otpauth://totp/Fixture:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Fixture"
)
auth.saveEntryEditor(
draft.id,
page.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value.orEmpty())
}
)
auth.createDirectory("", "destination")
auth.setAuthenticationTimeout(30u)
}
open()
val sensitive = fields().filter { it.sensitive }
assertEquals(3, sensitive.size)
assertTrue(sensitive.all { it.value == "Hidden" && !it.revealed && !it.selectable })
assertEquals(2, fields().count { it.label == "Custom" })
assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline })
assertNotNull(repository.state.value.totp)
assertTrue(repository.state.value.totp!!.remainingSeconds in 1uL..30uL)
val pin = sensitive.single { it.label == "PIN" }
request { repository.reveal(pin.id) }
assertEquals("123493", fields().single { it.id == pin.id }.value)
request { repository.hide(pin.id) }
assertEquals("Hidden", fields().single { it.id == pin.id }.value)
request { repository.copy(pin.id) }
withTimeout(10000) { while (clipboard.value == null) delay(20) }
assertEquals("123493", clipboard.value!!.toString(Charsets.UTF_8))
request { repository.edit(pin.id) }
assertEquals(pin.id, repository.state.value.edit!!.id)
request { repository.saveEdit("654321") }
assertNull(repository.state.value.error)
assertNull(repository.state.value.edit)
assertEquals("Hidden", fields().single { it.id == pin.id }.value)
request { repository.reveal(pin.id) }
assertEquals("654321", fields().single { it.id == pin.id }.value)
request { repository.prepareMutation(PasswordMutation.Copy) }
val target = repository.state.value.mutation!!.destinations.single {
it.path ==
"destination"
}
request { repository.mutate(target.path, false) }
assertNull(repository.state.value.error)
request { repository.restore("destination", null, null) }
assertEquals("viewer", repository.state.value.rows.single().title)
open()
request { repository.prepareMutation(PasswordMutation.Delete) }
request { repository.mutate(null, false) }
assertTrue(repository.state.value.rows.none { it.title == "viewer" })
val copied = request { repository.restore("destination", null, null) }
request { repository.activate(copied.rows.single().id, copied.revision) }
request { repository.unlock(password) }
request { repository.edit(fields().first().id) }
request { repository.lock() }
assertNull(repository.state.value.sections)
assertNull(repository.state.value.edit)
assertNull(repository.state.value.totp)
withTimeout(10000) { while (clipboard.value != null) delay(20) }
request { repository.unlock(password) }
authentication.authenticated {
it.replaceEntryField("destination/viewer", pin.id, "stale external update")
}
request { repository.reveal(pin.id) }
assertNull(repository.state.value.sections)
assertFalse(authentication.state.value.unlocked)
val changed = request { repository.restore("destination", null, null) }
request { repository.activate(changed.rows.single().id, changed.revision) }
request { repository.unlock(password) }
authentication.authenticated { it.setAuthenticationTimeout(2u) }
withTimeout(10000) { authentication.state.first { !it.unlocked } }
withTimeout(10000) { repository.state.first { it.sections == null } }
assertNull(repository.state.value.totp)
assertFalse(authentication.state.value.unlocked)
} finally {
withContext(Dispatchers.Main.immediate) { repository.lock() }
scope.cancel()
}
}
private class MemoryClipboard : SecretClipboard {
@Volatile var value: ByteArray? = null
override fun read() = value?.copyOf()
override fun write(value: ByteArray) {
this.value = value.copyOf()
}
override fun clear() {
value = null
}
}
private class MemoryKeystore : AndroidKeystore {
private val keys = mutableSetOf<String>()
override fun deviceState() =
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, false, false)
override fun createKey(specification: KeystoreKeySpecification) {
keys.add(specification.alias)
}
private data class Record(val alias: String, val aad: ByteArray, val secret: ByteArray)
private val ciphertexts = mutableMapOf<List<Byte>, Record>()
override fun encrypt(
alias: String,
authorization: KeystoreAuthorization,
aad: ByteArray,
value: ByteArray
): KeystoreCiphertext {
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
val seed = UUID.randomUUID().toString().toByteArray()
val token = ByteArray(value.size + 16) { seed[it % seed.size] }
ciphertexts[token.toList()] = Record(alias, aad.copyOf(), value.copyOf())
return KeystoreCiphertext(token.take(12).toByteArray(), token)
}
override fun decrypt(
alias: String,
authorization: KeystoreAuthorization,
aad: ByteArray,
value: KeystoreCiphertext
): ByteArray {
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
val record =
ciphertexts[value.bytes.toList()]
?: throw KeystoreException(KeystoreFailure.Corrupted)
if (record.alias != alias || !record.aad.contentEquals(aad) ||
!value.nonce.contentEquals(value.bytes.take(12).toByteArray())
) {
throw KeystoreException(KeystoreFailure.Corrupted)
}
return record.secret.copyOf()
}
override fun deleteKey(alias: String) {
keys.remove(alias)
}
override fun aliases() = keys.toList()
override fun cancelPending() = Unit
}
}
@@ -1,17 +1,29 @@
package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileClipboard
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardContent
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardWait
import de.rfc1437.ironstorage.bridge.generated.MobileEntryPresentation
import de.rfc1437.ironstorage.bridge.generated.MobileMutationAction
import de.rfc1437.ironstorage.bridge.generated.MobileMutationRequest
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -20,7 +32,8 @@ class NativePasswordRepository(
private val session: NativePrivateSession,
private val scope: CoroutineScope,
private val authentication: NativeAuthenticationRepository,
private val io: CoroutineDispatcher
private val io: CoroutineDispatcher,
private val clipboard: SecretClipboard
) : PasswordRepository {
private val mutable = MutableStateFlow(PasswordState())
override val state: StateFlow<PasswordState> = mutable
@@ -29,7 +42,32 @@ class NativePasswordRepository(
init {
scope.launch {
authentication.state.collect { lease ->
if (!lease.unlocked) mutable.value = mutable.value.copy(sections = null)
if (!lease.unlocked) mutable.value = mutable.value.cleared()
}
}
scope.launch {
while (true) {
delay(1000)
val current = mutable.value
if (current.totp != null && !current.loading &&
authentication.state.value.unlocked &&
!authentication.state.value.busy
) {
content { current ->
val detail = authentication.authenticated { auth ->
auth.refreshEntryPresentation(
requireNotNull(current.selected).path,
requireNotNull(current.documentRevision),
unixSeconds()
).totp
}
current.copy(
totp = detail?.let {
PasswordTotp(it.code, it.account, it.remainingSeconds)
}
)
}
}
}
}
}
@@ -83,31 +121,228 @@ class NativePasswordRepository(
(System.currentTimeMillis() / 1000).toULong()
)
}
verified.copy(
sections = page.page.sections.map { section ->
PasswordSection(
section.title,
section.fields.map { field ->
PasswordField(
field.id,
field.label,
field.value ?: field.maskedValue,
field.diagnostic
)
}
)
}
)
verified.present(page)
}
}
override fun lock() {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(sections = null, loading = false)
mutable.value = mutable.value.cleared().copy(loading = false)
authentication.lock()
}
private fun run(action: suspend () -> PasswordState) {
override fun hide(id: ULong) {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(
loading = false,
sections = mutable.value.sections?.map { section ->
section.copy(
fields = section.fields.map { field ->
if (field.id ==
id
) {
field.copy(value = field.maskedValue, revealed = false)
} else {
field
}
}
)
}
)
}
override fun reveal(id: ULong) = content { current ->
val value = authentication.authenticated {
it.revealPresentedEntryField(
current.entryPath(),
id,
requireNotNull(current.documentRevision)
)
}
current.copy(
sections = current.sections?.map { section ->
section.copy(
fields = section.fields.map { field ->
if (field.id == id) field.copy(value = value, revealed = true) else field
}
)
}
)
}
override fun edit(id: ULong) = content { current ->
val field =
current.sections?.flatMap { it.fields }?.find { it.id == id && it.editable }
?: return@content current
val value = authentication.authenticated {
it.revealPresentedEntryField(
current.entryPath(),
id,
requireNotNull(current.documentRevision)
)
}
current.copy(
edit = PasswordEdit(
id,
field.label,
value,
field.multiline,
field.sensitive
)
)
}
override fun dismissEdit() {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(edit = null, loading = false)
}
override fun saveEdit(value: String) = content { current ->
val edit = current.edit ?: return@content current
authentication.authenticated {
it.replacePresentedEntryField(
current.entryPath(),
edit.id,
requireNotNull(current.documentRevision),
value
)
}
val page = authentication.authenticated { it.entryPage(current.entryPath(), unixSeconds()) }
val folder = session.storage().passwordNavigation(current.path, null, null, false)
val refreshed = session.storage().passwordNavigation(
current.path,
current.selected?.id,
folder.revision,
false
).toFacade()
if (refreshed.selected == null) refreshed else refreshed.present(page)
}
override fun copy(id: ULong) = copyValue { auth, current ->
auth.copyPresentedEntryField(
current.entryPath(),
id,
requireNotNull(current.documentRevision)
)
}
override fun copyTotp() = copyValue { auth, current ->
auth.copyPresentedTotpCode(
current.entryPath(),
requireNotNull(current.documentRevision),
unixSeconds()
)
}
private fun copyValue(
get: (de.rfc1437.ironstorage.bridge.generated.MobileAuthentication, PasswordState) ->
de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy
) = content { current ->
val copy = authentication.authenticated { get(it, current) }
val ticket = sequence.get()
scope.launch(io) {
try {
copyMobileEntryClearing(
copy,
object : MobileClipboard {
override fun read(): MobileClipboardContent =
runBlocking(Dispatchers.Main.immediate) {
clipboard.read()?.let { MobileClipboardContent.Text(it) }
?: MobileClipboardContent.EmptyOrNonText
}
override fun write(value: ByteArray) =
runBlocking(Dispatchers.Main.immediate) {
// A lock may have happened after Rust returned the copy value.
if (!authentication.state.value.unlocked) {
throw MobilePlatformFfiException.Cancelled()
}
clipboard.write(value)
if (ticket == sequence.get()) {
mutable.value = mutable.value.copy(
notice = "Copied. The clipboard will be " +
"cleared automatically."
)
}
}
override fun clear() = runBlocking(Dispatchers.Main.immediate) {
clipboard.clear()
}
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
val start = SystemClock.elapsedRealtime()
while ((SystemClock.elapsedRealtime() - start).toULong() <
milliseconds
) {
if (!authentication.state.value.unlocked) {
return MobileClipboardWait.CANCELLED
}
Thread.sleep(50)
}
return MobileClipboardWait.ELAPSED
}
}
)
} catch (_: MobilePlatformFfiException.Cancelled) {
// Rust cleans up before returning cancellation.
} catch (_: MobilePlatformFfiException) {
withContext(Dispatchers.Main.immediate) {
if (ticket == sequence.get()) {
mutable.value =
mutable.value.copy(error = "Copy is unavailable.", notice = null)
}
}
}
}
current
}
override fun prepareMutation(action: PasswordMutation) = content { current ->
val plan = authentication.authenticated {
it.preparePresentedEntryMutation(
current.entryPath(),
requireNotNull(current.documentRevision),
action.native()
)
}
current.copy(
mutation = PasswordMutationPlan(
action,
plan.source,
plan.revision,
plan.destinations.map {
PasswordDestination(it.path, it.title, it.detail, it.requiresOverwrite)
},
plan.hasOpenEditor
)
)
}
override fun dismissMutation() {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(mutation = null, loading = false)
}
override fun mutate(destination: String?, overwrite: Boolean) = content { current ->
val plan = current.mutation ?: return@content current
authentication.authenticated {
it.performEntryMutation(
MobileMutationRequest(
plan.action.native(),
plan.source,
plan.revision,
destination,
true,
overwrite,
plan.discardEditor
)
)
}
session.storage().passwordNavigation(current.path, null, null, false).toFacade()
}
private fun content(action: suspend (PasswordState) -> PasswordState) {
val current = mutable.value
if (current.sections == null || current.selected == null ||
current.documentRevision == null || !authentication.state.value.unlocked ||
current.loading
) {
return
}
run(false) { action(current) }
}
private fun run(clear: Boolean = true, action: suspend () -> PasswordState) {
val ticket = sequence.incrementAndGet()
mutable.value = mutable.value.copy(loading = true, error = null, sections = null)
mutable.value =
(if (clear) mutable.value.cleared() else mutable.value).copy(
loading = true,
error = null
)
scope.launch {
mutex.withLock {
if (sequence.get() != ticket) return@withLock
@@ -130,10 +365,9 @@ class NativePasswordRepository(
)
}
if (sequence.get() == ticket) {
mutable.value = result.copy(
loading = false,
sections = result.sections.takeIf { authentication.state.value.unlocked }
)
mutable.value =
(if (authentication.state.value.unlocked) result else result.cleared())
.copy(loading = false)
}
}
}
@@ -164,3 +398,40 @@ private fun MobilePasswordNavigation.toFacade(): PasswordState {
stale = stale
)
}
private fun unixSeconds() = (System.currentTimeMillis() / 1000).toULong()
private fun PasswordState.cleared() = copy(
sections = null,
totp = null,
documentRevision = null,
edit = null,
mutation = null,
notice = null
)
private fun PasswordState.present(value: MobileEntryPresentation) = copy(
documentRevision = value.page.revision,
sections = value.page.sections.map { section ->
PasswordSection(
section.title,
section.fields.map { field ->
PasswordField(
field.id, field.label, field.value ?: field.maskedValue, field.diagnostic,
field.sensitive, field.selectable, field.editable,
field.multiline, field.detail,
maskedValue = field.maskedValue
)
}
)
},
totp = value.totp?.let { PasswordTotp(it.code, it.account, it.remainingSeconds) },
notice = value.cacheNotice,
edit = null,
mutation = null
)
private fun PasswordMutation.native() = when (this) {
PasswordMutation.Move -> MobileMutationAction.MOVE
PasswordMutation.Copy -> MobileMutationAction.COPY
PasswordMutation.Delete -> MobileMutationAction.DELETE
}
private fun PasswordState.entryPath() = requireNotNull(selected).path
@@ -16,9 +16,44 @@ data class PasswordField(
val id: ULong,
val label: String,
val value: String,
val diagnostic: String?
)
val diagnostic: String?,
val sensitive: Boolean = false,
val selectable: Boolean = false,
val editable: Boolean = false,
val multiline: Boolean = false,
val detail: String? = null,
val revealed: Boolean = false,
val maskedValue: String = value
) {
override fun toString() = "PasswordField(id=$id, [REDACTED])"
}
data class PasswordSection(val title: String, val fields: List<PasswordField>)
data class PasswordTotp(val code: String, val account: String, val remainingSeconds: ULong) {
override fun toString() = "PasswordTotp([REDACTED])"
}
data class PasswordEdit(
val id: ULong,
val label: String,
val value: String,
val multiline: Boolean,
val sensitive: Boolean
) {
override fun toString() = "PasswordEdit(id=$id, [REDACTED])"
}
enum class PasswordMutation { Move, Copy, Delete }
data class PasswordDestination(
val path: String,
val title: String,
val detail: String,
val overwrite: Boolean
)
data class PasswordMutationPlan(
val action: PasswordMutation,
val source: String,
val revision: String,
val destinations: List<PasswordDestination>,
val discardEditor: Boolean
)
data class PasswordState(
val path: String = "",
val title: String = "Passwords",
@@ -29,7 +64,12 @@ data class PasswordState(
val loading: Boolean = false,
val stale: Boolean = false,
val error: String? = null,
val sections: List<PasswordSection>? = null
val sections: List<PasswordSection>? = null,
val totp: PasswordTotp? = null,
val documentRevision: String? = null,
val notice: String? = null,
val edit: PasswordEdit? = null,
val mutation: PasswordMutationPlan? = null
)
interface PasswordRepository {
val state: StateFlow<PasswordState>
@@ -40,4 +80,14 @@ interface PasswordRepository {
fun back()
fun unlock(passphrase: String)
fun lock()
fun reveal(id: ULong)
fun hide(id: ULong)
fun copy(id: ULong)
fun copyTotp()
fun edit(id: ULong)
fun dismissEdit()
fun saveEdit(value: String)
fun prepareMutation(action: PasswordMutation)
fun dismissMutation()
fun mutate(destination: String?, overwrite: Boolean)
}
@@ -0,0 +1,8 @@
package de.rfc1437.ironstorage.core
/** OS clipboard capability; Rust owns timeout, cancellation cleanup and content comparison. */
interface SecretClipboard {
fun read(): ByteArray?
fun write(value: ByteArray)
fun clear()
}
+2 -1
View File
@@ -132,7 +132,8 @@
"org.jetbrains:annotations:23.0.0": "Apache-2.0",
"org.jspecify:jspecify:1.0.0": "Apache-2.0",
"androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0": "Apache-2.0",
"androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0": "Apache-2.0"
"androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-common:2.3.1": "Apache-2.0"
},
"rust_licenses": [
"(Apache-2.0 OR MIT) AND BSD-3-Clause",
@@ -0,0 +1,332 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.pluralStringResource
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 de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordState
@OptIn(ExperimentalMaterial3Api::class)
fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordRepository) {
item {
var menu by remember { mutableStateOf(false) }
var edit by remember { mutableStateOf(false) }
TopAppBar(title = { Text(stringResource(R.string.entry_details)) }, actions = {
TextButton(enabled = !state.loading, onClick = { edit = true }) {
Text(stringResource(R.string.entry_edit))
}
TextButton(enabled = !state.loading, onClick = { menu = true }) {
Text(stringResource(R.string.entry_more))
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
PasswordMutation.entries.forEach { action ->
DropdownMenuItem(text = { Text(stringResource(action.label())) }, onClick = {
menu = false
repository.prepareMutation(action)
})
}
}
})
TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) }
if (edit) {
AlertDialog(
onDismissRequest = { edit = false },
title = { Text(stringResource(R.string.entry_edit)) },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
state.sections?.flatMap {
it.fields
}?.filter { it.editable }?.forEach { field ->
TextButton(onClick = {
edit = false
repository.edit(field.id)
}) { Text(field.label) }
}
}
},
confirmButton = {
TextButton(onClick = {
edit = false
}) { Text(stringResource(R.string.auth_cancel)) }
},
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
)
}
}
state.notice?.let { item { Text(it) } }
state.totp?.let { totp ->
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.entry_totp), Modifier.semantics { heading() })
Text(totp.account)
Text(totp.code, style = MaterialTheme.typography.headlineMedium)
Text(
pluralStringResource(
R.plurals.entry_countdown,
totp.remainingSeconds.toInt(),
totp.remainingSeconds.toLong()
)
)
TextButton(enabled = !state.loading, onClick = repository::copyTotp) {
Text(stringResource(R.string.entry_copy_code))
}
}
}
}
state.sections?.forEach { section ->
item {
Text(
section.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() }
)
}
items(section.fields, key = { "field-${it.id}" }) { field ->
Column(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(field.label, style = MaterialTheme.typography.labelLarge)
if (field.selectable && !field.sensitive) {
val clipboard = remember(repository, field.id) {
object : Clipboard {
override suspend fun getClipEntry(): ClipEntry? = null
override suspend fun setClipEntry(clipEntry: ClipEntry?) {
repository.copy(field.id)
}
}
}
// Selection copy uses the complete storage field, including its cleanup policy.
CompositionLocalProvider(LocalClipboard provides clipboard) {
SelectionContainer { Text(field.value) }
}
} else {
Text(field.value)
}
field.detail?.let { Text(it) }
field.diagnostic?.let { Text(it, color = MaterialTheme.colorScheme.error) }
FlowRow {
if (field.sensitive) {
val reveal =
stringResource(
if (field.revealed) R.string.entry_hide else R.string.entry_reveal
)
TextButton(
enabled = !state.loading,
modifier = Modifier.semantics {
contentDescription =
"$reveal ${field.label}"
},
onClick = {
if (field.revealed) {
repository.hide(
field.id
)
} else {
repository.reveal(field.id)
}
}
) { Text(reveal) }
}
val copy = stringResource(R.string.entry_copy)
TextButton(
enabled = !state.loading,
modifier = Modifier.semantics {
contentDescription = "$copy ${field.label}"
},
onClick = { repository.copy(field.id) }
) { Text(copy) }
if (field.editable) {
val edit = stringResource(R.string.entry_edit)
TextButton(
enabled = !state.loading,
modifier = Modifier.semantics {
contentDescription =
"$edit ${field.label}"
},
onClick = { repository.edit(field.id) }
) { Text(edit) }
}
}
}
}
}
}
@Composable
fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
state.edit?.let { edit ->
var value by remember(edit) { mutableStateOf(edit.value) }
var revealed by remember(edit) { mutableStateOf(false) }
val systemClipboard = LocalClipboard.current
val clipboard = remember(repository, edit.id, systemClipboard) {
object : Clipboard {
override suspend fun getClipEntry() = systemClipboard.getClipEntry()
override suspend fun setClipEntry(clipEntry: ClipEntry?) {
repository.copy(edit.id)
}
}
}
AlertDialog(
onDismissRequest = repository::dismissEdit,
title = { Text(edit.label) },
text = {
CompositionLocalProvider(LocalClipboard provides clipboard) {
Column {
OutlinedTextField(
value,
onValueChange = {
value = it
},
label = { Text(edit.label) },
singleLine = !edit.multiline,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
imeAction = if (edit.multiline) {
ImeAction.Default
} else {
ImeAction.Done
},
keyboardType = if (edit.sensitive) {
KeyboardType.Password
} else {
KeyboardType.Text
}
),
keyboardActions = KeyboardActions(onDone = {
repository.saveEdit(value)
}),
visualTransformation = if (edit.sensitive && !revealed) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
}
)
Text(stringResource(R.string.entry_copy_stored_hint))
if (edit.sensitive) {
TextButton(onClick = { revealed = !revealed }) {
Text(
stringResource(
if (revealed) R.string.entry_hide else R.string.entry_reveal
)
)
}
}
}
}
},
confirmButton = {
TextButton(enabled = !state.loading, onClick = { repository.saveEdit(value) }) {
Text(stringResource(R.string.entry_save))
}
},
dismissButton = {
TextButton(onClick = repository::dismissEdit) {
Text(stringResource(R.string.auth_cancel))
}
},
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
)
}
state.mutation?.let { plan ->
var destination by remember(plan) { mutableStateOf<String?>(null) }
var overwrite by remember(plan, destination) { mutableStateOf(false) }
val selected = plan.destinations.find { it.path == destination }
AlertDialog(
onDismissRequest = repository::dismissMutation,
title = { Text(stringResource(plan.action.label())) },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(plan.source)
if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor))
if (plan.action == PasswordMutation.Delete) {
Text(stringResource(R.string.entry_delete_confirm))
} else {
plan.destinations.forEach { candidate ->
TextButton(onClick = { destination = candidate.path }) {
Text(candidate.title + "\n" + candidate.detail)
}
}
selected?.let { Text(stringResource(R.string.entry_destination, it.path)) }
if (selected?.overwrite == true) {
TextButton(onClick = { overwrite = !overwrite }) {
Text(
stringResource(
if (overwrite) {
R.string.entry_overwrite_enabled
} else {
R.string.entry_overwrite
}
)
)
}
}
}
}
},
confirmButton = {
TextButton(
enabled = !state.loading && (
plan.action == PasswordMutation.Delete ||
selected != null && (!selected.overwrite || overwrite)
),
onClick = { repository.mutate(destination, overwrite) }
) {
Text(stringResource(R.string.entry_confirm))
}
},
dismissButton = {
TextButton(onClick = repository::dismissMutation) {
Text(stringResource(R.string.auth_cancel))
}
},
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
)
}
}
private fun PasswordMutation.label() = when (this) {
PasswordMutation.Move -> R.string.entry_move
PasswordMutation.Copy -> R.string.entry_copy_entry
PasswordMutation.Delete -> R.string.entry_delete
}
@@ -31,6 +31,7 @@ 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.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
@@ -203,7 +204,7 @@ fun PasswordScreen(
detailPane = {
AnimatedPane {
LazyColumn(
Modifier.fillMaxSize().padding(16.dp),
Modifier.fillMaxSize().padding(16.dp).testTag("entry-detail"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (entry == null) {
@@ -241,32 +242,7 @@ fun PasswordScreen(
}
}
} else {
item {
TextButton(onClick = repository::lock) {
Text(stringResource(R.string.password_lock))
}
}
sections.forEach { section ->
item {
Text(
section.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics {
heading()
}
)
}
items(section.fields, key = { it.id.toString() }) { field ->
ListItem(headlineContent = {
Text(field.label)
}, supportingContent = {
Column {
Text(field.value)
field.diagnostic?.let { Text(it) }
}
})
}
}
entryViewer(state, repository)
}
}
}
@@ -284,6 +260,7 @@ fun PasswordScreen(
}
}
}
if (lease.unlocked && state.sections != null) EntryDialogs(state, repository)
if (manual && entry != null) {
PassphraseDialog(
stringResource(R.string.auth_manual),
@@ -79,4 +79,27 @@
<item quantity="one">Unlocked: %1$d second remaining</item>
<item quantity="other">Unlocked: %1$d seconds remaining</item>
</plurals>
<string name="entry_details">Entry details</string>
<string name="entry_edit">Edit</string>
<string name="entry_more">More</string>
<string name="entry_totp">One-time password</string>
<plurals name="entry_countdown">
<item quantity="one">Valid for %1$d second</item>
<item quantity="other">Valid for %1$d seconds</item>
</plurals>
<string name="entry_copy_code">Copy code</string>
<string name="entry_hide">Hide</string>
<string name="entry_reveal">Reveal</string>
<string name="entry_copy">Copy</string>
<string name="entry_save">Save</string>
<string name="entry_move">Move entry</string>
<string name="entry_copy_entry">Copy entry</string>
<string name="entry_delete">Delete entry</string>
<string name="entry_discard_editor">This action discards the open editor.</string>
<string name="entry_delete_confirm">Permanently delete this encrypted entry?</string>
<string name="entry_destination">Destination: %1$s</string>
<string name="entry_overwrite">Allow replacing the existing entry</string>
<string name="entry_overwrite_enabled">Replacing the existing entry confirmed</string>
<string name="entry_confirm">Confirm</string>
<string name="entry_copy_stored_hint">Selection copies the stored field. Save edits first to copy changes.</string>
</resources>
+3
View File
@@ -884,6 +884,9 @@
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-common" version="2.3.1">
<artifact name="lifecycle-common-2.3.1.jar">
<sha256 value="15848fb56db32f4c7cdc72b324003183d52a4884d6bf09be708ac7f587d139b5" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-common-2.3.1.module">
<sha256 value="5fb7c8514d8c56cada5e29ef89dc0289e71942ab4cb0b2e6dca137b9dcb8fdd4" origin="Generated by Gradle"/>
</artifact>
@@ -0,0 +1,29 @@
package de.rfc1437.ironstorage.platform
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import de.rfc1437.ironstorage.core.SecretClipboard
class AndroidSecretClipboard(context: Context) : SecretClipboard {
private val manager = context.getSystemService(ClipboardManager::class.java)
override fun read(): ByteArray? = manager.primaryClip?.let { clip ->
if (clip.itemCount == 1) clip.getItemAt(0).text?.toString()?.toByteArray() else null
}
override fun write(value: ByteArray) {
val clip = ClipData.newPlainText("IronStorage", value.toString(Charsets.UTF_8))
clip.description.extras = PersistableBundle().apply {
putBoolean("android.content.extra.IS_SENSITIVE", true)
}
manager.setPrimaryClip(clip)
}
override fun clear() {
if (Build.VERSION.SDK_INT >= 28) {
manager.clearPrimaryClip()
} else {
manager.setPrimaryClip(ClipData.newPlainText("", ""))
}
}
}