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
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(