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
+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)
|
||||
}
|
||||
Reference in new issue
Block a user