diff --git a/android/README.md b/android/README.md index 154efae..7259dc9 100644 --- a/android/README.md +++ b/android/README.md @@ -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 diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile index f712eb2..cb5f033 100644 --- a/android/app/gradle.lockfile +++ b/android/app/gradle.lockfile @@ -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 diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt new file mode 100644 index 0000000..d129851 --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt @@ -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() + + @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) + } + } +} diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt index eb9a02e..b226e28 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt @@ -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( diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt index 9ef0be5..b3c8259 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt @@ -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 diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt new file mode 100644 index 0000000..c56c2a7 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt @@ -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 = 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 + ) +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt new file mode 100644 index 0000000..d55460f --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt @@ -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) +data class PasswordState( + val path: String = "", + val title: String = "Passwords", + val parent: String? = null, + val revision: String = "", + val rows: List = emptyList(), + val selected: PasswordRow? = null, + val loading: Boolean = false, + val stale: Boolean = false, + val error: String? = null, + val sections: List? = null +) +interface PasswordRepository { + val state: StateFlow + 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() +} diff --git a/android/dependency-policy.json b/android/dependency-policy.json index 731b9ac..2cd7233 100644 --- a/android/dependency-policy.json +++ b/android/dependency-policy.json @@ -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", diff --git a/android/features/build.gradle.kts b/android/features/build.gradle.kts index 4bea6c6..3c8075a 100644 --- a/android/features/build.gradle.kts +++ b/android/features/build.gradle.kts @@ -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) diff --git a/android/features/gradle.lockfile b/android/features/gradle.lockfile index b2f7ccf..016d0d1 100644 --- a/android/features/gradle.lockfile +++ b/android/features/gradle.lockfile @@ -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 diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt new file mode 100644 index 0000000..8339ad0 --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt @@ -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() + 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)) } + } + ) + } +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt index f64578f..3d1a57c 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt @@ -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, diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt index 159a4f2..55cd7dc 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt @@ -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() + } } diff --git a/android/features/src/main/res/drawable/ic_folder.xml b/android/features/src/main/res/drawable/ic_folder.xml new file mode 100644 index 0000000..5913d00 --- /dev/null +++ b/android/features/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index 6181734..989e1f2 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -30,4 +30,18 @@ Replace application token Back to setup choices Reload setup state + Back + Refresh + Password Store + The repository changed. Stale selections were cleared. + This folder is empty. + Select an entry to view it. + Actions + Actions for %1$s + Folder + Locked password entry + Open + Close + Unlock + Lock diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index c3916bb..8be86f2 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" } diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index a9d1966..d5d86b9 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -418,6 +418,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -624,6 +650,11 @@ + + + + + @@ -899,6 +930,11 @@ + + + + + @@ -1033,6 +1069,11 @@ + + + + + @@ -1135,6 +1176,11 @@ + + + + + @@ -1250,6 +1296,11 @@ + + + + + @@ -1273,6 +1324,11 @@ + + + + + diff --git a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt index a916642..07f163e 100644 --- a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt +++ b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt @@ -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") == diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index bdd36ae..77301c6 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -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, + unix_seconds: u64, + ) -> Result { + 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, diff --git a/crates/mobile-bridge/src/password_navigation.rs b/crates/mobile-bridge/src/password_navigation.rs new file mode 100644 index 0000000..73ae7d7 --- /dev/null +++ b/crates/mobile-bridge/src/password_navigation.rs @@ -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 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, + pub revision: String, + pub rows: Vec, + pub selected: Option, + pub stale: bool, +} +impl From 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, + revision: Option, + activate: bool, + ) -> Result { + ironstorage::mobile_passwords::navigation::navigate( + &self.storage, + &path, + selected.as_deref(), + revision.as_deref(), + activate, + ) + .map(Into::into) + .map_err(Into::into) + } +} diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index a83a4d7..d908642 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -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, + unix_seconds: u64, + ) -> Result { + 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, diff --git a/crates/storage/src/mobile_passwords.rs b/crates/storage/src/mobile_passwords.rs index 14fee5f..88bba6f 100644 --- a/crates/storage/src/mobile_passwords.rs +++ b/crates/storage/src/mobile_passwords.rs @@ -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 { @@ -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, diff --git a/crates/storage/src/mobile_passwords/navigation.rs b/crates/storage/src/mobile_passwords/navigation.rs new file mode 100644 index 0000000..1424832 --- /dev/null +++ b/crates/storage/src/mobile_passwords/navigation.rs @@ -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, + pub revision: String, + pub rows: Vec, + pub selected: Option, + 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 { + 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 { + 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::, 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::>() + .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()); + } +} diff --git a/crates/storage/src/mobile_private_storage.rs b/crates/storage/src/mobile_private_storage.rs index cf0c482..a6f4bd7 100644 --- a/crates/storage/src/mobile_private_storage.rs +++ b/crates/storage/src/mobile_private_storage.rs @@ -687,7 +687,10 @@ pub(crate) fn private_permissions( } else { 0o600 | (file.metadata()?.permissions().mode() & 0o100) }; - file.set_permissions(Permissions::from_mode(mode))?; + // 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); @@ -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(); diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index 6c57d76..30d51dc 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -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 { + 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, diff --git a/docs/android/password-navigation.md b/docs/android/password-navigation.md new file mode 100644 index 0000000..da16c30 --- /dev/null +++ b/docs/android/password-navigation.md @@ -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.