Merge pull request #6944 from element-hq/feature/custom-recovery-key-wellknown

Add well-known parsing and extension seam for custom recovery passphrase
This commit is contained in:
Benoit Marty
2026-06-17 13:58:22 +02:00
committed by GitHub
29 changed files with 525 additions and 116 deletions
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.modifiers
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.focus.onFocusChanged
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
/**
* Keeps the focused field visible above the keyboard. Intended for a field inside a scrollable
* container: the field is never clipped by a pinned footer, but the IME is shown only after focus
* arrives, so we re-request bringIntoView once it is visible to scroll the field back into the
* reduced viewport.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Modifier.bringIntoViewOnImeVisible(): Modifier {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var isFocused by remember { mutableStateOf(false) }
val isImeVisible = WindowInsets.isImeVisible
LaunchedEffect(isImeVisible, isFocused) {
if (isImeVisible && isFocused) {
// Delay to ensure the keyboard is fully shown before scrolling the field into view.
delay(100.milliseconds)
bringIntoViewRequester.bringIntoView()
}
}
return this
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged { isFocused = it.isFocused }
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Show/hide toggle for a password [TextField], intended for its `trailingIcon` slot.
* Shared so every password field reveals plaintext the same way and announces the
* same accessibility labels.
*/
@Composable
fun PasswordVisibilityToggle(
visible: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier.clickable(onClick = onToggle)) {
Icon(
imageVector = if (visible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff(),
contentDescription = stringResource(
if (visible) CommonStrings.a11y_hide_password else CommonStrings.a11y_show_password
),
)
}
}
@@ -18,39 +18,35 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.theme.components.Snackbar
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
*
* The current head of the queue is exposed as a [MutableStateFlow] so that every collector — including
* hosts that subscribe *after* a message was posted (e.g. a screen recomposing as a flow pops back to
* it) — observes the current message. An earlier mutex-gated implementation delivered each message to
* whichever single collector happened to be parked on the lock, which could starve the host that was
* actually on screen, dropping the snackbar.
*/
class SnackbarDispatcher {
private val queueMutex = Mutex()
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
val snackbarMessage: Flow<SnackbarMessage?> = flow {
while (currentCoroutineContext().isActive) {
queueMutex.lock()
emit(snackBarMessageQueue.firstOrNull())
}
}
private val currentMessage = MutableStateFlow<SnackbarMessage?>(null)
val snackbarMessage: Flow<SnackbarMessage?> = currentMessage.asStateFlow()
@Synchronized
fun post(message: SnackbarMessage) {
if (snackBarMessageQueue.isEmpty()) {
snackBarMessageQueue.add(message)
if (queueMutex.isLocked) queueMutex.unlock()
} else {
snackBarMessageQueue.add(message)
}
snackBarMessageQueue.add(message)
currentMessage.value = snackBarMessageQueue.firstOrNull()
}
@Synchronized
fun clear() {
if (snackBarMessageQueue.isNotEmpty()) {
snackBarMessageQueue.removeFirstOrNull()
if (queueMutex.isLocked) queueMutex.unlock()
}
snackBarMessageQueue.removeFirstOrNull()
currentMessage.value = snackBarMessageQueue.firstOrNull()
}
}
@@ -54,6 +54,27 @@ class SnackbarDispatcherTest {
}
}
@Test
fun `a host that subscribes after a message is posted still receives it`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val message = SnackbarMessage(0)
// A first host is already collecting (mirrors a background screen still subscribed).
snackbarDispatcher.snackbarMessage.test {
assertThat(awaitItem()).isNull()
snackbarDispatcher.post(message)
assertThat(awaitItem()).isEqualTo(message)
// A second host subscribes only afterwards — e.g. the screen a flow pops back to once the
// message has already been posted. It must observe the current message rather than being
// starved waiting for the next post (regression: an earlier mutex-gated implementation
// delivered each post to a single parked collector, dropping the snackbar for any host
// that subscribed later).
snackbarDispatcher.snackbarMessage.test {
assertThat(awaitItem()).isEqualTo(message)
}
}
}
@Test
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()