Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enabling QR codes for users (WPB-12115) 🍒 #3621

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -188,7 +188,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 @@ -203,7 +203,11 @@ private fun SelfQRCodeContent(
color = colorsScheme().secondaryText
)
Spacer(modifier = Modifier.weight(1f))
<<<<<<< HEAD
ShareLinkButton(state.userProfileLink, trackAnalyticsEvent)
=======
ShareLinkButton(state.userAccountProfileLink)
>>>>>>> 8cb199b6b (feat: enabling QR codes for users (WPB-12115) (#3616))
VerticalSpace.x8()
ShareQRCodeButton {
trackAnalyticsEvent(AnalyticsEvent.QrCode.Modal.ShareQrCode)
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 @@ -100,21 +100,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 @@ -995,6 +995,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 @@ -34,9 +34,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