Complete Android adaptive and accessibility hardening (#105)
This commit is contained in:
1 parent
f4a6edbc68
commit
45d78aa5b3
33 files changed
+4596
-1376
No files matched your search
+6
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -14,11 +15,16 @@ import de.rfc1437.ironstorage.core.CredentialCandidate
|
||||
import de.rfc1437.ironstorage.core.ProviderRequest
|
||||
import de.rfc1437.ironstorage.features.CredentialSelectionScreen
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class CredentialSelectionScreenTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Before fun enableChecks() {
|
||||
compose.enableAccessibilityChecks()
|
||||
}
|
||||
private var selected by mutableStateOf<String?>(null)
|
||||
private var destination by mutableStateOf("")
|
||||
private var consent by mutableStateOf(false)
|
||||
|
||||
@@ -2,8 +2,12 @@ package de.rfc1437.ironstorage
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.adaptive.HingeInfo
|
||||
import androidx.compose.material3.adaptive.Posture
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.input.InputModeManager
|
||||
import androidx.compose.ui.input.key.Key
|
||||
@@ -20,10 +24,13 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
@@ -35,6 +42,7 @@ import de.rfc1437.ironstorage.core.EditorState
|
||||
import de.rfc1437.ironstorage.core.ProtectedPresentation
|
||||
import de.rfc1437.ironstorage.features.EditorDiscardDialog
|
||||
import de.rfc1437.ironstorage.features.EntryEditorScreen
|
||||
import de.rfc1437.ironstorage.features.HingeSafeLayout
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -50,6 +58,55 @@ class EntryEditorScreenTest {
|
||||
compose.enableAccessibilityChecks()
|
||||
}
|
||||
|
||||
@Test fun tabletopGeneratorKeepsEveryControlReachableAtLargeFont() {
|
||||
val repository = Fixture()
|
||||
val authentication = object : AuthenticationRepository by compose.activity.authentication {
|
||||
override val state = MutableStateFlow(AuthenticationState(unlocked = true))
|
||||
}
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
BoxWithConstraints {
|
||||
val density = LocalDensity.current
|
||||
val width = with(density) { maxWidth.toPx() }
|
||||
val height = with(density) { maxHeight.toPx() }
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
|
||||
MaterialTheme {
|
||||
HingeSafeLayout(
|
||||
Posture(
|
||||
true,
|
||||
listOf(
|
||||
HingeInfo(
|
||||
Rect(
|
||||
0f,
|
||||
height * .5f,
|
||||
width,
|
||||
height * .5f + 12f
|
||||
),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
)
|
||||
)
|
||||
)
|
||||
) { EntryEditorScreen(repository, authentication) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithTag("entry-editor")
|
||||
.performScrollToNode(hasText("Generate password"))
|
||||
compose.onNodeWithText("Generate password").performClick()
|
||||
compose.onNodeWithText("Password length").performScrollTo().performTextReplacement("24")
|
||||
compose.onNodeWithTag("hinge-dialog-content").performTouchInput { swipeUp() }
|
||||
compose.onNodeWithText("Use letters and numbers only").performScrollTo()
|
||||
compose.onNodeWithText("Use letters and numbers only").assertIsDisplayed()
|
||||
compose.onNodeWithText("Generate replacement").performScrollTo().performClick()
|
||||
assertEquals(1, repository.generations)
|
||||
assertEquals(24u, repository.length)
|
||||
}
|
||||
|
||||
@Test fun structuredInputsGenerationAndAccessibleReorderForwardTypedIds() {
|
||||
val repository = Fixture()
|
||||
val lease = MutableStateFlow(AuthenticationState(unlocked = true))
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.adaptive.HingeInfo
|
||||
import androidx.compose.material3.adaptive.Posture
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextContains
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
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.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import de.rfc1437.ironstorage.core.HomeActionKind
|
||||
import de.rfc1437.ironstorage.features.HingeSafeLayout
|
||||
import de.rfc1437.ironstorage.features.HomeScreen
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class HingeWindowTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Test fun livePostureChangesKeepContentAndProtectedDialogOutsideTheHinge() {
|
||||
compose.enableAccessibilityChecks()
|
||||
val repository = HomeScreenTest.Fixture()
|
||||
var mode by mutableStateOf(0)
|
||||
var hinge = Rect.Zero
|
||||
var density = 1f
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
density = LocalDensity.current.density
|
||||
val width = maxWidth.value * density
|
||||
val height = maxHeight.value * density
|
||||
hinge = if (mode == 1) {
|
||||
Rect(width * .35f, 0f, width * .35f + 12f, height)
|
||||
} else {
|
||||
Rect(0f, height * .5f, width, height * .5f + 12f)
|
||||
}
|
||||
val posture = Posture(
|
||||
isTabletop = mode == 2,
|
||||
hingeList = if (mode == 0) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(
|
||||
HingeInfo(hinge, false, mode == 1, true, true)
|
||||
)
|
||||
}
|
||||
)
|
||||
MaterialTheme {
|
||||
HingeSafeLayout(posture) { HomeScreen(repository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onAllNodesWithText("Commit")[0].performScrollTo().performClick()
|
||||
compose.onNodeWithText("Commit message").performTextInput("Public posture fixture")
|
||||
for (posture in listOf(1, 2, 0)) {
|
||||
compose.runOnIdle { mode = posture }
|
||||
if (posture != 0) {
|
||||
compose.onNodeWithText("Create commit").performScrollTo()
|
||||
}
|
||||
val action = compose.onNodeWithText("Create commit")
|
||||
action.assertIsDisplayed()
|
||||
if (posture != 0) {
|
||||
val bounds = action.getUnclippedBoundsInRoot()
|
||||
val pixels = Rect(
|
||||
bounds.left.value * density,
|
||||
bounds.top.value * density,
|
||||
bounds.right.value * density,
|
||||
bounds.bottom.value * density
|
||||
)
|
||||
assertTrue("Dialog action must avoid the hinge", !pixels.overlaps(hinge))
|
||||
if (posture == 2) assertTrue(pixels.bottom <= hinge.top)
|
||||
compose.onNodeWithText("Public posture fixture").performScrollTo()
|
||||
}
|
||||
compose.onNodeWithText("Public posture fixture")
|
||||
.assertTextContains("Public posture fixture")
|
||||
}
|
||||
compose.onNodeWithText("Create commit").performClick()
|
||||
assertEquals(
|
||||
HomeActionKind.Commit to "Public posture fixture",
|
||||
repository.requests.single()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
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.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
@@ -93,12 +92,12 @@ class HomeScreenTest {
|
||||
@Test fun swipeRefreshRequestsTheRustRefreshWorkflow() {
|
||||
val repository = Fixture()
|
||||
show(repository)
|
||||
compose.onRoot().performTouchInput { swipeDown() }
|
||||
compose.onNode(hasScrollAction()).performTouchInput { swipeDown() }
|
||||
compose.waitUntil(5000) { repository.refreshes == 1 }
|
||||
assertTrue(repository.requests.isEmpty())
|
||||
compose.onNodeWithText("Cancel").performClick()
|
||||
}
|
||||
private class Fixture(changes: Int = 1) : HomeRepository {
|
||||
internal class Fixture(changes: Int = 1) : HomeRepository {
|
||||
private val actions = HomeActionKind.entries.map {
|
||||
HomeAction(
|
||||
it,
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import androidx.compose.ui.test.swipeRight
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.core.AuthenticationState
|
||||
import de.rfc1437.ironstorage.core.PasswordDestination
|
||||
@@ -38,12 +40,17 @@ import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class MutationWorkflowTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Before fun enableChecks() {
|
||||
compose.enableAccessibilityChecks()
|
||||
}
|
||||
private fun display(fixture: Fixture) {
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
@@ -55,7 +62,10 @@ class MutationWorkflowTest {
|
||||
@Test fun fullSwipeOnlySelectsDeleteAndCancelNeverMutates() {
|
||||
val fixture = Fixture()
|
||||
display(fixture)
|
||||
compose.onNodeWithText("Source").performTouchInput { swipeLeft() }
|
||||
val rtl = InstrumentationRegistry.getArguments().getString("hardeningRtl") == "true"
|
||||
compose.onNodeWithText("Source").performTouchInput {
|
||||
if (rtl) swipeRight() else swipeLeft()
|
||||
}
|
||||
compose.waitUntil(5000) { fixture.state.value.mutation != null }
|
||||
assertEquals(PasswordMutation.Delete, fixture.state.value.mutation!!.action)
|
||||
assertEquals(0, fixture.committed)
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.platform.InterceptPlatformTextInput
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
@@ -49,6 +50,43 @@ class PresentationSecurityTest {
|
||||
compose.enableAccessibilityChecks()
|
||||
}
|
||||
|
||||
@Test fun foregroundCleanupUnmasksOnlyAfterTheCleanGenerationIsComposed() {
|
||||
val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
|
||||
val dirty = mutableStateOf(true)
|
||||
val registration = presentation.register { dirty.value = false }
|
||||
try {
|
||||
compose.runOnIdle {
|
||||
compose.activity.setProtectedTestContent(presentation) {
|
||||
MaterialTheme {
|
||||
Text(
|
||||
if (dirty.value) "Disposable protected fixture" else "Clean foreground"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Disposable protected fixture").assertIsDisplayed()
|
||||
compose.runOnIdle {
|
||||
presentation.clear()
|
||||
assertEquals(
|
||||
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
|
||||
compose.activity.window.decorView.importantForAccessibility
|
||||
)
|
||||
assertFalse(dirty.value)
|
||||
}
|
||||
compose.onNodeWithText("Clean foreground").assertIsDisplayed()
|
||||
compose.onNodeWithText("Disposable protected fixture").assertDoesNotExist()
|
||||
compose.runOnIdle {
|
||||
assertFalse(
|
||||
"The rebuilt foreground surface must be visible to accessibility",
|
||||
compose.activity.window.decorView.importantForAccessibility ==
|
||||
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
registration.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun inspectTaskSwitchingWithDisposableProtectedInput() {
|
||||
assumeTrue(InstrumentationRegistry.getArguments().getString("securityInspect") == "true")
|
||||
val done = CountDownLatch(1)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isHeading
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
@@ -28,9 +29,7 @@ class ShellScreenTest {
|
||||
compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick()
|
||||
ShellTab.entries.forEach { tab ->
|
||||
val page = ShellFixtures.all.getValue(state).first { it.tab == tab }
|
||||
compose.onNode(
|
||||
hasText(page.title) and androidx.compose.ui.test.isSelectable()
|
||||
).performClick()
|
||||
compose.onNodeWithTag("shell-tab-${tab.name}").performClick()
|
||||
compose.waitUntil(timeoutMillis = 10_000) {
|
||||
compose.onAllNodes(hasText(page.stateTitle) and isHeading())
|
||||
.fetchSemanticsNodes().size == 1
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.input.InputModeManager
|
||||
import androidx.compose.ui.input.key.Key
|
||||
@@ -20,6 +24,7 @@ import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -30,10 +35,12 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.AuthenticationState
|
||||
import de.rfc1437.ironstorage.core.ProtectedPresentation
|
||||
import de.rfc1437.ironstorage.features.ApplicationTheme
|
||||
import de.rfc1437.ironstorage.features.SearchScreen
|
||||
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||
import de.rfc1437.ironstorage.features.ShellScreen
|
||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -43,8 +50,32 @@ class Tier2WindowTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Test fun settingsAndKeyboardNavigationFitTheCurrentWindow() {
|
||||
compose.runOnIdle {
|
||||
if (Build.VERSION.SDK_INT >= 27) compose.activity.setTurnScreenOn(true)
|
||||
val keyguard = compose.activity.getSystemService(KeyguardManager::class.java)
|
||||
if (!keyguard.isDeviceSecure) keyguard.requestDismissKeyguard(compose.activity, null)
|
||||
}
|
||||
compose.enableAccessibilityChecks()
|
||||
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
|
||||
if (InstrumentationRegistry.getArguments().getString("hardeningResize") == "true") {
|
||||
val initialWidth = compose.activity.window.decorView.width
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
MaterialTheme { SearchScreen(original.search, {}) }
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Entry name or path").performTextReplacement("appearance")
|
||||
InstrumentationRegistry.getInstrumentation().sendStatus(
|
||||
0,
|
||||
Bundle().apply { putBoolean("ironstorage_resize_ready", true) }
|
||||
)
|
||||
compose.waitUntil(30000) { compose.activity.window.decorView.width != initialWidth }
|
||||
compose.runOnIdle {
|
||||
val current = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
|
||||
assertEquals("appearance", current.search.state.value.query)
|
||||
current.search.clear()
|
||||
}
|
||||
}
|
||||
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
|
||||
val authentication = object : AuthenticationRepository by original.authentication {
|
||||
override val state = MutableStateFlow(AuthenticationState())
|
||||
@@ -72,7 +103,8 @@ class Tier2WindowTest {
|
||||
val width =
|
||||
compose.activity.window.decorView.width /
|
||||
compose.activity.resources.displayMetrics.density
|
||||
val navigation = if (width >= 600) "shell-navigation-rail" else "shell-bottom-navigation"
|
||||
val navigation = InstrumentationRegistry.getArguments().getString("expectedNavigation")
|
||||
?: if (width >= 600) "shell-navigation-rail" else "shell-bottom-navigation"
|
||||
compose.onNodeWithTag(navigation).assertIsDisplayed()
|
||||
compose.onNodeWithTag("settings-search").assertIsDisplayed()
|
||||
if (InstrumentationRegistry.getArguments().getString("hardeningIme") == "true") {
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onLast
|
||||
@@ -36,6 +37,7 @@ import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@@ -43,6 +45,10 @@ import org.junit.Test
|
||||
class TotpScreenTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Before fun enableChecks() {
|
||||
compose.enableAccessibilityChecks()
|
||||
}
|
||||
|
||||
private fun display(fixture: Fixture, fontScale: Float = 1f) {
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
|
||||
+3432
-1116
File diff suppressed because it is too large.
Load diff
+9
@@ -1,5 +1,7 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
||||
@@ -109,6 +111,7 @@ class NativeCredentialProviderTest {
|
||||
emptyList()
|
||||
)
|
||||
}
|
||||
val fillStart = SystemClock.elapsedRealtime()
|
||||
val value = credentials.fill(
|
||||
request.token,
|
||||
caller,
|
||||
@@ -117,6 +120,12 @@ class NativeCredentialProviderTest {
|
||||
passphrase,
|
||||
emptyList()
|
||||
)
|
||||
val fillMs = SystemClock.elapsedRealtime() - fillStart
|
||||
assertTrue("Authenticated provider fill must finish within 5 s", fillMs < 5000)
|
||||
InstrumentationRegistry.getInstrumentation().sendStatus(
|
||||
0,
|
||||
Bundle().apply { putLong("ironstorage_provider_authenticated_fill_ms", fillMs) }
|
||||
)
|
||||
assertEquals("alice", value.username)
|
||||
assertEquals("fixture-password", value.password)
|
||||
assertEquals("CredentialValue([REDACTED])", value.toString())
|
||||
|
||||
+8
-8
@@ -270,24 +270,24 @@ class NativeEntryViewerTest {
|
||||
repeat(50) { directory ->
|
||||
val folder = File(vault, "load/slot-${directory.toString().padStart(3, '0')}")
|
||||
assertTrue(folder.mkdirs())
|
||||
repeat(20) { entry -> File(folder, "entry-$entry.gpg").writeBytes(ciphertext) }
|
||||
repeat(200) { entry -> File(folder, "entry-$entry.gpg").writeBytes(ciphertext) }
|
||||
}
|
||||
val storage = session.storage()
|
||||
val navigateStart = android.os.SystemClock.elapsedRealtime()
|
||||
assertEquals(50, storage.passwordNavigation("load", null, null, false).rows.size)
|
||||
assertEquals(
|
||||
20,
|
||||
200,
|
||||
storage.passwordNavigation("load/slot-000", null, null, false).rows.size
|
||||
)
|
||||
val navigateMs = android.os.SystemClock.elapsedRealtime() - navigateStart
|
||||
assertTrue("1000-entry navigation must finish in 3 s", navigateMs < 3000)
|
||||
assertTrue("10000-entry navigation must finish in 3 s", navigateMs < 3000)
|
||||
val searchStart = android.os.SystemClock.elapsedRealtime()
|
||||
assertEquals(1000, storage.passwordNameSearch("load").size)
|
||||
assertEquals(20, storage.passwordNameSearch("load/slot-019").size)
|
||||
assertEquals(10000, storage.passwordNameSearch("load").size)
|
||||
assertEquals(200, storage.passwordNameSearch("load/slot-019").size)
|
||||
val searchMs = android.os.SystemClock.elapsedRealtime() - searchStart
|
||||
assertTrue("1000-entry name search must finish in 5 s", searchMs < 5000)
|
||||
performance("tree_1000_navigation_ms", navigateMs)
|
||||
performance("tree_1000_search_ms", searchMs)
|
||||
assertTrue("10000-entry name search must finish in 5 s", searchMs < 5000)
|
||||
performance("tree_10000_navigation_ms", navigateMs)
|
||||
performance("tree_10000_search_ms", searchMs)
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { repository.lock() }
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryPhase
|
||||
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeTotpPerformanceTest {
|
||||
@Test fun cachedCatalogAndProviderStayResponsiveWhileLocked(): Unit = runBlocking {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
|
||||
File(it, NativeEntryViewerTest.rootName).apply { mkdirs() }
|
||||
}
|
||||
val session = NativePrivateSession(context, {
|
||||
PrivateDirectories(
|
||||
roots[0].canonicalPath,
|
||||
roots[1].canonicalPath,
|
||||
roots[2].canonicalPath
|
||||
)
|
||||
}, NativeEntryViewerTest.MemoryKeystore())
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
val auth = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
|
||||
val passphrase = "public-totp-performance-fixture"
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
session.initialize()
|
||||
val setup = session.storage().androidOnboarding(session.backend())
|
||||
val key =
|
||||
requireNotNull(setup.generate("Performance fixture", passphrase).selectedKey)
|
||||
setup.setupLocal(key, passphrase)
|
||||
}
|
||||
auth.authenticated { native ->
|
||||
val editor = native.beginCreateEntry("", "seed", passphrase)
|
||||
val page = native.addEntryEditorField(
|
||||
editor.id,
|
||||
MobileEntryEditorFieldKind.OTP_URI,
|
||||
null,
|
||||
"otpauth://totp/Public:seed?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||
)
|
||||
native.saveEntryEditor(
|
||||
editor.id,
|
||||
page.fields.map {
|
||||
MobileEntryEditorInput(it.id, it.name, it.value.orEmpty())
|
||||
}
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
// Replicate only Rust-encrypted public fixture bytes in this isolated test root.
|
||||
val vault = File(roots[1], "ironstorage/vault")
|
||||
val ciphertext = File(vault, "seed.gpg").readBytes()
|
||||
repeat(99) { File(vault, "seed-$it.gpg").writeBytes(ciphertext) }
|
||||
}
|
||||
auth.authenticated { native ->
|
||||
mobileTotpOperation().use { operation ->
|
||||
assertEquals(100u, native.refreshTotpCatalog(operation, null).progress.matches)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main.immediate) { auth.lock() }
|
||||
measure("totp_cached_open_p95_us", 1_000_000) {
|
||||
auth.metadata { native ->
|
||||
mobileTotpOperation().use { operation ->
|
||||
assertEquals(100, native.searchCachedTotpNavigation("", operation)!!.size)
|
||||
}
|
||||
}
|
||||
assertFalse(auth.state.value.unlocked)
|
||||
}
|
||||
measure("totp_warm_refresh_p95_us", 3_000_000) {
|
||||
auth.metadata { native ->
|
||||
mobileTotpOperation().use { operation ->
|
||||
val progress = native.refreshTotpCatalog(operation, null).progress
|
||||
assertEquals(MobileTotpDiscoveryPhase.COMPLETE, progress.phase)
|
||||
assertEquals(100u, progress.cacheHits)
|
||||
assertEquals(100u, progress.matches)
|
||||
}
|
||||
}
|
||||
assertFalse(auth.state.value.unlocked)
|
||||
}
|
||||
val credentials = NativeCredentialRepository(auth)
|
||||
val caller =
|
||||
CredentialCaller("public.performance.client", listOf("AA".repeat(32)), null)
|
||||
measure("provider_prepare_p95_us", 1_000_000) {
|
||||
val request = credentials.begin(caller)
|
||||
try {
|
||||
assertEquals(100, request.candidates.size)
|
||||
assertFalse(auth.state.value.unlocked)
|
||||
} finally {
|
||||
credentials.cancel(request.token)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { scope.cancel() }
|
||||
withContext(Dispatchers.IO) { roots.forEach { it.deleteRecursively() } }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun measure(name: String, budgetUs: Long, action: suspend () -> Unit) {
|
||||
repeat(10) { action() }
|
||||
val samples = LongArray(30)
|
||||
repeat(samples.size) { index ->
|
||||
val start = SystemClock.elapsedRealtimeNanos()
|
||||
action()
|
||||
samples[index] = (SystemClock.elapsedRealtimeNanos() - start) / 1000
|
||||
}
|
||||
samples.sort()
|
||||
val p95 = samples[28]
|
||||
assertTrue("$name must finish within $budgetUs microseconds", p95 < budgetUs)
|
||||
InstrumentationRegistry.getInstrumentation().sendStatus(
|
||||
0,
|
||||
Bundle().apply { putLong("ironstorage_$name", p95) }
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -2,8 +2,9 @@ package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -15,6 +16,7 @@ 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.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -108,7 +110,7 @@ internal fun PassphraseDialog(
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { secret = "" } }
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = {
|
||||
secret = ""
|
||||
@@ -116,7 +118,10 @@ internal fun PassphraseDialog(
|
||||
},
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ProtectedInputSurface(presentation)
|
||||
Text(detail)
|
||||
OutlinedTextField(
|
||||
|
||||
+102
-98
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -45,108 +46,111 @@ fun CredentialSelectionScreen(
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(
|
||||
if (saving) R.string.provider_save_title else R.string.provider_fill_title
|
||||
),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(request?.title ?: stringResource(R.string.provider_checking))
|
||||
}
|
||||
if (saving) {
|
||||
HingeSafeLayout {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
destination,
|
||||
onDestination,
|
||||
label = { Text(stringResource(R.string.provider_destination)) },
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
ProviderConsent(
|
||||
overwrite,
|
||||
!busy,
|
||||
stringResource(R.string.provider_overwrite),
|
||||
onOverwrite
|
||||
)
|
||||
}
|
||||
} else if (selected == null) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
query,
|
||||
onQuery,
|
||||
label = { Text(stringResource(R.string.provider_search)) },
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
items(request?.candidates ?: emptyList(), key = { it.path }) { candidate ->
|
||||
TextButton(onClick = {
|
||||
onSelect(candidate.path)
|
||||
}, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(candidate.path)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
Text(stringResource(R.string.provider_selected, selected))
|
||||
TextButton(onClick = { onSelect(null) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.provider_choose_another))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (saving || selected != null) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
passphrase,
|
||||
onPassphrase,
|
||||
label = { Text(stringResource(R.string.provider_passphrase)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
val recipient = request?.title ?: stringResource(R.string.provider_this_application)
|
||||
val confirmation = if (saving) {
|
||||
stringResource(
|
||||
R.string.provider_confirm_save
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.provider_confirm_fill, recipient)
|
||||
}
|
||||
ProviderConsent(consent, !busy, confirmation, onConsent)
|
||||
}
|
||||
}
|
||||
item {
|
||||
failure?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = !busy && consent && request != null &&
|
||||
(if (saving) destination.isNotBlank() else selected != null)
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (busy) {
|
||||
R.string.provider_working
|
||||
} else if (saving) {
|
||||
R.string.provider_save
|
||||
} else {
|
||||
R.string.provider_fill
|
||||
}
|
||||
)
|
||||
if (saving) R.string.provider_save_title else R.string.provider_fill_title
|
||||
),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(request?.title ?: stringResource(R.string.provider_checking))
|
||||
}
|
||||
if (saving) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
destination,
|
||||
onDestination,
|
||||
label = { Text(stringResource(R.string.provider_destination)) },
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
ProviderConsent(
|
||||
overwrite,
|
||||
!busy,
|
||||
stringResource(R.string.provider_overwrite),
|
||||
onOverwrite
|
||||
)
|
||||
}
|
||||
} else if (selected == null) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
query,
|
||||
onQuery,
|
||||
label = { Text(stringResource(R.string.provider_search)) },
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
items(request?.candidates ?: emptyList(), key = { it.path }) { candidate ->
|
||||
TextButton(onClick = {
|
||||
onSelect(candidate.path)
|
||||
}, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(candidate.path)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
Text(stringResource(R.string.provider_selected, selected))
|
||||
TextButton(onClick = { onSelect(null) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.provider_choose_another))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (saving || selected != null) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
passphrase,
|
||||
onPassphrase,
|
||||
label = { Text(stringResource(R.string.provider_passphrase)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
val recipient =
|
||||
request?.title ?: stringResource(R.string.provider_this_application)
|
||||
val confirmation = if (saving) {
|
||||
stringResource(
|
||||
R.string.provider_confirm_save
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.provider_confirm_fill, recipient)
|
||||
}
|
||||
ProviderConsent(consent, !busy, confirmation, onConsent)
|
||||
}
|
||||
}
|
||||
item {
|
||||
failure?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = !busy && consent && request != null &&
|
||||
(if (saving) destination.isNotBlank() else selected != null)
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (busy) {
|
||||
R.string.provider_working
|
||||
} else if (saving) {
|
||||
R.string.provider_save
|
||||
} else {
|
||||
R.string.provider_fill
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onCancel) { Text(stringResource(R.string.auth_cancel)) }
|
||||
}
|
||||
TextButton(onClick = onCancel) { Text(stringResource(R.string.auth_cancel)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +163,7 @@ private fun ProviderConsent(
|
||||
onChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(
|
||||
Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
|
||||
value = checked,
|
||||
enabled = enabled,
|
||||
role = Role.Checkbox,
|
||||
|
||||
+14
-8
@@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -287,11 +288,16 @@ fun EditorDiscardDialog(repository: EditorRepository) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
if (!state.discardRequested) return
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = repository::keepEditing,
|
||||
title = { Text(stringResource(R.string.editor_discard_title)) },
|
||||
text = { Text(stringResource(R.string.editor_discard_detail)) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.editor_discard_detail),
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = repository::confirmDiscard) {
|
||||
Text(stringResource(R.string.editor_discard))
|
||||
@@ -320,7 +326,7 @@ private fun EditorAddDialog(
|
||||
var name by rememberProtectedInput(authentication.presentation)
|
||||
var value by rememberProtectedInput(authentication.presentation)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = {
|
||||
@@ -338,7 +344,7 @@ private fun EditorAddDialog(
|
||||
},
|
||||
text = {
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
Column {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (kind ==
|
||||
EditorFieldKind.Field
|
||||
@@ -399,12 +405,12 @@ private fun EditorGenerateDialog(
|
||||
var noSymbols by remember { mutableStateOf(false) }
|
||||
val noSymbolsLabel = stringResource(R.string.editor_no_symbols)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = { Text(stringResource(R.string.editor_generate)) },
|
||||
text = {
|
||||
Column {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(stringResource(R.string.editor_generate_detail))
|
||||
OutlinedTextField(
|
||||
length,
|
||||
@@ -466,7 +472,7 @@ internal fun EditorCreationDialog(
|
||||
) {
|
||||
var name by rememberProtectedInput(authentication.presentation)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = {
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -73,7 +72,7 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
|
||||
TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) }
|
||||
if (edit) {
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
onDismissRequest = { edit = false },
|
||||
title = { Text(stringResource(R.string.entry_edit)) },
|
||||
text = {
|
||||
@@ -215,12 +214,12 @@ fun EntryDialogs(
|
||||
}
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
onDismissRequest = repository::dismissEdit,
|
||||
title = { Text(edit.label) },
|
||||
text = {
|
||||
CompositionLocalProvider(LocalClipboard provides clipboard) {
|
||||
Column {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
OutlinedTextField(
|
||||
value,
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
internal fun HingeSafeAlertDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
confirmButton: @Composable () -> Unit,
|
||||
dismissButton: (@Composable () -> Unit)? = null,
|
||||
title: (@Composable () -> Unit)? = null,
|
||||
text: (@Composable () -> Unit)? = null,
|
||||
properties: DialogProperties = DialogProperties()
|
||||
) {
|
||||
val posture = LocalWindowPosture.current ?: currentWindowAdaptiveInfoV2().windowPosture
|
||||
if (posture.hingeList.none { it.isSeparating || it.isOccluding }) {
|
||||
AlertDialog(
|
||||
onDismissRequest,
|
||||
confirmButton,
|
||||
dismissButton = dismissButton,
|
||||
title = title,
|
||||
text = text,
|
||||
properties = properties
|
||||
)
|
||||
return
|
||||
}
|
||||
Dialog(
|
||||
onDismissRequest,
|
||||
DialogProperties(
|
||||
dismissOnBackPress = properties.dismissOnBackPress,
|
||||
dismissOnClickOutside = properties.dismissOnClickOutside,
|
||||
securePolicy = properties.securePolicy,
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
ProtectedInputSurface()
|
||||
HingeSafeLayout(posture) {
|
||||
val paneHeight = maxHeight
|
||||
Box(
|
||||
Modifier.fillMaxSize().systemBarsPadding().imePadding(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
Modifier.padding(16.dp).widthIn(max = 560.dp).fillMaxWidth()
|
||||
.heightIn(max = (paneHeight - 32.dp).coerceAtLeast(0.dp)),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(24.dp).verticalScroll(rememberScrollState())
|
||||
.testTag("hinge-dialog-content"),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
title?.let {
|
||||
Box(Modifier.semantics { heading() }) {
|
||||
ProvideTextStyle(MaterialTheme.typography.headlineSmall) {
|
||||
it()
|
||||
}
|
||||
}
|
||||
}
|
||||
text?.let {
|
||||
Box(
|
||||
Modifier.heightIn(
|
||||
max = (paneHeight - 80.dp).coerceAtLeast(0.dp)
|
||||
)
|
||||
) {
|
||||
ProvideTextStyle(MaterialTheme.typography.bodyMedium) { it() }
|
||||
}
|
||||
}
|
||||
FlowRow(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||
) {
|
||||
dismissButton?.invoke()
|
||||
confirmButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.adaptive.Posture
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal val LocalWindowPosture = staticCompositionLocalOf<Posture?> { null }
|
||||
|
||||
/** Keep tabletop content above the fold; otherwise use the largest unobscured pane. */
|
||||
@Composable
|
||||
fun HingeSafeLayout(
|
||||
posture: Posture? = null,
|
||||
content: @Composable BoxWithConstraintsScope.() -> Unit
|
||||
) {
|
||||
val current =
|
||||
posture ?: LocalWindowPosture.current ?: currentWindowAdaptiveInfoV2().windowPosture
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxSize().onGloballyPositioned { origin = it.positionInWindow() }
|
||||
) {
|
||||
val bounds = with(density) { Rect(origin, Size(maxWidth.toPx(), maxHeight.toPx())) }
|
||||
val pane = hingeSafeBounds(bounds, current)
|
||||
val modifier = with(density) {
|
||||
Modifier.absoluteOffset(
|
||||
(pane.left - bounds.left).toDp(),
|
||||
(pane.top - bounds.top).toDp()
|
||||
)
|
||||
.size(pane.width.toDp(), pane.height.toDp())
|
||||
.consumeWindowInsets(
|
||||
WindowInsets(
|
||||
left = (pane.left - bounds.left).roundToInt(),
|
||||
top = (pane.top - bounds.top).roundToInt(),
|
||||
right = (bounds.right - pane.right).roundToInt(),
|
||||
bottom = (bounds.bottom - pane.bottom).roundToInt()
|
||||
)
|
||||
)
|
||||
}
|
||||
CompositionLocalProvider(LocalWindowPosture provides current) {
|
||||
BoxWithConstraints(modifier) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun hingeSafeBounds(bounds: Rect, posture: Posture): Rect {
|
||||
var panes = listOf(bounds)
|
||||
for (hinge in posture.hingeList.filter { it.isSeparating || it.isOccluding }) {
|
||||
panes = panes.flatMap { pane ->
|
||||
if (!pane.overlaps(hinge.bounds)) {
|
||||
listOf(pane)
|
||||
} else if (hinge.isVertical) {
|
||||
listOf(
|
||||
Rect(
|
||||
pane.left,
|
||||
pane.top,
|
||||
hinge.bounds.left.coerceIn(pane.left, pane.right),
|
||||
pane.bottom
|
||||
),
|
||||
Rect(
|
||||
hinge.bounds.right.coerceIn(pane.left, pane.right),
|
||||
pane.top,
|
||||
pane.right,
|
||||
pane.bottom
|
||||
)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Rect(
|
||||
pane.left,
|
||||
pane.top,
|
||||
pane.right,
|
||||
hinge.bounds.top.coerceIn(pane.top, pane.bottom)
|
||||
),
|
||||
Rect(
|
||||
pane.left,
|
||||
hinge.bounds.bottom.coerceIn(pane.top, pane.bottom),
|
||||
pane.right,
|
||||
pane.bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}.filter { it.width > 0 && it.height > 0 }
|
||||
}
|
||||
val pane = if (posture.isTabletop) {
|
||||
panes.minWithOrNull(compareBy<Rect> { it.top }.thenByDescending { it.width * it.height })
|
||||
} else {
|
||||
panes.maxByOrNull { it.width * it.height }
|
||||
}
|
||||
return pane ?: Rect(bounds.topLeft, Size.Zero)
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -208,7 +209,7 @@ fun HomeScreen(
|
||||
}
|
||||
if (committing) {
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
onDismissRequest = {
|
||||
committing = false
|
||||
message = ""
|
||||
@@ -216,7 +217,7 @@ fun HomeScreen(
|
||||
title = { Text(stringResource(R.string.home_commit_title)) },
|
||||
text = {
|
||||
ProtectedInputSurface()
|
||||
Column {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(state.name)
|
||||
Text(state.email)
|
||||
OutlinedTextField(
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
@@ -67,7 +66,7 @@ internal fun MutationDialog(
|
||||
if (lease.busy) authentication.cancel()
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = cancel,
|
||||
title = { Text(stringResource(plan.action.label(plan.kind))) },
|
||||
@@ -112,7 +111,9 @@ internal fun MutationDialog(
|
||||
)
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(discard, role = Role.Checkbox) {
|
||||
Modifier.fillMaxWidth().heightIn(
|
||||
min = 48.dp
|
||||
).toggleable(discard, role = Role.Checkbox) {
|
||||
discard = it
|
||||
}
|
||||
) {
|
||||
@@ -127,7 +128,7 @@ internal fun MutationDialog(
|
||||
if (plan.action != PasswordMutation.Delete) {
|
||||
items(plan.destinations, key = { it.path }) { candidate ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(
|
||||
Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(
|
||||
start = (
|
||||
candidate.depth.coerceAtMost(12u).toInt() *
|
||||
12
|
||||
@@ -162,7 +163,7 @@ internal fun MutationDialog(
|
||||
Text(stringResource(R.string.mutation_collision))
|
||||
target.collisionTargets.forEach { Text(it) }
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(
|
||||
Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
|
||||
overwrite,
|
||||
role = Role.Checkbox
|
||||
) {
|
||||
|
||||
+6
-3
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
@@ -127,7 +128,7 @@ fun OnboardingScreen(
|
||||
Text(stringResource(R.string.setup_choose_key))
|
||||
state.keys.forEach { item ->
|
||||
Row(
|
||||
Modifier.selectable(
|
||||
Modifier.heightIn(min = 48.dp).selectable(
|
||||
selected = selectedKey == item.fingerprint,
|
||||
role = Role.RadioButton,
|
||||
onClick = { key = item.fingerprint }
|
||||
@@ -253,7 +254,7 @@ fun OnboardingScreen(
|
||||
"branch" -> {
|
||||
state.branches.forEach { item ->
|
||||
Row(
|
||||
Modifier.selectable(
|
||||
Modifier.heightIn(min = 48.dp).selectable(
|
||||
selected = selectedBranch == item,
|
||||
role = Role.RadioButton,
|
||||
onClick = { branch = item }
|
||||
@@ -268,7 +269,9 @@ fun OnboardingScreen(
|
||||
it
|
||||
}
|
||||
Row(
|
||||
Modifier.toggleable(value = reuse, role = Role.Checkbox, onValueChange = {
|
||||
Modifier.heightIn(
|
||||
min = 48.dp
|
||||
).toggleable(value = reuse, role = Role.Checkbox, onValueChange = {
|
||||
reuse =
|
||||
it
|
||||
})
|
||||
|
||||
@@ -4,8 +4,9 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -138,11 +139,16 @@ internal fun OtpCodeView(
|
||||
}
|
||||
if (confirmation) {
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = { confirmation = false },
|
||||
title = { Text(stringResource(R.string.otp_generate_hotp)) },
|
||||
text = { Text(stringResource(R.string.otp_confirm_hotp, code.nextCounter.toString())) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.otp_confirm_hotp, code.nextCounter.toString()),
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = !busy, onClick = {
|
||||
confirmation = false
|
||||
|
||||
@@ -13,7 +13,8 @@ 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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -432,14 +433,14 @@ fun PasswordScreen(
|
||||
if (actions && state.selected != null && !state.loading) {
|
||||
val selected = requireNotNull(state.selected)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
onDismissRequest = { actions = false },
|
||||
title = {
|
||||
Text(selected.title)
|
||||
},
|
||||
text = {
|
||||
ProtectedInputSurface()
|
||||
Column {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(selected.detail + "\n" + selected.path)
|
||||
PasswordMutation.entries.forEach { action ->
|
||||
TextButton(onClick = {
|
||||
|
||||
@@ -44,7 +44,8 @@ fun ProtectedContent(presentation: ProtectedPresentation, content: @Composable (
|
||||
val view = LocalView.current.rootView
|
||||
val surface = remember(view) { protectedSurface(view) }
|
||||
val focused = remember(view) { mutableStateOf(view.hasWindowFocus()) }
|
||||
val generation by presentation.generation.collectAsState()
|
||||
// Read here so cleanup also reruns the parent surface's reveal effect.
|
||||
val generation = presentation.generation.collectAsState().value
|
||||
val lifecycle by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
|
||||
DisposableEffect(presentation, view) {
|
||||
val registration = presentation.register(surface::cover)
|
||||
@@ -77,7 +78,7 @@ fun ProtectedContent(presentation: ProtectedPresentation, content: @Composable (
|
||||
}
|
||||
val revealCleanContent = focused.value && lifecycle.isAtLeast(Lifecycle.State.RESUMED)
|
||||
SideEffect {
|
||||
if (revealCleanContent) {
|
||||
if (revealCleanContent && generation == presentation.generation.value) {
|
||||
surface.uncover()
|
||||
} else {
|
||||
surface.cover()
|
||||
|
||||
@@ -8,13 +8,13 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -308,7 +308,7 @@ fun SettingsScreen(
|
||||
resources.openRawResource(R.raw.license).bufferedReader().use { it.readText() }
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
onDismissRequest = {
|
||||
licenses = false
|
||||
},
|
||||
@@ -358,7 +358,7 @@ private fun SettingsEditDialog(
|
||||
timeout = state.page?.timeout?.toString().orEmpty()
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = {
|
||||
@@ -455,7 +455,7 @@ internal fun KeyTransferDialog(
|
||||
}
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(
|
||||
securePolicy = SecureFlagPolicy.SecureOn,
|
||||
usePlatformDefaultWidth = false
|
||||
@@ -475,7 +475,7 @@ internal fun KeyTransferDialog(
|
||||
if (!state.transfer.setup) {
|
||||
state.transfer.keys.forEach { key ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().selectable(
|
||||
Modifier.fillMaxWidth().heightIn(min = 48.dp).selectable(
|
||||
selected = fingerprint == key.fingerprint,
|
||||
enabled = !state.busy,
|
||||
role = Role.RadioButton,
|
||||
@@ -675,7 +675,7 @@ internal fun KeyTransferDialog(
|
||||
@Composable
|
||||
private fun SettingsToggle(label: String, checked: Boolean, change: (Boolean) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(
|
||||
Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
|
||||
value = checked,
|
||||
role = Role.Checkbox,
|
||||
onValueChange = change
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -78,7 +77,7 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
listOf(route)
|
||||
}
|
||||
// Actual window bounds, rather than device type, also handle desktop and split-screen resize.
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
HingeSafeLayout {
|
||||
val rail = maxWidth >= 600.dp
|
||||
val largeFont = LocalDensity.current.fontScale >= 1.6f
|
||||
Scaffold(
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.material3.adaptive.HingeInfo
|
||||
import androidx.compose.material3.adaptive.Posture
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class HingeSafeLayoutTest {
|
||||
private val window = Rect(10f, 20f, 1010f, 820f)
|
||||
|
||||
@Test fun bookTabletopAndZeroWidthSeparatorsKeepTheLargestPane() {
|
||||
assertEquals(
|
||||
Rect(430f, 20f, 1010f, 820f),
|
||||
boundsFor(listOf(hinge(Rect(410f, 20f, 430f, 820f), true)))
|
||||
)
|
||||
assertEquals(
|
||||
Rect(10f, 340f, 1010f, 820f),
|
||||
boundsFor(listOf(hinge(Rect(10f, 320f, 1010f, 340f), false)))
|
||||
)
|
||||
assertEquals(
|
||||
Rect(410f, 20f, 1010f, 820f),
|
||||
boundsFor(listOf(hinge(Rect(410f, 20f, 410f, 820f), true)))
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun flatCreasesAndHingesOutsideTheWindowDoNotReduceUsableSpace() {
|
||||
assertEquals(window, boundsFor(emptyList()))
|
||||
assertEquals(
|
||||
window,
|
||||
boundsFor(listOf(HingeInfo(window, true, true, false, false)))
|
||||
)
|
||||
assertEquals(
|
||||
window,
|
||||
boundsFor(listOf(hinge(Rect(1500f, 0f, 1520f, 900f), true)))
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun multipleOccludingHingesNeverReturnAnOccludedPane() {
|
||||
assertEquals(
|
||||
Rect(430f, 340f, 1010f, 820f),
|
||||
boundsFor(
|
||||
listOf(
|
||||
hinge(Rect(410f, 20f, 430f, 820f), true),
|
||||
hinge(Rect(10f, 320f, 1010f, 340f), false)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun tabletopKeepsContentAboveTheFoldEvenWhenTheLowerPaneIsLarger() {
|
||||
assertEquals(
|
||||
Rect(10f, 20f, 1010f, 320f),
|
||||
hingeSafeBounds(
|
||||
window,
|
||||
Posture(true, listOf(hinge(Rect(10f, 320f, 1010f, 340f), false)))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun boundsFor(hinges: List<HingeInfo>) =
|
||||
hingeSafeBounds(window, Posture(hingeList = hinges))
|
||||
|
||||
private fun hinge(bounds: Rect, vertical: Boolean) =
|
||||
HingeInfo(bounds, false, vertical, true, true)
|
||||
}
|
||||
+13
@@ -29,5 +29,18 @@ class BaselineProfile {
|
||||
"Searchable settings must finish loading",
|
||||
device.wait(Until.findObject(By.text("Search settings")), 10000)
|
||||
)
|
||||
for ((destination, ready) in listOf(
|
||||
"Passwords" to "Password Store",
|
||||
"Search" to "Entry name or path",
|
||||
"TOTP" to "Search cached TOTP entries"
|
||||
)) {
|
||||
val navigation = device.wait(Until.findObject(By.text(destination)), 10000)
|
||||
assertNotNull("Cached navigation must be available", navigation)
|
||||
navigation.click()
|
||||
assertNotNull(
|
||||
"Cached destination must finish loading",
|
||||
device.wait(Until.findObject(By.text(ready)), 10000)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -1,5 +1,6 @@
|
||||
package de.rfc1437.ironstorage.platform
|
||||
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyInfo
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.filters.SdkSuppress
|
||||
@@ -35,7 +36,7 @@ class AndroidKeystoreTest {
|
||||
) = KeystoreKeySpecification(
|
||||
alias,
|
||||
auth,
|
||||
true,
|
||||
Build.VERSION.SDK_INT >= 28,
|
||||
auth == KeystoreAuthorization.StrongBiometric,
|
||||
false
|
||||
)
|
||||
@@ -51,6 +52,23 @@ class AndroidKeystoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SdkSuppress(maxSdkVersion = 27)
|
||||
fun unsupportedUnlockedDeviceFlagDoesNotCreateAKey() {
|
||||
val os = AndroidKeystoreAdapter(context)
|
||||
val alias = alias()
|
||||
try {
|
||||
failure(KeystoreFailure.UnsupportedProtection) {
|
||||
os.createKey(
|
||||
KeystoreKeySpecification(alias, KeystoreAuthorization.None, true, false, false)
|
||||
)
|
||||
}
|
||||
assertFalse(os.aliases().contains(alias))
|
||||
} finally {
|
||||
os.deleteKey(alias)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun deviceUnlockedRoundtripReplaceDeleteAndTamper() {
|
||||
val os = AndroidKeystoreAdapter(context)
|
||||
assertFalse(os.deviceState().locked)
|
||||
|
||||
+35
@@ -2,16 +2,51 @@ package de.rfc1437.ironstorage.platform
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AndroidQrCameraTest {
|
||||
@Test fun backgroundStopsAnalysisAndResumeRestartsPreview() {
|
||||
val frames = AtomicInteger()
|
||||
val received = AtomicReference(CountDownLatch(1))
|
||||
ActivityScenario.launch(KeystoreTestActivity::class.java).use { activity ->
|
||||
var camera: AndroidQrCamera? = null
|
||||
activity.onActivity { host ->
|
||||
camera = AndroidQrCamera(host, { frame ->
|
||||
try {
|
||||
frames.incrementAndGet()
|
||||
received.get().countDown()
|
||||
} finally {
|
||||
frame.pixels.fill(0)
|
||||
}
|
||||
}, { throw AssertionError("CameraX failed to bind") })
|
||||
host.setContentView(requireNotNull(camera).view)
|
||||
}
|
||||
try {
|
||||
assertTrue(received.get().await(15, TimeUnit.SECONDS))
|
||||
activity.moveToState(Lifecycle.State.CREATED)
|
||||
Thread.sleep(500)
|
||||
val stopped = frames.get()
|
||||
Thread.sleep(500)
|
||||
assertEquals("Background camera analysis must stop", stopped, frames.get())
|
||||
received.set(CountDownLatch(1))
|
||||
activity.moveToState(Lifecycle.State.RESUMED)
|
||||
assertTrue("Camera preview must resume", received.get().await(15, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
activity.onActivity { camera?.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun livePreviewDeliversBoundedPlanesAndCanBeClosedAndReopened() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals(
|
||||
|
||||
Reference in new issue
Block a user