Skip to content

Commit

Permalink
feat: enabling QR codes for users (WPB-12115) (#3616)
Browse files Browse the repository at this point in the history
  • Loading branch information
yamilmedina authored Nov 12, 2024
1 parent d74b546 commit 84c2755
Show file tree
Hide file tree
Showing 12 changed files with 141 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/

package com.wire.android.ui.common.dialogs

import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import com.wire.android.R
import com.wire.android.ui.common.WireDialog
import com.wire.android.ui.common.WireDialogButtonProperties
import com.wire.android.ui.common.WireDialogButtonType
import com.wire.android.ui.common.wireDialogPropertiesBuilder
import com.wire.android.ui.theme.WireTheme
import com.wire.android.util.ui.PreviewMultipleThemes

@Composable
fun UserNotFoundDialog(
onActionButtonClicked: () -> Unit
) {
UserNotFoundDialogContent(
onConfirm = onActionButtonClicked,
onDismiss = onActionButtonClicked,
buttonText = R.string.label_ok,
dialogProperties = wireDialogPropertiesBuilder(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}

@Composable
fun UserNotFoundDialogContent(
@StringRes buttonText: Int,
onConfirm: () -> Unit,
dialogProperties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false),
onDismiss: () -> Unit
) {
WireDialog(
title = stringResource(R.string.connection_label_user_not_found_warning_title),
text = stringResource(R.string.connection_label_user_not_found_warning_description),
onDismiss = onDismiss,
optionButton1Properties = WireDialogButtonProperties(
text = stringResource(buttonText),
onClick = onConfirm,
type = WireDialogButtonType.Primary
),
properties = dialogProperties
)
}

@PreviewMultipleThemes
@Composable
fun PreviewUserNotFoundDialog() {
WireTheme {
UserNotFoundDialogContent(onConfirm = { }, onDismiss = { }, buttonText = R.string.label_ok)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
* along with this program. If not, see http://www.gnu.org/licenses/.
*/

@file:OptIn(ExperimentalMaterial3Api::class)

package com.wire.android.ui.userprofile.other

import android.annotation.SuppressLint
Expand All @@ -37,7 +35,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -79,6 +76,7 @@ import com.wire.android.ui.common.dialogs.BlockUserDialogContent
import com.wire.android.ui.common.dialogs.BlockUserDialogState
import com.wire.android.ui.common.dialogs.UnblockUserDialogContent
import com.wire.android.ui.common.dialogs.UnblockUserDialogState
import com.wire.android.ui.common.dialogs.UserNotFoundDialog
import com.wire.android.ui.common.dimensions
import com.wire.android.ui.common.snackbar.LocalSnackbarHostState
import com.wire.android.ui.common.spacers.VerticalSpace
Expand Down Expand Up @@ -216,6 +214,10 @@ fun OtherUserProfileScreen(
legalHoldSubjectDialogState::dismiss
)
}

if (viewModel.state.errorLoadingUser != null) {
UserNotFoundDialog(onActionButtonClicked = navigator::navigateBack)
}
}

@SuppressLint("UnusedCrossfadeTargetStateParameter", "LongParameterList")
Expand Down Expand Up @@ -612,7 +614,6 @@ enum class OtherUserProfileTabItem(@StringRes val titleResId: Int) : TabItem {
override val title: UIText = UIText.StringResource(titleResId)
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
@PreviewMultipleThemes
fun PreviewOtherProfileScreenGroupMemberContent() {
Expand All @@ -634,7 +635,6 @@ fun PreviewOtherProfileScreenGroupMemberContent() {
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
@PreviewMultipleThemes
fun PreviewOtherProfileScreenContent() {
Expand All @@ -657,7 +657,6 @@ fun PreviewOtherProfileScreenContent() {
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
@PreviewMultipleThemes
fun PreviewOtherProfileScreenContentNotConnected() {
Expand All @@ -679,7 +678,6 @@ fun PreviewOtherProfileScreenContentNotConnected() {
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
@PreviewMultipleThemes
fun PreviewOtherProfileScreenTempUser() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import com.wire.android.ui.userprofile.group.RemoveConversationMemberState
import com.wire.android.ui.userprofile.other.OtherUserProfileInfoMessageType.BlockingUserOperationError
import com.wire.android.ui.userprofile.other.OtherUserProfileInfoMessageType.BlockingUserOperationSuccess
import com.wire.android.ui.userprofile.other.OtherUserProfileInfoMessageType.ChangeGroupRoleError
import com.wire.android.ui.userprofile.other.OtherUserProfileInfoMessageType.LoadUserInformationError
import com.wire.android.ui.userprofile.other.OtherUserProfileInfoMessageType.MutingOperationError
import com.wire.android.ui.userprofile.other.OtherUserProfileInfoMessageType.RemoveConversationMemberError
import com.wire.android.ui.userprofile.other.OtherUserProfileInfoMessageType.UnblockingUserOperationError
Expand Down Expand Up @@ -70,8 +69,8 @@ import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStat
import com.wire.kalium.logic.feature.conversation.UpdateConversationMemberRoleResult
import com.wire.kalium.logic.feature.conversation.UpdateConversationMemberRoleUseCase
import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase
import com.wire.kalium.logic.feature.e2ei.usecase.IsOtherUserE2EIVerifiedUseCase
import com.wire.kalium.logic.feature.e2ei.usecase.GetUserE2eiCertificatesUseCase
import com.wire.kalium.logic.feature.e2ei.usecase.IsOtherUserE2EIVerifiedUseCase
import com.wire.kalium.logic.feature.user.GetUserInfoResult
import com.wire.kalium.logic.feature.user.ObserveUserInfoUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
Expand Down Expand Up @@ -189,8 +188,8 @@ class OtherUserProfileScreenViewModel @Inject constructor(
.collect { (userResult, groupInfo, oneToOneConversation) ->
when (userResult) {
is GetUserInfoResult.Failure -> {
appLogger.d("Couldn't not find the user with provided id: $userId")
closeBottomSheetAndShowInfoMessage(LoadUserInformationError)
appLogger.e("Couldn't not find the user with provided id: ${userId.toLogString()}")
updateUserInfoStateForError()
}

is GetUserInfoResult.Success -> {
Expand Down Expand Up @@ -370,6 +369,14 @@ class OtherUserProfileScreenViewModel @Inject constructor(
}
}

private fun updateUserInfoStateForError() {
state = state.copy(
isDataLoading = false,
isAvatarLoading = false,
errorLoadingUser = ErrorLoadingUser.USER_NOT_FOUND
)
}

private fun updateUserInfoState(
userResult: GetUserInfoResult.Success,
groupInfo: OtherUserProfileGroupState?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ data class OtherUserProfileState(
val isUnderLegalHold: Boolean = false,
val isConversationStarted: Boolean = false,
val expiresAt: Instant? = null,
val accentId: Int = -1
val accentId: Int = -1,
val errorLoadingUser: ErrorLoadingUser? = null
) {
fun updateMuteStatus(status: MutedConversationStatus): OtherUserProfileState {
return conversationSheetContent?.let {
Expand Down Expand Up @@ -96,3 +97,8 @@ data class OtherUserProfileGroupState(
val isSelfAdmin: Boolean,
val conversationId: ConversationId
)

enum class ErrorLoadingUser {
UNKNOWN, // We might want to expand other errors here as dialogs, ie: federation fallback.
USER_NOT_FOUND,
}
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ private fun SelfQRCodeContent(
VerticalSpace.x16()
Text(
modifier = Modifier.padding(horizontal = dimensions().spacing24x),
text = state.userProfileLink,
text = state.userAccountProfileLink,
style = MaterialTheme.wireTypography.subline01,
color = Color.Black,
textAlign = TextAlign.Center
Expand All @@ -190,7 +190,7 @@ private fun SelfQRCodeContent(
color = colorsScheme().secondaryText
)
Spacer(modifier = Modifier.weight(1f))
ShareLinkButton(state.userProfileLink)
ShareLinkButton(state.userAccountProfileLink)
VerticalSpace.x8()
ShareQRCodeButton {
coroutineScope.launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ data class SelfQRCodeState(
val avatarAsset: UserAvatarAsset? = null,
val handle: String = "",
val userProfileLink: String = "",
val userAccountProfileLink: String = "",
val hasError: Boolean = false
)
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,21 @@ class SelfQRCodeViewModel @Inject constructor(
selfQRCodeState =
when (val result = selfServerLinks()) {
is SelfServerConfigUseCase.Result.Failure -> selfQRCodeState.copy(hasError = true)
is SelfServerConfigUseCase.Result.Success -> generateSelfUserUrl(result.serverLinks.links.accounts)
is SelfServerConfigUseCase.Result.Success -> generateSelfUserUrls(result.serverLinks.links.accounts)
}
}

private fun generateSelfUserUrl(accountsUrl: String): SelfQRCodeState =
private fun generateSelfUserUrls(accountsUrl: String): SelfQRCodeState =
selfQRCodeState.copy(
userProfileLink = String.format(BASE_USER_PROFILE_URL, accountsUrl, selfUserId.value),
userAccountProfileLink = String.format(BASE_USER_PROFILE_URL, accountsUrl, selfUserId.value),
userProfileLink = String.format(DIRECT_BASE_USER_PROFILE_URL, selfUserId.domain, selfUserId.value)
)

companion object {
const val TEMP_SELF_QR_FILENAME = "temp_self_qr.jpg"
const val BASE_USER_PROFILE_URL = "%s/user-profile/?id=%s"

// This URL, can be used when we have a direct link to user profile Milestone2
const val DIRECT_BASE_USER_PROFILE_URL = "wire://user/%s"
const val DIRECT_BASE_USER_PROFILE_URL = "wire://user/%s/%s"
const val QR_QUALITY_COMPRESSION = 80
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ object FeatureVisibilityFlags {
const val MessageEditIcon = true
const val SearchConversationMessages = true
const val DrawingIcon = true
const val QRCodeEnabled = false
const val QRCodeEnabled = true
}

val LocalFeatureVisibilityFlags = staticCompositionLocalOf { FeatureVisibilityFlags }
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import com.wire.android.feature.AccountSwitchUseCase
import com.wire.android.feature.SwitchAccountParam
import com.wire.android.feature.SwitchAccountResult
import com.wire.android.util.EMPTY
import com.wire.android.util.debug.FeatureVisibilityFlags
import com.wire.kalium.logic.CoreLogic
import com.wire.kalium.logic.data.auth.AccountInfo
import com.wire.kalium.logic.data.id.ConversationId
Expand Down Expand Up @@ -148,31 +147,18 @@ class DeepLinkProcessor @Inject constructor(
}
}

/**
* TODO(Rewrite)
* Wait for definitions of Deeplink processing RFC (https://wearezeta.atlassian.net/wiki/x/AgAsWg)
*
* REF: WPB-10532
*/
private fun getConnectingUserProfile(uri: Uri, switchedAccount: Boolean, accountInfo: AccountInfo.Valid): DeepLinkResult {
return if (FeatureVisibilityFlags.QRCodeEnabled) {
// TODO: Wait for definitions of Deeplink processing RFC (https://wearezeta.atlassian.net/wiki/x/AgAsWg)
// TODO: define format of deeplink wire://user/domain/user-id
uri.lastPathSegment?.toDefaultQualifiedId(accountInfo.userId.domain)?.let {
DeepLinkResult.OpenOtherUserProfile(it, switchedAccount)
}
DeepLinkResult.Unknown
} else {
DeepLinkResult.Unknown
}
// todo. handle with domain case, before lastPathSegment. format of deeplink wire://user/domain/user-id
return uri.lastPathSegment?.toDefaultQualifiedId(accountInfo.userId.domain)?.let {
DeepLinkResult.OpenOtherUserProfile(it, switchedAccount)
} ?: return DeepLinkResult.Unknown
}

/**
* TODO(Rewrite)
* Wait for definitions of Deeplink processing RFC (https://wearezeta.atlassian.net/wiki/x/AgAsWg)
* i.e. Define format of deeplink wire://user/domain/user-id
* Converts the string to a [QualifiedID] with the current user domain or default, to preserve retro compatibility.
* When implementing Milestone 2 this should be replaced with a new qualifiedIdMapper, implementing wire://user/domain/user-id
*
* REF: WPB-10532
* - new mapper should follow "domain/user-id" parsing.
*/
private fun String.toDefaultQualifiedId(currentUserDomain: String?): QualifiedID {
val domain = currentUserDomain ?: "wire.com"
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,8 @@
<string name="connection_label_ignore">Ignore</string>
<string name="connection_label_send_unverified_warning">Get certainty about the identity of %s\'s before connecting.</string>
<string name="connection_label_received_unverified_warning">Please verify the person\'s identity before accepting the connection request.</string>
<string name="connection_label_user_not_found_warning_title">Wire can\'t find this person</string>
<string name="connection_label_user_not_found_warning_description">You may not have permission with this account or the person may not be on Wire.</string>
<!-- Missing keyPackages dialog -->
<string name="missing_keypackage_dialog_title">Unable to start conversation</string>
<string name="missing_keypackage_dialog_body">You can\'t start the conversation with %1$s right now. %1$s needs to open Wire or log in again first. Please try again later.</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,14 @@ class SelfQRCodeViewModelTest {

// when - then
assertEquals(
expected = "${ServerConfig.STAGING.accounts}/user-profile/?id=${TestUser.SELF_USER.id.value}",
expected = "wire://user/${TestUser.SELF_USER.id.domain}/${TestUser.SELF_USER.id.value}",
actual = viewModel.selfQRCodeState.userProfileLink,
)

assertEquals(
expected = "${ServerConfig.STAGING.accounts}/user-profile/?id=${TestUser.SELF_USER.id.value}",
actual = viewModel.selfQRCodeState.userAccountProfileLink,
)
}

private class Arrangement {
Expand Down
20 changes: 20 additions & 0 deletions app/src/test/kotlin/com/wire/android/util/DeepLinkProcessorTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,20 @@ class DeepLinkProcessorTest {
assertEquals(DeepLinkResult.SharingIntent, result)
}

@Test
fun `given an other profile deeplink from QR code, returns Conversation with conversationId`() = runTest {
val (arrangement, deepLinkProcessor) = Arrangement()
.withOtherUserProfileQRDeepLink(userIdToOpen = OTHER_USER_ID, userId = CURRENT_USER_ID)
.withCurrentSessionSuccess(CURRENT_USER_ID)
.arrange()
val conversationResult = deepLinkProcessor(arrangement.uri, false)
assertInstanceOf(DeepLinkResult.OpenOtherUserProfile::class.java, conversationResult)
assertEquals(
DeepLinkResult.OpenOtherUserProfile(UserId("other_user", "domain"), false),
conversationResult
)
}

class Arrangement {

@MockK
Expand Down Expand Up @@ -318,6 +332,12 @@ class DeepLinkProcessorTest {
coEvery { uri.getQueryParameter(DeepLinkProcessor.USER_TO_USE_QUERY_PARAM) } returns userId.toString()
}

fun withOtherUserProfileQRDeepLink(userIdToOpen: UserId = OTHER_USER_ID, userId: UserId = CURRENT_USER_ID) = apply {
coEvery { uri.host } returns DeepLinkProcessor.OPEN_USER_PROFILE_DEEPLINK_HOST
coEvery { uri.lastPathSegment } returns userIdToOpen.value
coEvery { uri.getQueryParameter(DeepLinkProcessor.USER_TO_USE_QUERY_PARAM) } returns userId.toString()
}

fun withCurrentSession(result: CurrentSessionResult) = apply {
coEvery { currentSession() } returns result
}
Expand Down

0 comments on commit 84c2755

Please sign in to comment.