Implement structured Android entry viewer (#99)
This commit is contained in:
1 parent
647dc1bb4b
commit
c0ba6d43d8
29 files changed
+1765
-75
No files matched your search
@@ -97,7 +97,7 @@ def verification_phases():
|
||||
return (
|
||||
tuple(f":{module}:testDebugUnitTest" for module in MODULES),
|
||||
(":app:assembleDebug", ":app:assembleRelease", ":app:bundleRelease"),
|
||||
*((f":{module}:assembleDebugAndroidTest",) for module in ("app", "rust-bridge", "platform")),
|
||||
*((f":{module}:assembleDebugAndroidTest",) for module in ("app", "core", "rust-bridge", "platform")),
|
||||
*((f":{module}:ktlintCheck", f":{module}:lintDebug") for module in MODULES),
|
||||
("androidVerification",),
|
||||
)
|
||||
@@ -268,6 +268,7 @@ def emulator(serial):
|
||||
command("install", "-r", apk)
|
||||
# Android's test runner and Rust fixtures cover typed errors, callbacks and cancellation.
|
||||
suites = [("app", "de.rfc1437.ironstorage.test", None),
|
||||
("core", "de.rfc1437.ironstorage.core.test", None),
|
||||
("rust-bridge", "de.rfc1437.ironstorage.rustbridge.test", None)]
|
||||
native_timings = []
|
||||
for module, package, _ in suites:
|
||||
@@ -311,7 +312,7 @@ class SelfTest(unittest.TestCase):
|
||||
phases = verification_phases()
|
||||
expected = {f":{module}:{task}" for module in MODULES
|
||||
for task in ("testDebugUnitTest", "ktlintCheck", "lintDebug")}
|
||||
expected.update(f":{module}:assembleDebugAndroidTest" for module in ("app", "rust-bridge", "platform"))
|
||||
expected.update(f":{module}:assembleDebugAndroidTest" for module in ("app", "core", "rust-bridge", "platform"))
|
||||
expected.update((":app:assembleDebug", ":app:assembleRelease", ":app:bundleRelease", "androidVerification"))
|
||||
self.assertEqual({task for phase in phases for task in phase}, expected)
|
||||
self.assertEqual(phases[-1], ("androidVerification",))
|
||||
|
||||
@@ -79,6 +79,8 @@ The native Android architecture, support policy and complete mobile parity/issue
|
||||
map are recorded in [`docs/android/architecture.md`](docs/android/architecture.md)
|
||||
and [`docs/android/parity.md`](docs/android/parity.md). Android implementation
|
||||
belongs to milestone 06; the decision record does not claim a shipped client.
|
||||
The structured Android viewer and its validation are documented in
|
||||
[`docs/android/entry-viewer.md`](docs/android/entry-viewer.md).
|
||||
|
||||
## Project layout
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+228
@@ -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
|
||||
}
|
||||
}
|
||||
+295
-24
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
+29
@@ -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("", ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1006,6 +1006,7 @@ pub struct MobileEntrySection {
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileEntryPage {
|
||||
pub id: String,
|
||||
pub revision: String,
|
||||
pub title: String,
|
||||
pub sections: Vec<MobileEntrySection>,
|
||||
}
|
||||
@@ -1014,6 +1015,7 @@ impl From<StorageEntryPage> for MobileEntryPage {
|
||||
fn from(page: StorageEntryPage) -> Self {
|
||||
Self {
|
||||
id: page.id().to_owned(),
|
||||
revision: page.revision().to_owned(),
|
||||
title: page.title().to_owned(),
|
||||
sections: page
|
||||
.sections()
|
||||
@@ -1221,6 +1223,7 @@ pub struct MobileTotpDetail {
|
||||
pub account: String,
|
||||
pub code: String,
|
||||
pub valid_until: u64,
|
||||
pub remaining_seconds: u64,
|
||||
pub period: u64,
|
||||
#[cfg(feature = "watch")]
|
||||
pub shared_with_watch: bool,
|
||||
@@ -1237,6 +1240,7 @@ impl From<StorageTotpDetail> for MobileTotpDetail {
|
||||
code: String::from_utf8(detail.code().expose().to_vec())
|
||||
.expect("storage-generated TOTP codes are ASCII"),
|
||||
valid_until: detail.valid_until(),
|
||||
remaining_seconds: detail.remaining_seconds(),
|
||||
period: detail.period(),
|
||||
#[cfg(feature = "watch")]
|
||||
shared_with_watch: detail.shared_with_watch(),
|
||||
@@ -1831,6 +1835,78 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn reveal_presented_entry_field(
|
||||
&self,
|
||||
path: String,
|
||||
field_id: u64,
|
||||
revision: String,
|
||||
) -> Result<String, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.reveal_presented_entry_field(&path, field_id, &revision)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn copy_presented_entry_field(
|
||||
&self,
|
||||
path: String,
|
||||
field_id: u64,
|
||||
revision: String,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.copy_presented_entry_field(&path, field_id, &revision)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn replace_presented_entry_field(
|
||||
&self,
|
||||
path: String,
|
||||
field_id: u64,
|
||||
revision: String,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.replace_presented_entry_field(&path, field_id, &revision, value)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn refresh_entry_presentation(
|
||||
&self,
|
||||
path: String,
|
||||
revision: String,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryPresentation, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.refresh_entry_presentation(&path, &revision, unix_seconds)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn copy_presented_totp_code(
|
||||
&self,
|
||||
path: String,
|
||||
revision: String,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.copy_presented_totp_code(&path, &revision, unix_seconds)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn prepare_presented_entry_mutation(
|
||||
&self,
|
||||
path: String,
|
||||
revision: String,
|
||||
action: MobileMutationAction,
|
||||
) -> Result<MobileMutationPlan, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.prepare_presented_entry_mutation(&path, &revision, action.into())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn copy_entry_field(
|
||||
&self,
|
||||
path: String,
|
||||
|
||||
@@ -391,19 +391,39 @@ impl ClipboardBackend for ClipboardAdapter {
|
||||
pub fn copy_mobile_entry(
|
||||
copy: MobileEntryCopy,
|
||||
clipboard: Arc<dyn MobileClipboard>,
|
||||
) -> Result<MobileClipboardDisposition, MobilePlatformFfiError> {
|
||||
copy_entry(copy, clipboard, false)
|
||||
}
|
||||
|
||||
/// Android never retains or resurrects previous clipboard contents.
|
||||
#[uniffi::export]
|
||||
pub fn copy_mobile_entry_clearing(
|
||||
copy: MobileEntryCopy,
|
||||
clipboard: Arc<dyn MobileClipboard>,
|
||||
) -> Result<MobileClipboardDisposition, MobilePlatformFfiError> {
|
||||
copy_entry(copy, clipboard, true)
|
||||
}
|
||||
|
||||
fn copy_entry(
|
||||
copy: MobileEntryCopy,
|
||||
clipboard: Arc<dyn MobileClipboard>,
|
||||
clear: bool,
|
||||
) -> Result<MobileClipboardDisposition, MobilePlatformFfiError> {
|
||||
let timeout = presentation::ClipboardTimeout::new(Duration::from_secs(copy.timeout_seconds))
|
||||
.map_err(|_| MobilePlatformFfiError::ClipboardFailed)?;
|
||||
let mut manager =
|
||||
presentation::ClipboardManager::new(ClipboardAdapter(Arc::clone(&clipboard)), timeout);
|
||||
manager
|
||||
.copy_with(
|
||||
&SecretBytes::new(copy.value.into_bytes()),
|
||||
|duration| match clipboard.wait_for_timeout(duration.as_millis() as u64) {
|
||||
let wait = |duration: Duration| match clipboard.wait_for_timeout(duration.as_millis() as u64) {
|
||||
MobileClipboardWait::Elapsed => presentation::ClipboardWait::Elapsed,
|
||||
MobileClipboardWait::Cancelled => presentation::ClipboardWait::Cancelled,
|
||||
},
|
||||
)
|
||||
};
|
||||
let value = SecretBytes::new(copy.value.into_bytes());
|
||||
let result = if clear {
|
||||
manager.copy_clearing_with(&value, wait)
|
||||
} else {
|
||||
manager.copy_with(&value, wait)
|
||||
};
|
||||
result
|
||||
.map(|result| match result {
|
||||
presentation::ClipboardDisposition::RestoredPrevious => {
|
||||
MobileClipboardDisposition::RestoredPrevious
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
mobile_entry::{
|
||||
MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
|
||||
MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage,
|
||||
MobileEntryValueError, field_value,
|
||||
MobileEntryValueError, document_revision, field_value,
|
||||
},
|
||||
mobile_mutation::{
|
||||
MobileMutationAction, MobileMutationError, MobileMutationOutcome, MobileMutationPlan,
|
||||
@@ -949,6 +949,98 @@ impl MobileAuthentication {
|
||||
field_value(&document, field).map_err(value_error)
|
||||
}
|
||||
|
||||
/// Field IDs are scoped to the exact document that the viewer presented.
|
||||
pub fn reveal_presented_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
field_id: u64,
|
||||
revision: &str,
|
||||
) -> Result<String, MobileAuthenticationError> {
|
||||
let document = self.open_presented_document(path, revision)?;
|
||||
field_value(&document, field_id).map_err(value_error)
|
||||
}
|
||||
|
||||
pub fn copy_presented_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
field_id: u64,
|
||||
revision: &str,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
|
||||
Ok(MobileEntryCopy {
|
||||
value: self.reveal_presented_entry_field(path, field_id, revision)?,
|
||||
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_entry_presentation(
|
||||
&self,
|
||||
path: &str,
|
||||
revision: &str,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryPresentation, MobileAuthenticationError> {
|
||||
let presentation = self.entry_page(path, unix_seconds)?;
|
||||
self.check_document_revision(presentation.page().revision(), revision)?;
|
||||
Ok(presentation)
|
||||
}
|
||||
|
||||
fn open_presented_document(
|
||||
&self,
|
||||
path: &str,
|
||||
revision: &str,
|
||||
) -> Result<EntryDocument, MobileAuthenticationError> {
|
||||
let document = self.open_active_document(path)?;
|
||||
self.check_document_revision(&document_revision(&document), revision)?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
fn check_document_revision(
|
||||
&self,
|
||||
current: &str,
|
||||
expected: &str,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
if current != expected {
|
||||
self.manual_lock()?;
|
||||
return Err(MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Conflict,
|
||||
"Entry Changed",
|
||||
"Refresh and unlock the entry again before using its fields.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn copy_presented_totp_code(
|
||||
&self,
|
||||
path: &str,
|
||||
revision: &str,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
|
||||
let presentation = self.refresh_entry_presentation(path, revision, unix_seconds)?;
|
||||
let detail = presentation.totp().ok_or_else(|| {
|
||||
entry_detail(
|
||||
"TOTP Code Is Unavailable",
|
||||
"The entry has no current TOTP code.",
|
||||
)
|
||||
})?;
|
||||
Ok(MobileEntryCopy {
|
||||
value: String::from_utf8(detail.code().expose().to_vec())
|
||||
.map_err(|_| entry_detail("TOTP Code Is Unavailable", "The code is not UTF-8."))?,
|
||||
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare_presented_entry_mutation(
|
||||
&self,
|
||||
path: &str,
|
||||
revision: &str,
|
||||
action: MobileMutationAction,
|
||||
) -> Result<MobileMutationPlan, MobileAuthenticationError> {
|
||||
self.open_presented_document(path, revision)?;
|
||||
let plan = self.prepare_entry_mutation(path, action)?;
|
||||
self.open_presented_document(path, revision)?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub fn copy_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -1234,17 +1326,42 @@ impl MobileAuthentication {
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let mut document = self.open_active_document(path)?;
|
||||
self.save_replaced_entry_field(&mut document, field, value)
|
||||
}
|
||||
|
||||
pub fn replace_presented_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
field_id: u64,
|
||||
revision: &str,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let mut document = self.open_presented_document(path, revision)?;
|
||||
self.save_replaced_entry_field(&mut document, field_id, value)
|
||||
}
|
||||
|
||||
fn save_replaced_entry_field(
|
||||
&self,
|
||||
document: &mut EntryDocument,
|
||||
field_id: u64,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
document
|
||||
.replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
|
||||
.replace_field_value(EntryFieldId::from_value(field_id), value.into_bytes())
|
||||
.map_err(document_error)?;
|
||||
let identity = self.git_identity()?;
|
||||
let mut committer = AutomaticEntryCommitter::for_entry(&self.repository, path, identity)
|
||||
let mut committer = AutomaticEntryCommitter::for_entry(
|
||||
&self.repository,
|
||||
&document.path().to_string(),
|
||||
identity,
|
||||
)
|
||||
.map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?;
|
||||
EntryDocumentService::new(&self.repository, &self.keys)
|
||||
.save_recoverable(&document, None, &mut committer)
|
||||
.save_recoverable(document, None, &mut committer)
|
||||
.map_err(document_error)?;
|
||||
self.reconcile_saved_document(&document);
|
||||
Ok(MobileEntryPage::from_document(&document))
|
||||
self.reconcile_saved_document(document);
|
||||
Ok(MobileEntryPage::from_document(document))
|
||||
}
|
||||
|
||||
pub fn begin_entry_editor(
|
||||
|
||||
@@ -104,6 +104,7 @@ impl MobileEntrySection {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntryPage {
|
||||
revision: String,
|
||||
id: String,
|
||||
title: String,
|
||||
sections: Vec<MobileEntrySection>,
|
||||
@@ -518,11 +519,16 @@ impl MobileEntryPage {
|
||||
}
|
||||
Self {
|
||||
id: format!("entry:{}", document.path()),
|
||||
revision: document_revision(document),
|
||||
title,
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> &str {
|
||||
&self.revision
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
@@ -559,6 +565,10 @@ impl From<DocumentError> for MobileEntryValueError {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn document_revision(document: &EntryDocument) -> String {
|
||||
data_encoding::HEXLOWER.encode(document.conflict_token().as_bytes())
|
||||
}
|
||||
|
||||
pub fn field_value(document: &EntryDocument, id: u64) -> Result<String, MobileEntryValueError> {
|
||||
let value = document.copy_field_value(EntryFieldId::from_value(id))?;
|
||||
String::from_utf8(value.expose().to_vec()).map_err(|_| MobileEntryValueError::NonUtf8)
|
||||
|
||||
@@ -198,6 +198,7 @@ pub struct MobileTotpDetail {
|
||||
account: String,
|
||||
code: SecretBytes,
|
||||
valid_until: u64,
|
||||
remaining_seconds: u64,
|
||||
period: u64,
|
||||
shared_with_watch: bool,
|
||||
watch: MobileWatchSnapshotStatus,
|
||||
@@ -228,6 +229,10 @@ impl MobileTotpDetail {
|
||||
self.period
|
||||
}
|
||||
|
||||
pub fn remaining_seconds(&self) -> u64 {
|
||||
self.remaining_seconds
|
||||
}
|
||||
|
||||
pub fn shared_with_watch(&self) -> bool {
|
||||
self.shared_with_watch
|
||||
}
|
||||
@@ -556,6 +561,7 @@ fn detail_for_uri(
|
||||
account: uri.account().to_owned(),
|
||||
code: uri.code_at(unix_seconds)?,
|
||||
valid_until,
|
||||
remaining_seconds: valid_until - unix_seconds,
|
||||
period,
|
||||
shared_with_watch: shared.contains(path),
|
||||
watch: snapshot_status(shared.len()),
|
||||
|
||||
@@ -147,9 +147,30 @@ impl<B: ClipboardBackend> ClipboardManager<B> {
|
||||
return Err(ClipboardError::EmptySecret);
|
||||
}
|
||||
let previous = self.backend.read()?;
|
||||
self.copy_and_cleanup(value, &previous, wait)
|
||||
}
|
||||
|
||||
/// Copy without retaining or later restoring previous clipboard contents.
|
||||
pub fn copy_clearing_with(
|
||||
&mut self,
|
||||
value: &SecretBytes,
|
||||
wait: impl FnOnce(Duration) -> ClipboardWait,
|
||||
) -> Result<ClipboardDisposition, ClipboardError> {
|
||||
if value.expose().is_empty() {
|
||||
return Err(ClipboardError::EmptySecret);
|
||||
}
|
||||
self.copy_and_cleanup(value, &ClipboardContent::EmptyOrNonText, wait)
|
||||
}
|
||||
|
||||
fn copy_and_cleanup(
|
||||
&mut self,
|
||||
value: &SecretBytes,
|
||||
previous: &ClipboardContent,
|
||||
wait: impl FnOnce(Duration) -> ClipboardWait,
|
||||
) -> Result<ClipboardDisposition, ClipboardError> {
|
||||
self.backend.write(value)?;
|
||||
let wait_result = wait(self.timeout.duration());
|
||||
let disposition = self.cleanup(value, &previous)?;
|
||||
let disposition = self.cleanup(value, previous)?;
|
||||
if wait_result == ClipboardWait::Cancelled {
|
||||
return Err(ClipboardError::Cancelled);
|
||||
}
|
||||
|
||||
@@ -740,6 +740,79 @@ fn mobile_expiry_and_user_input_never_reveal_old_editor_handles() -> TestResult
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presented_field_ids_reject_replaced_documents_and_ticks_do_not_renew_the_lease() -> TestResult {
|
||||
use ironstorage::{
|
||||
config::Config,
|
||||
mobile_authentication::{MobileAuthentication, MobileAuthenticationErrorKind},
|
||||
};
|
||||
let fixture = FixtureSet::load()?;
|
||||
let vault = fixture.materialize_store("basic")?;
|
||||
let root = tempfile::tempdir()?;
|
||||
let source = root.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&source,
|
||||
format!(
|
||||
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n",
|
||||
vault.path().to_str().unwrap(),
|
||||
fixture.path("keys").to_str().unwrap(),
|
||||
fixture.key("alice")?.primary_fingerprint
|
||||
),
|
||||
)?;
|
||||
let clock = ManualClock::default();
|
||||
let authentication = MobileAuthentication::with_platform(
|
||||
Config::load(Some(&source))?,
|
||||
Arc::new(MemoryBackend::default()),
|
||||
Arc::new(clock.clone()),
|
||||
)?;
|
||||
authentication.set_authentication_timeout(2)?;
|
||||
authentication.unlock_entry(
|
||||
"email/personal",
|
||||
Some(SecretBytes::new(
|
||||
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||
)),
|
||||
)?;
|
||||
let page = authentication.entry_page("email/personal", 59)?;
|
||||
let id = page.page().sections()[0].fields()[0].id();
|
||||
assert!(
|
||||
!authentication
|
||||
.reveal_presented_entry_field("email/personal", id, page.page().revision())?
|
||||
.is_empty()
|
||||
);
|
||||
clock.advance(Duration::from_secs(1));
|
||||
authentication.refresh_entry_presentation("email/personal", page.page().revision(), 60)?;
|
||||
assert_eq!(authentication.state()?.remaining_seconds(), 1);
|
||||
authentication.replace_entry_field("email/personal", id, "changed password".to_owned())?;
|
||||
let error = authentication
|
||||
.replace_presented_entry_field(
|
||||
"email/personal",
|
||||
id,
|
||||
page.page().revision(),
|
||||
"must not overwrite".to_owned(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), MobileAuthenticationErrorKind::Conflict);
|
||||
assert!(!authentication.state()?.unlocked());
|
||||
authentication.unlock_entry(
|
||||
"email/personal",
|
||||
Some(SecretBytes::new(
|
||||
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||
)),
|
||||
)?;
|
||||
assert_eq!(
|
||||
authentication.reveal_entry_field("email/personal", id)?,
|
||||
"changed password"
|
||||
);
|
||||
clock.advance(Duration::from_secs(2));
|
||||
assert!(
|
||||
authentication
|
||||
.refresh_entry_presentation("email/personal", page.page().revision(), 61)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!authentication.state()?.unlocked());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locking_interrupts_an_os_prompt_before_waiting_for_the_lease_mutex() -> TestResult {
|
||||
struct PromptBackend {
|
||||
|
||||
@@ -145,6 +145,7 @@ fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestRe
|
||||
let detail = service.detail("otp/alice", 59, &BTreeSet::new(), &mut secrets)?;
|
||||
assert_eq!(detail.code().expose(), b"94287082");
|
||||
assert_eq!(detail.valid_until(), 60);
|
||||
assert_eq!(detail.remaining_seconds(), 1);
|
||||
assert_eq!(detail.period(), 30);
|
||||
assert!(!format!("{detail:?}").contains("94287082"));
|
||||
|
||||
@@ -161,6 +162,7 @@ fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestRe
|
||||
let detail = service.detail("otp/alice", 60, &selected, &mut secrets)?;
|
||||
assert!(detail.shared_with_watch());
|
||||
assert_eq!(detail.valid_until(), 90);
|
||||
assert_eq!(detail.remaining_seconds(), 30);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,32 @@ fn clipboard_races_and_cancellation_never_overwrite_newer_user_content() -> Test
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_clipboard_never_reads_or_restores_previous_secrets() -> TestResult {
|
||||
let backend = MemoryClipboard::with_text(b"previous secret");
|
||||
let mut manager = ClipboardManager::new(backend.clone(), ClipboardTimeout::default());
|
||||
let secret = SecretBytes::new(b"copied secret".to_vec());
|
||||
assert_eq!(
|
||||
manager.copy_clearing_with(&secret, |_| ClipboardWait::Elapsed)?,
|
||||
ClipboardDisposition::Cleared
|
||||
);
|
||||
assert_eq!(backend.text(), None);
|
||||
assert_eq!(
|
||||
manager.copy_clearing_with(&secret, |_| {
|
||||
backend.set_text(b"newer value");
|
||||
ClipboardWait::Elapsed
|
||||
})?,
|
||||
ClipboardDisposition::PreservedNewer
|
||||
);
|
||||
assert_eq!(backend.text().as_deref(), Some(b"newer value".as_slice()));
|
||||
assert_eq!(
|
||||
manager.copy_clearing_with(&secret, |_| ClipboardWait::Cancelled),
|
||||
Err(ClipboardError::Cancelled)
|
||||
);
|
||||
assert_eq!(backend.text(), None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_validation_and_cleanup_failures_are_typed_and_redacted() -> TestResult {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Android structured entry viewer (#99)
|
||||
|
||||
The viewer renders Rust-provided sections and fields without parsing or serializing
|
||||
entries in Compose. Passwords, PINs, OTP URIs and other sensitive fields start masked.
|
||||
Reveal, copy and edit use the field ID together with the exact document revision.
|
||||
An external replacement rejects the old field IDs and locks the authentication
|
||||
lease. Editing saves through Rust's recoverable encryption and automatic Git commit
|
||||
workflow, then refreshes the checked navigation and document revisions.
|
||||
|
||||
Rust supplies TOTP codes, account labels and remaining seconds. The presentation
|
||||
refresh does not renew the lease. Expiry, backgrounding and manual lock remove
|
||||
sections, codes, editor inputs and mutation dialogs. Decrypted values never enter
|
||||
saved state; secret-bearing facade objects redact their string representation.
|
||||
|
||||
The Material top bar offers an editable-field picker and Move, Copy and Delete
|
||||
plans. Rust supplies destinations, overwrite requirements and checked mutation
|
||||
revisions. Confirmation is explicit. Compact and adaptive list/detail panes share
|
||||
the same viewer; wrapped values, multiline notes and repeated fields remain intact.
|
||||
Headings, labelled actions and keyboard submission expose the same typed operations.
|
||||
|
||||
Only fields marked selectable by Rust permit text selection. Selection-copy and
|
||||
explicit Copy both request the complete stored field from Rust, preserving its
|
||||
clipboard timeout. The editor explains that copying uses the saved value; saving
|
||||
first makes edits available to copy. Android marks clips sensitive, and Rust clears
|
||||
its own clip on timeout or lock without restoring previous clipboard contents or
|
||||
erasing a newer clip. Screen capture is blocked for the activity and dialogs.
|
||||
|
||||
## Validation
|
||||
|
||||
Rust regressions cover revision-bound field actions, external edits, lease expiry,
|
||||
TOTP countdown boundaries and clearing clipboard behavior. Existing document
|
||||
fixtures cover dynamic, repeated, multiline, Unicode, malformed and OTP-only
|
||||
entries without replacing their original format. Native Android instrumentation
|
||||
creates an isolated GPG-encrypted store through Rust and exercises reveal, hide,
|
||||
copy, save, copy/delete mutations, stale revisions and expiry during TOTP refresh.
|
||||
Compose checks cover masking, field actions, diagnostics, dialog revocation,
|
||||
mutation confirmation, clipboard sensitivity and screen-capture protection.
|
||||
All four required Rust workspace gates and the complete Android build, lint,
|
||||
dependency/license, module-boundary and native-package gates passed. Full app,
|
||||
core and bridge instrumentation plus five cold starts passed on ARM64 API 26/4 KB,
|
||||
API 37/4 KB and API 37/strict 16 KB. Both ARM64 and ARMv7 release artifacts were
|
||||
built and audited.
|
||||
|
||||
Computer use in Android Studio's embedded emulator checked the disposable viewer,
|
||||
masked PIN editor, repeated fields, Unicode and contextual selection at 200% font
|
||||
size. Native contextual Copy was observed dispatching the Username's field ID.
|
||||
Manual TalkBack speech was not checked; labelled accessibility actions are covered
|
||||
by instrumentation. ARMv7 is built and packaged but has no available emulator
|
||||
image. Physical-device testing is outside this milestone's scope.
|
||||
Reference in new issue
Block a user