Sign in with QR code (#2793)

* Add QR code login.
* Add FF to disable it in release mode.
* Force portrait orientation on the login flow.
* Create `NumberedList` UI components.
* Improve camera permission dialog.
* Make nodes in qrcode feature use `QrCodeLoginScope` instead of `AppScope`
* Bump SDK version.
* Fix maestro tests

---------

Co-authored-by: Benoit Marty <benoit@matrix.org>
Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2024-05-31 14:38:27 +02:00
committed by GitHub
parent e0c55ff4c8
commit 35702c04e9
253 changed files with 4421 additions and 326 deletions

View File

@@ -1,6 +1,6 @@
appId: ${MAESTRO_APP_ID}
---
- tapOn: "Continue"
- tapOn: "Sign in manually"
- runFlow: ../assertions/assertLoginDisplayed.yaml
- takeScreenshot: build/maestro/100-SignIn
- runFlow: changeServer.yaml

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,9 +14,12 @@
* limitations under the License.
*/
package io.element.android.features.onboarding.impl
package io.element.android.appconfig
object OnBoardingConfig {
/** Whether the user can use QR code login. */
const val CAN_LOGIN_WITH_QR_CODE = false
/** Whether the user can create an account using the app. */
const val CAN_CREATE_ACCOUNT = false
}

View File

@@ -31,10 +31,13 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginFlowType
import io.element.android.features.onboarding.api.OnBoardingEntryPoint
import io.element.android.features.preferences.api.ConfigureTracingEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.ui.media.NotLoggedInImageLoaderFactory
import kotlinx.parcelize.Parcelize
@@ -73,9 +76,7 @@ class NotLoggedInFlowNode @AssistedInject constructor(
data object OnBoarding : NavTarget
@Parcelize
data class LoginFlow(
val isAccountCreation: Boolean,
) : NavTarget
data class LoginFlow(val type: LoginFlowType) : NavTarget
@Parcelize
data object ConfigureTracing : NavTarget
@@ -86,11 +87,15 @@ class NotLoggedInFlowNode @AssistedInject constructor(
NavTarget.OnBoarding -> {
val callback = object : OnBoardingEntryPoint.Callback {
override fun onSignUp() {
backstack.push(NavTarget.LoginFlow(isAccountCreation = true))
backstack.push(NavTarget.LoginFlow(type = LoginFlowType.SIGN_UP))
}
override fun onSignIn() {
backstack.push(NavTarget.LoginFlow(isAccountCreation = false))
backstack.push(NavTarget.LoginFlow(type = LoginFlowType.SIGN_IN_MANUAL))
}
override fun onSignInWithQrCode() {
backstack.push(NavTarget.LoginFlow(type = LoginFlowType.SIGN_IN_QR_CODE))
}
override fun onOpenDeveloperSettings() {
@@ -108,7 +113,7 @@ class NotLoggedInFlowNode @AssistedInject constructor(
}
is NavTarget.LoginFlow -> {
loginEntryPoint.nodeBuilder(this, buildContext)
.params(LoginEntryPoint.Params(isAccountCreation = navTarget.isAccountCreation))
.params(LoginEntryPoint.Params(flowType = navTarget.type))
.build()
}
NavTarget.ConfigureTracing -> {
@@ -119,6 +124,9 @@ class NotLoggedInFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
// The login flow doesn't support landscape mode on mobile devices yet
ForceOrientationInMobileDevices(orientation = ScreenOrientation.PORTRAIT)
BackstackView()
}
}

View File

@@ -11,11 +11,11 @@
<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_device_not_signed_in_scan_state_subtitle">"Sign in to your other device and then try again, or use another device thats already signed in."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_title">"Other device not signed in"</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_description">"Sign in to your other device and then try again, or use another device thats already signed in."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Other device not signed in"</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 request on your other device was not accepted."</string>
<string name="screen_qr_code_login_error_declined_subtitle">"The sign in was declined on the other device."</string>
<string name="screen_qr_code_login_error_declined_title">"Sign in declined"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Sign in expired. Please try again."</string>
<string name="screen_qr_code_login_error_expired_title">"The sign in was not completed in time"</string>

View File

@@ -15,6 +15,7 @@
*/
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
}
android {

View File

@@ -16,13 +16,15 @@
package io.element.android.features.login.api
import android.os.Parcelable
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
import kotlinx.parcelize.Parcelize
interface LoginEntryPoint : FeatureEntryPoint {
data class Params(
val isAccountCreation: Boolean,
val flowType: LoginFlowType
)
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
@@ -32,3 +34,10 @@ interface LoginEntryPoint : FeatureEntryPoint {
fun build(): Node
}
}
@Parcelize
enum class LoginFlowType : Parcelable {
SIGN_IN_MANUAL,
SIGN_IN_QR_CODE,
SIGN_UP
}

View File

@@ -49,6 +49,8 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.qrcode)
implementation(libs.androidx.browser)
implementation(platform(libs.network.retrofit.bom))
implementation(libs.network.retrofit)
@@ -57,10 +59,15 @@ dependencies {
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.tests.testutils)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -32,7 +32,7 @@ class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint {
return object : LoginEntryPoint.NodeBuilder {
override fun params(params: LoginEntryPoint.Params): LoginEntryPoint.NodeBuilder {
plugins += LoginFlowNode.Inputs(isAccountCreation = params.isAccountCreation)
plugins += LoginFlowNode.Inputs(flowType = params.flowType)
return this
}

View File

@@ -35,12 +35,14 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.api.LoginFlowType
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
import io.element.android.features.login.impl.oidc.webview.OidcNode
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
@@ -69,7 +71,7 @@ class LoginFlowNode @AssistedInject constructor(
private val oidcActionFlow: OidcActionFlow,
) : BaseFlowNode<LoginFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.ConfirmAccountProvider,
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
@@ -79,7 +81,7 @@ class LoginFlowNode @AssistedInject constructor(
private var darkTheme: Boolean = false
data class Inputs(
val isAccountCreation: Boolean,
val flowType: LoginFlowType,
) : NodeInputs
private val inputs: Inputs = inputs()
@@ -107,6 +109,9 @@ class LoginFlowNode @AssistedInject constructor(
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data object ConfirmAccountProvider : NavTarget
@@ -128,9 +133,16 @@ class LoginFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
if (inputs.flowType == LoginFlowType.SIGN_IN_QR_CODE) {
createNode<QrCodeLoginFlowNode>(buildContext)
} else {
resolve(NavTarget.ConfirmAccountProvider, buildContext)
}
}
NavTarget.ConfirmAccountProvider -> {
val inputs = ConfirmAccountProviderNode.Inputs(
isAccountCreation = inputs.isAccountCreation
isAccountCreation = inputs.flowType == LoginFlowType.SIGN_UP,
)
val callback = object : ConfirmAccountProviderNode.Callback {
override fun onOidcDetails(oidcDetails: OidcDetails) {

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.login.impl.qrcode.QrCodeLoginManager
@ContributesTo(QrCodeLoginScope::class)
interface QrCodeLoginBindings {
fun qrCodeLoginManager(): QrCodeLoginManager
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.di
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.MergeSubcomponent
import dagger.Subcomponent
import io.element.android.libraries.architecture.NodeFactoriesBindings
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
@SingleIn(QrCodeLoginScope::class)
@MergeSubcomponent(QrCodeLoginScope::class)
interface QrCodeLoginComponent : NodeFactoriesBindings {
@Subcomponent.Builder
interface Builder {
fun build(): QrCodeLoginComponent
}
@ContributesTo(AppScope::class)
interface ParentBindings {
fun qrCodeLoginComponentBuilder(): Builder
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.di
abstract class QrCodeLoginScope private constructor()

View File

@@ -89,6 +89,6 @@ fun OidcView(
internal fun OidcViewPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = ElementPreview {
OidcView(
state = state,
onNavigateBack = { },
onNavigateBack = {},
)
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.qrcode
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.login.impl.di.QrCodeLoginScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@SingleIn(QrCodeLoginScope::class)
@ContributesBinding(QrCodeLoginScope::class)
class DefaultQrCodeLoginManager @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
) : QrCodeLoginManager {
private val _currentLoginStep = MutableStateFlow<QrCodeLoginStep>(QrCodeLoginStep.Uninitialized)
override val currentLoginStep: StateFlow<QrCodeLoginStep> = _currentLoginStep
override suspend fun authenticate(qrCodeLoginData: MatrixQrCodeLoginData): Result<SessionId> {
reset()
return authenticationService.loginWithQrCode(qrCodeLoginData) { step ->
_currentLoginStep.value = step
}.onFailure { throwable ->
if (throwable is QrLoginException) {
_currentLoginStep.value = QrCodeLoginStep.Failed(throwable)
}
}
}
override fun reset() {
_currentLoginStep.value = QrCodeLoginStep.Uninitialized
}
}

View File

@@ -0,0 +1,248 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.qrcode
import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.di.QrCodeLoginBindings
import io.element.android.features.login.impl.di.QrCodeLoginComponent
import io.element.android.features.login.impl.screens.qrcode.confirmation.QrCodeConfirmationNode
import io.element.android.features.login.impl.screens.qrcode.confirmation.QrCodeConfirmationStep
import io.element.android.features.login.impl.screens.qrcode.error.QrCodeErrorNode
import io.element.android.features.login.impl.screens.qrcode.intro.QrCodeIntroNode
import io.element.android.features.login.impl.screens.qrcode.scan.QrCodeScanNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(AppScope::class)
class QrCodeLoginFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
qrCodeLoginComponentBuilder: QrCodeLoginComponent.Builder,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val coroutineDispatchers: CoroutineDispatchers,
) : BaseFlowNode<QrCodeLoginFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Initial,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
), DaggerComponentOwner {
private var authenticationJob: Job? = null
override val daggerComponent = qrCodeLoginComponentBuilder.build()
private val qrCodeLoginManager by lazy { bindings<QrCodeLoginBindings>().qrCodeLoginManager() }
sealed interface NavTarget : Parcelable {
@Parcelize
data object Initial : NavTarget
@Parcelize
data object QrCodeScan : NavTarget
@Parcelize
data class QrCodeConfirmation(val step: QrCodeConfirmationStep) : NavTarget
@Parcelize
data class Error(val errorType: QrCodeErrorScreenType) : NavTarget
}
override fun onBuilt() {
super.onBuilt()
observeLoginStep()
}
fun isLoginInProgress(): Boolean {
return authenticationJob?.isActive == true
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun observeLoginStep() {
lifecycleScope.launch {
qrCodeLoginManager.currentLoginStep
.collect { step ->
when (step) {
is QrCodeLoginStep.EstablishingSecureChannel -> {
backstack.replace(NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayCheckCode(step.checkCode)))
}
is QrCodeLoginStep.WaitingForToken -> {
backstack.replace(NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayVerificationCode(step.userCode)))
}
is QrCodeLoginStep.Failed -> {
when (val error = step.error) {
is QrLoginException.OtherDeviceNotSignedIn -> {
// Do nothing here, it'll be handled in the scan QR screen
}
is QrLoginException.Cancelled -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Cancelled))
}
is QrLoginException.Expired -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Expired))
}
is QrLoginException.Declined -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Declined))
}
is QrLoginException.ConnectionInsecure -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.InsecureChannelDetected))
}
is QrLoginException.LinkingNotSupported -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.ProtocolNotSupported))
}
is QrLoginException.SlidingSyncNotAvailable -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.SlidingSyncNotAvailable))
}
is QrLoginException.OidcMetadataInvalid -> {
Timber.e(error, "OIDC metadata is invalid")
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError))
}
else -> {
Timber.e(error, "Unknown error found")
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError))
}
}
}
else -> Unit
}
}
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Initial -> {
val callback = object : QrCodeIntroNode.Callback {
override fun onCancelClicked() {
navigateUp()
}
override fun onContinue() {
backstack.push(NavTarget.QrCodeScan)
}
}
createNode<QrCodeIntroNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.QrCodeScan -> {
val callback = object : QrCodeScanNode.Callback {
override fun onScannedCode(qrCodeLoginData: MatrixQrCodeLoginData) {
lifecycleScope.startAuthentication(qrCodeLoginData)
}
override fun onCancelClicked() {
backstack.pop()
}
}
createNode<QrCodeScanNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.QrCodeConfirmation -> {
val callback = object : QrCodeConfirmationNode.Callback {
override fun onCancel() = reset()
}
createNode<QrCodeConfirmationNode>(buildContext, plugins = listOf(navTarget.step, callback))
}
is NavTarget.Error -> {
val callback = object : QrCodeErrorNode.Callback {
override fun onRetry() = reset()
}
createNode<QrCodeErrorNode>(buildContext, plugins = listOf(navTarget.errorType, callback))
}
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun reset() {
authenticationJob?.cancel()
authenticationJob = null
qrCodeLoginManager.reset()
backstack.newRoot(NavTarget.Initial)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun CoroutineScope.startAuthentication(qrCodeLoginData: MatrixQrCodeLoginData) {
authenticationJob = launch(coroutineDispatchers.main) {
qrCodeLoginManager.authenticate(qrCodeLoginData)
.onSuccess {
defaultLoginUserStory.setLoginFlowIsDone(true)
authenticationJob = null
}
.onFailure { throwable ->
Timber.e(throwable, "QR code authentication failed")
authenticationJob = null
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}
@Immutable
sealed interface QrCodeErrorScreenType : NodeInputs, Parcelable {
@Parcelize
data object Cancelled : QrCodeErrorScreenType
@Parcelize
data object Expired : QrCodeErrorScreenType
@Parcelize
data object InsecureChannelDetected : QrCodeErrorScreenType
@Parcelize
data object Declined : QrCodeErrorScreenType
@Parcelize
data object ProtocolNotSupported : QrCodeErrorScreenType
@Parcelize
data object SlidingSyncNotAvailable : QrCodeErrorScreenType
@Parcelize
data object UnknownError : QrCodeErrorScreenType
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.qrcode
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.StateFlow
/**
* Helper to handle the QR code login flow after the QR code data has been provided.
*/
interface QrCodeLoginManager {
/**
* The current QR code login step.
*/
val currentLoginStep: StateFlow<QrCodeLoginStep>
/**
* Authenticate using the provided [qrCodeLoginData].
* @param qrCodeLoginData the QR code login data from the scanned QR code.
* @return the logged in [SessionId] if the authentication was successful or a failure result.
*/
suspend fun authenticate(qrCodeLoginData: MatrixQrCodeLoginData): Result<SessionId>
fun reset()
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.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 com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.di.QrCodeLoginScope
import io.element.android.libraries.architecture.inputs
@ContributesNode(QrCodeLoginScope::class)
class QrCodeConfirmationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext = buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onCancel()
}
private val step = inputs<QrCodeConfirmationStep>()
private fun onCancel() {
plugins<Callback>().forEach { it.onCancel() }
}
@Composable
override fun View(modifier: Modifier) {
QrCodeConfirmationView(
step = step,
onCancel = ::onCancel,
)
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.confirmation
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import io.element.android.libraries.architecture.NodeInputs
import kotlinx.parcelize.Parcelize
@Immutable
sealed interface QrCodeConfirmationStep : NodeInputs, Parcelable {
@Parcelize
data class DisplayCheckCode(val code: String) : QrCodeConfirmationStep
@Parcelize
data class DisplayVerificationCode(val code: String) : QrCodeConfirmationStep
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.confirmation
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class QrCodeConfirmationStepPreviewProvider : PreviewParameterProvider<QrCodeConfirmationStep> {
override val values: Sequence<QrCodeConfirmationStep>
get() = sequenceOf(
QrCodeConfirmationStep.DisplayCheckCode("12"),
QrCodeConfirmationStep.DisplayVerificationCode("123456"),
QrCodeConfirmationStep.DisplayVerificationCode("123456789"),
)
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.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.tooling.preview.PreviewParameter
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.login.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 QrCodeConfirmationView(
step: QrCodeConfirmationStep,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler {
onCancel()
}
val icon = when (step) {
is QrCodeConfirmationStep.DisplayCheckCode -> CompoundIcons.Computer()
is QrCodeConfirmationStep.DisplayVerificationCode -> CompoundIcons.LockSolid()
}
val title = when (step) {
is QrCodeConfirmationStep.DisplayCheckCode -> stringResource(R.string.screen_qr_code_login_device_code_title)
is QrCodeConfirmationStep.DisplayVerificationCode -> stringResource(R.string.screen_qr_code_login_verify_code_title)
}
val subtitle = when (step) {
is QrCodeConfirmationStep.DisplayCheckCode -> stringResource(R.string.screen_qr_code_login_device_code_subtitle)
is QrCodeConfirmationStep.DisplayVerificationCode -> stringResource(R.string.screen_qr_code_login_verify_code_subtitle)
}
FlowStepPage(
modifier = modifier,
iconStyle = BigIcon.Style.Default(icon),
title = title,
subTitle = subtitle,
content = { Content(step = step) },
buttons = { Buttons(onCancel = onCancel) }
)
}
@Composable
private fun Content(step: QrCodeConfirmationStep) {
Column(
modifier = Modifier.padding(top = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
when (step) {
is QrCodeConfirmationStep.DisplayCheckCode -> {
Digits(code = step.code)
Spacer(modifier = Modifier.height(32.dp))
WaitingForOtherDevice()
}
is QrCodeConfirmationStep.DisplayVerificationCode -> {
Digits(code = step.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 QrCodeConfirmationViewPreview(@PreviewParameter(QrCodeConfirmationStepPreviewProvider::class) step: QrCodeConfirmationStep) {
ElementPreview {
QrCodeConfirmationView(
step = step,
onCancel = {},
)
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.error
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 com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.di.QrCodeLoginScope
import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.meta.BuildMeta
@ContributesNode(QrCodeLoginScope::class)
class QrCodeErrorNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val buildMeta: BuildMeta,
) : Node(buildContext = buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onRetry()
}
private fun onRetry() {
plugins<Callback>().forEach { it.onRetry() }
}
private val qrCodeErrorScreenType = inputs<QrCodeErrorScreenType>()
@Composable
override fun View(modifier: Modifier) {
QrCodeErrorView(
modifier = modifier,
errorScreenType = qrCodeErrorScreenType,
appName = buildMeta.productionApplicationName,
onRetry = ::onRetry,
)
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.error
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType
import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
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.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
fun QrCodeErrorView(
errorScreenType: QrCodeErrorScreenType,
appName: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler {
onRetry()
}
FlowStepPage(
modifier = modifier,
iconStyle = BigIcon.Style.AlertSolid,
title = titleText(errorScreenType, appName),
subTitle = subtitleText(errorScreenType, appName),
content = { Content(errorScreenType) },
buttons = { Buttons(onRetry) }
)
}
@Composable
private fun titleText(errorScreenType: QrCodeErrorScreenType, appName: String) = when (errorScreenType) {
QrCodeErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_title)
QrCodeErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_title)
QrCodeErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_title)
QrCodeErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_title)
QrCodeErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_title)
QrCodeErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_title, appName)
is QrCodeErrorScreenType.UnknownError -> stringResource(CommonStrings.common_something_went_wrong)
}
@Composable
private fun subtitleText(errorScreenType: QrCodeErrorScreenType, appName: String) = when (errorScreenType) {
QrCodeErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_subtitle)
QrCodeErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_subtitle)
QrCodeErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_subtitle)
QrCodeErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_subtitle, appName)
QrCodeErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_description)
QrCodeErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_subtitle, appName)
is QrCodeErrorScreenType.UnknownError -> stringResource(R.string.screen_qr_code_login_unknown_error_description)
}
@Composable
private fun ColumnScope.InsecureChannelDetectedError() {
Text(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
text = stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_header),
style = ElementTheme.typography.fontBodyLgMedium,
textAlign = TextAlign.Center,
)
NumberedListOrganism(
modifier = Modifier.fillMaxSize(),
items = persistentListOf(
AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_1)),
AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_2)),
AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_3)),
)
)
}
@Composable
private fun Content(errorScreenType: QrCodeErrorScreenType) {
when (errorScreenType) {
QrCodeErrorScreenType.InsecureChannelDetected -> {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
InsecureChannelDetectedError()
}
}
else -> Unit
}
}
@Composable
private fun Buttons(onRetry: () -> Unit) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_qr_code_login_start_over_button),
onClick = onRetry
)
}
@PreviewsDayNight
@Composable
internal fun QrCodeErrorViewPreview(@PreviewParameter(QrCodeErrorScreenTypeProvider::class) errorScreenType: QrCodeErrorScreenType) {
ElementPreview {
QrCodeErrorView(
errorScreenType = errorScreenType,
appName = "Element X",
onRetry = {}
)
}
}
class QrCodeErrorScreenTypeProvider : PreviewParameterProvider<QrCodeErrorScreenType> {
override val values: Sequence<QrCodeErrorScreenType> = sequenceOf(
QrCodeErrorScreenType.Cancelled,
QrCodeErrorScreenType.Declined,
QrCodeErrorScreenType.Expired,
QrCodeErrorScreenType.ProtocolNotSupported,
QrCodeErrorScreenType.InsecureChannelDetected,
QrCodeErrorScreenType.SlidingSyncNotAvailable,
QrCodeErrorScreenType.UnknownError
)
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.intro
sealed interface QrCodeIntroEvents {
data object Continue : QrCodeIntroEvents
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.intro
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 com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.di.QrCodeLoginScope
@ContributesNode(QrCodeLoginScope::class)
class QrCodeIntroNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: QrCodeIntroPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onCancelClicked()
fun onContinue()
}
private fun onCancelClicked() {
plugins<Callback>().forEach { it.onCancelClicked() }
}
private fun onContinue() {
plugins<Callback>().forEach { it.onContinue() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
QrCodeIntroView(
state = state,
onBackClick = ::onCancelClicked,
onContinue = ::onContinue,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.intro
import android.Manifest
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 io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import javax.inject.Inject
class QrCodeIntroPresenter @Inject constructor(
private val buildMeta: BuildMeta,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<QrCodeIntroState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingPermissionRequest by mutableStateOf(false)
@Composable
override fun present(): QrCodeIntroState {
val cameraPermissionState = cameraPermissionPresenter.present()
var canContinue by remember { mutableStateOf(false) }
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false
canContinue = true
}
}
fun handleEvents(event: QrCodeIntroEvents) {
when (event) {
QrCodeIntroEvents.Continue -> if (cameraPermissionState.permissionGranted) {
canContinue = true
} else {
pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
}
}
}
return QrCodeIntroState(
desktopAppName = buildMeta.desktopApplicationName,
cameraPermissionState = cameraPermissionState,
canContinue = canContinue,
eventSink = ::handleEvents
)
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.intro
import io.element.android.libraries.permissions.api.PermissionsState
data class QrCodeIntroState(
val desktopAppName: String,
val cameraPermissionState: PermissionsState,
val canContinue: Boolean,
val eventSink: (QrCodeIntroEvents) -> Unit
)

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.intro
import android.Manifest
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
open class QrCodeIntroStateProvider : PreviewParameterProvider<QrCodeIntroState> {
override val values: Sequence<QrCodeIntroState>
get() = sequenceOf(
aQrCodeIntroState(),
aQrCodeIntroState(cameraPermissionState = aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA)),
// Add other state here
)
}
fun aQrCodeIntroState(
desktopAppName: String = "Element",
cameraPermissionState: PermissionsState = aPermissionsState(
showDialog = false,
permission = Manifest.permission.CAMERA,
),
canContinue: Boolean = false,
eventSink: (QrCodeIntroEvents) -> Unit = {},
) = QrCodeIntroState(
desktopAppName = desktopAppName,
cameraPermissionState = cameraPermissionState,
canContinue = canContinue,
eventSink = eventSink
)

View File

@@ -0,0 +1,115 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.intro
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
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.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
fun QrCodeIntroView(
state: QrCodeIntroState,
onBackClick: () -> Unit,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
val latestOnContinue by rememberUpdatedState(onContinue)
LaunchedEffect(state.canContinue) {
if (state.canContinue) {
latestOnContinue()
}
}
FlowStepPage(
modifier = modifier,
onBackClick = onBackClick,
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
title = stringResource(id = R.string.screen_qr_code_login_initial_state_title, state.desktopAppName),
content = { Content(state = state) },
buttons = { Buttons(state = state) }
)
PermissionsView(
title = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_title),
content = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_description),
icon = { Icon(imageVector = CompoundIcons.TakePhotoSolid(), contentDescription = null) },
state = state.cameraPermissionState,
)
}
@Composable
private fun Content(state: QrCodeIntroState) {
NumberedListOrganism(
modifier = Modifier.padding(top = 50.dp, start = 20.dp, end = 20.dp),
items = persistentListOf(
AnnotatedString(stringResource(R.string.screen_qr_code_login_initial_state_item_1, state.desktopAppName)),
AnnotatedString(stringResource(R.string.screen_qr_code_login_initial_state_item_2)),
annotatedTextWithBold(
text = stringResource(
id = R.string.screen_qr_code_login_initial_state_item_3,
stringResource(R.string.screen_qr_code_login_initial_state_item_3_action),
),
boldText = stringResource(R.string.screen_qr_code_login_initial_state_item_3_action)
),
AnnotatedString(stringResource(R.string.screen_qr_code_login_initial_state_item_4)),
),
)
}
@Composable
private fun ColumnScope.Buttons(
state: QrCodeIntroState,
) {
Button(
text = stringResource(id = CommonStrings.action_continue),
modifier = Modifier.fillMaxWidth(),
onClick = {
state.eventSink.invoke(QrCodeIntroEvents.Continue)
}
)
}
@PreviewsDayNight
@Composable
internal fun QrCodeIntroViewPreview(@PreviewParameter(QrCodeIntroStateProvider::class) state: QrCodeIntroState) = ElementPreview {
QrCodeIntroView(
state = state,
onBackClick = {},
onContinue = {},
)
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.scan
sealed interface QrCodeScanEvents {
data class QrCodeScanned(val code: ByteArray) : QrCodeScanEvents
data object TryAgain : QrCodeScanEvents
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.scan
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 com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.di.QrCodeLoginScope
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
@ContributesNode(QrCodeLoginScope::class)
class QrCodeScanNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: QrCodeScanPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onScannedCode(qrCodeLoginData: MatrixQrCodeLoginData)
fun onCancelClicked()
}
private fun onQrCodeDataReady(qrCodeLoginData: MatrixQrCodeLoginData) {
plugins<Callback>().forEach { it.onScannedCode(qrCodeLoginData) }
}
private fun onCancelClicked() {
plugins<Callback>().forEach { it.onCancelClicked() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
QrCodeScanView(
state = state,
onQrCodeDataReady = ::onQrCodeDataReady,
onBackClick = ::onCancelClicked,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.scan
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.login.impl.qrcode.QrCodeLoginManager
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
class QrCodeScanPresenter @Inject constructor(
private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory,
private val qrCodeLoginManager: QrCodeLoginManager,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<QrCodeScanState> {
private var isScanning by mutableStateOf(true)
private val isProcessingCode = AtomicBoolean(false)
@Composable
override fun present(): QrCodeScanState {
val coroutineScope = rememberCoroutineScope()
val authenticationAction: MutableState<AsyncAction<MatrixQrCodeLoginData>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
ObserveQRCodeLoginFailures {
authenticationAction.value = AsyncAction.Failure(it)
}
fun handleEvents(event: QrCodeScanEvents) {
when (event) {
QrCodeScanEvents.TryAgain -> {
isScanning = true
authenticationAction.value = AsyncAction.Uninitialized
}
is QrCodeScanEvents.QrCodeScanned -> {
isScanning = false
coroutineScope.getQrCodeData(authenticationAction, event.code)
}
}
}
return QrCodeScanState(
isScanning = isScanning,
authenticationAction = authenticationAction.value,
eventSink = ::handleEvents
)
}
@Composable
private fun ObserveQRCodeLoginFailures(onQrCodeLoginError: (QrLoginException) -> Unit) {
LaunchedEffect(onQrCodeLoginError) {
qrCodeLoginManager.currentLoginStep
.onEach { state ->
if (state is QrCodeLoginStep.Failed) {
onQrCodeLoginError(state.error)
// The error was handled here, reset the login state
qrCodeLoginManager.reset()
}
}
.launchIn(this)
}
}
private fun CoroutineScope.getQrCodeData(codeScannedAction: MutableState<AsyncAction<MatrixQrCodeLoginData>>, code: ByteArray) {
if (codeScannedAction.value.isSuccess() || isProcessingCode.compareAndSet(true, true)) return
launch(coroutineDispatchers.computation) {
suspend {
qrCodeLoginDataFactory.parseQrCodeData(code).onFailure {
Timber.e(it, "Error parsing QR code data")
}.getOrThrow()
}.runCatchingUpdatingState(codeScannedAction)
}.invokeOnCompletion {
isProcessingCode.set(false)
}
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.scan
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
data class QrCodeScanState(
val isScanning: Boolean,
val authenticationAction: AsyncAction<MatrixQrCodeLoginData>,
val eventSink: (QrCodeScanEvents) -> Unit
)

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.scan
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
open class QrCodeScanStateProvider : PreviewParameterProvider<QrCodeScanState> {
override val values: Sequence<QrCodeScanState>
get() = sequenceOf(
aQrCodeScanState(),
aQrCodeScanState(isScanning = false, authenticationAction = AsyncAction.Loading),
aQrCodeScanState(isScanning = false, authenticationAction = AsyncAction.Failure(Exception("Error"))),
aQrCodeScanState(isScanning = false, authenticationAction = AsyncAction.Failure(QrLoginException.OtherDeviceNotSignedIn)),
// Add other state here
)
}
fun aQrCodeScanState(
isScanning: Boolean = true,
authenticationAction: AsyncAction<MatrixQrCodeLoginData> = AsyncAction.Uninitialized,
eventSink: (QrCodeScanEvents) -> Unit = {},
) = QrCodeScanState(
isScanning = isScanning,
authenticationAction = authenticationAction,
eventSink = eventSink
)

View File

@@ -0,0 +1,214 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.scan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.progressSemantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
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.login.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.modifiers.cornerBorder
import io.element.android.libraries.designsystem.modifiers.squareSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.qrcode.QrCodeCameraView
@Composable
fun QrCodeScanView(
state: QrCodeScanState,
onBackClick: () -> Unit,
onQrCodeDataReady: (MatrixQrCodeLoginData) -> Unit,
modifier: Modifier = Modifier,
) {
val updatedOnQrCodeDataReady by rememberUpdatedState(onQrCodeDataReady)
// QR code data parsed successfully, notify the parent node
if (state.authenticationAction is AsyncAction.Success) {
LaunchedEffect(state.authenticationAction, updatedOnQrCodeDataReady) {
updatedOnQrCodeDataReady(state.authenticationAction.data)
}
}
FlowStepPage(
modifier = modifier,
onBackClick = onBackClick,
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
title = stringResource(R.string.screen_qr_code_login_scanning_state_title),
content = { Content(state = state) },
buttons = { Buttons(state = state) }
)
}
@Composable
private fun Content(
state: QrCodeScanState,
) {
BoxWithConstraints(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val modifier = if (constraints.maxWidth > constraints.maxHeight) {
Modifier.fillMaxHeight()
} else {
Modifier.fillMaxWidth()
}.then(
Modifier
.padding(start = 20.dp, end = 20.dp, top = 50.dp, bottom = 32.dp)
.squareSize()
.cornerBorder(
strokeWidth = 4.dp,
color = ElementTheme.colors.textPrimary,
cornerSizeDp = 42.dp,
)
)
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
QrCodeCameraView(
modifier = Modifier.fillMaxSize(),
onScanQrCode = { state.eventSink.invoke(QrCodeScanEvents.QrCodeScanned(it)) },
renderPreview = state.isScanning,
)
}
}
}
@Composable
private fun ColumnScope.Buttons(
state: QrCodeScanState,
) {
Column(Modifier.heightIn(min = 130.dp)) {
when (state.authenticationAction) {
is AsyncAction.Failure -> {
Button(
text = stringResource(id = R.string.screen_qr_code_login_invalid_scan_state_retry_button),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
onClick = {
state.eventSink.invoke(QrCodeScanEvents.TryAgain)
}
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
val error = state.authenticationAction.error
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = CompoundIcons.Error(),
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = when (error) {
is QrLoginException.OtherDeviceNotSignedIn -> {
stringResource(R.string.screen_qr_code_login_device_not_signed_in_scan_state_subtitle)
}
else -> stringResource(R.string.screen_qr_code_login_invalid_scan_state_subtitle)
},
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.error,
style = ElementTheme.typography.fontBodySmMedium,
)
}
Text(
text = when (error) {
is QrLoginException.OtherDeviceNotSignedIn -> {
stringResource(R.string.screen_qr_code_login_device_not_signed_in_scan_state_description)
}
else -> stringResource(R.string.screen_qr_code_login_invalid_scan_state_description)
},
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
AsyncAction.Loading, is AsyncAction.Success -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(20.dp),
strokeWidth = 2.dp
)
Text(
text = stringResource(R.string.screen_qr_code_login_connecting_subtitle),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
AsyncAction.Uninitialized,
AsyncAction.Confirming -> Unit
}
}
}
@PreviewsDayNight
@Composable
internal fun QrCodeScanViewPreview(@PreviewParameter(QrCodeScanStateProvider::class) state: QrCodeScanState) = ElementPreview {
QrCodeScanView(
state = state,
onQrCodeDataReady = {},
onBackClick = {},
)
}

View File

@@ -30,6 +30,33 @@
<string name="screen_login_subtitle">"Matrix - гэта адкрытая сетка для бяспечнай, дэцэнтралізаванай сувязі."</string>
<string name="screen_login_title">"Сардэчна запрашаем!"</string>
<string name="screen_login_title_with_homeserver">"Увайдзіце ў %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Ўсталяванне бяспечнага злучэння"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Не атрымалася ўсталяваць бяспечнае злучэнне з новай прыладай. Існуючыя прылады па-ранейшаму ў бяспецы, і вам не трэба турбавацца пра іх."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Што зараз?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Паспрабуйце зноў увайсці ў сістэму з дапамогай QR-кода, калі гэта была сеткавая праблема"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Калі гэта не дапамагло, увайдзіце ўручную"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Злучэнне небяспечнае"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Вам будзе прапанавана ўвесці дзве лічбы, паказаныя на гэтай прыладзе."</string>
<string name="screen_qr_code_login_device_code_title">"Увядзіце наступны нумар на іншай прыладзе."</string>
<string name="screen_qr_code_login_initial_state_item_1">"Адкрыйце %1$s на настольнай прыладзе"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Націсніце на свой аватар"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Выберыце %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Звязаць новую прыладу”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Выконвайце паказаныя інструкцыі"</string>
<string name="screen_qr_code_login_initial_state_title">"Адкрыйце %1$s на іншай прыладзе, каб атрымаць QR-код"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Выкарыстоўвайце QR-код, паказаны на іншай прыладзе."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Паўтарыць спробу"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Няправільны QR-код"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Перайсці ў налады камеры"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Дазвольце доступ да камеры для сканавання QR-кода"</string>
<string name="screen_qr_code_login_scanning_state_title">"Сканаваць QR-код"</string>
<string name="screen_qr_code_login_start_over_button">"Пачаць спачатку"</string>
<string name="screen_qr_code_login_unknown_error_description">"Адбылася нечаканая памылка. Калі ласка, паспрабуйце яшчэ раз."</string>
<string name="screen_qr_code_login_verify_code_loading">"У чаканні іншай прылады"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Ваш правайдэр уліковага запісу можа запытаць наступны код для праверкі ўваходу."</string>
<string name="screen_qr_code_login_verify_code_title">"Ваш код спраўджання"</string>
<string name="screen_server_confirmation_change_server">"Змяніць правайдара ўліковага запісу"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Прыватны сервер для супрацоўнікаў Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix - гэта адкрытая сетка для бяспечнай, дэцэнтралізаванай сувязі."</string>

View File

@@ -18,6 +18,7 @@
<string name="screen_login_subtitle">"Matrix е отворена мрежа за сигурна, децентрализирана комуникация."</string>
<string name="screen_login_title">"Добре дошли отново!"</string>
<string name="screen_login_title_with_homeserver">"Влизане в %1$s"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Повторен опит"</string>
<string name="screen_server_confirmation_change_server">"Промяна на доставчика на акаунт"</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix е отворена мрежа за сигурна, децентрализирана комуникация."</string>
<string name="screen_server_confirmation_message_register">"Това е мястото, където ще живеят вашите разговори — точно както бихте използвали имейл доставчик, за да съхранявате вашите имейли."</string>

View File

@@ -30,6 +30,33 @@
<string name="screen_login_subtitle">"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."</string>
<string name="screen_login_title">"Vítejte zpět!"</string>
<string name="screen_login_title_with_homeserver">"Přihlaste se k %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Navazování zabezpečeného spojení"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"K novému zařízení se nepodařilo navázat bezpečné připojení. Vaše stávající zařízení jsou stále v bezpečí a nemusíte se o ně obávat."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Co teď?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Zkuste se znovu přihlásit pomocí QR kódu v případě, že se jednalo o problém se sítí"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Pokud narazíte na stejný problém, zkuste jinou síť wifi nebo použijte mobilní data místo wifi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Pokud to nefunguje, přihlaste se ručně"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Připojení není zabezpečené"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Budete požádáni o zadání dvou níže uvedených číslic."</string>
<string name="screen_qr_code_login_device_code_title">"Zadejte níže uvedené číslo na svém dalším zařízení"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Otevřete %1$s na stolním počítači"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klikněte na svůj avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Vybrat %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Připojit nové zařízení\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Postupujte podle uvedených pokynů"</string>
<string name="screen_qr_code_login_initial_state_title">"Otevřete %1$s na jiném zařízení pro získání QR kódu"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Použijte QR kód zobrazený na druhém zařízení."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Zkusit znovu"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Špatný QR kód"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Přejděte na nastavení fotoaparátu"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Povolte přístup k fotoaparátu a naskenujte QR kód"</string>
<string name="screen_qr_code_login_scanning_state_title">"Naskenujte QR kód"</string>
<string name="screen_qr_code_login_start_over_button">"Začít znovu"</string>
<string name="screen_qr_code_login_unknown_error_description">"Vyskytla se neočekávaná chyba. Prosím zkuste to znovu."</string>
<string name="screen_qr_code_login_verify_code_loading">"Čekání na vaše další zařízení"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Váš poskytovatel účtu může požádat o následující kód pro ověření přihlášení."</string>
<string name="screen_qr_code_login_verify_code_title">"Váš ověřovací kód"</string>
<string name="screen_server_confirmation_change_server">"Změnit poskytovatele účtu"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Soukromý server pro zaměstnance Elementu."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."</string>

View File

@@ -30,6 +30,33 @@
<string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string>
<string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_title_with_homeserver">"Anmelden bei %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Sichere Verbindung aufbauen"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Es konnte keine sichere Verbindung zu dem neuen Gerät hergestellt werden."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Und jetzt?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Versuche, dich erneut mit einem QR-Code anzumelden, falls dies ein Netzwerkproblem war."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Wenn das nicht funktioniert, melde dich manuell an"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Die Verbindung ist nicht sicher"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben."</string>
<string name="screen_qr_code_login_device_code_title">"Trage die unten angezeigte Zahl auf einem anderen Device ein"</string>
<string name="screen_qr_code_login_initial_state_item_1">"%1$s auf einem Desktop-Gerät öffnen"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klick auf deinen Avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Wähle %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Neues Gerät verknüpfen\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Befolge die angezeigten Anweisungen"</string>
<string name="screen_qr_code_login_initial_state_title">"Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Erneut versuchen"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Falscher QR-Code"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Gehe zu den Kameraeinstellungen"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Du musst %1$s die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes"</string>
<string name="screen_qr_code_login_scanning_state_title">"QR-Code scannen"</string>
<string name="screen_qr_code_login_start_over_button">"Neu beginnen"</string>
<string name="screen_qr_code_login_unknown_error_description">"Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut."</string>
<string name="screen_qr_code_login_verify_code_loading">"Warten auf dein anderes Gerät"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Dein Account-Provider kann nach dem folgenden Code fragen, um die Anmeldung zu bestätigen."</string>
<string name="screen_qr_code_login_verify_code_title">"Dein Verifizierungscode"</string>
<string name="screen_server_confirmation_change_server">"Kontoanbieter wechseln"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Ein privater Server für die Mitarbeiter von Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string>

View File

@@ -30,6 +30,7 @@
<string name="screen_login_subtitle">"Matrix es una red abierta para una comunicación segura y descentralizada."</string>
<string name="screen_login_title">"¡Hola de nuevo!"</string>
<string name="screen_login_title_with_homeserver">"Iniciar sesión en %1$s"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Inténtalo de nuevo"</string>
<string name="screen_server_confirmation_change_server">"Cambiar el proveedor de la cuenta"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Un servidor privado para los empleados de Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix es una red abierta para una comunicación segura y descentralizada."</string>

View File

@@ -30,6 +30,33 @@
<string name="screen_login_subtitle">"Matrix est un réseau ouvert pour une communication sécurisée et décentralisée."</string>
<string name="screen_login_title">"Content de vous revoir !"</string>
<string name="screen_login_title_with_homeserver">"Connectez-vous à %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Établissement dune connexion sécurisée"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Aucune connexion sécurisée na pu être établie avec la nouvelle session. Vos sessions existantes sont toujours en sécurité et vous navez pas à vous en soucier."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Et maintenant ?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Essayez de vous connecter à nouveau à laide du code QR au cas où il sagirait dun problème réseau"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Si vous rencontrez le même problème, essayez un autre réseau wifi ou utilisez vos données mobiles au lieu du wifi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Si cela ne fonctionne pas, connectez-vous manuellement"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"La connexion nest pas sécurisée"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Il vous sera demandé de saisir les deux chiffres affichés sur cet appareil."</string>
<string name="screen_qr_code_login_device_code_title">"Saisissez le nombre ci-dessous sur votre autre appareil"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Ouvrez %1$s sur un ordinateur"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Cliquez sur votre image de profil"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Choisissez %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Associer une nouvelle session”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Suivez les instructions affichées"</string>
<string name="screen_qr_code_login_initial_state_title">"Ouvrez %1$s sur un autre appareil pour obtenir le QR code"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Scannez le QR code affiché sur lautre appareil."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Essayer à nouveau"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"QR code erroné"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Accéder aux paramètres de lappareil photo"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Autoriser lusage de la caméra pour scanner le code QR"</string>
<string name="screen_qr_code_login_scanning_state_title">"Scannez le QR code"</string>
<string name="screen_qr_code_login_start_over_button">"Recommencer"</string>
<string name="screen_qr_code_login_unknown_error_description">"Une erreur inattendue sest produite. Veuillez réessayer."</string>
<string name="screen_qr_code_login_verify_code_loading">"En attente de votre autre session"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Votre fournisseur de compte peut vous demander le code suivant pour vérifier la connexion."</string>
<string name="screen_qr_code_login_verify_code_title">"Votre code de vérification"</string>
<string name="screen_server_confirmation_change_server">"Changer de fournisseur de compte"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Un serveur privé pour les employés dElement."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix est un réseau ouvert pour une communication sécurisée et décentralisée."</string>

View File

@@ -30,6 +30,33 @@
<string name="screen_login_subtitle">"A Matrix egy nyitott hálózat a biztonságos, decentralizált kommunikációhoz."</string>
<string name="screen_login_title">"Örülünk, hogy visszatért!"</string>
<string name="screen_login_title_with_homeserver">"Bejelentkezés ide: %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Biztonságos kapcsolat létesítése"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Most mi lesz?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Ha ez nem működik, jelentkezzen be kézileg"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"A kapcsolat nem biztonságos"</string>
<string name="screen_qr_code_login_device_code_subtitle">"A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén."</string>
<string name="screen_qr_code_login_device_code_title">"Adja meg az alábbi számot a másik eszközén"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Nyissa meg az %1$set egy asztali eszközön"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Kattintson a profilképére"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Válassza ezt: %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"„Új eszköz összekapcsolása”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Kövesse a látható utasításokat"</string>
<string name="screen_qr_code_login_initial_state_title">"Nyissa meg az %1$set egy másik eszközön a QR-kód lekéréséhez."</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Használja a másik eszközön látható QR-kódot."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Próbálja újra"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Hibás QR-kód"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Ugrás a kamerabeállításokhoz"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Engedélyezze a kamera elérését a QR-kód beolvasásához"</string>
<string name="screen_qr_code_login_scanning_state_title">"Olvassa be a QR-kódot"</string>
<string name="screen_qr_code_login_start_over_button">"Újrakezdés"</string>
<string name="screen_qr_code_login_unknown_error_description">"Váratlan hiba történt. Próbálja meg újra."</string>
<string name="screen_qr_code_login_verify_code_loading">"Várakozás a másik eszközre"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"A fiókszolgáltatója kérheti a következő kódot a bejelentkezése ellenőrzéséhez."</string>
<string name="screen_qr_code_login_verify_code_title">"Az Ön ellenőrzőkódja"</string>
<string name="screen_server_confirmation_change_server">"Fiókszolgáltató módosítása"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Egy privát kiszolgáló az Element alkalmazottai számára."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"A Matrix egy nyitott hálózat a biztonságos, decentralizált kommunikációhoz."</string>

View File

@@ -30,6 +30,32 @@
<string name="screen_login_subtitle">"Matrix adalah jaringan terbuka untuk komunikasi yang aman dan terdesentralisasi."</string>
<string name="screen_login_title">"Selamat datang kembali!"</string>
<string name="screen_login_title_with_homeserver">"Masuk ke %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Membuat koneksi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Apa sekarang?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Jika tidak berhasil, masuk secara manual"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Koneksi tidak aman"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Anda akan diminta untuk memasukkan dua digit yang ditunjukkan di bawah ini."</string>
<string name="screen_qr_code_login_device_code_title">"Masukkan nomor di perangkat Anda"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Buka %1$s di perangkat desktop"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klik pada avatar Anda"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Pilih %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Tautkan perangkat baru”"</string>
<string name="screen_qr_code_login_initial_state_title">"Buka %1$s di perangkat lain untuk mendapatkan kode QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Gunakan kode QR yang ditampilkan di perangkat lain."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Coba lagi"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Kode QR salah"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Pergi ke pengaturan kamera"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Izinkan akses kamera untuk memindai kode QR"</string>
<string name="screen_qr_code_login_scanning_state_title">"Pindai kode QR"</string>
<string name="screen_qr_code_login_start_over_button">"Mulai dari awal"</string>
<string name="screen_qr_code_login_unknown_error_description">"Terjadi kesalahan tak terduga. Silakan coba lagi."</string>
<string name="screen_qr_code_login_verify_code_loading">"Menunggu perangkat Anda yang lain"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Penyedia akun Anda mungkin meminta kode berikut untuk memverifikasi proses masuk."</string>
<string name="screen_qr_code_login_verify_code_title">"Kode verifikasi Anda"</string>
<string name="screen_server_confirmation_change_server">"Ubah penyedia akun"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Server pribadi untuk karyawan Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix adalah jaringan terbuka untuk komunikasi yang aman dan terdesentralisasi."</string>

View File

@@ -30,6 +30,7 @@
<string name="screen_login_subtitle">"Matrix è una rete aperta per comunicazioni sicure e decentralizzate."</string>
<string name="screen_login_title">"Bentornato!"</string>
<string name="screen_login_title_with_homeserver">"Accedi a %1$s"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Riprova"</string>
<string name="screen_server_confirmation_change_server">"Cambia fornitore dell\'account"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Un server privato per i dipendenti di Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix è una rete aperta per comunicazioni sicure e decentralizzate."</string>

View File

@@ -30,6 +30,7 @@
<string name="screen_login_subtitle">"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."</string>
<string name="screen_login_title">"Bine ați revenit!"</string>
<string name="screen_login_title_with_homeserver">"Conectați-vă la %1$s"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Încercați din nou"</string>
<string name="screen_server_confirmation_change_server">"Schimbați furnizorul contului"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Un server privat pentru angajații Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."</string>

View File

@@ -30,6 +30,32 @@
<string name="screen_login_subtitle">"Matrix — это открытая сеть для безопасной децентрализованной связи."</string>
<string name="screen_login_title">"Рады видеть вас снова!"</string>
<string name="screen_login_title_with_homeserver">"Войти в %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Установление соединения"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Что теперь?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Попробуйте снова войти в систему с помощью QR-кода, если это была сетевая проблема"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Если это не помогло, войдите вручную"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Соединение не защищено"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Вам будет предложено ввести две цифры, показанные ниже."</string>
<string name="screen_qr_code_login_device_code_title">"Введите номер на своем устройстве"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Откройте %1$s на настольном устройстве"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Выбрать %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Привязать новое устройство\""</string>
<string name="screen_qr_code_login_initial_state_title">"Откройте %1$s на другом устройстве, чтобы получить QR-код"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Используйте QR-код, показанный на другом устройстве."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Повторить попытку"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Неверный QR-код"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Перейдите в настройки камеры"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Разрешите доступ к камере для сканирования QR-кода"</string>
<string name="screen_qr_code_login_scanning_state_title">"Сканировать QR-код"</string>
<string name="screen_qr_code_login_start_over_button">"Начать заново"</string>
<string name="screen_qr_code_login_unknown_error_description">"Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."</string>
<string name="screen_qr_code_login_verify_code_loading">"В ожидании другого устройства"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Поставщик учетной записи может запросить следующий код для подтверждения входа."</string>
<string name="screen_qr_code_login_verify_code_title">"Ваш код подтверждения"</string>
<string name="screen_server_confirmation_change_server">"Сменить учетную запись"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Частный сервер для сотрудников Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix — это открытая сеть для безопасной децентрализованной связи."</string>

View File

@@ -30,6 +30,33 @@
<string name="screen_login_subtitle">"Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string>
<string name="screen_login_title">"Vitajte späť!"</string>
<string name="screen_login_title_with_homeserver">"Prihlásiť sa do %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Nadväzovanie bezpečného spojenia"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Čo teraz?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Ak narazíte na rovnaký problém, vyskúšajte inú sieť Wi-Fi alebo namiesto siete Wi-Fi použite mobilné dáta"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Ak to nefunguje, prihláste sa manuálne"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Pripojenie nie je bezpečené"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Budete požiadaní o zadanie dvoch číslic zobrazených na tomto zariadení."</string>
<string name="screen_qr_code_login_device_code_title">"Zadajte nižšie uvedené číslo na vašom druhom zariadení"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Otvorte %1$s na stolnom zariadení"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Kliknite na svoj obrázok"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Vyberte %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"„Prepojiť nové zariadenie“"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Postupujte podľa zobrazených pokynov"</string>
<string name="screen_qr_code_login_initial_state_title">"Ak chcete získať QR kód, otvorte %1$s na inom zariadení"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Použite QR kód zobrazený na druhom zariadení."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Skúste to znova"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Nesprávny QR kód"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Prejsť na nastavenia fotoaparátu"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Povoľte prístup k fotoaparátu na naskenovanie QR kódu"</string>
<string name="screen_qr_code_login_scanning_state_title">"Naskenovať QR kód"</string>
<string name="screen_qr_code_login_start_over_button">"Začať odznova"</string>
<string name="screen_qr_code_login_unknown_error_description">"Vyskytla sa neočakávaná chyba. Prosím, skúste to znova."</string>
<string name="screen_qr_code_login_verify_code_loading">"Čaká sa na vaše druhé zariadenie"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Váš poskytovateľ účtu môže požiadať o nasledujúci kód na overenie prihlásenia."</string>
<string name="screen_qr_code_login_verify_code_title">"Váš overovací kód"</string>
<string name="screen_server_confirmation_change_server">"Zmeniť poskytovateľa účtu"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Súkromný server pre zamestnancov spoločnosti Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string>

View File

@@ -30,6 +30,7 @@
<string name="screen_login_subtitle">"Matrix är ett öppet nätverk för säker, decentraliserad kommunikation."</string>
<string name="screen_login_title">"Välkommen tillbaka!"</string>
<string name="screen_login_title_with_homeserver">"Logga in på %1$s"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Försök igen"</string>
<string name="screen_server_confirmation_change_server">"Byt kontoleverantör"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"En privat server för Element-anställda."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix är ett öppet nätverk för säker, decentraliserad kommunikation."</string>

View File

@@ -30,6 +30,7 @@
<string name="screen_login_subtitle">"Matrix — це відкрита мережа для безпечної, децентралізованої комунікації."</string>
<string name="screen_login_title">"З поверненням!"</string>
<string name="screen_login_title_with_homeserver">"Увійти в %1$s"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Спробуйте ще раз"</string>
<string name="screen_server_confirmation_change_server">"Змінити провайдера облікового запису"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Приватний сервер для співробітників Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix — це відкрита мережа для безпечної, децентралізованої комунікації."</string>

View File

@@ -23,6 +23,7 @@
<string name="screen_login_subtitle">"Matrix 是一個開放網路,為了安全且去中心化的通訊而生。"</string>
<string name="screen_login_title">"歡迎回來!"</string>
<string name="screen_login_title_with_homeserver">"登入 %1$s"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"再試一次"</string>
<string name="screen_server_confirmation_change_server">"更改帳號提供者"</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix 是一個開放網路,為了安全且去中心化的通訊而生。"</string>
<string name="screen_server_confirmation_message_register">"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"</string>

View File

@@ -30,6 +30,48 @@
<string name="screen_login_subtitle">"Matrix is an open network for secure, decentralised communication."</string>
<string name="screen_login_title">"Welcome back!"</string>
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Establishing a secure connection"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"What now?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Try signing in again with a QR code in case this was a network problem"</string>
<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_device_not_signed_in_scan_state_description">"Sign in to your other device and then try again, or use another device thats already signed in."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Other device not signed in"</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>
<string name="screen_qr_code_login_error_declined_title">"Sign in declined"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Sign in expired. Please try again."</string>
<string name="screen_qr_code_login_error_expired_title">"The sign in was not completed in time"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Your other device does not support signing in to %s with a QR code.
Try signing in manually, or scan the QR code with another device."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"QR code not supported"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Your account provider does not support %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s not supported"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Ready to scan"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Open %1$s on a desktop device"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Click on your avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Select %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Link new device”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Scan the QR code with this device"</string>
<string name="screen_qr_code_login_initial_state_title">"Open %1$s on another device to get the QR code"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Use the QR code shown on the other device."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Try again"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Wrong QR code"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Go to camera settings"</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_scanning_state_title">"Scan the QR code"</string>
<string name="screen_qr_code_login_start_over_button">"Start over"</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>
<string name="screen_qr_code_login_verify_code_subtitle">"Your account provider may ask for the following code to verify the sign in."</string>
<string name="screen_qr_code_login_verify_code_title">"Your verification code"</string>
<string name="screen_server_confirmation_change_server">"Change account provider"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"A private server for Element employees."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix is an open network for secure, decentralised communication."</string>

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.di
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
import io.element.android.features.login.impl.qrcode.QrCodeLoginManager
import io.element.android.libraries.architecture.AssistedNodeFactory
import io.element.android.libraries.architecture.createNode
internal class FakeQrCodeLoginComponent(private val qrCodeLoginManager: QrCodeLoginManager) :
QrCodeLoginComponent {
// Ignore this error, it does override a method once code generation is done
override fun qrCodeLoginManager(): QrCodeLoginManager = qrCodeLoginManager
class Builder(private val qrCodeLoginManager: QrCodeLoginManager = FakeQrCodeLoginManager()) :
QrCodeLoginComponent.Builder {
override fun build(): QrCodeLoginComponent {
return FakeQrCodeLoginComponent(qrCodeLoginManager)
}
}
override fun nodeFactories(): Map<Class<out Node>, AssistedNodeFactory<*>> {
return mapOf(
QrCodeLoginFlowNode::class.java to object : AssistedNodeFactory<QrCodeLoginFlowNode> {
override fun create(buildContext: BuildContext, plugins: List<Plugin>): QrCodeLoginFlowNode {
return createNode<QrCodeLoginFlowNode>(buildContext, plugins)
}
}
)
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.qrcode
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultQrCodeLoginManagerTest {
@Test
fun `authenticate - returns success if the login succeeded`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
loginWithQrCodeResult = { _, _ -> Result.success(A_SESSION_ID) }
)
val manager = DefaultQrCodeLoginManager(authenticationService)
val result = manager.authenticate(FakeMatrixQrCodeLoginData())
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEqualTo(A_SESSION_ID)
}
@Test
fun `authenticate - returns failure if the login failed`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
loginWithQrCodeResult = { _, _ -> Result.failure(IllegalStateException("Auth failed")) }
)
val manager = DefaultQrCodeLoginManager(authenticationService)
val result = manager.authenticate(FakeMatrixQrCodeLoginData())
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isNotNull()
}
@Test
fun `authenticate - emits the auth steps`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
loginWithQrCodeResult = { _, progressListener ->
progressListener(QrCodeLoginStep.EstablishingSecureChannel("00"))
progressListener(QrCodeLoginStep.Starting)
progressListener(QrCodeLoginStep.WaitingForToken("000000"))
progressListener(QrCodeLoginStep.Finished)
Result.success(A_SESSION_ID)
}
)
val manager = DefaultQrCodeLoginManager(authenticationService)
manager.currentLoginStep.test {
manager.authenticate(FakeMatrixQrCodeLoginData())
assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.Uninitialized)
assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.EstablishingSecureChannel("00"))
assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.Starting)
assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.WaitingForToken("000000"))
assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.Finished)
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.qrcode
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.flow.MutableStateFlow
class FakeQrCodeLoginManager(
var authenticateResult: (MatrixQrCodeLoginData) -> Result<SessionId> =
lambdaRecorder<MatrixQrCodeLoginData, Result<SessionId>> { Result.success(A_SESSION_ID) },
var resetAction: () -> Unit = lambdaRecorder<Unit> { },
) : QrCodeLoginManager {
override val currentLoginStep: MutableStateFlow<QrCodeLoginStep> =
MutableStateFlow(QrCodeLoginStep.Uninitialized)
override suspend fun authenticate(qrCodeLoginData: MatrixQrCodeLoginData): Result<SessionId> {
return authenticateResult(qrCodeLoginData)
}
override fun reset() {
resetAction()
}
}

View File

@@ -0,0 +1,219 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.qrcode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.AncestryInfo
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.di.FakeQrCodeLoginComponent
import io.element.android.features.login.impl.screens.qrcode.confirmation.QrCodeConfirmationStep
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class QrCodeLoginFlowNodeTest {
@Test
fun `backstack changes when confirmation steps are received`() = runTest {
val qrCodeLoginManager = FakeQrCodeLoginManager()
val flowNode = createLoginFlowNode(qrCodeLoginManager = qrCodeLoginManager)
flowNode.observeLoginStep()
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial)
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.EstablishingSecureChannel("12")
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayCheckCode("12")))
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.WaitingForToken("123456")
assertThat(flowNode.currentNavTarget())
.isEqualTo(QrCodeLoginFlowNode.NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayVerificationCode("123456")))
}
@Test
fun `backstack changes when failure step is received`() = runTest {
val qrCodeLoginManager = FakeQrCodeLoginManager()
val flowNode = createLoginFlowNode(qrCodeLoginManager = qrCodeLoginManager)
flowNode.observeLoginStep()
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial)
// Only case when this doesn't happen, since it's handled by the already displayed UI
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OtherDeviceNotSignedIn)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial)
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Expired)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Expired))
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Declined)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Declined))
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Cancelled)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Cancelled))
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.SlidingSyncNotAvailable)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.SlidingSyncNotAvailable))
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.LinkingNotSupported)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.ProtocolNotSupported))
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.ConnectionInsecure)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.InsecureChannelDetected))
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OidcMetadataInvalid)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.UnknownError))
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Unknown)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.UnknownError))
}
@Test
fun `backstack doesn't change when other steps are received`() = runTest {
val qrCodeLoginManager = FakeQrCodeLoginManager()
val flowNode = createLoginFlowNode(qrCodeLoginManager = qrCodeLoginManager)
flowNode.observeLoginStep()
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial)
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Starting
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial)
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Finished
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `startAuthentication - success marks the login flow as done`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService(
loginWithQrCodeResult = { _, progress ->
progress(QrCodeLoginStep.Finished)
Result.success(A_SESSION_ID)
}
)
// Test with a real manager to ensure the flow is correctly done
val qrCodeLoginManager = DefaultQrCodeLoginManager(fakeAuthenticationService)
val defaultLoginUserStory = DefaultLoginUserStory().apply {
loginFlowIsDone.value = false
}
val flowNode = createLoginFlowNode(
qrCodeLoginManager = qrCodeLoginManager,
defaultLoginUserStory = defaultLoginUserStory,
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
flowNode.run { startAuthentication(FakeMatrixQrCodeLoginData()) }
assertThat(flowNode.isLoginInProgress()).isTrue()
advanceUntilIdle()
assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Finished)
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isTrue()
assertThat(flowNode.isLoginInProgress()).isFalse()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `startAuthentication - failure is correctly handled`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService(
loginWithQrCodeResult = { _, progress ->
progress(QrCodeLoginStep.Failed(QrLoginException.Unknown))
Result.failure(IllegalStateException("Failed"))
}
)
// Test with a real manager to ensure the flow is correctly done
val qrCodeLoginManager = DefaultQrCodeLoginManager(fakeAuthenticationService)
val defaultLoginUserStory = DefaultLoginUserStory().apply {
loginFlowIsDone.value = false
}
val flowNode = createLoginFlowNode(
qrCodeLoginManager = qrCodeLoginManager,
defaultLoginUserStory = defaultLoginUserStory,
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
flowNode.run { startAuthentication(FakeMatrixQrCodeLoginData()) }
assertThat(flowNode.isLoginInProgress()).isTrue()
advanceUntilIdle()
assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Failed(QrLoginException.Unknown))
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
assertThat(flowNode.isLoginInProgress()).isFalse()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `startAuthentication - then reset`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService(
loginWithQrCodeResult = { _, progress ->
progress(QrCodeLoginStep.Finished)
Result.success(A_SESSION_ID)
}
)
// Test with a real manager to ensure the flow is correctly done
val qrCodeLoginManager = DefaultQrCodeLoginManager(fakeAuthenticationService)
val defaultLoginUserStory = DefaultLoginUserStory().apply {
loginFlowIsDone.value = false
}
val flowNode = createLoginFlowNode(
qrCodeLoginManager = qrCodeLoginManager,
defaultLoginUserStory = defaultLoginUserStory,
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
flowNode.run { startAuthentication(FakeMatrixQrCodeLoginData()) }
assertThat(flowNode.isLoginInProgress()).isTrue()
flowNode.reset()
advanceUntilIdle()
assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Uninitialized)
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
assertThat(flowNode.isLoginInProgress()).isFalse()
}
private fun TestScope.createLoginFlowNode(
qrCodeLoginManager: QrCodeLoginManager = FakeQrCodeLoginManager(),
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
): QrCodeLoginFlowNode {
val buildContext = BuildContext(
ancestryInfo = AncestryInfo.Root,
savedStateMap = null,
customisations = NodeCustomisationDirectoryImpl()
)
return QrCodeLoginFlowNode(
buildContext = buildContext,
plugins = emptyList(),
qrCodeLoginComponentBuilder = FakeQrCodeLoginComponent.Builder(qrCodeLoginManager),
defaultLoginUserStory = defaultLoginUserStory,
coroutineDispatchers = coroutineDispatchers,
)
}
private fun QrCodeLoginFlowNode.currentNavTarget() = backstack.elements.value.last().key.navTarget
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.confirmation
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class QrCodeConfirmationViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `on back pressed - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeConfirmationView(
step = QrCodeConfirmationStep.DisplayCheckCode("12"),
onCancel = callback
)
rule.pressBackKey()
}
}
@Test
fun `on Cancel button clicked - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeConfirmationView(
step = QrCodeConfirmationStep.DisplayVerificationCode("123456"),
onCancel = callback
)
rule.clickOn(CommonStrings.action_cancel)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeConfirmationView(
step: QrCodeConfirmationStep,
onCancel: () -> Unit
) {
setContent {
QrCodeConfirmationView(
step = step,
onCancel = onCancel
)
}
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.error
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class QrCodeErrorViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `on back pressed - calls the onRetry callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeErrorView(
onRetry = callback
)
rule.pressBackKey()
}
}
@Test
fun `on try again button clicked - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeErrorView(
onRetry = callback
)
rule.clickOn(R.string.screen_qr_code_login_start_over_button)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeErrorView(
onRetry: () -> Unit,
errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError,
appName: String = "Element X",
) {
setContent {
QrCodeErrorView(
errorScreenType = errorScreenType,
appName = appName,
onRetry = onRetry
)
}
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.intro
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import kotlinx.coroutines.test.runTest
import org.junit.Test
class QrCodeIntroPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createQrCodeIntroPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().run {
assertThat(desktopAppName).isEmpty()
assertThat(cameraPermissionState.permission).isEqualTo("android.permission.POST_NOTIFICATIONS")
assertThat(canContinue).isFalse()
}
}
}
@Test
fun `present - Continue with camera permissions can continue`() = runTest {
val permissionsPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
val presenter = createQrCodeIntroPresenter(permissionsPresenterFactory = permissionsPresenterFactory)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(QrCodeIntroEvents.Continue)
assertThat(awaitItem().canContinue).isTrue()
}
}
@Test
fun `present - Continue with unknown camera permissions opens permission dialog`() = runTest {
val permissionsPresenter = FakePermissionsPresenter()
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
val presenter = createQrCodeIntroPresenter(permissionsPresenterFactory = permissionsPresenterFactory)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(QrCodeIntroEvents.Continue)
assertThat(awaitItem().cameraPermissionState.showDialog).isTrue()
}
}
private fun createQrCodeIntroPresenter(
buildMeta: BuildMeta = aBuildMeta(),
permissionsPresenterFactory: FakePermissionsPresenterFactory = FakePermissionsPresenterFactory(),
): QrCodeIntroPresenter {
return QrCodeIntroPresenter(
buildMeta = buildMeta,
permissionsPresenterFactory = permissionsPresenterFactory,
)
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.intro
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class QrCodeIntroViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `on back pressed - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeIntroView(
state = aQrCodeIntroState(),
onBackClicked = callback
)
rule.pressBackKey()
}
}
@Test
fun `on back button clicked - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeIntroView(
state = aQrCodeIntroState(),
onBackClicked = callback
)
rule.pressBack()
}
}
@Test
fun `when can continue - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeIntroView(
state = aQrCodeIntroState(canContinue = true),
onContinue = callback
)
}
}
@Test
fun `on continue button clicked - emits the Continue event`() {
val eventRecorder = EventsRecorder<QrCodeIntroEvents>()
rule.setQrCodeIntroView(
state = aQrCodeIntroState(eventSink = eventRecorder),
)
rule.clickOn(CommonStrings.action_continue)
eventRecorder.assertSingle(QrCodeIntroEvents.Continue)
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeIntroView(
state: QrCodeIntroState,
onBackClicked: () -> Unit = EnsureNeverCalled(),
onContinue: () -> Unit = EnsureNeverCalled(),
) {
setContent {
QrCodeIntroView(
state = state,
onBackClick = onBackClicked,
onContinue = onContinue,
)
}
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.scan
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginDataFactory
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class QrCodeScanPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createQrCodeScanPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().run {
assertThat(isScanning).isTrue()
assertThat(authenticationAction.isUninitialized()).isTrue()
}
}
}
@Test
fun `present - scanned QR code successfully`() = runTest {
val presenter = createQrCodeScanPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(QrCodeScanEvents.QrCodeScanned(byteArrayOf()))
assertThat(awaitItem().isScanning).isFalse()
assertThat(awaitItem().authenticationAction.isLoading()).isTrue()
assertThat(awaitItem().authenticationAction.isSuccess()).isTrue()
}
}
@Test
fun `present - scanned QR code failed and can be retried`() = runTest {
val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(
parseQrCodeLoginDataResult = { Result.failure(Exception("Failed to parse QR code")) }
)
val presenter = createQrCodeScanPresenter(qrCodeLoginDataFactory = qrCodeLoginDataFactory)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(QrCodeScanEvents.QrCodeScanned(byteArrayOf()))
assertThat(awaitItem().isScanning).isFalse()
assertThat(awaitItem().authenticationAction.isLoading()).isTrue()
val errorState = awaitItem()
assertThat(errorState.authenticationAction.isFailure()).isTrue()
errorState.eventSink(QrCodeScanEvents.TryAgain)
assertThat(awaitItem().isScanning).isTrue()
assertThat(awaitItem().authenticationAction.isUninitialized()).isTrue()
}
}
@Test
fun `present - login failed with so we display the error and recover from it`() = runTest {
val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory()
val qrCodeLoginManager = FakeQrCodeLoginManager()
val resetAction = lambdaRecorder<Unit> {
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Uninitialized
}
qrCodeLoginManager.resetAction = resetAction
val presenter = createQrCodeScanPresenter(qrCodeLoginDataFactory = qrCodeLoginDataFactory, qrCodeLoginManager = qrCodeLoginManager)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip initial item
skipItems(1)
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OtherDeviceNotSignedIn)
val errorState = awaitItem()
// The state for this screen is failure
assertThat(errorState.authenticationAction.isFailure()).isTrue()
// However, the QrCodeLoginManager is reset
resetAction.assertions().isCalledOnce()
assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Uninitialized)
}
}
private fun TestScope.createQrCodeScanPresenter(
qrCodeLoginDataFactory: FakeMatrixQrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(),
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
) = QrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
qrCodeLoginManager = qrCodeLoginManager,
coroutineDispatchers = coroutineDispatchers,
)
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.qrcode.scan
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class QrCodeScanViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `on back pressed - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeScanView(
state = aQrCodeScanState(),
onBackClick = callback
)
rule.pressBackKey()
}
}
@Test
fun `on QR code data ready - calls the expected callback`() {
val data = FakeMatrixQrCodeLoginData()
ensureCalledOnceWithParam<MatrixQrCodeLoginData>(data) { callback ->
rule.setQrCodeScanView(
state = aQrCodeScanState(authenticationAction = AsyncAction.Success(data)),
onQrCodeDataReady = callback
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeScanView(
state: QrCodeScanState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onQrCodeDataReady: (MatrixQrCodeLoginData) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
QrCodeScanView(
state = state,
onBackClick = onBackClick,
onQrCodeDataReady = onQrCodeDataReady
)
}
}
}

View File

@@ -33,6 +33,7 @@ import io.element.android.features.logout.impl.tools.isBackingUp
import io.element.android.features.logout.impl.ui.LogoutActionDialog
import io.element.android.libraries.architecture.AsyncAction
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.Button
@@ -62,7 +63,7 @@ fun LogoutView(
onBackClick = onBackClick,
title = title(state),
subTitle = subtitle(state),
iconVector = CompoundIcons.KeySolid(),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
modifier = modifier,
buttons = {
Buttons(

View File

@@ -32,6 +32,7 @@ interface OnBoardingEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onSignUp()
fun onSignIn()
fun onSignInWithQrCode()
fun onOpenDeveloperSettings()
fun onReportProblem()
}

View File

@@ -23,6 +23,12 @@ plugins {
android {
namespace = "io.element.android.features.onboarding.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
anvil {
@@ -32,21 +38,28 @@ anvil {
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.appconfig)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
api(projects.features.onboarding.api)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -45,6 +45,10 @@ class OnBoardingNode @AssistedInject constructor(
plugins<OnBoardingEntryPoint.Callback>().forEach { it.onSignUp() }
}
private fun onSignInWithQrCode() {
plugins<OnBoardingEntryPoint.Callback>().forEach { it.onSignInWithQrCode() }
}
private fun onOpenDeveloperSettings() {
plugins<OnBoardingEntryPoint.Callback>().forEach { it.onOpenDeveloperSettings() }
}
@@ -61,7 +65,7 @@ class OnBoardingNode @AssistedInject constructor(
modifier = modifier,
onSignIn = ::onSignIn,
onCreateAccount = ::onSignUp,
onSignInWithQrCode = { /* Not supported yet */ },
onSignInWithQrCode = ::onSignInWithQrCode,
onOpenDeveloperSettings = ::onOpenDeveloperSettings,
onReportProblem = ::onReportProblem,
)

View File

@@ -17,9 +17,14 @@
package io.element.android.features.onboarding.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import javax.inject.Inject
/**
@@ -28,13 +33,17 @@ import javax.inject.Inject
*/
class OnBoardingPresenter @Inject constructor(
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
) : Presenter<OnBoardingState> {
@Composable
override fun present(): OnBoardingState {
return OnBoardingState(
val canLoginWithQrCode by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
}
return OnBoardingState(
isDebugBuild = buildMeta.buildType != BuildType.RELEASE,
productionApplicationName = buildMeta.productionApplicationName,
canLoginWithQrCode = OnBoardingConfig.CAN_LOGIN_WITH_QR_CODE,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = OnBoardingConfig.CAN_CREATE_ACCOUNT,
)
}

View File

@@ -21,6 +21,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
@@ -34,31 +36,38 @@ class OnBoardingPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = OnBoardingPresenter(
aBuildMeta(
buildMeta = aBuildMeta(
applicationName = "A",
productionApplicationName = "B",
desktopApplicationName = "C",
)
),
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.QrCodeLogin.name to true)),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isDebugBuild).isTrue()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isFalse()
assertThat(awaitItem().canLoginWithQrCode).isTrue()
}
}
@Test
fun `present - initial state release`() = runTest {
val presenter = OnBoardingPresenter(aBuildMeta(buildType = BuildType.RELEASE))
val presenter = OnBoardingPresenter(
buildMeta = aBuildMeta(buildType = BuildType.RELEASE),
featureFlagService = FakeFeatureFlagService(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isDebugBuild).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.onboarding.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class OnboardingViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `when can create account - clicking on create account calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(canCreateAccount = true),
onCreateAccount = callback,
)
rule.clickOn(R.string.screen_onboarding_sign_up)
}
}
@Test
fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(canLoginWithQrCode = true),
onSignInWithQrCode = callback,
)
rule.clickOn(R.string.screen_onboarding_sign_in_with_qr_code)
}
}
@Test
fun `when can login with QR code - clicking on sign in manually calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(canLoginWithQrCode = true),
onSignIn = callback,
)
rule.clickOn(R.string.screen_onboarding_sign_in_manually)
}
}
@Test
fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`() {
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(
canLoginWithQrCode = false,
canCreateAccount = false,
),
onSignIn = callback,
)
rule.clickOn(CommonStrings.action_continue)
}
}
@Test
fun `when on debug build - clicking on the settings icon opens the developer settings`() {
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(isDebugBuild = true),
onOpenDeveloperSettings = callback
)
rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.common_settings))).performClick()
}
}
@Test
fun `clicking on report a problem calls the sign in callback`() {
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(),
onReportProblem = callback,
)
rule.clickOn(CommonStrings.common_report_a_problem)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
state: OnBoardingState,
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
onSignIn: () -> Unit = EnsureNeverCalled(),
onCreateAccount: () -> Unit = EnsureNeverCalled(),
onOpenDeveloperSettings: () -> Unit = EnsureNeverCalled(),
onReportProblem: () -> Unit = EnsureNeverCalled(),
) {
setContent {
OnBoardingView(
state = state,
onSignInWithQrCode = onSignInWithQrCode,
onSignIn = onSignIn,
onCreateAccount = onCreateAccount,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onReportProblem = onReportProblem,
)
}
}
}

View File

@@ -16,35 +16,26 @@
package io.element.android.features.securebackup.impl.createkey
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
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.securebackup.impl.R
import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.squareSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -74,55 +65,19 @@ fun CreateNewRecoveryKeyView(
@Composable
private fun Content(desktopApplicationName: String) {
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
Item(index = 1, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_1, desktopApplicationName)))
Item(index = 2, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_2)))
Item(
index = 3,
text = buildAnnotatedString {
val resetAllAction = stringResource(R.string.screen_create_new_recovery_key_list_item_3_reset_all)
val text = stringResource(R.string.screen_create_new_recovery_key_list_item_3, resetAllAction)
append(text)
val start = text.indexOf(resetAllAction)
val end = start + resetAllAction.length
if (start in text.indices && end in text.indices) {
addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
}
}
)
Item(index = 4, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_4)))
Item(index = 5, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_5)))
}
}
@Composable
private fun Item(index: Int, text: AnnotatedString) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ItemNumber(index = index)
Text(text = text, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textPrimary)
}
}
@Composable
private fun ItemNumber(
index: Int,
) {
val color = ElementTheme.colors.textPlaceholder
Box(
modifier = Modifier
.border(1.dp, color, CircleShape)
.squareSize()
) {
Text(
modifier = Modifier.padding(1.5.dp),
text = index.toString(),
style = ElementTheme.typography.fontBodySmRegular,
color = color,
textAlign = TextAlign.Center,
val listItems = buildList {
add(AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_1, desktopApplicationName)))
add(AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_2)))
add(
annotatedTextWithBold(
text = stringResource(R.string.screen_create_new_recovery_key_list_item_3),
boldText = stringResource(R.string.screen_create_new_recovery_key_list_item_3_reset_all)
)
)
add(AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_4)))
add(AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_5)))
}
NumberedListOrganism(modifier = Modifier.padding(horizontal = 16.dp), items = listItems.toImmutableList())
}
@PreviewsDayNight

View File

@@ -32,6 +32,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securebackup.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.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -52,7 +53,7 @@ fun SecureBackupDisableView(
onBackClick = onBackClick,
title = stringResource(id = R.string.screen_key_backup_disable_title),
subTitle = stringResource(id = R.string.screen_key_backup_disable_description),
iconVector = CompoundIcons.KeyOffSolid(),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeyOffSolid()),
buttons = { Buttons(state = state) },
) {
Content(state = state)

View File

@@ -25,6 +25,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securebackup.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.components.async.AsyncActionView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -41,7 +42,7 @@ fun SecureBackupEnableView(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
iconVector = CompoundIcons.KeySolid(),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
buttons = { Buttons(state = state) }
)
AsyncActionView(

View File

@@ -28,6 +28,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -55,7 +56,7 @@ fun SecureBackupEnterRecoveryKeyView(
FlowStepPage(
modifier = modifier,
onBackClick = onBackClick,
iconVector = CompoundIcons.KeySolid(),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
title = stringResource(id = R.string.screen_recovery_key_confirm_title),
subTitle = stringResource(id = R.string.screen_recovery_key_confirm_description),
buttons = { Buttons(state = state, onCreateRecoveryKey = onCreateNewRecoveryKey) }

View File

@@ -31,6 +31,7 @@ import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -51,7 +52,7 @@ fun SecureBackupSetupView(
onBackClick = onBackClick.takeIf { state.canGoBack() },
title = title(state),
subTitle = subtitle(state),
iconVector = CompoundIcons.KeySolid(),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
buttons = { Buttons(state, onFinish = onSuccess) },
) {
Content(state = state)

View File

@@ -21,6 +21,7 @@ constraintlayout_compose = "1.0.1"
lifecycle = "2.7.0"
activity = "1.8.2"
media3 = "1.3.1"
camera = "1.3.2"
# Compose
compose_bom = "2024.05.00"
@@ -80,6 +81,9 @@ androidx_datastore_datastore = { module = "androidx.datastore:datastore", versio
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.7"
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
androidx_constraintlayout_compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayout_compose" }
androidx_camera_lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }
androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" }
androidx_camera_camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }
androidx_recyclerview = "androidx.recyclerview:recyclerview:1.3.2"
androidx_browser = "androidx.browser:browser:1.8.0"
@@ -98,7 +102,9 @@ androidx_preference = "androidx.preference:preference:1.2.1"
androidx_webkit = "androidx.webkit:webkit:1.11.0"
androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" }
androidx_compose_material3 = "androidx.compose.material3:material3:1.2.1"
androidx_compose_material3 = { module = "androidx.compose.material3:material3" }
androidx_compose_material3_windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" }
androidx_compose_material3_adaptive = "androidx.compose.material3:material3-adaptive-android:1.0.0-alpha06"
androidx_compose_ui = { module = "androidx.compose.ui:ui" }
androidx_compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" }
@@ -175,6 +181,7 @@ maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.0"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.0"
opusencoder = "io.element.android:opusencoder:1.1.0"
kotlinpoet = "com.squareup:kotlinpoet:1.17.0"
zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
# Analytics
posthog = "com.posthog:posthog-android:3.3.0"

View File

@@ -36,7 +36,9 @@ android {
dependencies {
api(libs.compound)
// Should not be there, but this is a POC
implementation(libs.androidx.compose.material3.windowsizeclass)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.coil.compose)
implementation(libs.vanniktech.blurhash)
implementation(projects.libraries.architecture)

View File

@@ -0,0 +1,67 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.atomic.molecules
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.modifiers.squareSize
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun NumberedListMolecule(
index: Int,
text: AnnotatedString,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ItemNumber(index = index)
Text(text = text, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textPrimary)
}
}
@Composable
private fun ItemNumber(
index: Int,
) {
val color = ElementTheme.colors.textPlaceholder
Box(
modifier = Modifier
.border(1.dp, color, CircleShape)
.squareSize()
) {
Text(
modifier = Modifier.padding(1.5.dp),
text = index.toString(),
style = ElementTheme.typography.fontBodySmRegular,
color = color,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.atomic.organisms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.molecules.NumberedListMolecule
import kotlinx.collections.immutable.ImmutableList
@Composable
fun NumberedListOrganism(
items: ImmutableList<AnnotatedString>,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
itemsIndexed(items) { index, item ->
NumberedListMolecule(index = index + 1, text = item)
}
}
}

View File

@@ -25,12 +25,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -49,7 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FlowStepPage(
iconVector: ImageVector?,
iconStyle: BigIcon.Style,
title: String,
modifier: Modifier = Modifier,
onBackClick: (() -> Unit)? = null,
@@ -73,10 +73,10 @@ fun FlowStepPage(
)
},
header = {
IconTitleSubtitleMolecule(
iconImageVector = iconVector,
PageTitle(
title = title,
subTitle = subTitle,
subtitle = subTitle,
iconStyle = iconStyle,
)
},
content = content,
@@ -97,7 +97,7 @@ internal fun FlowStepPagePreview() = ElementPreview {
onBackClick = {},
title = "Title",
subTitle = "Subtitle",
iconVector = CompoundIcons.Computer(),
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
buttons = {
TextButton(text = "A button", onClick = { })
Button(text = "Continue", onClick = { })

View File

@@ -44,6 +44,7 @@ fun ConfirmationDialog(
thirdButtonText: String? = null,
onCancelClick: () -> Unit = onDismiss,
onThirdButtonClick: () -> Unit = {},
icon: @Composable (() -> Unit)? = null,
) {
BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) {
ConfirmationDialogContent(
@@ -56,6 +57,7 @@ fun ConfirmationDialog(
onSubmitClick = onSubmitClick,
onCancelClick = onCancelClick,
onThirdButtonClick = onThirdButtonClick,
icon = icon,
)
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.modifiers
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import io.element.android.libraries.designsystem.text.toPx
/**
* Draw a border on corners around the content.
*/
@Suppress("ModifierComposed")
fun Modifier.cornerBorder(
strokeWidth: Dp,
color: Color,
cornerSizeDp: Dp,
) = composed(
factory = {
val strokeWidthPx = strokeWidth.toPx()
val cornerSize = cornerSizeDp.toPx()
drawWithContent {
drawContent()
val width = size.width
val height = size.height
drawPath(
path = Path().apply {
// Top left corner
moveTo(0f, cornerSize)
lineTo(0f, 0f)
lineTo(cornerSize, 0f)
// Top right corner
moveTo(width - cornerSize, 0f)
lineTo(width, 0f)
lineTo(width, cornerSize)
// Bottom right corner
moveTo(width, height - cornerSize)
lineTo(width, height)
lineTo(width - cornerSize, height)
// Bottom left corner
moveTo(cornerSize, height)
lineTo(0f, height)
lineTo(0f, height - cornerSize)
},
color = color,
style = Stroke(
width = strokeWidthPx,
pathEffect = PathEffect.cornerPathEffect(strokeWidthPx / 2),
cap = StrokeCap.Round,
),
)
}
}
)

View File

@@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -150,6 +151,7 @@ internal fun SimpleAlertDialogContent(
Text(
text = titleText,
style = ElementTheme.typography.fontHeadingSmMedium,
textAlign = TextAlign.Center,
)
}
},

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.utils
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
@Composable
fun annotatedTextWithBold(text: String, boldText: String): AnnotatedString {
return buildAnnotatedString {
append(text)
val start = text.indexOf(boldText)
val end = start + boldText.length
val textRange = 0..text.length
if (start in textRange && end in textRange) {
addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.utils
import android.content.pm.ActivityInfo
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
@Composable
fun ForceOrientation(orientation: ScreenOrientation) {
val activity = LocalContext.current as? ComponentActivity ?: return
val orientationFlags = when (orientation) {
ScreenOrientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
ScreenOrientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
DisposableEffect(orientation) {
activity.requestedOrientation = orientationFlags
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
}
enum class ScreenOrientation {
PORTRAIT,
LANDSCAPE
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.utils
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ForceOrientationInMobileDevices(orientation: ScreenOrientation) {
val windowAdaptiveInfo = currentWindowAdaptiveInfo()
if (windowAdaptiveInfo.windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
windowAdaptiveInfo.windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
) {
ForceOrientation(orientation = orientation)
}
}

View File

@@ -89,4 +89,11 @@ enum class FeatureFlags(
defaultValue = false,
isFinished = false,
),
QrCodeLogin(
key = "feature.qrCodeLogin",
title = "Enable login using QR code",
description = "Allow the user to login using the QR code flow",
defaultValue = true,
isFinished = false,
),
}

View File

@@ -33,6 +33,7 @@ dependencies {
api(projects.libraries.featureflag.api)
implementation(libs.dagger)
implementation(libs.androidx.datastore.preferences)
implementation(projects.appconfig)
implementation(projects.libraries.di)
implementation(projects.libraries.core)
implementation(libs.coroutines.core)

View File

@@ -39,7 +39,7 @@ class DefaultFeatureFlagService @Inject constructor(
}
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean {
return providers.filterIsInstance(MutableFeatureFlagProvider::class.java)
return providers.filterIsInstance<MutableFeatureFlagProvider>()
.sortedBy(FeatureFlagProvider::priority)
.firstOrNull()
?.setFeatureEnabled(feature, enabled)

View File

@@ -16,6 +16,7 @@
package io.element.android.libraries.featureflag.impl
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.coroutines.flow.Flow
@@ -42,6 +43,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.MarkAsUnread -> true
FeatureFlags.RoomDirectorySearch -> false
FeatureFlags.ShowBlockedUsersDetails -> false
FeatureFlags.QrCodeLogin -> OnBoardingConfig.CAN_LOGIN_WITH_QR_CODE
}
} else {
false

View File

@@ -17,6 +17,8 @@
package io.element.android.libraries.matrix.api.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.LoggedInState
import kotlinx.coroutines.flow.Flow
@@ -53,4 +55,6 @@ interface MatrixAuthenticationService {
* Attempt to login using the [callbackUrl] provided by the Oidc page.
*/
suspend fun loginWithOidc(callbackUrl: String): Result<SessionId>
suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result<SessionId>
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.auth.qrlogin
interface MatrixQrCodeLoginData

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.auth.qrlogin
interface MatrixQrCodeLoginDataFactory {
fun parseQrCodeData(data: ByteArray): Result<MatrixQrCodeLoginData>
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.auth.qrlogin
sealed class QrCodeDecodeException(message: String) : Exception(message) {
class Crypto(
message: String,
// val reason: Reason
) : QrCodeDecodeException(message) {
// We plan to restore it in the future when UniFFi can process them
// enum class Reason {
// NOT_ENOUGH_DATA,
// NOT_UTF8,
// URL_PARSE,
// INVALID_MODE,
// INVALID_VERSION,
// BASE64,
// INVALID_PREFIX
// }
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.auth.qrlogin
sealed interface QrCodeLoginStep {
data object Uninitialized : QrCodeLoginStep
data class EstablishingSecureChannel(val checkCode: String) : QrCodeLoginStep
data object Starting : QrCodeLoginStep
data class WaitingForToken(val userCode: String) : QrCodeLoginStep
data class Failed(val error: QrLoginException) : QrCodeLoginStep
data object Finished : QrCodeLoginStep
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.auth.qrlogin
sealed class QrLoginException : Exception() {
data object Cancelled : QrLoginException()
data object ConnectionInsecure : QrLoginException()
data object Declined : QrLoginException()
data object Expired : QrLoginException()
data object LinkingNotSupported : QrLoginException()
data object OidcMetadataInvalid : QrLoginException()
data object SlidingSyncNotAvailable : QrLoginException()
data object OtherDeviceNotSignedIn : QrLoginException()
data object Unknown : QrLoginException()
}

View File

@@ -46,25 +46,11 @@ class RustMatrixClientFactory @Inject constructor(
private val utdTracker: UtdTracker,
) {
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
val client = ClientBuilder()
.basePath(baseDirectory.absolutePath)
val client = getBaseClientBuilder()
.homeserverUrl(sessionData.homeserverUrl)
.username(sessionData.userId)
.passphrase(sessionData.passphrase)
.userAgent(userAgentProvider.provide())
.let {
// Sadly ClientBuilder.proxy() does not accept null :/
// Tracked by https://github.com/matrix-org/matrix-rust-sdk/issues/3159
val proxy = proxyProvider.provides()
if (proxy != null) {
it.proxy(proxy)
} else {
it
}
}
.addRootCertificates(userCertificatesProvider.provides())
// FIXME Quick and dirty fix for stopping version requests on startup https://github.com/matrix-org/matrix-rust-sdk/pull/1376
.serverVersions(listOf("v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v1.5"))
.use { it.build() }
client.restoreSession(sessionData.toSession())
@@ -84,6 +70,24 @@ class RustMatrixClientFactory @Inject constructor(
clock = clock,
)
}
internal fun getBaseClientBuilder(): ClientBuilder {
return ClientBuilder()
.basePath(baseDirectory.absolutePath)
.userAgent(userAgentProvider.provide())
.addRootCertificates(userCertificatesProvider.provides())
.serverVersions(listOf("v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v1.5"))
.let {
// Sadly ClientBuilder.proxy() does not accept null :/
// Tracked by https://github.com/matrix-org/matrix-rust-sdk/issues/3159
val proxy = proxyProvider.provides()
if (proxy != null) {
it.proxy(proxy)
} else {
it
}
}
}
}
private fun SessionData.toSession() = Session(

View File

@@ -25,8 +25,13 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
import io.element.android.libraries.matrix.impl.auth.qrlogin.toStep
import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator
@@ -36,11 +41,16 @@ import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.HumanQrLoginException
import org.matrix.rustcomponents.sdk.OidcAuthenticationData
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import org.matrix.rustcomponents.sdk.QrLoginProgress
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
@@ -197,4 +207,43 @@ class RustMatrixAuthenticationService @Inject constructor(
}
}
}
override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) =
withContext(coroutineDispatchers.io) {
runCatching {
val client = rustMatrixClientFactory.getBaseClientBuilder()
.passphrase(pendingPassphrase)
.buildWithQrCode(
qrCodeData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData,
oidcConfiguration = oidcConfiguration,
progressListener = object : QrLoginProgressListener {
override fun onUpdate(state: QrLoginProgress) {
Timber.d("QR Code login progress: $state")
progress(state.toStep())
}
}
)
client.use { rustClient ->
val sessionData = rustClient.session()
.toSessionData(
isTokenValid = true,
loginType = LoginType.QR,
passphrase = pendingPassphrase,
)
sessionStore.storeData(sessionData)
SessionId(sessionData.userId)
}
}.mapFailure {
when (it) {
is QrCodeDecodeException -> QrErrorMapper.map(it)
is HumanQrLoginException -> QrErrorMapper.map(it)
else -> it
}
}.onFailure { throwable ->
if (throwable is CancellationException) {
throw throwable
}
Timber.e(throwable, "Failed to login with QR code")
}
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.auth.qrlogin
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import org.matrix.rustcomponents.sdk.HumanQrLoginException as RustHumanQrLoginException
import org.matrix.rustcomponents.sdk.QrCodeDecodeException as RustQrCodeDecodeException
object QrErrorMapper {
fun map(qrCodeDecodeException: RustQrCodeDecodeException): QrCodeDecodeException = when (qrCodeDecodeException) {
is RustQrCodeDecodeException.Crypto -> {
// We plan to restore it in the future when UniFFi can process them
// val reason = when (qrCodeDecodeException.error) {
// LoginQrCodeDecodeError.NOT_ENOUGH_DATA -> QrCodeDecodeException.Crypto.Reason.NOT_ENOUGH_DATA
// LoginQrCodeDecodeError.NOT_UTF8 -> QrCodeDecodeException.Crypto.Reason.NOT_UTF8
// LoginQrCodeDecodeError.URL_PARSE -> QrCodeDecodeException.Crypto.Reason.URL_PARSE
// LoginQrCodeDecodeError.INVALID_MODE -> QrCodeDecodeException.Crypto.Reason.INVALID_MODE
// LoginQrCodeDecodeError.INVALID_VERSION -> QrCodeDecodeException.Crypto.Reason.INVALID_VERSION
// LoginQrCodeDecodeError.BASE64 -> QrCodeDecodeException.Crypto.Reason.BASE64
// LoginQrCodeDecodeError.INVALID_PREFIX -> QrCodeDecodeException.Crypto.Reason.INVALID_PREFIX
// }
QrCodeDecodeException.Crypto(
qrCodeDecodeException.message.orEmpty(),
// reason
)
}
}
fun map(humanQrLoginError: RustHumanQrLoginException): QrLoginException = when (humanQrLoginError) {
is RustHumanQrLoginException.Cancelled -> QrLoginException.Cancelled
is RustHumanQrLoginException.ConnectionInsecure -> QrLoginException.ConnectionInsecure
is RustHumanQrLoginException.Declined -> QrLoginException.Declined
is RustHumanQrLoginException.Expired -> QrLoginException.Expired
is RustHumanQrLoginException.OtherDeviceNotSignedIn -> QrLoginException.OtherDeviceNotSignedIn
is RustHumanQrLoginException.LinkingNotSupported -> QrLoginException.LinkingNotSupported
is RustHumanQrLoginException.Unknown -> QrLoginException.Unknown
is RustHumanQrLoginException.OidcMetadataInvalid -> QrLoginException.OidcMetadataInvalid
is RustHumanQrLoginException.SlidingSyncNotAvailable -> QrLoginException.SlidingSyncNotAvailable
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.auth.qrlogin
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import org.matrix.rustcomponents.sdk.QrLoginProgress
fun QrLoginProgress.toStep(): QrCodeLoginStep {
return when (this) {
is QrLoginProgress.EstablishingSecureChannel -> QrCodeLoginStep.EstablishingSecureChannel(checkCodeString)
is QrLoginProgress.Starting -> QrCodeLoginStep.Starting
is QrLoginProgress.WaitingForToken -> QrCodeLoginStep.WaitingForToken(userCode)
is QrLoginProgress.Done -> QrCodeLoginStep.Finished
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.auth.qrlogin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory
import org.matrix.rustcomponents.sdk.QrCodeData
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class RustQrCodeLoginDataFactory @Inject constructor() : MatrixQrCodeLoginDataFactory {
override fun parseQrCodeData(data: ByteArray): Result<MatrixQrCodeLoginData> {
return runCatching { SdkQrCodeLoginData(QrCodeData.fromBytes(data)) }
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.auth.qrlogin
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import org.matrix.rustcomponents.sdk.QrCodeData as RustQrCodeData
class SdkQrCodeLoginData(
internal val rustQrCodeData: RustQrCodeData,
) : MatrixQrCodeLoginData

View File

@@ -20,9 +20,13 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,7 +36,9 @@ import kotlinx.coroutines.flow.flowOf
val A_OIDC_DATA = OidcDetails(url = "a-url")
class FakeMatrixAuthenticationService(
private val matrixClientResult: ((SessionId) -> Result<MatrixClient>)? = null
var matrixClientResult: ((SessionId) -> Result<MatrixClient>)? = null,
var loginWithQrCodeResult: (qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) -> Result<SessionId> =
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
) : MatrixAuthenticationService {
private val homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private var oidcError: Throwable? = null
@@ -50,8 +56,8 @@ class FakeMatrixAuthenticationService(
override suspend fun getLatestSessionId(): SessionId? = getLatestSessionIdLambda()
override suspend fun restoreSession(sessionId: SessionId): Result<MatrixClient> {
if (matrixClientResult != null) {
return matrixClientResult.invoke(sessionId)
matrixClientResult?.let {
return it.invoke(sessionId)
}
return if (matrixClient != null) {
Result.success(matrixClient!!)
@@ -88,6 +94,10 @@ class FakeMatrixAuthenticationService(
loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
}
override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result<SessionId> = simulateLongTask {
loginWithQrCodeResult(qrCodeData, progress)
}
fun givenOidcError(throwable: Throwable?) {
oidcError = throwable
}

Some files were not shown because too many files have changed in this diff Show More