Implement Rust-owned adaptive Android password navigation (#96)
Android ARM verification / rust (push) Failing after 46m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-09 12:25:02 +02:00
1 parent 36e5f55283
commit 8c9341f214
26 files changed
+1553 -16

No files matched your search

+4 -2
View File
@@ -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
+4
View File
@@ -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(
@@ -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()
}
+3 -1
View File
@@ -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",
+3
View File
@@ -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)
+10 -6
View File
@@ -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>
+2
View File
@@ -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" }
+56
View File
@@ -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"/>
@@ -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") ==