Skip to content

Commit

Permalink
Sign in with QR code (#2793)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored May 31, 2024
1 parent e29f919 commit c8bd04c
Show file tree
Hide file tree
Showing 253 changed files with 4,421 additions and 326 deletions.
2 changes: 1 addition & 1 deletion .maestro/tests/account/login.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand All @@ -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 -> {
Expand All @@ -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()
}
}
6 changes: 3 additions & 3 deletions features/ftue/impl/src/main/res/values/localazy.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">"You’ll 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 that’s 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 that’s 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>
Expand Down
1 change: 1 addition & 0 deletions features/login/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
}

android {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,3 +34,10 @@ interface LoginEntryPoint : FeatureEntryPoint {
fun build(): Node
}
}

@Parcelize
enum class LoginFlowType : Parcelable {
SIGN_IN_MANUAL,
SIGN_IN_QR_CODE,
SIGN_UP
}
7 changes: 7 additions & 0 deletions features/login/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -107,6 +109,9 @@ class LoginFlowNode @AssistedInject constructor(
}

sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget

@Parcelize
data object ConfirmAccountProvider : NavTarget

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,6 @@ fun OidcView(
internal fun OidcViewPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = ElementPreview {
OidcView(
state = state,
onNavigateBack = { },
onNavigateBack = {},
)
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit c8bd04c

Please sign in to comment.