Implement Rust-owned adaptive Android password navigation (#96)
This commit is contained in:
1 parent
36e5f55283
commit
8c9341f214
26 files changed
+1552
-15
No files matched your search
+4
-2
@@ -1,10 +1,12 @@
|
||||
# Native Android build
|
||||
|
||||
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
|
||||
[parity matrix](../docs/android/parity.md). The native foundation and onboarding implement #89–#95, including private storage, Keystore
|
||||
[parity matrix](../docs/android/parity.md). The native foundation, onboarding and navigation implement #89–#96, including private storage, Keystore
|
||||
callbacks and build gates. Both variants invoke Rust through the generated
|
||||
UniFFI API. Local-first and HTTPS onboarding is described in
|
||||
[onboarding](../docs/android/onboarding.md); feature screens remain owned by their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
|
||||
[onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned,
|
||||
revision-checked [adaptive navigation](../docs/android/password-navigation.md);
|
||||
remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
|
||||
ARM-only runner/toolchain policy and artifact audits.
|
||||
|
||||
## Reproducible setup
|
||||
|
||||
@@ -30,6 +30,10 @@ androidx.compose.foundation:foundation-layout-android:1.12.1=debugAndroidTestCom
|
||||
androidx.compose.foundation:foundation-layout:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.foundation:foundation:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive-android:1.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive-layout:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive-navigation:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive:1.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.input.InputModeManager
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performCustomAccessibilityActionWithLabel
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.unit.Density
|
||||
import de.rfc1437.ironstorage.core.PasswordField
|
||||
import de.rfc1437.ironstorage.core.PasswordKind
|
||||
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.ShellIcon
|
||||
import de.rfc1437.ironstorage.features.PasswordScreen
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class PasswordScreenTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Test fun foldersActionsDetailAndStaleRefreshUseOnlyTypedIdentities() {
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
compose.waitUntil(10000) {
|
||||
compose.activity.resources.configuration.orientation ==
|
||||
Configuration.ORIENTATION_PORTRAIT
|
||||
}
|
||||
val repository = Fixture()
|
||||
lateinit var inputMode: InputModeManager
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setContent {
|
||||
inputMode = LocalInputModeManager.current
|
||||
MaterialTheme { PasswordScreen(repository) }
|
||||
}
|
||||
}
|
||||
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
|
||||
compose.onNodeWithText("仕事").performSemanticsAction(SemanticsActions.RequestFocus) {
|
||||
assertTrue(it())
|
||||
}
|
||||
compose.onNodeWithText("仕事").performKeyInput {
|
||||
keyDown(Key.Enter)
|
||||
keyUp(Key.Enter)
|
||||
}
|
||||
compose.waitUntil(5000) { repository.lastId == "folder-id" }
|
||||
assertEquals("folder-id", repository.lastId)
|
||||
compose.onNodeWithText("Back").performClick()
|
||||
compose.onNodeWithText("仕事").performSemanticsAction(SemanticsActions.RequestFocus) {
|
||||
assertTrue(it())
|
||||
}
|
||||
compose.onNodeWithText("仕事").performKeyInput {
|
||||
keyDown(Key.DirectionCenter)
|
||||
keyUp(Key.DirectionCenter)
|
||||
}
|
||||
compose.waitUntil(5000) { repository.state.value.path == "仕事" }
|
||||
assertEquals("folder-id", repository.lastId)
|
||||
compose.onNodeWithText("Long Unicode 密碼")
|
||||
.performCustomAccessibilityActionWithLabel("Actions for Long Unicode 密碼")
|
||||
compose.onNodeWithText("Close").performClick()
|
||||
compose.onNodeWithText("Back").performClick()
|
||||
compose.onAllNodesWithText("Actions")[0].performClick()
|
||||
compose.onNodeWithText("Open").performClick()
|
||||
compose.onNodeWithText("Unlock").assertIsDisplayed()
|
||||
compose.onNodeWithText("GPG passphrase").performTextInput("incorrect")
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
compose.onNodeWithText("Incorrect passphrase. Retry unlocking.").assertIsDisplayed()
|
||||
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
compose.onNodeWithText("Fixture account").assertIsDisplayed()
|
||||
compose.onNodeWithText("Lock").performClick()
|
||||
compose.onNodeWithText("Unlock").assertIsDisplayed()
|
||||
repository.replaceOnRefresh = true
|
||||
compose.onNodeWithText("Refresh").performClick()
|
||||
compose.onNodeWithText(
|
||||
"The repository changed. Stale selections were cleared."
|
||||
).assertIsDisplayed()
|
||||
assertTrue(repository.state.value.selected == null)
|
||||
compose.onNodeWithText("Back").performClick()
|
||||
compose.onNodeWithText("仕事").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun expandedPanesAndLargeFontsKeepTheSelectedEntryAndControlsUsable() {
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
compose.waitUntil(10000) {
|
||||
compose.activity.resources.configuration.orientation ==
|
||||
Configuration.ORIENTATION_LANDSCAPE
|
||||
}
|
||||
val repository = Fixture()
|
||||
repository.activate("folder-id", "root")
|
||||
repository.activate("entry-id", "folder")
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setContent {
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
|
||||
MaterialTheme { PasswordScreen(repository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Unlock").performScrollTo().assertIsDisplayed()
|
||||
assertEquals("entry-id", repository.state.value.selected?.id)
|
||||
compose.onNodeWithText("Back").performClick()
|
||||
compose.onNodeWithText("Long Unicode 密碼").performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithText("Back").performClick()
|
||||
compose.onNodeWithText("仕事").performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
private class Fixture : PasswordRepository {
|
||||
private val folder =
|
||||
PasswordRow(
|
||||
"folder-id",
|
||||
"仕事",
|
||||
"仕事",
|
||||
"1 entry",
|
||||
PasswordKind.Folder,
|
||||
"folder-revision",
|
||||
ShellIcon.Folder
|
||||
)
|
||||
private val entry =
|
||||
PasswordRow(
|
||||
"entry-id",
|
||||
"仕事/Long Unicode 密碼",
|
||||
"Long Unicode 密碼",
|
||||
"Locked password",
|
||||
PasswordKind.Entry,
|
||||
"entry-revision",
|
||||
ShellIcon.Key
|
||||
)
|
||||
override val state =
|
||||
MutableStateFlow(PasswordState(revision = "root", rows = listOf(folder)))
|
||||
var lastId: String? = null
|
||||
var replaceOnRefresh = false
|
||||
override fun restore(path: String, selected: String?, revision: String?) = Unit
|
||||
override fun refresh() {
|
||||
if (!replaceOnRefresh) return
|
||||
state.value =
|
||||
state.value.copy(selected = null, sections = null, stale = true)
|
||||
}
|
||||
override fun activate(id: String, revision: String) {
|
||||
assertEquals(state.value.revision, revision)
|
||||
lastId = id
|
||||
state.value =
|
||||
if (id ==
|
||||
folder.id
|
||||
) {
|
||||
PasswordState(
|
||||
path = folder.path,
|
||||
title = folder.title,
|
||||
parent = "",
|
||||
revision = "folder",
|
||||
rows = listOf(entry)
|
||||
)
|
||||
} else {
|
||||
state.value.copy(selected = entry)
|
||||
}
|
||||
}
|
||||
override fun inspect(id: String, revision: String) {
|
||||
assertEquals(entry.id, id)
|
||||
assertEquals(state.value.revision, revision)
|
||||
state.value = state.value.copy(selected = entry)
|
||||
}
|
||||
override fun back() {
|
||||
state.value = if (state.value.selected != null) {
|
||||
state.value.copy(selected = null, sections = null)
|
||||
} else {
|
||||
PasswordState(revision = "root", rows = listOf(folder))
|
||||
}
|
||||
}
|
||||
override fun unlock(passphrase: String) {
|
||||
if (passphrase != "public-test-passphrase") {
|
||||
state.value = state.value.copy(error = "Incorrect passphrase. Retry unlocking.")
|
||||
return
|
||||
}
|
||||
assertEquals("public-test-passphrase", passphrase)
|
||||
state.value =
|
||||
state.value.copy(
|
||||
error = null,
|
||||
sections = listOf(
|
||||
PasswordSection(
|
||||
"Fields",
|
||||
listOf(PasswordField(1u, "Username", "Fixture account", null))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun lock() {
|
||||
state.value = state.value.copy(sections = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
||||
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.NativePasswordRepository
|
||||
import de.rfc1437.ironstorage.core.NativePrivateSession
|
||||
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.privateDirectories
|
||||
@@ -21,6 +23,14 @@ import kotlinx.coroutines.SupervisorJob
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ShellModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun passwordRepository(session: NativePrivateSession): PasswordRepository =
|
||||
NativePasswordRepository(
|
||||
session,
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun privateSession(
|
||||
|
||||
+1
-1
@@ -173,7 +173,7 @@ private fun NativeFailureKind.toFacade(): NativeFailure = when (this) {
|
||||
NativeFailureKind.MainThreadCall -> NativeFailure.MainThreadCall
|
||||
}
|
||||
|
||||
private fun MobileIcon.toFacade(): ShellIcon = when (this) {
|
||||
internal fun MobileIcon.toFacade(): ShellIcon = when (this) {
|
||||
MobileIcon.HOME -> ShellIcon.Home
|
||||
MobileIcon.KEY -> ShellIcon.Key
|
||||
MobileIcon.SEARCH -> ShellIcon.Search
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import android.os.SystemClock
|
||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthentication
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClock
|
||||
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.MobilePrivateStorageFfiException
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
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.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class NativePasswordRepository(
|
||||
private val session: NativePrivateSession,
|
||||
private val scope: CoroutineScope
|
||||
) : PasswordRepository {
|
||||
private val mutable = MutableStateFlow(PasswordState())
|
||||
override val state: StateFlow<PasswordState> = mutable
|
||||
private val sequence = AtomicLong()
|
||||
private val mutex = Mutex()
|
||||
private var authentication: MobileAuthentication? = null
|
||||
private val clock = object : MobileClock {
|
||||
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
if (mutable.value.sections != null) {
|
||||
mutex.withLock {
|
||||
val active = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
authentication?.state()?.unlocked == true
|
||||
}
|
||||
} catch (_: MobileAuthenticationFfiException) {
|
||||
false
|
||||
} catch (_: NativeBridgeException) {
|
||||
false
|
||||
}
|
||||
if (!active) mutable.value = mutable.value.copy(sections = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun restore(path: String, selected: String?, revision: String?) =
|
||||
navigate(path, selected, revision, false)
|
||||
override fun refresh() {
|
||||
val current = mutable.value
|
||||
navigate(current.path, current.selected?.id, current.revision.ifEmpty { null }, false)
|
||||
}
|
||||
override fun activate(id: String, revision: String) =
|
||||
navigate(mutable.value.path, id, revision, true)
|
||||
override fun inspect(id: String, revision: String) =
|
||||
navigate(mutable.value.path, id, revision, false)
|
||||
override fun back() {
|
||||
val current = mutable.value
|
||||
navigate(
|
||||
if (current.selected !=
|
||||
null
|
||||
) {
|
||||
current.path
|
||||
} else {
|
||||
current.parent ?: ""
|
||||
},
|
||||
null,
|
||||
null,
|
||||
false
|
||||
)
|
||||
}
|
||||
private fun navigate(path: String, selected: String?, revision: String?, activate: Boolean) =
|
||||
run {
|
||||
authentication?.manualLock()
|
||||
authentication = null
|
||||
session.storage().passwordNavigation(path, selected, revision, activate).toFacade()
|
||||
}
|
||||
override fun unlock(passphrase: String) {
|
||||
val current = mutable.value
|
||||
val selected = current.selected ?: return
|
||||
if (selected.kind != PasswordKind.Entry) return
|
||||
run {
|
||||
val verified = session.storage().passwordNavigation(
|
||||
current.path,
|
||||
selected.id,
|
||||
current.revision,
|
||||
false
|
||||
).toFacade()
|
||||
if (verified.selected == null) return@run verified
|
||||
val auth =
|
||||
authentication
|
||||
?: session.storage().authentication(session.backend(), clock).also {
|
||||
authentication =
|
||||
it
|
||||
}
|
||||
val page = auth.navigationEntry(
|
||||
current.path,
|
||||
selected.id,
|
||||
current.revision,
|
||||
passphrase.ifEmpty { null },
|
||||
(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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun lock() {
|
||||
sequence.incrementAndGet()
|
||||
mutable.value = mutable.value.copy(sections = null, loading = false)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { authentication?.manualLock() }
|
||||
} catch (_: MobileAuthenticationFfiException) {
|
||||
mutable.value = mutable.value.copy(
|
||||
error = "Protected content was cleared. Retry locking the device storage."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun run(action: () -> PasswordState) {
|
||||
val ticket = sequence.incrementAndGet()
|
||||
mutable.value = mutable.value.copy(loading = true, error = null, sections = null)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
if (sequence.get() != ticket) return@withLock
|
||||
val result = try {
|
||||
withContext(Dispatchers.IO) { action() }
|
||||
} catch (error: MobilePasswordFfiException.Failed) {
|
||||
mutable.value.copy(error = error.detail, selected = null)
|
||||
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||
mutable.value.copy(error = error.detail)
|
||||
} catch (_: MobilePrivateStorageFfiException) {
|
||||
mutable.value.copy(
|
||||
error = "Unlock the device or recover the private store and refresh."
|
||||
)
|
||||
} catch (_: NativeBridgeException) {
|
||||
mutable.value.copy(error = "Update the application and retry.")
|
||||
} catch (_: java.io.IOException) {
|
||||
mutable.value.copy(
|
||||
error = "Private storage is unavailable. Unlock the device and refresh.",
|
||||
selected = null
|
||||
)
|
||||
}
|
||||
if (sequence.get() == ticket) {
|
||||
mutable.value = result.copy(loading = false)
|
||||
} else {
|
||||
withContext(Dispatchers.IO) { authentication?.manualLock() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MobilePasswordNavigation.toFacade(): PasswordState {
|
||||
fun row(value: de.rfc1437.ironstorage.bridge.generated.MobileNavigationRow): PasswordRow =
|
||||
PasswordRow(
|
||||
value.row.id,
|
||||
value.row.path,
|
||||
value.row.title,
|
||||
value.row.detail,
|
||||
when (value.row.kind) {
|
||||
MobilePasswordRowKind.DIRECTORY -> PasswordKind.Folder
|
||||
MobilePasswordRowKind.ENTRY -> PasswordKind.Entry
|
||||
},
|
||||
value.revision,
|
||||
value.row.icon.toFacade()
|
||||
)
|
||||
return PasswordState(
|
||||
path,
|
||||
title,
|
||||
parent,
|
||||
revision,
|
||||
rows.map(::row),
|
||||
selected?.let(::row),
|
||||
stale = stale
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class PasswordKind { Folder, Entry }
|
||||
data class PasswordRow(
|
||||
val id: String,
|
||||
val path: String,
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val kind: PasswordKind,
|
||||
val revision: String,
|
||||
val icon: ShellIcon
|
||||
)
|
||||
data class PasswordField(
|
||||
val id: ULong,
|
||||
val label: String,
|
||||
val value: String,
|
||||
val diagnostic: String?
|
||||
)
|
||||
data class PasswordSection(val title: String, val fields: List<PasswordField>)
|
||||
data class PasswordState(
|
||||
val path: String = "",
|
||||
val title: String = "Passwords",
|
||||
val parent: String? = null,
|
||||
val revision: String = "",
|
||||
val rows: List<PasswordRow> = emptyList(),
|
||||
val selected: PasswordRow? = null,
|
||||
val loading: Boolean = false,
|
||||
val stale: Boolean = false,
|
||||
val error: String? = null,
|
||||
val sections: List<PasswordSection>? = null
|
||||
)
|
||||
interface PasswordRepository {
|
||||
val state: StateFlow<PasswordState>
|
||||
fun restore(path: String, selected: String?, revision: String?)
|
||||
fun refresh()
|
||||
fun activate(id: String, revision: String)
|
||||
fun inspect(id: String, revision: String)
|
||||
fun back()
|
||||
fun unlock(passphrase: String)
|
||||
fun lock()
|
||||
}
|
||||
@@ -130,7 +130,9 @@
|
||||
"org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3": "Apache-2.0",
|
||||
"org.jetbrains:annotations:13.0": "Apache-2.0",
|
||||
"org.jetbrains:annotations:23.0.0": "Apache-2.0",
|
||||
"org.jspecify:jspecify:1.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"
|
||||
},
|
||||
"rust_licenses": [
|
||||
"(Apache-2.0 OR MIT) AND BSD-3-Clause",
|
||||
|
||||
@@ -40,6 +40,9 @@ dependencies {
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.adaptive)
|
||||
implementation(libs.compose.adaptive.layout)
|
||||
implementation(libs.compose.adaptive.navigation)
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.lifecycle.compose)
|
||||
implementation(libs.lifecycle.viewmodel)
|
||||
implementation(libs.navigation.runtime)
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
# To regenerate this file, run: ./gradlew :features:dependencies --write-locks
|
||||
androidx.activity:activity-compose:1.12.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.activity:activity-ktx:1.12.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.activity:activity:1.12.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.activity:activity-compose:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.activity:activity-ktx:1.13.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.annotation:annotation-experimental:1.5.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath
|
||||
@@ -25,6 +25,10 @@ androidx.compose.foundation:foundation-layout-android:1.12.1=debugAndroidTestCom
|
||||
androidx.compose.foundation:foundation-layout:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.foundation:foundation:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive-android:1.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive-layout:1.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive-navigation:1.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3.adaptive:adaptive:1.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
@@ -52,9 +56,9 @@ androidx.compose.ui:ui-util:1.12.1=debugAndroidTestCompileClasspath,debugAndroid
|
||||
androidx.compose.ui:ui:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.compose:compose-bom:2026.09.00=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.core:core-ktx:1.16.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.core:core-ktx:1.18.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.core:core:1.16.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.core:core:1.18.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
@@ -154,7 +158,7 @@ com.google.guava:failureaccess:1.0.2=androidLintTool
|
||||
com.google.guava:failureaccess:1.0.3=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
|
||||
com.google.guava:guava:33.4.0-jre=androidLintTool
|
||||
com.google.guava:guava:33.6.0-jre=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
|
||||
com.google.guava:listenablefuture:1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,androidLintTool,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool
|
||||
com.google.j2objc:j2objc-annotations:3.1=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.PasswordKind
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||
var actions by remember { mutableStateOf(false) }
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
val entry = state.selected?.takeIf { it.kind == PasswordKind.Entry }
|
||||
LaunchedEffect(entry?.id) {
|
||||
passphrase = ""
|
||||
navigator.navigateTo(
|
||||
if (entry ==
|
||||
null
|
||||
) {
|
||||
ListDetailPaneScaffoldRole.List
|
||||
} else {
|
||||
ListDetailPaneScaffoldRole.Detail
|
||||
},
|
||||
entry?.id
|
||||
)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
passphrase = ""
|
||||
repository.lock()
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
if (state.revision.isNotEmpty()) repository.refresh()
|
||||
}
|
||||
DisposableEffect(repository) { onDispose { repository.lock() } }
|
||||
BackHandler(state.selected != null || state.parent != null) {
|
||||
actions = false
|
||||
repository.back()
|
||||
}
|
||||
val actionLabel = stringResource(R.string.password_actions)
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
TextButton(
|
||||
enabled = !state.loading && (state.parent != null || state.selected != null),
|
||||
onClick = repository::back
|
||||
) {
|
||||
Text(stringResource(R.string.password_back))
|
||||
}
|
||||
TextButton(enabled = !state.loading, onClick = repository::refresh) {
|
||||
Text(stringResource(R.string.password_refresh))
|
||||
}
|
||||
}
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
modifier = Modifier.weight(1f),
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
state.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.semantics { heading() }
|
||||
)
|
||||
Text(state.path.ifEmpty { stringResource(R.string.password_root) })
|
||||
if (state.loading) CircularProgressIndicator()
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
if (state.stale) Text(stringResource(R.string.password_changed))
|
||||
}
|
||||
}
|
||||
if (!state.loading && state.error == null && state.rows.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.password_empty),
|
||||
Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(state.rows, key = { it.id }) { row ->
|
||||
val rowActions =
|
||||
stringResource(R.string.password_actions_for, row.title)
|
||||
val kindLabel = stringResource(
|
||||
if (row.kind ==
|
||||
PasswordKind.Folder
|
||||
) {
|
||||
R.string.password_folder
|
||||
} else {
|
||||
R.string.password_entry
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(row.title) },
|
||||
supportingContent = { Text(row.detail) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painterResource(
|
||||
when (row.icon) {
|
||||
ShellIcon.Folder -> R.drawable.ic_folder
|
||||
ShellIcon.Key -> R.drawable.ic_key
|
||||
else -> error("Invalid Rust navigation icon")
|
||||
}
|
||||
),
|
||||
null
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription =
|
||||
rowActions
|
||||
},
|
||||
enabled = !state.loading,
|
||||
onClick = {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
}
|
||||
) { Text(actionLabel) }
|
||||
},
|
||||
modifier = Modifier.combinedClickable(
|
||||
enabled = !state.loading,
|
||||
onClickLabel = stringResource(R.string.password_open),
|
||||
onLongClickLabel = rowActions,
|
||||
onClick = {
|
||||
actions = false
|
||||
repository.activate(row.id, state.revision)
|
||||
},
|
||||
onLongClick = {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
}
|
||||
).semantics {
|
||||
stateDescription = kindLabel
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction(rowActions) {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (entry == null) {
|
||||
item { Text(stringResource(R.string.password_choose)) }
|
||||
} else {
|
||||
item {
|
||||
Text(
|
||||
entry.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.semantics {
|
||||
heading()
|
||||
}
|
||||
)
|
||||
Text(entry.path)
|
||||
if (state.loading) CircularProgressIndicator()
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
val sections = state.sections
|
||||
if (sections == null) {
|
||||
item {
|
||||
Text(entry.detail)
|
||||
OutlinedTextField(
|
||||
passphrase,
|
||||
{
|
||||
passphrase = it
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.setup_passphrase))
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = !state.loading
|
||||
)
|
||||
Button(enabled = !state.loading, onClick = {
|
||||
val submitted = passphrase
|
||||
passphrase =
|
||||
""
|
||||
repository.unlock(submitted)
|
||||
}) { Text(stringResource(R.string.password_unlock)) }
|
||||
}
|
||||
} 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) }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (actions && state.selected != null && !state.loading) {
|
||||
val selected = requireNotNull(state.selected)
|
||||
AlertDialog(
|
||||
onDismissRequest = { actions = false },
|
||||
title = {
|
||||
Text(selected.title)
|
||||
},
|
||||
text = { Text(selected.detail + "\n" + selected.path) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
actions = false
|
||||
repository.activate(selected.id, state.revision)
|
||||
}) { Text(stringResource(R.string.password_open)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
actions = false
|
||||
}) { Text(stringResource(R.string.password_close)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -52,7 +53,15 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge)
|
||||
val browsing = !snapshot.fixture && snapshot.nativeFailure == null &&
|
||||
snapshot.storageRecovery == null && snapshot.state != ShellState.Empty &&
|
||||
snapshot.selectedTab == ShellTab.Passwords
|
||||
if (!browsing) {
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
)
|
||||
}
|
||||
val page = snapshot.pages.firstOrNull { it.tab == snapshot.selectedTab }
|
||||
val nativeFailure = snapshot.nativeFailure
|
||||
val title = if (nativeFailure != null) {
|
||||
@@ -80,6 +89,9 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
|
||||
) {
|
||||
OnboardingScreen(viewModel.onboarding, setupForm)
|
||||
} else if (browsing) {
|
||||
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
||||
PasswordScreen(viewModel.passwords, Modifier.weight(1f))
|
||||
} else {
|
||||
Text(
|
||||
title,
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
import de.rfc1437.ironstorage.core.ShellTab
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class ShellViewModel @Inject constructor(
|
||||
private val repository: ShellRepository,
|
||||
val onboarding: OnboardingRepository
|
||||
val onboarding: OnboardingRepository,
|
||||
val passwords: PasswordRepository,
|
||||
private val saved: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
val snapshot = repository.snapshot
|
||||
|
||||
fun selectTab(tab: ShellTab) = repository.selectTab(tab)
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
passwords.state.collect { value ->
|
||||
if (!value.loading && value.error == null && value.revision.isNotEmpty()) {
|
||||
saved["password.path"] = value.path
|
||||
saved["password.selection"] = value.selected?.id
|
||||
saved["password.revision"] = value.revision
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private var passwordsRestored = false
|
||||
fun openPasswords() {
|
||||
if (!passwordsRestored) {
|
||||
passwordsRestored = true
|
||||
passwords.restore(
|
||||
saved["password.path"] ?: "",
|
||||
saved["password.selection"],
|
||||
saved["password.revision"]
|
||||
)
|
||||
} else {
|
||||
passwords.refresh()
|
||||
}
|
||||
}
|
||||
fun selectTab(tab: ShellTab) {
|
||||
if (tab !=
|
||||
ShellTab.Passwords
|
||||
) {
|
||||
passwords.lock()
|
||||
}
|
||||
repository.selectTab(tab)
|
||||
}
|
||||
override fun onCleared() {
|
||||
passwords.lock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M10,4H2v16h20V6H12z" />
|
||||
</vector>
|
||||
@@ -30,4 +30,18 @@
|
||||
<string name="setup_replace_token">Replace application token</string>
|
||||
<string name="setup_back">Back to setup choices</string>
|
||||
<string name="setup_retry">Reload setup state</string>
|
||||
<string name="password_back">Back</string>
|
||||
<string name="password_refresh">Refresh</string>
|
||||
<string name="password_root">Password Store</string>
|
||||
<string name="password_changed">The repository changed. Stale selections were cleared.</string>
|
||||
<string name="password_empty">This folder is empty.</string>
|
||||
<string name="password_choose">Select an entry to view it.</string>
|
||||
<string name="password_actions">Actions</string>
|
||||
<string name="password_actions_for">Actions for %1$s</string>
|
||||
<string name="password_folder">Folder</string>
|
||||
<string name="password_entry">Locked password entry</string>
|
||||
<string name="password_open">Open</string>
|
||||
<string name="password_close">Close</string>
|
||||
<string name="password_unlock">Unlock</string>
|
||||
<string name="password_lock">Lock</string>
|
||||
</resources>
|
||||
@@ -32,6 +32,8 @@ compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose"
|
||||
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
compose-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
|
||||
compose-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptive" }
|
||||
compose-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "adaptive" }
|
||||
compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||
lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
|
||||
@@ -418,6 +418,32 @@
|
||||
<sha256 value="a5eb40a72cc8aff8c6f1ae819d778da0f22f8bb9e71fcb43bc90bbc68f90a1df" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.material3.adaptive" name="adaptive-layout" version="1.3.0">
|
||||
<artifact name="adaptive-layout-1.3.0.module">
|
||||
<sha256 value="67f934a92fe951eff67e3165d59fd6a467c0f1002ce4b90ac92410951a37dfac" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.material3.adaptive" name="adaptive-layout-android" version="1.3.0">
|
||||
<artifact name="adaptive-layout-android-1.3.0.module">
|
||||
<sha256 value="6fd72b9daea78565ecc13c90b87a4725bd85f15e5512b55caf34dd0752967cef" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="adaptive-layout.aar">
|
||||
<sha256 value="504a5fde768cb0c802697da68e6fa2e2e27d6d37a626a9c35410633c813f022a" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.material3.adaptive" name="adaptive-navigation" version="1.3.0">
|
||||
<artifact name="adaptive-navigation-1.3.0.module">
|
||||
<sha256 value="5931ccb361df740619473d7e38dd628bb316659ad3af9781ddf4545879ca0f3a" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.material3.adaptive" name="adaptive-navigation-android" version="1.3.0">
|
||||
<artifact name="adaptive-navigation-android-1.3.0.module">
|
||||
<sha256 value="5e0441ba0030324ad720fda2a1d20442b3d141e940d5478cff909a23a354c6ba" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="adaptive-navigation.aar">
|
||||
<sha256 value="0205752a39813b31f8087497f44a4bde491129e608fedf67f2ff6d0f0c660440" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.runtime" name="runtime" version="1.12.1">
|
||||
<artifact name="runtime-1.12.1.module">
|
||||
<sha256 value="6229c46e743d284155b9a0d3dbbc9e61ec6ced6a4fbdbef85ce2619895dfbcae" origin="Generated by Gradle"/>
|
||||
@@ -624,6 +650,11 @@
|
||||
<sha256 value="2a10979bbb3bcd7b25b7f664ab4e9b016fabf2174a26768b677e12e4bea4c7c4" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.core" name="core" version="1.15.0">
|
||||
<artifact name="core-1.15.0.module">
|
||||
<sha256 value="e8a6c386e1765d870012fed2221173d4a2e8d2fd5aec732c51afb4a9f4684519" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.core" name="core" version="1.16.0">
|
||||
<artifact name="core-1.16.0.aar">
|
||||
<sha256 value="6bf03d39dbe3744acce227d3b697374c3625aae1025fbec8ad9fd7bd58bce431" origin="Generated by Gradle"/>
|
||||
@@ -899,6 +930,11 @@
|
||||
<sha256 value="c9477d6784c8dcb992f1a1774dd95df31f3e101e5d1e9cb7b01aaff3924a1942" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.lifecycle" name="lifecycle-common-jvm" version="2.9.4">
|
||||
<artifact name="lifecycle-common-jvm-2.9.4.module">
|
||||
<sha256 value="1d1837f3942b33ea30aa2301fdcea2639408a0fd6fd4368c22478fa8153aebbf" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.lifecycle" name="lifecycle-livedata" version="2.10.0">
|
||||
<artifact name="lifecycle-livedata-2.10.0.aar">
|
||||
<sha256 value="3e8c009fc88da1c52e4ed05a81269d5ac9bd405046f300307f5e3a17e201a6df" origin="Generated by Gradle"/>
|
||||
@@ -1033,6 +1069,11 @@
|
||||
<sha256 value="8711865f8d3603bc05decc03bb42aab2faa8d8bc55b3b12a0a8e516b562b60ca" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.lifecycle" name="lifecycle-runtime-android" version="2.9.4">
|
||||
<artifact name="lifecycle-runtime-android-2.9.4.module">
|
||||
<sha256 value="c7888e850c40fd059fb2c1ec5bf46e88313e9b5d5152133c385a1aafb8433f47" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.lifecycle" name="lifecycle-runtime-compose" version="2.11.0">
|
||||
<artifact name="lifecycle-runtime-compose-2.11.0.module">
|
||||
<sha256 value="781630971b6d63f5deed846fe98701b946cb5fdc660d7e3894c62c7632a732a2" origin="Generated by Gradle"/>
|
||||
@@ -1135,6 +1176,11 @@
|
||||
<sha256 value="509aa5c0711e61a1571024b4f8431dc9aa20960d23cbadb01f21a33fd864352a" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.lifecycle" name="lifecycle-viewmodel-android" version="2.9.4">
|
||||
<artifact name="lifecycle-viewmodel-android-2.9.4.module">
|
||||
<sha256 value="b637acdd8aab5348974661830874a7456bac341f43a0b281827cc6b0fcfc73c1" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.lifecycle" name="lifecycle-viewmodel-compose" version="2.11.0">
|
||||
<artifact name="lifecycle-viewmodel-compose-2.11.0.module">
|
||||
<sha256 value="997b2a3833183f94e6013ad40982fcf5e7c8600e1070994f330645462a546c18" origin="Generated by Gradle"/>
|
||||
@@ -1250,6 +1296,11 @@
|
||||
<sha256 value="ce00086ea93af85e1adc332afae5b260707b8c6c75faa05f0fdc22995bad3173" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.navigationevent" name="navigationevent" version="1.0.0">
|
||||
<artifact name="navigationevent-1.0.0.module">
|
||||
<sha256 value="f33f1e030a0a7d10aab608a5a5d3ff70d25df325ce825ca204aa19c0e0578b53" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.navigationevent" name="navigationevent" version="1.1.1">
|
||||
<artifact name="navigationevent-1.1.1.module">
|
||||
<sha256 value="ed89d604fd391ddd183337f64b86ff02909293d751eab2dbcaf58d20a33f930f" origin="Generated by Gradle"/>
|
||||
@@ -1273,6 +1324,11 @@
|
||||
<sha256 value="9911c7abc222d6b21a987b649a2c9a4e67f154a43133b3261d57b59e8b9761a9" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.navigationevent" name="navigationevent-compose-android" version="1.0.0">
|
||||
<artifact name="navigationevent-compose-android-1.0.0.module">
|
||||
<sha256 value="38e11be3d6d544e1a00b987f74f56db52f77bbd4a1344f022c36c52f014e162f" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.navigationevent" name="navigationevent-compose-android" version="1.1.1">
|
||||
<artifact name="navigationevent-compose-android-1.1.1.module">
|
||||
<sha256 value="bfcb45e5b079b6c69628b1e8827190f974df0e92893256d00dc9819d84538ce4" origin="Generated by Gradle"/>
|
||||
|
||||
+128
@@ -1,6 +1,9 @@
|
||||
package de.rfc1437.ironstorage.bridge
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClock
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
|
||||
@@ -18,6 +21,131 @@ import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AndroidOnboardingTest {
|
||||
@Test fun privateNavigationTraversesWithoutAuthenticationAndRejectsReplacedEntries() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val id = "navigation-${UUID.randomUUID()}"
|
||||
val directories = MobilePrivateDirectories(
|
||||
File(context.filesDir, id).apply { mkdirs() }.canonicalPath,
|
||||
File(context.noBackupFilesDir, id).apply { mkdirs() }.canonicalPath,
|
||||
File(context.cacheDir, id).apply { mkdirs() }.canonicalPath
|
||||
)
|
||||
val backend = MemoryBackend()
|
||||
val native = NativeRuntime(context)
|
||||
native.privateStorage(directories).use { runtime ->
|
||||
runtime.androidOnboarding(backend).use { setup ->
|
||||
val key =
|
||||
requireNotNull(
|
||||
setup.generate("Navigation fixture", "public-test-passphrase").selectedKey
|
||||
)
|
||||
setup.setupLocal(key, "public-test-passphrase")
|
||||
}
|
||||
val clock = object : MobileClock {
|
||||
override fun monotonicMilliseconds(): ULong =
|
||||
android.os.SystemClock.elapsedRealtime().toULong()
|
||||
}
|
||||
runtime.authentication(backend, clock).use { authentication ->
|
||||
authentication.createDirectory("", "仕事")
|
||||
authentication.createDirectory("仕事", "銀行")
|
||||
authentication.createDirectory("仕事/銀行", "Empty")
|
||||
val draft = authentication.beginCreateEntry(
|
||||
"仕事/銀行",
|
||||
"Long Unicode 密碼",
|
||||
"public-test-passphrase"
|
||||
)
|
||||
authentication.updateEntryEditor(
|
||||
draft.id,
|
||||
listOf(
|
||||
MobileEntryEditorInput(draft.page.fields[0].id, null, "fixture-password")
|
||||
)
|
||||
)
|
||||
authentication.saveEntryEditor(
|
||||
draft.id,
|
||||
listOf(
|
||||
MobileEntryEditorInput(draft.page.fields[0].id, null, "fixture-password")
|
||||
)
|
||||
)
|
||||
authentication.manualLock()
|
||||
val root = runtime.passwordNavigation("", null, null, false)
|
||||
val folder = runtime.passwordNavigation(
|
||||
"",
|
||||
root.rows[0].row.id,
|
||||
root.revision,
|
||||
true
|
||||
)
|
||||
assertEquals("仕事", folder.path)
|
||||
val bank = runtime.passwordNavigation("仕事/銀行", null, null, false)
|
||||
val row = bank.rows.single { it.row.title == "Long Unicode 密碼" }
|
||||
val selected = runtime.passwordNavigation(
|
||||
bank.path,
|
||||
row.row.id,
|
||||
bank.revision,
|
||||
true
|
||||
)
|
||||
assertEquals(row.row.id, selected.selected?.row?.id)
|
||||
assertFalse(authentication.state().unlocked)
|
||||
val presentation = authentication.navigationEntry(
|
||||
bank.path,
|
||||
row.row.id,
|
||||
bank.revision,
|
||||
"public-test-passphrase",
|
||||
0u
|
||||
)
|
||||
assertTrue(presentation.page.sections.isNotEmpty())
|
||||
assertTrue(
|
||||
presentation.page.sections.flatMap { it.fields }.none {
|
||||
it.value ==
|
||||
"fixture-password"
|
||||
}
|
||||
)
|
||||
authentication.manualLock()
|
||||
assertTrue(
|
||||
runtime.passwordNavigation("仕事/銀行/Empty", null, null, false).rows.isEmpty()
|
||||
)
|
||||
native.privateStorage(directories).use { recreated ->
|
||||
assertEquals(
|
||||
row.row.id,
|
||||
recreated.passwordNavigation(
|
||||
bank.path,
|
||||
row.row.id,
|
||||
bank.revision,
|
||||
false
|
||||
).selected?.row?.id
|
||||
)
|
||||
}
|
||||
authentication.unlockEntry(row.row.path, "public-test-passphrase")
|
||||
val editor = authentication.beginEntryEditor(row.row.path)
|
||||
authentication.updateEntryEditor(
|
||||
editor.id,
|
||||
listOf(
|
||||
MobileEntryEditorInput(editor.page.fields[0].id, null, "changed-password")
|
||||
)
|
||||
)
|
||||
authentication.saveEntryEditor(
|
||||
editor.id,
|
||||
listOf(
|
||||
MobileEntryEditorInput(editor.page.fields[0].id, null, "changed-password")
|
||||
)
|
||||
)
|
||||
authentication.manualLock()
|
||||
val stale = runtime.passwordNavigation(bank.path, row.row.id, bank.revision, true)
|
||||
assertTrue(stale.stale)
|
||||
assertEquals(null, stale.selected)
|
||||
assertThrows(
|
||||
MobileAuthenticationFfiException.Failed::class.java
|
||||
) {
|
||||
authentication.navigationEntry(
|
||||
bank.path,
|
||||
row.row.id,
|
||||
bank.revision,
|
||||
"public-test-passphrase",
|
||||
0u
|
||||
)
|
||||
}
|
||||
assertFalse(authentication.state().unlocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun realPublicHttpsDiscoveryAndConnectionUseThePlatformVerifier() {
|
||||
assumeTrue(
|
||||
InstrumentationRegistry.getArguments().getString("ironstorage_https_integration") ==
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
mod private_storage;
|
||||
pub use private_storage::*;
|
||||
mod password_navigation;
|
||||
pub use password_navigation::*;
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
@@ -1747,6 +1749,27 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn navigation_entry(
|
||||
&self,
|
||||
directory: String,
|
||||
row_id: String,
|
||||
revision: String,
|
||||
passphrase: Option<String>,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryPresentation, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.navigation_entry(
|
||||
&directory,
|
||||
&row_id,
|
||||
&revision,
|
||||
passphrase
|
||||
.map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
|
||||
unix_seconds,
|
||||
)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn entry_page(
|
||||
&self,
|
||||
path: String,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Mechanical exports for revision-checked private-store navigation.
|
||||
use crate::{MobileIcon, MobilePasswordFfiError, MobilePasswordRow, MobilePrivateRuntime};
|
||||
use ironstorage::mobile_passwords::navigation::{NavigationRow, PasswordNavigation};
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileNavigationRow {
|
||||
pub row: MobilePasswordRow,
|
||||
pub revision: String,
|
||||
}
|
||||
impl From<NavigationRow> for MobileNavigationRow {
|
||||
fn from(value: NavigationRow) -> Self {
|
||||
let row = value.row;
|
||||
Self {
|
||||
revision: value.revision,
|
||||
row: MobilePasswordRow {
|
||||
id: row.id().into(),
|
||||
path: row.path().into(),
|
||||
title: row.title().into(),
|
||||
detail: row.detail().into(),
|
||||
icon: MobileIcon::from(row.icon()),
|
||||
kind: row.kind().into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePasswordNavigation {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub parent: Option<String>,
|
||||
pub revision: String,
|
||||
pub rows: Vec<MobileNavigationRow>,
|
||||
pub selected: Option<MobileNavigationRow>,
|
||||
pub stale: bool,
|
||||
}
|
||||
impl From<PasswordNavigation> for MobilePasswordNavigation {
|
||||
fn from(value: PasswordNavigation) -> Self {
|
||||
Self {
|
||||
path: value.path,
|
||||
title: value.title,
|
||||
parent: value.parent,
|
||||
revision: value.revision,
|
||||
rows: value.rows.into_iter().map(Into::into).collect(),
|
||||
selected: value.selected.map(Into::into),
|
||||
stale: value.stale,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[uniffi::export]
|
||||
impl MobilePrivateRuntime {
|
||||
pub fn password_navigation(
|
||||
&self,
|
||||
path: String,
|
||||
selected: Option<String>,
|
||||
revision: Option<String>,
|
||||
activate: bool,
|
||||
) -> Result<MobilePasswordNavigation, MobilePasswordFfiError> {
|
||||
ironstorage::mobile_passwords::navigation::navigate(
|
||||
&self.storage,
|
||||
&path,
|
||||
selected.as_deref(),
|
||||
revision.as_deref(),
|
||||
activate,
|
||||
)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -737,6 +737,50 @@ impl MobileAuthentication {
|
||||
.map_err(MobileAuthenticationError::authentication)
|
||||
}
|
||||
|
||||
/// Open only the entry selected from a still-current navigation page.
|
||||
pub fn navigation_entry(
|
||||
&self,
|
||||
directory: &str,
|
||||
row_id: &str,
|
||||
revision: &str,
|
||||
passphrase: Option<SecretBytes>,
|
||||
unix_seconds: u64,
|
||||
) -> Result<MobileEntryPresentation, MobileAuthenticationError> {
|
||||
let validate = || {
|
||||
let page = crate::mobile_passwords::navigation::navigate_repository(
|
||||
&self.repository,
|
||||
directory,
|
||||
Some(row_id),
|
||||
Some(revision),
|
||||
false,
|
||||
)
|
||||
.map_err(|_| {
|
||||
entry_detail(
|
||||
"Selection Is Unavailable",
|
||||
"Refresh the folder and select the entry again.",
|
||||
)
|
||||
})?;
|
||||
page.selected
|
||||
.filter(|row| {
|
||||
row.row.kind() == crate::mobile_passwords::MobilePasswordRowKind::Entry
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
entry_detail(
|
||||
"Selection Changed",
|
||||
"Refresh the folder and select the entry again.",
|
||||
)
|
||||
})
|
||||
};
|
||||
let selected = validate()?;
|
||||
self.unlock_entry(selected.row.path(), passphrase)?;
|
||||
let presentation = self.entry_page(selected.row.path(), unix_seconds)?;
|
||||
if let Err(error) = validate() {
|
||||
self.manual_lock()?;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(presentation)
|
||||
}
|
||||
|
||||
pub fn entry_page(
|
||||
&self,
|
||||
path: &str,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use crate::mobile::MobileIcon;
|
||||
|
||||
pub mod navigation;
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use crate::{
|
||||
@@ -91,7 +93,7 @@ impl MobilePasswordPage {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
fn from_repository(
|
||||
pub(crate) fn from_repository(
|
||||
repository: &Repository,
|
||||
path: Option<&str>,
|
||||
) -> Result<Self, MobilePasswordError> {
|
||||
@@ -255,7 +257,7 @@ impl MobilePasswordError {
|
||||
}
|
||||
}
|
||||
|
||||
fn repository(error: RepositoryError) -> Self {
|
||||
pub(crate) fn repository(error: RepositoryError) -> Self {
|
||||
let kind = match error {
|
||||
RepositoryError::InvalidPath { .. } => MobilePasswordErrorKind::InvalidPath,
|
||||
RepositoryError::NotFound { .. } => MobilePasswordErrorKind::DirectoryMissing,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Revision-checked, authentication-free Android directory navigation.
|
||||
use super::{
|
||||
MobilePasswordError, MobilePasswordErrorKind, MobilePasswordPage, MobilePasswordRow,
|
||||
MobilePasswordRowKind,
|
||||
};
|
||||
use crate::{
|
||||
mobile_private_storage::{PrivateStorage, exclusive_lock},
|
||||
repository::{DirectoryPath, Repository},
|
||||
};
|
||||
use cap_std::{ambient_authority, fs::Dir};
|
||||
use data_encoding::HEXLOWER;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NavigationRow {
|
||||
pub row: MobilePasswordRow,
|
||||
pub revision: String,
|
||||
}
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PasswordNavigation {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub parent: Option<String>,
|
||||
pub revision: String,
|
||||
pub rows: Vec<NavigationRow>,
|
||||
pub selected: Option<NavigationRow>,
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// Only identifiers and revisions cross this boundary; there is no secret provider.
|
||||
pub fn navigate(
|
||||
storage: &PrivateStorage,
|
||||
path: &str,
|
||||
selected: Option<&str>,
|
||||
revision: Option<&str>,
|
||||
activate: bool,
|
||||
) -> Result<PasswordNavigation, MobilePasswordError> {
|
||||
let root =
|
||||
Dir::open_ambient_dir(storage.root(), ambient_authority()).map_err(|_| unavailable())?;
|
||||
let _lock = exclusive_lock(&root, "runtime.lock").map_err(|_| unavailable())?;
|
||||
let config = storage
|
||||
.configuration()
|
||||
.map_err(|_| unavailable())?
|
||||
.ok_or_else(unavailable)?;
|
||||
let repository = Repository::open(config.vault()).map_err(MobilePasswordError::repository)?;
|
||||
navigate_repository(&repository, path, selected, revision, activate)
|
||||
}
|
||||
|
||||
fn unavailable() -> MobilePasswordError {
|
||||
MobilePasswordError {
|
||||
kind: MobilePasswordErrorKind::Configuration,
|
||||
title: "Passwords Are Unavailable".into(),
|
||||
detail: "Unlock the device or recover its private store, then refresh.".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn navigate_repository(
|
||||
repository: &Repository,
|
||||
requested: &str,
|
||||
selected: Option<&str>,
|
||||
expected: Option<&str>,
|
||||
activate: bool,
|
||||
) -> Result<PasswordNavigation, MobilePasswordError> {
|
||||
let mut path = DirectoryPath::parse(requested)
|
||||
.map_err(MobilePasswordError::repository)?
|
||||
.as_path()
|
||||
.to_owned();
|
||||
let page = loop {
|
||||
match MobilePasswordPage::from_repository(repository, path.to_str()) {
|
||||
Ok(page) => break page,
|
||||
Err(error)
|
||||
if error.kind() == MobilePasswordErrorKind::DirectoryMissing
|
||||
&& !path.as_os_str().is_empty() =>
|
||||
{
|
||||
path.pop();
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
};
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(
|
||||
repository
|
||||
.navigation_revision(&path, false)
|
||||
.map_err(MobilePasswordError::repository)?,
|
||||
);
|
||||
let rows = page
|
||||
.rows()
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let revision = repository
|
||||
.navigation_revision(
|
||||
std::path::Path::new(row.path()),
|
||||
row.kind() == MobilePasswordRowKind::Entry,
|
||||
)
|
||||
.map_err(MobilePasswordError::repository)?;
|
||||
let revision = HEXLOWER.encode(&Sha256::digest(revision.as_bytes()));
|
||||
hash.update(row.id().as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(revision.as_bytes());
|
||||
Ok(NavigationRow {
|
||||
row: row.clone(),
|
||||
revision,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, MobilePasswordError>>()?;
|
||||
let revision = HEXLOWER.encode(&hash.finalize());
|
||||
let stale = page.path() != requested || expected.is_some_and(|value| value != revision);
|
||||
let selection = if stale || expected.is_none() {
|
||||
None
|
||||
} else {
|
||||
selected
|
||||
.and_then(|id| rows.iter().find(|row| row.row.id() == id))
|
||||
.cloned()
|
||||
};
|
||||
if activate
|
||||
&& let Some(row) = &selection
|
||||
&& row.row.kind() == MobilePasswordRowKind::Directory
|
||||
{
|
||||
return navigate_repository(repository, row.row.path(), None, None, false);
|
||||
}
|
||||
Ok(PasswordNavigation {
|
||||
parent: path
|
||||
.parent()
|
||||
.map(|parent| parent.to_string_lossy().into_owned()),
|
||||
path: page.path().into(),
|
||||
title: page.title().into(),
|
||||
rows,
|
||||
revision,
|
||||
stale: stale || selected.is_some() && selection.is_none(),
|
||||
selected: selection,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn traverses_without_reading_or_decrypting_entries_and_restores_verified_selection() {
|
||||
let temporary = tempdir().unwrap();
|
||||
fs::create_dir_all(temporary.path().join("仕事/銀行/Empty")).unwrap();
|
||||
fs::write(
|
||||
temporary.path().join("仕事/銀行/a long Unicode 密碼.gpg"),
|
||||
b"not even valid ciphertext",
|
||||
)
|
||||
.unwrap();
|
||||
let repository = Repository::open(temporary.path()).unwrap();
|
||||
let root = navigate_repository(&repository, "", None, None, false).unwrap();
|
||||
let folder = navigate_repository(
|
||||
&repository,
|
||||
"",
|
||||
Some(&root.rows[0].row.id),
|
||||
Some(&root.revision),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(folder.path, "仕事");
|
||||
let bank = navigate_repository(&repository, "仕事/銀行", None, None, false).unwrap();
|
||||
let entry = bank
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.row.kind() == MobilePasswordRowKind::Entry)
|
||||
.unwrap();
|
||||
let selected = navigate_repository(
|
||||
&repository,
|
||||
&bank.path,
|
||||
Some(entry.row.id()),
|
||||
Some(&bank.revision),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
selected.selected.as_ref().unwrap().row.path(),
|
||||
"仕事/銀行/a long Unicode 密碼"
|
||||
);
|
||||
assert_eq!(
|
||||
selected,
|
||||
navigate_repository(
|
||||
&repository,
|
||||
&selected.path,
|
||||
Some(entry.row.id()),
|
||||
Some(&selected.revision),
|
||||
false
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let empty = navigate_repository(&repository, "仕事/銀行/Empty", None, None, false).unwrap();
|
||||
assert!(empty.rows.is_empty());
|
||||
assert_eq!(empty.parent.as_deref(), Some("仕事/銀行"));
|
||||
let deep = (0..32)
|
||||
.map(|index| format!("level-{index}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
fs::create_dir_all(temporary.path().join(&deep)).unwrap();
|
||||
let page = navigate_repository(&repository, &deep, None, None, false).unwrap();
|
||||
assert_eq!(page.path, deep);
|
||||
assert!(page.rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_rows_and_disappearing_ancestors_cannot_reuse_stale_actions() {
|
||||
let temporary = tempdir().unwrap();
|
||||
fs::create_dir_all(temporary.path().join("a/b/c")).unwrap();
|
||||
fs::write(temporary.path().join("a/b/login.gpg"), b"original").unwrap();
|
||||
let repository = Repository::open(temporary.path()).unwrap();
|
||||
let page = navigate_repository(&repository, "a/b", None, None, false).unwrap();
|
||||
fs::write(temporary.path().join("replacement"), b"original").unwrap();
|
||||
fs::rename(
|
||||
temporary.path().join("replacement"),
|
||||
temporary.path().join("a/b/login.gpg"),
|
||||
)
|
||||
.unwrap();
|
||||
let stale = navigate_repository(
|
||||
&repository,
|
||||
"a/b",
|
||||
Some("entry:a/b/login"),
|
||||
Some(&page.revision),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(stale.stale);
|
||||
assert!(stale.selected.is_none());
|
||||
assert_ne!(page.revision, stale.revision);
|
||||
fs::remove_dir_all(temporary.path().join("a/b")).unwrap();
|
||||
let restored =
|
||||
navigate_repository(&repository, "a/b/c", None, Some(&page.revision), false).unwrap();
|
||||
assert_eq!(restored.path, "a");
|
||||
assert!(restored.stale);
|
||||
assert!(navigate_repository(&repository, "../escape", None, None, false).is_err());
|
||||
}
|
||||
}
|
||||
@@ -687,8 +687,11 @@ pub(crate) fn private_permissions(
|
||||
} else {
|
||||
0o600 | (file.metadata()?.permissions().mode() & 0o100)
|
||||
};
|
||||
// Avoid changing object revisions when an already-private store is reopened.
|
||||
if file.metadata()?.permissions().mode() & 0o7777 != mode {
|
||||
file.set_permissions(Permissions::from_mode(mode))?;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = (file, directory);
|
||||
Ok(())
|
||||
@@ -700,6 +703,23 @@ mod tests {
|
||||
use crate::{mobile::MobileShellState, repository::SecretBytes};
|
||||
use std::os::unix::{fs::PermissionsExt, fs::symlink};
|
||||
|
||||
#[test]
|
||||
fn enforcing_private_permissions_preserves_an_already_private_object_revision() {
|
||||
use cap_std::fs::{MetadataExt, PermissionsExt as _};
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let directory = Dir::open_ambient_dir(temporary.path(), ambient_authority()).unwrap();
|
||||
let file = directory.create("entry").unwrap();
|
||||
private_permissions(&file, false).unwrap();
|
||||
let before = file.metadata().unwrap();
|
||||
private_permissions(&file, false).unwrap();
|
||||
let after = file.metadata().unwrap();
|
||||
assert_eq!(
|
||||
(before.ctime(), before.ctime_nsec()),
|
||||
(after.ctime(), after.ctime_nsec())
|
||||
);
|
||||
assert_eq!(after.permissions().mode() & 0o7777, 0o600);
|
||||
}
|
||||
|
||||
fn roots(root: &Path) -> PrivateDirectories {
|
||||
for name in ["files", "no-backup", "cache"] {
|
||||
fs::create_dir_all(root.join(name)).unwrap();
|
||||
|
||||
@@ -391,6 +391,60 @@ impl Repository {
|
||||
Ok(EncryptedEntry(bytes))
|
||||
}
|
||||
|
||||
/// A non-secret object revision; navigation never opens encrypted contents.
|
||||
pub(crate) fn navigation_revision(
|
||||
&self,
|
||||
path: &Path,
|
||||
entry: bool,
|
||||
) -> Result<String, RepositoryError> {
|
||||
let relative = if entry {
|
||||
EntryPath::parse(path)?.encrypted_relative_path()
|
||||
} else {
|
||||
DirectoryPath::parse(path)?.as_path().to_owned()
|
||||
};
|
||||
let metadata = if relative.as_os_str().is_empty() {
|
||||
self.root
|
||||
.dir_metadata()
|
||||
.map_err(|error| io_error("inspect repository", path, error))?
|
||||
} else {
|
||||
let parent = self.open_directory(relative.parent().unwrap_or(Path::new("")))?;
|
||||
child_metadata(
|
||||
&parent,
|
||||
relative.file_name().expect("relative object"),
|
||||
&relative,
|
||||
)?
|
||||
.ok_or_else(|| RepositoryError::NotFound {
|
||||
path: relative.clone(),
|
||||
})?
|
||||
};
|
||||
if entry {
|
||||
require_regular_file(metadata.clone(), &relative)?;
|
||||
} else {
|
||||
require_directory(metadata.clone(), &relative)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use cap_std::fs::MetadataExt;
|
||||
Ok(format!(
|
||||
"{}:{}:{}:{}:{}:{}:{}",
|
||||
metadata.dev(),
|
||||
metadata.ino(),
|
||||
metadata.len(),
|
||||
metadata.mtime(),
|
||||
metadata.mtime_nsec(),
|
||||
if entry { metadata.ctime() } else { 0 },
|
||||
if entry { metadata.ctime_nsec() } else { 0 }
|
||||
))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
Ok(format!(
|
||||
"{}:{:?}:{:?}",
|
||||
metadata.len(),
|
||||
metadata.modified(),
|
||||
metadata.created()
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn read_policy_file(
|
||||
&self,
|
||||
directory: &DirectoryPath,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Android password navigation (#96)
|
||||
|
||||
The Passwords tab loads typed Rust pages from the configured app-private store.
|
||||
Folder traversal uses repository snapshots and capability-scoped metadata; it never
|
||||
reads encrypted entry contents, loads a key, or requests authentication. Rust supplies
|
||||
paths, parent paths, stable row IDs, kinds, icons, titles and entry counts.
|
||||
|
||||
Rust hashes object revisions and page membership. Actions and restored selections
|
||||
must match the current page revision. A replacement at the same path cannot inherit
|
||||
an old selection. Missing folders return to the nearest existing ancestor; malformed
|
||||
or inaccessible stores report an error with explicit refresh. The permission checks
|
||||
avoid changing revisions when already-private objects are reopened.
|
||||
|
||||
Compact windows push into folders and entry detail; expanded windows use Material 3
|
||||
Adaptive list/detail panes, including hinge-aware directives. The same checked state
|
||||
drives both layouts. Folder context and status messages scroll with their pane, so
|
||||
landscape and large fonts retain usable content. Keyboard focus, activation, labelled
|
||||
overflow controls, long press, heading semantics and custom accessibility actions use
|
||||
the same revision-checked Rust targets. The actions dialog offers Open and object
|
||||
details; future mutation actions can consume the same checked target.
|
||||
|
||||
Only path, selected ID and page revision enter saved state. Restore and foreground
|
||||
refresh revalidate them in Rust. The native worker serializes requests and discards
|
||||
obsolete results. Entry selection opens a locked detail view without authentication;
|
||||
explicit Unlock uses the existing Rust authentication lease and structured entry
|
||||
presentation. Rust checks the target before and after opening it. Secrets are absent
|
||||
from saved state, submitted passphrases are cleared, and leaving/backgrounding the
|
||||
screen clears presentation and locks the lease. Expired leases clear displayed fields.
|
||||
The remaining viewer, biometric and lifecycle features retain their own milestone
|
||||
issues.
|
||||
|
||||
## Validation
|
||||
|
||||
Rust regressions cover Unicode and long names, 32 levels, empty folders, verified
|
||||
restoration, identical-byte replacement objects, deleted ancestors, invalid paths,
|
||||
and idempotent private permissions. Native instrumentation creates its isolated
|
||||
folders and encrypted entries through Rust APIs, traverses them with authentication
|
||||
locked, recreates the runtime, opens structured masked detail, replaces an entry and
|
||||
rejects the old selection before authentication. Compose instrumentation exercises
|
||||
keyboard activation, touch/overflow, detail unlock/lock, stale refresh, expanded
|
||||
panes and large fonts.
|
||||
|
||||
Computer-use checks operate the persistent user-created local store, without
|
||||
installing app-data packages or resetting its data. The emulator matrix remains
|
||||
ARM64 API 26/4 KB, API 37/4 KB and API 37/strict 16 KB; ARMv7 is built and audited.
|
||||
Manual TalkBack speech was not observed; labelled semantics and custom actions
|
||||
are exercised through the Compose UI. Physical-device testing is excluded from
|
||||
this milestone's validation scope.
|
||||
|
||||
The issue delivery passed all four required workspace gates (462 tests), the full
|
||||
Android verification build, strict dependency/license and ARM/16 KB package
|
||||
audits, and native/Compose instrumentation plus five cold starts on each of the
|
||||
three persistent emulators. Compose checks include Enter, D-pad Center, labelled
|
||||
custom accessibility actions, touch/overflow, compact unlock errors, and expanded
|
||||
panes at 200% font scale. Computer use verified the configured store's empty root,
|
||||
landscape list/detail panes, refresh without authentication and portrait recovery.
|
||||
No emulator, repository, preferences or Keystore data was reset.
|
||||
Reference in new issue
Block a user