Merge branch 'develop' into hughns/link-new-device-done

This commit is contained in:
Hugh Nimmo-Smith 2026-04-29 12:06:40 +01:00 committed by GitHub
commit 026c448e48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
198 changed files with 999 additions and 686 deletions

View file

@ -27,6 +27,7 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.features.linknewdevice.impl.screens.confirmation.CodeConfirmationNode
import io.element.android.features.linknewdevice.impl.screens.desktop.DesktopNoticeNode
import io.element.android.features.linknewdevice.impl.screens.error.ErrorNode
import io.element.android.features.linknewdevice.impl.screens.error.ErrorScreenType
@ -107,6 +108,11 @@ class LinkNewDeviceFlowNode(
val data: String,
) : NavTarget
@Parcelize
data class CodeConfirmation(
val code: String,
) : NavTarget
@Parcelize
data object MobileEnterNumber : NavTarget
@ -163,7 +169,9 @@ class LinkNewDeviceFlowNode(
is LinkDesktopStep.Error -> {
navigateToError(linkDesktopStep.errorType)
}
is LinkDesktopStep.EstablishingSecureChannel -> Unit
is LinkDesktopStep.EstablishingSecureChannel -> {
backstack.push(NavTarget.CodeConfirmation(linkDesktopStep.checkCodeString))
}
is LinkDesktopStep.InvalidQrCode -> {
// This error will be handled by the ScanQrCodeNode
}
@ -180,20 +188,20 @@ class LinkNewDeviceFlowNode(
private fun navigateToError(errorType: ErrorType) {
// Map the error to an error screen
// TODO Update this mapping
val error = when (errorType) {
is ErrorType.DeviceIdAlreadyInUse -> ErrorScreenType.UnknownError
is ErrorType.InvalidCheckCode -> ErrorScreenType.InsecureChannelDetected
is ErrorType.MissingSecretsBackup -> ErrorScreenType.UnknownError
is ErrorType.NotFound -> ErrorScreenType.Expired
is ErrorType.DeviceNotFound -> ErrorScreenType.UnknownError
is ErrorType.Unknown -> ErrorScreenType.UnknownError
is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError
is ErrorType.Cancelled -> ErrorScreenType.UnknownError
is ErrorType.InvalidCheckCode -> ErrorScreenType.Mismatch2Digits
is ErrorType.UnsupportedProtocol -> ErrorScreenType.ProtocolNotSupported
is ErrorType.Cancelled -> ErrorScreenType.Cancelled
is ErrorType.ConnectionInsecure -> ErrorScreenType.InsecureChannelDetected
is ErrorType.Expired -> ErrorScreenType.Expired
is ErrorType.OtherDeviceAlreadySignedIn -> ErrorScreenType.UnknownError
is ErrorType.Expired,
is ErrorType.NotFound,
is ErrorType.DeviceNotFound -> ErrorScreenType.Expired
is ErrorType.OtherDeviceAlreadySignedIn -> ErrorScreenType.OtherDeviceAlreadySignedIn
// TODO check if we expect to hit this here or if it should be caught earlier on
is ErrorType.UnsupportedQrCodeType -> ErrorScreenType.UnknownError
is ErrorType.MissingSecretsBackup,
is ErrorType.DeviceIdAlreadyInUse,
is ErrorType.Unknown -> ErrorScreenType.UnknownError
}
// It is OK to push on backstack, since when user leaves the error screen, a new root will be set,
// or the whole flow will be popped.
@ -247,6 +255,18 @@ class LinkNewDeviceFlowNode(
}
createNode<EnterNumberNode>(buildContext, listOf(callback))
}
is NavTarget.CodeConfirmation -> {
val callback = object : CodeConfirmationNode.Callback {
override fun onCancel() {
// Push error
backstack.push(NavTarget.Error(ErrorScreenType.Cancelled))
}
}
val inputs = CodeConfirmationNode.Inputs(
code = navTarget.code,
)
createNode<CodeConfirmationNode>(buildContext, listOf(inputs, callback))
}
is NavTarget.MobileShowQrCode -> {
val callback = object : ShowQrCodeNode.Callback {
override fun navigateBack() {

View file

@ -0,0 +1,47 @@
/*
* 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.features.linknewdevice.impl.screens.confirmation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@AssistedInject
class CodeConfirmationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext = buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onCancel()
}
data class Inputs(
val code: String,
) : NodeInputs
private val callback: Callback = callback()
private val input = inputs<Inputs>()
@Composable
override fun View(modifier: Modifier) {
CodeConfirmationView(
code = input.code,
onCancel = callback::onCancel,
)
}
}

View file

@ -0,0 +1,134 @@
/*
* 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.features.linknewdevice.impl.screens.confirmation
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.linknewdevice.impl.R
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun CodeConfirmationView(
code: String,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onCancel)
FlowStepPage(
modifier = modifier,
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
title = stringResource(R.string.screen_qr_code_login_device_code_title),
subTitle = stringResource(R.string.screen_qr_code_login_device_code_subtitle),
content = { Content(code = code) },
buttons = { Buttons(onCancel = onCancel) }
)
}
@Composable
private fun Content(code: String) {
Column(
modifier = Modifier.padding(top = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Digits(code = code)
Spacer(modifier = Modifier.height(32.dp))
WaitingForOtherDevice()
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun Digits(code: String) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
code.forEach {
Text(
modifier = Modifier
.padding(horizontal = 6.dp, vertical = 4.dp)
.clip(RoundedCornerShape(4.dp))
.background(ElementTheme.colors.bgActionSecondaryPressed)
.padding(horizontal = 16.dp, vertical = 17.dp),
text = it.toString()
)
}
}
}
@Composable
private fun WaitingForOtherDevice() {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
CircularProgressIndicator(
modifier = Modifier
.size(20.dp)
.padding(2.dp),
strokeWidth = 2.dp,
)
Text(
text = stringResource(R.string.screen_qr_code_login_verify_code_loading),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun Buttons(
onCancel: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
onClick = onCancel,
)
}
}
@PreviewsDayNight
@Composable
internal fun CodeConfirmationViewPreview() {
ElementPreview {
CodeConfirmationView(
code = "67",
onCancel = {},
)
}
}

View file

@ -20,6 +20,9 @@ sealed interface ErrorScreenType : NodeInputs, Parcelable {
@Parcelize
data object Expired : ErrorScreenType
@Parcelize
data object OtherDeviceAlreadySignedIn : ErrorScreenType
@Parcelize
data object Mismatch2Digits : ErrorScreenType

View file

@ -19,5 +19,6 @@ class ErrorScreenTypeProvider : PreviewParameterProvider<ErrorScreenType> {
ErrorScreenType.InsecureChannelDetected,
ErrorScreenType.SlidingSyncNotAvailable,
ErrorScreenType.UnknownError,
ErrorScreenType.OtherDeviceAlreadySignedIn,
)
}

View file

@ -47,17 +47,26 @@ fun ErrorView(
) {
val appName = LocalBuildMeta.current.applicationName
BackHandler(onBack = onCancel)
val iconStyle = when (errorScreenType) {
ErrorScreenType.OtherDeviceAlreadySignedIn -> BigIcon.Style.SuccessSolid
else -> BigIcon.Style.AlertSolid
}
FlowStepPage(
modifier = modifier,
iconStyle = BigIcon.Style.AlertSolid,
iconStyle = iconStyle,
title = titleText(errorScreenType, appName),
subTitle = subtitleText(errorScreenType, appName),
content = { Content(errorScreenType) },
buttons = {
Buttons(
onRetry = onRetry,
onCancel = onCancel,
)
when (errorScreenType) {
ErrorScreenType.OtherDeviceAlreadySignedIn -> DoneButton(
onDone = onCancel,
)
else -> Buttons(
onRetry = onRetry,
onCancel = onCancel,
)
}
},
)
}
@ -72,6 +81,7 @@ private fun titleText(errorScreenType: ErrorScreenType, appName: String) = when
ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_title)
ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_title, appName)
is ErrorScreenType.UnknownError -> stringResource(CommonStrings.common_something_went_wrong)
ErrorScreenType.OtherDeviceAlreadySignedIn -> stringResource(R.string.screen_qr_code_login_error_device_already_signed_in_title)
}
@Composable
@ -84,6 +94,7 @@ private fun subtitleText(errorScreenType: ErrorScreenType, appName: String) = wh
ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_description)
ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_subtitle, appName)
is ErrorScreenType.UnknownError -> stringResource(R.string.screen_qr_code_login_unknown_error_description)
ErrorScreenType.OtherDeviceAlreadySignedIn -> stringResource(R.string.screen_qr_code_login_error_device_already_signed_in_subtitle)
}
@Composable
@ -124,6 +135,17 @@ private fun Content(errorScreenType: ErrorScreenType) {
}
}
@Composable
private fun DoneButton(
onDone: () -> Unit,
) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_done),
onClick = onDone,
)
}
@Composable
private fun Buttons(
onRetry: () -> Unit,

View file

@ -34,6 +34,8 @@
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"If that doesnt work, sign in manually"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Connection not secure"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Youll be asked to enter the two digits shown on this device."</string>
<string name="screen_qr_code_login_device_code_title">"Enter the number below on your other device"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"The sign in was cancelled on the other device."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Sign in request cancelled"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"The sign in was declined on the other device."</string>
@ -54,4 +56,5 @@ Try signing in manually, or scan the QR code with another device."</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"You need to give permission for %1$s to use your devices camera in order to continue."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Allow camera access to scan the QR code"</string>
<string name="screen_qr_code_login_unknown_error_description">"An unexpected error occurred. Please try again."</string>
<string name="screen_qr_code_login_verify_code_loading">"Waiting for your other device"</string>
</resources>