Complete Android adaptive and accessibility hardening (#105)
Android ARM verification / rust (push) Failing after 50m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Failing after 22s

This commit is contained in:
hugo committed 2026-10-15 11:44:31 +02:00
1 parent f4a6edbc68
commit 45d78aa5b3
33 files changed
+4596 -1376

No files matched your search

@@ -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(
@@ -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,
@@ -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,
@@ -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
) {
@@ -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(
@@ -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)
}