Implement Rust-owned adaptive Android password navigation (#96)
This commit is contained in:
1 parent
36e5f55283
commit
8c9341f214
26 files changed
+1553
-16
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user