From dc2e24956678ed5935b0b7f2adde8656cd20bec4 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 3 Apr 2023 09:56:44 -0400 Subject: [PATCH] Add QR scanning to username link flow. --- app/build.gradle | 1 + .../settings/app/usernamelinks/QrCode.kt | 9 +- .../app/usernamelinks/main/QrScanResult.kt | 16 + .../main/UsernameLinkSettingsFragment.kt | 154 ++++++- .../main/UsernameLinkSettingsState.kt | 11 +- .../main/UsernameLinkSettingsViewModel.kt | 65 +++ .../main/UsernameLinkShareScreen.kt | 10 +- .../main/UsernameQrScanScreen.kt | 136 +++++- .../manage/ManageProfileFragment.java | 9 +- .../thoughtcrime/securesms/util/Base64.java | 4 + .../securesms/util/UsernameUtil.java | 37 +- app/src/main/res/values/strings.xml | 6 + .../main/java/org/signal/core/ui/Dialogs.kt | 33 ++ dependencies.gradle | 4 + gradle/verification-metadata.xml | 423 ++++++++++++++++++ 15 files changed, 882 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/QrScanResult.kt diff --git a/app/build.gradle b/app/build.gradle index 32e2e96c88..92337b988f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -559,6 +559,7 @@ dependencies { } implementation libs.dnsjava implementation libs.kotlinx.collections.immutable + implementation libs.accompanist.permissions spinnerImplementation project(":spinner") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt index 2f479d4e2e..ed272dce11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Surface import androidx.compose.runtime.Composable @@ -93,12 +94,12 @@ private fun DrawScope.drawQr( } // Logo border - val deadzonePaddingPercent = 0.02f + val deadzonePaddingPercent = 0.03f val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2 drawCircle( color = foregroundColor, radius = logoBorderRadiusPx, - style = Stroke(width = cellWidthPx * 0.7f), + style = Stroke(width = 4.dp.toPx()), center = this.center ) @@ -156,9 +157,7 @@ private fun Preview() { Surface { QrCode( data = QrCodeData.forData("https://signal.org", 64), - modifier = Modifier - .width(100.dp) - .height(100.dp), + modifier = Modifier.size(200.dp), deadzonePercent = 0.3f ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/QrScanResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/QrScanResult.kt new file mode 100644 index 0000000000..afe6bdbe19 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/QrScanResult.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Result of taking data from the QR scanner and trying to resolve it to a recipient. + */ +sealed class QrScanResult { + class Success(val recipient: Recipient) : QrScanResult() + + class NotFound(val username: String) : QrScanResult() + + object InvalidData : QrScanResult() + + object NetworkError : QrScanResult() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt index 262c664594..f23080e58f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt @@ -1,25 +1,61 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main +import android.os.Bundle +import android.view.View +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.fragment.findNavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import kotlinx.coroutines.CoroutineScope +import org.signal.core.ui.Buttons +import org.signal.core.ui.Dialogs +import org.signal.core.ui.theme.SignalTheme +import org.signal.core.util.concurrent.LifecycleDisposable +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab import org.thoughtcrime.securesms.compose.ComposeFragment -@OptIn(ExperimentalMaterial3Api::class) +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalPermissionsApi::class +) class UsernameLinkSettingsFragment : ComposeFragment() { - val viewModel: UsernameLinkSettingsViewModel by viewModels() + private val viewModel: UsernameLinkSettingsViewModel by viewModels() + private val disposables: LifecycleDisposable = LifecycleDisposable() @Composable override fun FragmentContent() { @@ -29,23 +65,121 @@ class UsernameLinkSettingsFragment : ComposeFragment() { val navController: NavController by remember { mutableStateOf(findNavController()) } Scaffold( - snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { TopAppBarContent(state.activeTab) } ) { contentPadding -> - UsernameLinkShareScreen( - state = state, - snackbarHostState = snackbarHostState, - scope = scope, - contentPadding = contentPadding, - navController = navController - ) + + if (state.indeterminateProgress) { + Dialogs.IndeterminateProgressDialog() + } + + AnimatedVisibility( + visible = state.activeTab == ActiveTab.Code, + enter = slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }), + exit = slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth }) + ) { + UsernameLinkShareScreen( + state = state, + snackbarHostState = snackbarHostState, + scope = scope, + modifier = Modifier.padding(contentPadding), + navController = navController + ) + } + + AnimatedVisibility( + visible = state.activeTab == ActiveTab.Scan, + enter = slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }), + exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }) + ) { + UsernameQrScanScreen( + lifecycleOwner = viewLifecycleOwner, + disposables = disposables.disposables, + qrScanResult = state.qrScanResult, + onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) }, + onQrResultHandled = { viewModel.onQrResultHandled() }, + modifier = Modifier.padding(contentPadding) + ) + } } } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + disposables.bindTo(viewLifecycleOwner) + } + override fun onResume() { super.onResume() viewModel.onResume() } + @Composable + private fun TopAppBarContent(activeTab: ActiveTab) { + val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + TabButton( + label = stringResource(R.string.UsernameLinkSettings_code_tab_name), + active = activeTab == ActiveTab.Code, + onClick = { viewModel.onTabSelected(ActiveTab.Code) }, + modifier = Modifier.padding(end = 8.dp) + ) + TabButton( + label = stringResource(R.string.UsernameLinkSettings_scan_tab_name), + active = activeTab == ActiveTab.Scan, + onClick = { + if (cameraPermissionState.status.isGranted) { + viewModel.onTabSelected(ActiveTab.Scan) + } else { + cameraPermissionState.launchPermissionRequest() + } + } + ) + } + } + } + + @Composable + private fun TabButton(label: String, active: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val colors = if (active) { + ButtonDefaults.filledTonalButtonColors() + } else { + ButtonDefaults.buttonColors( + containerColor = SignalTheme.colors.colorSurface2, + contentColor = MaterialTheme.colorScheme.onSurface + ) + } + Buttons.MediumTonal( + onClick = onClick, + modifier = modifier.defaultMinSize(minWidth = 100.dp), + shape = RoundedCornerShape(12.dp), + colors = colors + ) { + Text(label) + } + } + + @Preview + @Composable + private fun AppBarPreview() { + SignalTheme(isDarkMode = false) { + Surface { + TopAppBarContent(activeTab = ActiveTab.Code) + } + } + } + @Preview @Composable fun PreviewAll() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsState.kt index 4636ac696e..26958914ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsState.kt @@ -7,8 +7,15 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.Username * Represents the UI state of the [UsernameLinkSettingsFragment]. */ data class UsernameLinkSettingsState( + val activeTab: ActiveTab, val username: String, val usernameLink: String, val qrCodeData: QrCodeData?, - val qrCodeColorScheme: UsernameQrCodeColorScheme -) + val qrCodeColorScheme: UsernameQrCodeColorScheme, + val qrScanResult: QrScanResult? = null, + val indeterminateProgress: Boolean = false +) { + enum class ActiveTab { + Code, Scan + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt index 6aedf80777..b66ff20163 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt @@ -9,17 +9,27 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.signal.core.util.logging.Log +import org.signal.libsignal.usernames.BaseUsernameException import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.UsernameUtil +import org.whispersystems.signalservice.api.push.ACI +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException +import java.io.IOException class UsernameLinkSettingsViewModel : ViewModel() { + private val TAG = Log.tag(UsernameLinkSettingsViewModel::class.java) + private val username: BehaviorSubject = BehaviorSubject.createDefault(Recipient.self().username.get()) private val _state = mutableStateOf( UsernameLinkSettingsState( + activeTab = ActiveTab.Code, username = username.value!!, usernameLink = UsernameUtil.generateLink(username.value!!), qrCodeData = null, @@ -54,6 +64,61 @@ class UsernameLinkSettingsViewModel : ViewModel() { ) } + fun onTabSelected(tab: ActiveTab) { + _state.value = _state.value.copy( + activeTab = tab + ) + } + + fun onQrCodeScanned(url: String) { + _state.value = _state.value.copy( + indeterminateProgress = true + ) + + disposable += Single + .fromCallable { + val username: String? = UsernameUtil.parseLink(url) + + if (username == null) { + Log.w(TAG, "Failed to parse username from url") + return@fromCallable QrScanResult.InvalidData + } + + return@fromCallable try { + val hashed: String = UsernameUtil.hashUsernameToBase64(username) + val aci: ACI = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsernameHash(hashed) + QrScanResult.Success(Recipient.externalUsername(aci, username)) + } catch (e: BaseUsernameException) { + Log.w(TAG, "Invalid username", e) + QrScanResult.InvalidData + } catch (e: NonSuccessfulResponseCodeException) { + Log.w(TAG, "Non-successful response during username resolution", e) + if (e.code == 404) { + QrScanResult.NotFound(username) + } else { + QrScanResult.NetworkError + } + } catch (e: IOException) { + Log.w(TAG, "Network error during username resolution", e) + QrScanResult.NetworkError + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { result -> + _state.value = _state.value.copy( + qrScanResult = result, + indeterminateProgress = false + ) + } + } + + fun onQrResultHandled() { + _state.value = _state.value.copy( + qrScanResult = null + ) + } + private fun generateQrCodeData(url: String): Single { return Single.fromCallable { QrCodeData.forData(url, 64) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt index 3ee94a29ba..77e60ad1f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -36,6 +35,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab import org.thoughtcrime.securesms.util.UsernameUtil import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -49,12 +49,10 @@ fun UsernameLinkShareScreen( snackbarHostState: SnackbarHostState, scope: CoroutineScope, navController: NavController, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(0.dp) + modifier: Modifier = Modifier ) { Column( modifier = modifier - .padding(contentPadding) .verticalScroll(rememberScrollState()) ) { QrCodeBadge( @@ -85,7 +83,8 @@ fun UsernameLinkShareScreen( text = stringResource(id = R.string.UsernameLinkSettings_qr_description), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp) + modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant ) Row( @@ -186,6 +185,7 @@ private fun ScreenPreviewDarkTheme() { private fun previewState(): UsernameLinkSettingsState { val link = UsernameUtil.generateLink("maya.45") return UsernameLinkSettingsState( + activeTab = ActiveTab.Code, username = "maya.45", usernameLink = link, qrCodeData = QrCodeData.forData(link, 64), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt index 5cad84d08c..94a9d51fa8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt @@ -1,14 +1,144 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.LifecycleOwner +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.signal.core.ui.Dialogs +import org.signal.qr.QrScannerView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist +import org.thoughtcrime.securesms.util.CommunicationActions /** * A screen that allows you to scan a QR code to start a chat. */ @Composable -fun UsernameQrScanScreen(modifier: Modifier = Modifier) { - // TODO - Text(text = "QR Scanner Placeholder") +fun UsernameQrScanScreen( + lifecycleOwner: LifecycleOwner, + disposables: CompositeDisposable, + qrScanResult: QrScanResult?, + onQrCodeScanned: (String) -> Unit, + onQrResultHandled: () -> Unit, + modifier: Modifier = Modifier +) { + when (qrScanResult) { + QrScanResult.InvalidData -> { + QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_invalid), onDismiss = onQrResultHandled) + } + QrScanResult.NetworkError -> { + QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled) + } + is QrScanResult.NotFound -> { + QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled) + } + is QrScanResult.Success -> { + CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null) + onQrResultHandled() + } + null -> {} + } + + Column( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + AndroidView( + factory = { context -> + val view = QrScannerView(context) + disposables += view.qrData.subscribe { data -> + onQrCodeScanned(data) + } + view + }, + update = { view -> + view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted()) + }, + modifier = Modifier + .fillMaxWidth() + .weight(1f, true) + .drawWithContent { + drawContent() + drawQrCrosshair() + } + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.UsernameLinkSettings_qr_scan_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun QrScanResultDialog(message: String, onDismiss: () -> Unit) { + Dialogs.SimpleMessageDialog( + message = message, + dismiss = stringResource(id = android.R.string.ok), + onDismiss = onDismiss + ) +} + +private fun DrawScope.drawQrCrosshair() { + val crosshairWidth: Float = size.minDimension * 0.6f + val clearWidth: Float = crosshairWidth * 0.75f + + // Draw a full white rounded rect... + drawRoundRect( + color = Color.White, + topLeft = center - Offset(crosshairWidth / 2, crosshairWidth / 2), + style = Stroke(width = 3.dp.toPx()), + size = Size(crosshairWidth, crosshairWidth), + cornerRadius = CornerRadius(10.dp.toPx(), 10.dp.toPx()) + ) + + // ...then cut out the middle parts with BlendMode.Clear to leave us with just the corners + drawRect( + color = Color.White, + topLeft = Offset(center.x - clearWidth / 2, 0f), + style = Fill, + size = Size(clearWidth, size.height), + blendMode = BlendMode.Clear + ) + + drawRect( + color = Color.White, + topLeft = Offset(0f, center.y - clearWidth / 2), + style = Fill, + size = Size(size.width, clearWidth), + blendMode = BlendMode.Clear + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java index a45886afdd..f60bdb4b4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java @@ -247,14 +247,7 @@ public class ManageProfileFragment extends LoggingFragment { binding.manageProfileUsernameShare.setVisibility(View.GONE); } else { binding.manageProfileUsername.setText(username); - - try { - binding.manageProfileUsernameSubtitle.setText(UsernameUtil.generateLink(username)); - } catch (BaseUsernameException e) { - Log.w(TAG, "Could not format username link", e); - binding.manageProfileUsernameSubtitle.setText(R.string.ManageProfileFragment_your_username); - } - + binding.manageProfileUsernameSubtitle.setText(UsernameUtil.generateLink(username)); binding.manageProfileUsernameShare.setVisibility(View.VISIBLE); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Base64.java b/app/src/main/java/org/thoughtcrime/securesms/util/Base64.java index beeb5d9b53..31baaa4bc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Base64.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Base64.java @@ -14,6 +14,10 @@ public final class Base64 { return org.whispersystems.util.Base64.decode(s); } + public static @NonNull byte[] decodeWithoutPadding(@NonNull String s) throws IOException { + return org.whispersystems.util.Base64.decodeWithoutPadding(s); + } + public static @NonNull String encodeBytes(@NonNull byte[] source) { return org.whispersystems.util.Base64.encodeBytes(source); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java index 781687ca55..851f0f343b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java @@ -18,8 +18,10 @@ import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.util.Base64UrlSafe; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.Optional; +import java.util.regex.Matcher; import java.util.regex.Pattern; public class UsernameUtil { @@ -31,6 +33,7 @@ public class UsernameUtil { private static final Pattern FULL_PATTERN = Pattern.compile(String.format(Locale.US, "^[a-zA-Z_][a-zA-Z0-9_]{%d,%d}$", MIN_LENGTH - 1, MAX_LENGTH - 1), Pattern.CASE_INSENSITIVE); private static final Pattern DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$"); + private static final Pattern URL_PATTERN = Pattern.compile("(https://)?signal.me/#u/([a-zA-Z0-9+/]*={0,2})"); private static final String BASE_URL_SCHEMELESS = "signal.me/#u/"; @@ -80,6 +83,14 @@ public class UsernameUtil { } } + /** + * Hashes a username to a url-safe base64 string. + * @throws BaseUsernameException If the username is invalid and un-hashable. + */ + public static String hashUsernameToBase64(String username) throws BaseUsernameException { + return Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username)); + } + @WorkerThread public static @NonNull Optional fetchAciForUsernameHash(@NonNull String base64UrlSafeEncodedUsernameHash) { try { @@ -91,13 +102,33 @@ public class UsernameUtil { } } - public static String generateLink(String username) throws BaseUsernameException { - byte[] hash = Username.hash(username); - String base64 = Base64UrlSafe.encodeBytesWithoutPadding(hash); + public static String generateLink(String username) { + String base64 = Base64UrlSafe.encodeBytesWithoutPadding(username.getBytes(StandardCharsets.UTF_8)); return BASE_URL + base64; } + /** + * Parses the username from a link if possible, otherwise null. + */ + public static @Nullable String parseLink(String url) { + Matcher matcher = URL_PATTERN.matcher(url); + if (!matcher.matches()) { + return null; + } + + String base64 = matcher.group(2); + if (base64 == null) { + return null; + } + + try { + return new String(Base64.decodeWithoutPadding(base64)); + } catch (IOException e) { + return null; + } + } + public enum InvalidReason { TOO_SHORT, TOO_LONG, INVALID_CHARACTERS, STARTS_WITH_NUMBER } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e51d1d8f8..af1d8b54db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6095,6 +6095,12 @@ Scan the QR Code on your contact’s device. Color + + The QR code was invalid. + + A user with username $1$s could not be found. + + Experienced a network error. Please try again. diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt index fafd62925d..f6a096e649 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -1,11 +1,16 @@ package org.signal.core.ui +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import org.signal.core.ui.Dialogs.SimpleAlertDialog import org.signal.core.ui.Dialogs.SimpleMessageDialog @@ -72,6 +77,28 @@ object Dialogs { properties = properties ) } + + /** + * A dialog that *just* shows a spinner. Useful for short actions where you need to + * let the user know that some action is completing. + */ + @Composable + fun IndeterminateProgressDialog() { + androidx.compose.material3.AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + dismissButton = {}, + text = { + CircularProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) + }, + modifier = Modifier + .size(100.dp) + ) + } } @Preview @@ -96,3 +123,9 @@ private fun MessageDialogPreview() { onDismiss = {} ) } + +@Preview +@Composable +private fun IndeterminateProgressDialogPreview() { + Dialogs.IndeterminateProgressDialog() +} diff --git a/dependencies.gradle b/dependencies.gradle index a694b31ffd..3ef870618f 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -16,6 +16,7 @@ dependencyResolutionManagement { version('libsignal-client', '0.25.0') version('mp4parser', '1.9.39') version('android-gradle-plugin', '7.4.1') + version('accompanist', '0.28.0') // Android Plugins alias('android-library').to('com.android.library', 'com.android.library.gradle.plugin').versionRef('android-gradle-plugin') @@ -28,6 +29,9 @@ dependencyResolutionManagement { alias('androidx-compose-ui-tooling-core').to('androidx.compose.ui', 'ui-tooling').withoutVersion() alias('ktlint-twitter-compose').to('com.twitter.compose.rules:ktlint:0.0.26') + // Accompanist + alias('accompanist-permissions').to('com.google.accompanist', 'accompanist-permissions').versionRef('accompanist') + // Desugaring alias('android-tools-desugar').to('com.android.tools:desugar_jdk_libs:1.1.5') diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 217146db18..92d0306b48 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -36,6 +36,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -52,6 +60,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -68,6 +84,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -173,6 +197,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -183,6 +215,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -365,6 +405,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -373,6 +418,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -381,6 +434,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -397,6 +458,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -405,6 +474,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -413,6 +490,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -421,6 +506,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -429,6 +522,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -437,6 +546,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + @@ -445,6 +564,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -453,6 +585,24 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + @@ -461,6 +611,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -469,6 +627,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -477,6 +648,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -485,6 +669,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -493,6 +685,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -501,6 +701,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -509,6 +717,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -517,6 +733,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -525,6 +754,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -713,6 +950,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -721,6 +966,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -819,6 +1072,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -827,6 +1085,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -840,6 +1106,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -853,6 +1127,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -866,6 +1148,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -874,6 +1164,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -882,6 +1180,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -898,6 +1204,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -906,6 +1220,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -914,6 +1236,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -935,6 +1265,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -943,6 +1281,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -956,6 +1302,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -972,6 +1326,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -980,6 +1342,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -1133,6 +1503,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -1162,6 +1540,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -1170,6 +1556,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2609,6 +3003,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -3708,6 +4110,27 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + +