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