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:
parent
e0c55ff4c8
commit
35702c04e9
253 changed files with 4421 additions and 326 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = { })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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.test.auth.qrlogin
|
||||
|
||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
|
||||
class FakeMatrixQrCodeLoginDataFactory(
|
||||
var parseQrCodeLoginDataResult: () -> Result<MatrixQrCodeLoginData> =
|
||||
lambdaRecorder<Result<MatrixQrCodeLoginData>> { Result.success(FakeMatrixQrCodeLoginData()) },
|
||||
) : MatrixQrCodeLoginDataFactory {
|
||||
override fun parseQrCodeData(data: ByteArray): Result<MatrixQrCodeLoginData> {
|
||||
return parseQrCodeLoginDataResult()
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMatrixQrCodeLoginData : MatrixQrCodeLoginData
|
||||
|
|
@ -30,18 +30,22 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
fun PermissionsView(
|
||||
state: PermissionsState,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = stringResource(id = CommonStrings.common_permission),
|
||||
content: String? = null,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
if (state.showDialog.not()) return
|
||||
|
||||
ConfirmationDialog(
|
||||
modifier = modifier,
|
||||
title = stringResource(id = CommonStrings.common_permission),
|
||||
content = state.permission.toDialogContent(),
|
||||
title = title,
|
||||
content = content ?: state.permission.toDialogContent(),
|
||||
submitText = stringResource(id = CommonStrings.action_open_settings),
|
||||
onSubmitClick = {
|
||||
state.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog)
|
||||
},
|
||||
onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) },
|
||||
icon = icon,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
30
libraries/qrcode/build.gradle.kts
Normal file
30
libraries/qrcode/build.gradle.kts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.qrcode"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.zxing.cpp)
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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.qrcode
|
||||
|
||||
import android.graphics.ImageFormat
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import timber.log.Timber
|
||||
import zxingcpp.BarcodeReader
|
||||
|
||||
internal class QRCodeAnalyzer(
|
||||
private val onScanQrCode: (result: ByteArray?) -> Unit
|
||||
) : ImageAnalysis.Analyzer {
|
||||
private val reader by lazy { BarcodeReader() }
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
if (image.format in SUPPORTED_IMAGE_FORMATS) {
|
||||
try {
|
||||
val bytes = reader.read(image).firstNotNullOfOrNull { it.bytes }
|
||||
bytes?.let { onScanQrCode(it) }
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Error decoding QR code")
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SUPPORTED_IMAGE_FORMATS = listOf(
|
||||
ImageFormat.YUV_420_888,
|
||||
ImageFormat.YUV_422_888,
|
||||
ImageFormat.YUV_444_888,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
/*
|
||||
* 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.qrcode
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@Composable
|
||||
fun QrCodeCameraView(
|
||||
onScanQrCode: (ByteArray) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
renderPreview: Boolean = true,
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(color = ElementTheme.colors.bgSubtlePrimary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("CameraView")
|
||||
}
|
||||
} else {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val localContext = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||
val previewUseCase = remember { Preview.Builder().build() }
|
||||
var lastFrame by remember { mutableStateOf<Bitmap?>(null) }
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
cameraProvider = localContext.getCameraProvider()
|
||||
}
|
||||
|
||||
suspend fun startQRCodeAnalysis(cameraProvider: ProcessCameraProvider, previewView: PreviewView, attempt: Int = 1) {
|
||||
lastFrame = null
|
||||
val cameraSelector = CameraSelector.Builder()
|
||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||
.build()
|
||||
imageAnalysis.setAnalyzer(
|
||||
ContextCompat.getMainExecutor(previewView.context),
|
||||
QRCodeAnalyzer { result ->
|
||||
result?.let {
|
||||
Timber.d("QR code scanned!")
|
||||
onScanQrCode(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
try {
|
||||
// Make sure we unbind all use cases before binding them again
|
||||
cameraProvider.unbindAll()
|
||||
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
previewUseCase,
|
||||
imageAnalysis
|
||||
)
|
||||
lastFrame = null
|
||||
} catch (e: Exception) {
|
||||
val maxAttempts = 3
|
||||
if (attempt > maxAttempts) {
|
||||
Timber.e(e, "Use case binding failed after $maxAttempts attempts. Giving up.")
|
||||
} else {
|
||||
Timber.e(e, "Use case binding failed (attempt #$attempt). Retrying after a delay...")
|
||||
delay(100)
|
||||
startQRCodeAnalysis(cameraProvider, previewView, attempt + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopQRCodeAnalysis(previewView: PreviewView) {
|
||||
// Stop analyzer
|
||||
imageAnalysis.clearAnalyzer()
|
||||
|
||||
// Save last frame to display it as the 'frozen' preview
|
||||
if (lastFrame == null) {
|
||||
lastFrame = previewView.bitmap
|
||||
Timber.d("Saving last frame for frozen preview.")
|
||||
}
|
||||
|
||||
// Unbind preview use case
|
||||
cameraProvider?.unbindAll()
|
||||
}
|
||||
|
||||
Box(modifier.clipToBounds()) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
val previewView = PreviewView(context)
|
||||
previewUseCase.setSurfaceProvider(previewView.surfaceProvider)
|
||||
previewView.previewStreamState.observe(lifecycleOwner) { state ->
|
||||
previewView.alpha = if (state == PreviewView.StreamState.STREAMING) 1f else 0f
|
||||
}
|
||||
previewView
|
||||
},
|
||||
update = { previewView ->
|
||||
if (renderPreview) {
|
||||
cameraProvider?.let { provider ->
|
||||
coroutineScope.launch { startQRCodeAnalysis(provider, previewView) }
|
||||
}
|
||||
} else {
|
||||
stopQRCodeAnalysis(previewView)
|
||||
}
|
||||
},
|
||||
onRelease = {
|
||||
cameraProvider?.unbindAll()
|
||||
cameraProvider = null
|
||||
},
|
||||
)
|
||||
lastFrame?.let {
|
||||
Image(bitmap = it.asImageBitmap(), contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun Context.getCameraProvider(): ProcessCameraProvider =
|
||||
suspendCoroutine { continuation ->
|
||||
ProcessCameraProvider.getInstance(this).also { cameraProvider ->
|
||||
cameraProvider.addListener({
|
||||
continuation.resume(cameraProvider.get())
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue