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()
@@ -24,9 +24,15 @@ interface EncryptionService {
suspend fun enableBackups(): Result<Unit>
/**
* Enable recovery. Observe enableProgressStateFlow to get progress and recovery key.
* Enable recovery and return the SDK-generated recovery key on success.
* Observe [enableRecoveryProgressStateFlow] for in-progress UI updates.
*
* @param waitForBackupsToUpload when true, suspends until existing room keys finish uploading.
* @param passphrase optional user-supplied passphrase. When set, the SDK derives the
* secret-storage key from it instead of a random base58 key; the passphrase can later be
* passed to [recover], and the returned base58 key should not be surfaced to the user.
*/
suspend fun enableRecovery(waitForBackupsToUpload: Boolean): Result<Unit>
suspend fun enableRecovery(waitForBackupsToUpload: Boolean, passphrase: String? = null): Result<String>
/**
* Change the recovery and return the new recovery key.
@@ -131,19 +131,28 @@ class RustEncryptionService(
override suspend fun enableRecovery(
waitForBackupsToUpload: Boolean,
): Result<Unit> = withContext(dispatchers.io) {
passphrase: String?,
): Result<String> = withContext(dispatchers.io) {
runCatchingExceptions {
service.enableRecovery(
// The key arrives as the suspend return value (like resetRecoveryKey), avoiding a
// flow/return-value race; the listener only feeds sub-progress.
enableRecoveryProgressStateFlow.value = EnableRecoveryProgress.Starting
val key = service.enableRecovery(
waitForBackupsToUpload = waitForBackupsToUpload,
progressListener = object : EnableRecoveryProgressListener {
override fun onUpdate(status: RustEnableRecoveryProgress) {
enableRecoveryProgressStateFlow.value = enableRecoveryProgressMapper.map(status)
}
},
passphrase = null,
passphrase = passphrase,
)
// enableRecovery returns the encryption key, but we read it from the state flow
.let { }
// Pin Done explicitly so observers get a coherent terminal value. For the passphrase
// path the user never sees the SDK base58 key, so keep it out of this session-scoped
// (in-memory) flow entirely; the caller still receives it as the return value and is
// responsible for scrubbing it. The auto-generated path must retain the key here since
// that is how it is surfaced to the user.
enableRecoveryProgressStateFlow.value = EnableRecoveryProgress.Done(if (passphrase != null) "" else key)
key
}.mapFailure {
it.mapRecoveryException()
}
@@ -28,7 +28,8 @@ class FakeEncryptionService(
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() },
private val getUserIdentityResult: (UserId) -> Result<IdentityState?> = { lambdaError() },
private val enableRecoveryLambda: (Boolean) -> Result<Unit> = { lambdaError() },
private val enableRecoveryLambda: (Boolean, String?) -> Result<String> = { _, _ -> lambdaError() },
private val resetRecoveryKeyLambda: () -> Result<String> = { Result.success(FAKE_RECOVERY_KEY) },
) : EncryptionService {
private var disableRecoveryFailure: Exception? = null
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
@@ -90,11 +91,11 @@ class FakeEncryptionService(
}
override suspend fun resetRecoveryKey(): Result<String> = simulateLongTask {
return Result.success(FAKE_RECOVERY_KEY)
return resetRecoveryKeyLambda()
}
override suspend fun enableRecovery(waitForBackupsToUpload: Boolean): Result<Unit> = simulateLongTask {
return enableRecoveryLambda(waitForBackupsToUpload)
override suspend fun enableRecovery(waitForBackupsToUpload: Boolean, passphrase: String?): Result<String> = simulateLongTask {
return enableRecoveryLambda(waitForBackupsToUpload, passphrase)
}
fun givenWaitForBackupUploadSteadyStateFlow(flow: Flow<BackupUploadState>) {
@@ -29,6 +29,8 @@ object TestTags {
* Verification screen.
*/
val recoveryKey = TestTag("verification-recovery_key")
val customRecoveryPassphrase = TestTag("verification-custom_recovery_passphrase")
val customRecoveryPassphraseConfirm = TestTag("verification-custom_recovery_passphrase_confirm")
/**
* Sign out screen.
@@ -0,0 +1,23 @@
/*
* 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.wellknown.api
/**
* Server-driven settings for a user-chosen recovery passphrase, advertised under the well-known
* `custom_recovery_passphrase` key. Today the only rule is a minimum character count; additional
* settings can be added here as the schema grows.
*/
data class CustomRecoveryPassphrase(
val minCharacterCount: Int,
) {
/** True when [input] meets every active rule. */
fun isSatisfiedBy(input: String): Boolean = isSatisfiedBy(input.length)
/** True when an input of [length] characters meets every active rule. */
fun isSatisfiedBy(length: Int): Boolean = length >= minCharacterCount
}
@@ -15,4 +15,5 @@ data class ElementWellKnown(
val brandColor: String?,
val notificationSound: String?,
val identityProviderAppScheme: String?,
val customRecoveryPassphrase: CustomRecoveryPassphrase?,
)
@@ -15,7 +15,15 @@ import kotlinx.serialization.Serializable
* Example:
* <pre>
* {
* "registration_helper_url": "https://element.io"
* "registration_helper_url": "https://element.io",
* "enforce_element_pro": true,
* "rageshake_url": "https://example.org/rageshake",
* "brand_color": "#FF0000",
* "notification_sound": "ring.flac",
* "idp_app_scheme": "io.element.app",
* "custom_recovery_passphrase": {
* "min_character_count": 8
* }
* }
* </pre>
* .
@@ -34,4 +42,12 @@ data class InternalElementWellKnown(
val notificationSound: String? = null,
@SerialName("idp_app_scheme")
val identityProviderAppScheme: String? = null,
@SerialName("custom_recovery_passphrase")
val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null,
)
@Serializable
data class InternalCustomRecoveryPassphrase(
@SerialName("min_character_count")
val minCharacterCount: Int? = null,
)
@@ -8,7 +8,19 @@
package io.element.android.libraries.wellknown.impl
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
import timber.log.Timber
private val loggerTag = LoggerTag("Wellknown")
/**
* Floor for the recovery passphrase minimum length. A value of 1 guarantees the derived passphrase is
* never empty (an empty passphrase derives a recoverable but effectively unprotected secret-storage
* key). Anything stronger than non-empty is left to the homeserver operator's configuration.
*/
private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1
internal fun InternalElementWellKnown.map() = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
@@ -17,4 +29,20 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown(
brandColor = brandColor,
notificationSound = notificationSound,
identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(),
)
private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase {
// Whenever the homeserver advertises the settings block we run the custom passphrase flow, flooring
// the minimum at 1 so the passphrase can never be empty even if the server omits min_character_count
// or advertises a non-positive value. The operator owns any stronger minimum.
val min = (minCharacterCount ?: MINIMUM_PASSPHRASE_CHARACTER_COUNT).coerceAtLeast(MINIMUM_PASSPHRASE_CHARACTER_COUNT)
if (min != minCharacterCount) {
Timber.tag(loggerTag.value).w(
"custom_recovery_passphrase.min_character_count was %s; flooring to %d",
minCharacterCount,
min,
)
}
return CustomRecoveryPassphrase(minCharacterCount = min)
}
@@ -0,0 +1,33 @@
/*
* 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.wellknown.api
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class CustomRecoveryPassphraseTest {
@Test
fun `empty input never satisfies a positive minimum`() {
assertThat(CustomRecoveryPassphrase(minCharacterCount = 1).isSatisfiedBy("")).isFalse()
}
@Test
fun `input shorter than minimum is not satisfied`() {
assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abc")).isFalse()
}
@Test
fun `input exactly at minimum is satisfied`() {
assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abcdefgh")).isTrue()
}
@Test
fun `input longer than minimum is satisfied`() {
assertThat(CustomRecoveryPassphrase(minCharacterCount = 4).isSatisfiedBy("abcdefgh")).isTrue()
}
}
@@ -19,6 +19,7 @@ import io.element.android.libraries.cachestore.api.CacheStore
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.api.systemclock.SystemClock
@@ -51,6 +52,7 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
customRecoveryPassphrase = null,
)
)
)
@@ -76,6 +78,7 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
)
)
)
@@ -105,6 +108,93 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
customRecoveryPassphrase = null,
)
)
)
}
@Test
fun `get element wellknown with custom recovery passphrase settings`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {
"min_character_count": 8
}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 8)
)
)
)
}
@Test
fun `get element wellknown with custom recovery passphrase settings missing min character count floors to 1`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown with zero min character count floors to 1`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {
"min_character_count": 0
}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown with negative min character count floors to 1`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {
"min_character_count": -5
}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
@@ -157,6 +247,7 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
)
)
)
@@ -210,6 +301,7 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
)
)
)
@@ -8,6 +8,7 @@
package io.element.android.features.wellknown.test
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
fun anElementWellKnown(
@@ -17,6 +18,7 @@ fun anElementWellKnown(
brandColor: String? = null,
notificationSound: String? = null,
identityProviderAppScheme: String? = null,
customRecoveryPassphrase: CustomRecoveryPassphrase? = null,
) = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
@@ -24,4 +26,11 @@ fun anElementWellKnown(
brandColor = brandColor,
notificationSound = notificationSound,
identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphrase = customRecoveryPassphrase,
)
fun aCustomRecoveryPassphrase(
minCharacterCount: Int = 8,
) = CustomRecoveryPassphrase(
minCharacterCount = minCharacterCount,
)