From 8a93814bacad3373e7ad18340168286fce3e3934 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 25 Aug 2023 09:33:57 -0400 Subject: [PATCH] Update to the new username link spec. --- ...wnProfileJob__checkUsernameIsInSyncTest.kt | 10 +- .../reminder/UsernameOutOfSyncReminder.kt | 2 +- .../settings/app/AppSettingsFragment.kt | 2 +- .../settings/app/usernamelinks/QrCode.kt | 2 +- .../settings/app/usernamelinks/QrCodeBadge.kt | 231 ++++++++++++--- .../settings/app/usernamelinks/QrCodeData.kt | 2 +- .../settings/app/usernamelinks/QrCodeState.kt | 17 ++ .../UsernameQrCodeColorScheme.kt | 2 +- .../UsernameLinkQrColorPickerFragment.kt | 10 +- .../UsernameLinkQrColorPickerState.kt | 4 +- .../UsernameLinkQrColorPickerViewModel.kt | 41 ++- .../app/usernamelinks/main/QrScanResult.kt | 2 +- .../main/UsernameLinkResetResult.kt | 20 ++ .../main/UsernameLinkSettingsFragment.kt | 50 +++- .../main/UsernameLinkSettingsState.kt | 7 +- .../main/UsernameLinkSettingsViewModel.kt | 115 +++++--- .../main/UsernameLinkShareScreen.kt | 136 ++++++--- .../usernamelinks/main/UsernameLinkState.kt | 18 ++ .../main/UsernameQrScanScreen.kt | 9 +- .../securesms/jobs/JobManagerFactories.java | 2 + .../securesms/jobs/RefreshOwnProfileJob.java | 3 +- .../securesms/keyvalue/AccountValues.kt | 42 ++- .../keyvalue/PhoneNumberPrivacyValues.java | 19 +- .../migrations/ApplicationMigrations.java | 7 +- .../CopyUsernameToSignalStoreMigrationJob.kt | 54 ++++ .../manage/ManageProfileFragment.java | 6 +- .../manage/ManageProfileViewModel.java | 6 +- .../manage/UsernameEditRepository.java | 131 --------- .../manage/UsernameEditViewModel.java | 8 +- .../profiles/manage/UsernameRepository.kt | 273 ++++++++++++++++++ .../profiles/manage/UsernameState.kt | 2 +- .../storage/AccountRecordProcessor.java | 14 +- .../securesms/storage/StorageSyncHelper.java | 27 ++ .../securesms/storage/StorageSyncModels.java | 29 ++ .../securesms/util/UsernameUtil.java | 135 --------- .../securesms/util/UsernameUtil.kt | 133 +++++++++ app/src/main/res/values/strings.xml | 18 +- .../org/signal/core/ui/theme/SignalTheme.kt | 4 +- .../api/SignalServiceAccountManager.java | 25 ++ .../api/push/UsernameLinkComponents.kt | 32 ++ .../api/storage/SignalAccountRecord.java | 14 + .../signalservice/api/util/UuidExtensions.kt | 10 + .../push/GetUsernameFromLinkResponseBody.kt | 6 + .../internal/push/PushServiceSocket.java | 27 ++ .../push/SetUsernameLinkRequestBody.kt | 6 + .../push/SetUsernameLinkResponseBody.kt | 13 + .../src/main/proto/StorageService.proto | 20 ++ 47 files changed, 1283 insertions(+), 463 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkResetResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/migrations/CopyUsernameToSignalStoreMigrationJob.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/UsernameLinkComponents.kt create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidExtensions.kt create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/GetUsernameFromLinkResponseBody.kt create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkResponseBody.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob__checkUsernameIsInSyncTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob__checkUsernameIsInSyncTest.kt index 5cc41aa90b..860ab4c711 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob__checkUsernameIsInSyncTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob__checkUsernameIsInSyncTest.kt @@ -32,7 +32,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest { @After fun tearDown() { InstrumentationApplicationDependencyProvider.clearHandlers() - SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync() + SignalStore.account().usernameOutOfSync = false } @Test @@ -78,7 +78,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest { // THEN assertTrue(didReserve) assertTrue(didConfirm) - assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync) + assertFalse(SignalStore.account().usernameOutOfSync) } @Test @@ -108,7 +108,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest { // THEN assertTrue(didReserve) assertTrue(didConfirm) - assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync) + assertFalse(SignalStore.account().usernameOutOfSync) } @Test @@ -142,7 +142,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest { // THEN assertFalse(didReserve) assertFalse(didConfirm) - assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync) + assertFalse(SignalStore.account().usernameOutOfSync) } @Test @@ -176,6 +176,6 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest { // THEN assertTrue(didReserve) assertFalse(didConfirm) - assertTrue(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync) + assertTrue(SignalStore.account().usernameOutOfSync) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UsernameOutOfSyncReminder.kt b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UsernameOutOfSyncReminder.kt index 40b40f5044..56e7edd673 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UsernameOutOfSyncReminder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/UsernameOutOfSyncReminder.kt @@ -26,7 +26,7 @@ class UsernameOutOfSyncReminder : Reminder(R.string.UsernameOutOfSyncReminder__s companion object { @JvmStatic fun isEligible(): Boolean { - return FeatureFlags.usernames() && SignalStore.phoneNumberPrivacy().isUsernameOutOfSync + return FeatureFlags.usernames() && SignalStore.account().usernameOutOfSync } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index a3552cf92f..a9d8cd1a94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -131,7 +131,7 @@ class AppSettingsFragment : DSLSettingsFragment( findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity) }, onQrButtonClicked = { - if (Recipient.self().username.isPresent && Recipient.self().username.get().isNotEmpty()) { + if (SignalStore.account().username != null) { findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment) } else { findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment) 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 b6e9ea462a..bf4dcb8cf0 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 @@ -60,7 +60,7 @@ private fun DrawScope.drawQr( deadzonePercent: Float, logo: ImageBitmap ) { - val deadzonePaddingPercent = 0.07f + val deadzonePaddingPercent = 0.045f // We want an even number of dots on either side of the deadzone val deadzoneRadius: Int = (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt index 30f5aa0153..50b804b8a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt @@ -1,14 +1,23 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks +import android.content.res.Configuration import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio 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.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Surface @@ -20,16 +29,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight 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.sp import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.compose.ScreenshotController import org.thoughtcrime.securesms.compose.getScreenshotBounds @@ -37,19 +52,25 @@ import org.thoughtcrime.securesms.compose.getScreenshotBounds * Renders a QR code and username as a badge. */ @Composable -fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier, screenshotController: ScreenshotController? = null) { - val borderColor by animateColorAsState(targetValue = colorScheme.borderColor) - val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor) - val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f) - val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White) +fun QrCodeBadge( + data: QrCodeState, + colorScheme: UsernameQrCodeColorScheme, + username: String, + modifier: Modifier = Modifier, + screenshotController: ScreenshotController? = null, + usernameCopyable: Boolean = false, + onClick: (() -> Unit) = {} +) { + val borderColor by animateColorAsState(targetValue = colorScheme.borderColor, label = "border") + val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor, label = "foreground") + val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f, label = "elevation") + val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White, label = "textColor") var badgeBounds by remember { mutableStateOf(null) } screenshotController?.bind(LocalView.current, badgeBounds) Surface( modifier = modifier - .fillMaxWidth() - .padding(horizontal = 59.dp, vertical = 24.dp) .onGloballyPositioned { badgeBounds = it.getScreenshotBounds() }, @@ -57,24 +78,32 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern shape = RoundedCornerShape(24.dp), shadowElevation = elevation.dp ) { - Column { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(296.dp) + ) { Surface( modifier = Modifier .padding( top = 32.dp, start = 40.dp, - end = 40.dp, - bottom = 16.dp + end = 40.dp ) .aspectRatio(1f) .fillMaxWidth(), shape = RoundedCornerShape(12.dp), color = Color.White ) { - if (data != null) { + if (data is QrCodeState.Present) { QrCode( - data = data, - modifier = Modifier.padding(16.dp), + data = data.data, + modifier = Modifier + .border( + width = if (colorScheme == UsernameQrCodeColorScheme.White) 2.dp else 0.dp, + color = Color(0xFFE9E9E9), + shape = RoundedCornerShape(size = 12.dp) + ) + .padding(16.dp), foregroundColor = foregroundColor, backgroundColor = Color.White ) @@ -85,40 +114,169 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern .fillMaxHeight(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator( - color = colorScheme.borderColor, - modifier = Modifier.size(56.dp) - ) + if (data is QrCodeState.Loading) { + CircularProgressIndicator( + color = colorScheme.borderColor, + modifier = Modifier.size(56.dp) + ) + } else if (data is QrCodeState.NotSet) { + Image( + painter = painterResource(id = R.drawable.symbol_error_circle_24), + contentDescription = stringResource(id = R.string.UsernameLinkSettings_link_not_set_label), + colorFilter = ColorFilter.tint(colorResource(R.color.core_grey_25)), + modifier = Modifier + .width(28.dp) + .height(28.dp) + ) + } } } } - Text( - text = username, - color = textColor, - fontSize = 20.sp, - lineHeight = 26.sp, - fontWeight = FontWeight.W600, - textAlign = TextAlign.Center, + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxWidth() .padding( - start = 40.dp, - end = 40.dp, - bottom = 32.dp + start = 32.dp, + end = 32.dp, + top = 8.dp, + bottom = 28.dp ) - ) + .clip(RoundedCornerShape(8.dp)) + .clickable( + enabled = usernameCopyable, + onClick = onClick + ) + .padding(8.dp) + ) { + if (usernameCopyable) { + Image( + painter = painterResource(id = R.drawable.symbol_copy_android_24), + contentDescription = null, + colorFilter = if (colorScheme == UsernameQrCodeColorScheme.White) { + ColorFilter.tint(Color.Black) + } else { + ColorFilter.tint(Color.White) + } + ) + } + + Text( + text = username, + color = textColor, + fontSize = 20.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.W600, + textAlign = TextAlign.Center, + modifier = Modifier.padding(start = 6.dp) + ) + } } } } -@Preview +@Preview(name = "Light Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun PreviewWithCode() { +private fun PreviewWithCodeShort() { + SignalTheme { + Surface { + Column { + QrCodeBadge( + data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)), + colorScheme = UsernameQrCodeColorScheme.Blue, + username = "parker.42", + usernameCopyable = false + ) + QrCodeBadge( + data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)), + colorScheme = UsernameQrCodeColorScheme.Blue, + username = "parker.42", + usernameCopyable = true + ) + } + } + } +} + +@Preview(group = "LongName") +@Composable +private fun PreviewWithCodeLong() { + SignalTheme { + Surface { + Column { + QrCodeBadge( + data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)), + colorScheme = UsernameQrCodeColorScheme.Blue, + username = "TheAmazingSpiderMan.42", + usernameCopyable = false + ) + Spacer(modifier = Modifier.height(8.dp)) + QrCodeBadge( + data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)), + colorScheme = UsernameQrCodeColorScheme.Blue, + username = "TheAmazingSpiderMan.42", + usernameCopyable = true + ) + } + } + } +} + +@Preview(group = "Colors", heightDp = 1500) +@Composable +private fun PreviewAllColorsP1() { SignalTheme(isDarkMode = false) { + Surface { + Column { + SampleCode(colorScheme = UsernameQrCodeColorScheme.Blue) + Spacer(modifier = Modifier.height(8.dp)) + SampleCode(colorScheme = UsernameQrCodeColorScheme.White) + Spacer(modifier = Modifier.height(8.dp)) + SampleCode(colorScheme = UsernameQrCodeColorScheme.Green) + Spacer(modifier = Modifier.height(8.dp)) + SampleCode(colorScheme = UsernameQrCodeColorScheme.Grey) + } + } + } +} + +@Preview(group = "Colors", heightDp = 1500) +@Composable +private fun PreviewAllColorsP2() { + SignalTheme(isDarkMode = false) { + Surface { + Column { + SampleCode(colorScheme = UsernameQrCodeColorScheme.Pink) + Spacer(modifier = Modifier.height(8.dp)) + SampleCode(colorScheme = UsernameQrCodeColorScheme.Orange) + Spacer(modifier = Modifier.height(8.dp)) + SampleCode(colorScheme = UsernameQrCodeColorScheme.Purple) + Spacer(modifier = Modifier.height(8.dp)) + SampleCode(colorScheme = UsernameQrCodeColorScheme.Tan) + } + } + } +} + +@Composable +private fun SampleCode(colorScheme: UsernameQrCodeColorScheme) { + QrCodeBadge( + data = QrCodeState.Present(QrCodeData.forData("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdf", 64)), + colorScheme = colorScheme, + username = "parker.42" + ) +} + +@Preview(name = "Light Theme", group = "Loading", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "Loading", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLoading() { + SignalTheme { Surface { QrCodeBadge( - data = QrCodeData.forData("https://signal.org", 64), + data = QrCodeState.Loading, colorScheme = UsernameQrCodeColorScheme.Blue, username = "parker.42" ) @@ -126,13 +284,14 @@ private fun PreviewWithCode() { } } -@Preview +@Preview(name = "Light Theme", group = "NotSet", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "NotSet", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun PreviewWithoutCode() { - SignalTheme(isDarkMode = false) { +private fun PreviewNotSet() { + SignalTheme { Surface { QrCodeBadge( - data = null, + data = QrCodeState.NotSet, colorScheme = UsernameQrCodeColorScheme.Blue, username = "parker.42" ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt index 64b70b93f0..797bb184f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt @@ -38,7 +38,7 @@ class QrCodeData( @WorkerThread fun forData(data: String, size: Int): QrCodeData { val qrCodeWriter = QRCodeWriter() - val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H.toString()) + val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q.toString()) val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints) val dimens = padded.enclosingRectangle diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeState.kt new file mode 100644 index 0000000000..5fb7f46aa4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeState.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.usernamelinks + +sealed class QrCodeState { + /** QR code data exists and is available. */ + data class Present(val data: QrCodeData) : QrCodeState() + + /** QR code data does not exist. */ + object NotSet : QrCodeState() + + /** QR code data is in an indeterminate loading state. */ + object Loading : QrCodeState() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt index f58634fc86..7419e67310 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt @@ -17,7 +17,7 @@ enum class UsernameQrCodeColorScheme( ), White( borderColor = Color(0xFFFFFFFF), - foregroundColor = Color(0xFF464852), + foregroundColor = Color(0xFF000000), key = "white" ), Grey( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerFragment.kt index 014d78643a..bf496653a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerFragment.kt @@ -65,12 +65,14 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() { .padding(contentPadding) .fillMaxWidth() .fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally ) { QrCodeBadge( data = state.qrCodeData, colorScheme = state.selectedColorScheme, - username = state.username + username = state.username, + modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp) ) ColorPicker( @@ -160,7 +162,7 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() { @Preview @Composable - private fun ColorPickerItemPreview() { + private fun PreviewColorPickerItem() { SignalTheme(isDarkMode = false) { Surface { Row(verticalAlignment = Alignment.CenterVertically) { @@ -173,7 +175,7 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() { @Preview @Composable - private fun ColorPickerPreview() { + private fun PreviewColorPicker() { SignalTheme(isDarkMode = false) { Surface { ColorPicker( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerState.kt index 688af12810..b060d79ad1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerState.kt @@ -1,12 +1,12 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker import kotlinx.collections.immutable.ImmutableList -import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme data class UsernameLinkQrColorPickerState( val username: String, - val qrCodeData: QrCodeData?, + val qrCodeData: QrCodeState, val colorSchemes: ImmutableList, val selectedColorScheme: UsernameQrCodeColorScheme ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt index 560fcd8b99..0f6b475bd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt @@ -9,20 +9,22 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.collections.immutable.toImmutableList +import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.UsernameUtil class UsernameLinkQrColorPickerViewModel : ViewModel() { - private val username: String = Recipient.self().username.get() - private val _state = mutableStateOf( UsernameLinkQrColorPickerState( - username = username, - qrCodeData = null, + username = SignalStore.account().username!!, + qrCodeData = QrCodeState.Loading, colorSchemes = UsernameQrCodeColorScheme.values().asList().toImmutableList(), selectedColorScheme = SignalStore.misc().usernameQrCodeColorScheme ) @@ -33,15 +35,23 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() { private val disposable: CompositeDisposable = CompositeDisposable() init { - disposable += Single - .fromCallable { QrCodeData.forData(UsernameUtil.generateLink(username), 64) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { qrData -> - _state.value = _state.value.copy( - qrCodeData = qrData - ) - } + val usernameLink = SignalStore.account().usernameLink + + if (usernameLink != null) { + disposable += Single + .fromCallable { QrCodeData.forData(UsernameUtil.generateLink(usernameLink), 64) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { qrData -> + _state.value = _state.value.copy( + qrCodeData = QrCodeState.Present(qrData) + ) + } + } else { + _state.value = _state.value.copy( + qrCodeData = QrCodeState.NotSet + ) + } } override fun onCleared() { @@ -50,6 +60,11 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() { fun onColorSelected(color: UsernameQrCodeColorScheme) { SignalStore.misc().usernameQrCodeColorScheme = color + SignalExecutors.BOUNDED.run { + SignalDatabase.recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() + } + _state.value = _state.value.copy( selectedColorScheme = color ) 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 index afe6bdbe19..0a4cb1af02 100644 --- 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 @@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.recipients.Recipient sealed class QrScanResult { class Success(val recipient: Recipient) : QrScanResult() - class NotFound(val username: String) : QrScanResult() + class NotFound(val username: String?) : QrScanResult() object InvalidData : QrScanResult() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkResetResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkResetResult.kt new file mode 100644 index 0000000000..c3421f062d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkResetResult.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import org.whispersystems.signalservice.api.push.UsernameLinkComponents + +/** + * Result of resetting the username link. + */ +sealed class UsernameLinkResetResult { + /** Successfully reset the username link. */ + data class Success(val components: UsernameLinkComponents) : UsernameLinkResetResult() + + /** Network failed when making the request. The username is still considered to be "reset". */ + object NetworkError : UsernameLinkResetResult() + + /** We never made the request because we detected the user had no network. */ + object NetworkUnavailable : UsernameLinkResetResult() + + /** We never made the request because we hit an unexpected error. */ + object UnexpectedError : UsernameLinkResetResult() +} 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 6b4c7ae81e..6b519ea1f2 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,6 +1,7 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main import android.content.Intent +import android.content.res.Configuration import android.graphics.Bitmap import android.os.Bundle import android.view.View @@ -17,7 +18,6 @@ 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 @@ -29,6 +29,7 @@ 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.res.stringResource @@ -55,7 +56,6 @@ import org.thoughtcrime.securesms.providers.BlobProvider import java.io.ByteArrayOutputStream @OptIn( - ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class ) class UsernameLinkSettingsFragment : ComposeFragment() { @@ -71,6 +71,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() { val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } val scope: CoroutineScope = rememberCoroutineScope() val navController: NavController by remember { mutableStateOf(findNavController()) } + var showResetDialog: Boolean by remember { mutableStateOf(false) } Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, @@ -95,7 +96,9 @@ class UsernameLinkSettingsFragment : ComposeFragment() { onShareBadge = { shareQrBadge(it) }, - screenshotController = screenshotController + screenshotController = screenshotController, + onResetClicked = { showResetDialog = true }, + onLinkResultHandled = { viewModel.onUsernameLinkResetResultHandled() } ) } @@ -114,6 +117,16 @@ class UsernameLinkSettingsFragment : ComposeFragment() { ) } } + + if (showResetDialog) { + ResetDialog( + onConfirm = { + viewModel.onUsernameLinkReset() + showResetDialog = false + }, + onDismiss = { showResetDialog = false } + ) + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -182,20 +195,43 @@ class UsernameLinkSettingsFragment : ComposeFragment() { } } + @Composable + private fun ResetDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + Dialogs.SimpleAlertDialog( + title = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_title), + body = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_body), + confirm = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_confirm_button), + dismiss = stringResource(id = android.R.string.cancel), + onConfirm = onConfirm, + onDismiss = onDismiss + ) + } + @Preview @Composable - private fun AppBarPreview() { - SignalTheme(isDarkMode = false) { + private fun PreviewAppBar() { + SignalTheme { Surface { TopAppBarContent(activeTab = ActiveTab.Code) } } } + @Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO) + @Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) + @Composable + private fun PreviewAll() { + FragmentContent() + } + @Preview @Composable - fun PreviewAll() { - FragmentContent() + private fun PreviewResetDialog() { + SignalTheme { + Surface { + ResetDialog(onConfirm = {}, onDismiss = {}) + } + } } private fun shareQrBadge(badge: Bitmap) { 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 26958914ba..0e2189b99d 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 @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main -import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme /** @@ -9,10 +9,11 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.Username data class UsernameLinkSettingsState( val activeTab: ActiveTab, val username: String, - val usernameLink: String, - val qrCodeData: QrCodeData?, + val usernameLinkState: UsernameLinkState, + val qrCodeState: QrCodeState, val qrCodeColorScheme: UsernameQrCodeColorScheme, val qrScanResult: QrScanResult? = null, + val usernameLinkResetResult: UsernameLinkResetResult? = null, val indeterminateProgress: Boolean = false ) { enum class ActiveTab { 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 dea0f1c751..6d16a316a2 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 @@ -10,46 +10,46 @@ 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.QrCodeState 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.profiles.manage.UsernameRepository import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.NetworkUtil import org.thoughtcrime.securesms.util.UsernameUtil -import org.whispersystems.signalservice.api.push.ServiceId.ACI -import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException -import java.io.IOException +import org.whispersystems.signalservice.api.push.UsernameLinkComponents +import java.util.Optional 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, + username = SignalStore.account().username!!, + usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(UsernameUtil.generateLink(it)) } ?: UsernameLinkState.NotSet, + qrCodeState = QrCodeState.Loading, qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme ) ) - val state: State = _state private val disposable: CompositeDisposable = CompositeDisposable() + private val usernameLink: BehaviorSubject> = BehaviorSubject.createDefault(Optional.ofNullable(SignalStore.account().usernameLink)) + private val usernameRepo: UsernameRepository = UsernameRepository() init { - disposable += username + disposable += usernameLink .observeOn(Schedulers.io()) - .map { UsernameUtil.generateLink(it) } + .map { link -> link.map { UsernameUtil.generateLink(it) } } .flatMapSingle { generateQrCodeData(it) } .observeOn(AndroidSchedulers.mainThread()) .subscribe { qrData -> _state.value = _state.value.copy( - qrCodeData = qrData + qrCodeState = if (qrData.isPresent) QrCodeState.Present(qrData.get()) else QrCodeState.NotSet ) } } @@ -70,37 +70,70 @@ class UsernameLinkSettingsViewModel : ViewModel() { ) } + fun onUsernameLinkReset() { + if (!NetworkUtil.isConnected(ApplicationDependencies.getApplication())) { + _state.value = _state.value.copy( + usernameLinkResetResult = UsernameLinkResetResult.NetworkUnavailable + ) + return + } + + val currentValue = _state.value + val previousQrValue: QrCodeData? = if (currentValue.qrCodeState is QrCodeState.Present) { + currentValue.qrCodeState.data + } else { + null + } + + _state.value = _state.value.copy( + usernameLinkState = UsernameLinkState.Resetting, + qrCodeState = QrCodeState.Loading + ) + + disposable += usernameRepo.createOrResetUsernameLink() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { result -> + val components: Optional = when (result) { + is UsernameLinkResetResult.Success -> Optional.of(result.components) + is UsernameLinkResetResult.NetworkError -> Optional.empty() + else -> { usernameLink.value ?: Optional.empty() } + } + + _state.value = _state.value.copy( + usernameLinkState = if (components.isPresent) { + val link = UsernameUtil.generateLink(components.get()) + UsernameLinkState.Present(link) + } else { + UsernameLinkState.NotSet + }, + usernameLinkResetResult = result, + qrCodeState = if (components.isPresent && previousQrValue != null) { + QrCodeState.Present(previousQrValue) + } else { + QrCodeState.NotSet + } + ) + } + } + + fun onUsernameLinkResetResultHandled() { + _state.value = _state.value.copy( + usernameLinkResetResult = null + ) + } + 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 + disposable += usernameRepo.convertLinkToUsernameAndAci(url) + .map { result -> + when (result) { + is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString())) + is UsernameRepository.UsernameLinkConversionResult.Invalid -> QrScanResult.InvalidData + is UsernameRepository.UsernameLinkConversionResult.NotFound -> QrScanResult.NotFound(result.username?.toString()) + is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.NetworkError } } .subscribeOn(Schedulers.io()) @@ -119,9 +152,9 @@ class UsernameLinkSettingsViewModel : ViewModel() { ) } - private fun generateQrCodeData(url: String): Single { + private fun generateQrCodeData(url: Optional): Single> { return Single.fromCallable { - QrCodeData.forData(url, 64) + url.map { QrCodeData.forData(it, 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 a59d47d13b..3342fec4dd 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 @@ -1,8 +1,10 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main +import android.content.res.Configuration import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState @@ -19,6 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -31,14 +35,15 @@ import androidx.navigation.NavController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.signal.core.ui.Buttons +import org.signal.core.ui.Dialogs import org.signal.core.ui.theme.SignalTheme 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.QrCodeState import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab import org.thoughtcrime.securesms.compose.ScreenshotController -import org.thoughtcrime.securesms.util.UsernameUtil import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -48,22 +53,43 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate @Composable fun UsernameLinkShareScreen( state: UsernameLinkSettingsState, + onLinkResultHandled: () -> Unit, snackbarHostState: SnackbarHostState, scope: CoroutineScope, navController: NavController, onShareBadge: (Bitmap) -> Unit, modifier: Modifier = Modifier, - screenshotController: ScreenshotController? = null + screenshotController: ScreenshotController? = null, + onResetClicked: () -> Unit ) { + when (state.usernameLinkResetResult) { + UsernameLinkResetResult.NetworkUnavailable -> { + ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_unavailable), onDismiss = onLinkResultHandled) + } + UsernameLinkResetResult.NetworkError -> { + ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_error), onDismiss = onLinkResultHandled) + } + else -> {} + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .verticalScroll(rememberScrollState()) ) { + val usernameCopiedString = stringResource(id = R.string.UsernameLinkSettings_username_copied_toast) QrCodeBadge( - data = state.qrCodeData, + data = state.qrCodeState, colorScheme = state.qrCodeColorScheme, username = state.username, - screenshotController = screenshotController + screenshotController = screenshotController, + usernameCopyable = true, + modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp), + onClick = { + scope.launch { + snackbarHostState.showSnackbar(usernameCopiedString) + } + } ) ButtonBar( @@ -76,16 +102,8 @@ fun UsernameLinkShareScreen( onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) } ) - CopyRow( - displayText = state.username, - copyMessage = stringResource(R.string.UsernameLinkSettings_username_copied_toast), - snackbarHostState = snackbarHostState, - scope = scope - ) - - CopyRow( - displayText = state.usernameLink, - copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast), + LinkRow( + linkState = state.usernameLinkState, snackbarHostState = snackbarHostState, scope = scope ) @@ -94,7 +112,7 @@ 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(bottom = 19.dp, start = 43.dp, end = 43.dp), color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -104,7 +122,7 @@ fun UsernameLinkShareScreen( .padding(bottom = 24.dp), horizontalArrangement = Arrangement.Center ) { - Buttons.Small(onClick = { /*TODO*/ }) { + Buttons.Small(onClick = onResetClicked) { Text( text = stringResource(id = R.string.UsernameLinkSettings_reset_button_label) ) @@ -133,29 +151,46 @@ private fun ButtonBar(onShareClicked: () -> Unit, onColorClicked: () -> Unit) { } @Composable -private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { +private fun LinkRow(linkState: UsernameLinkState, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { val context = LocalContext.current + val copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast) Row( modifier = Modifier .fillMaxWidth() .background(color = MaterialTheme.colorScheme.background) - .clickable { - Util.copyToClipboard(context, displayText) + .padding( + top = 32.dp, + bottom = 24.dp, + start = 24.dp, + end = 24.dp + ) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(12.dp) + ) + .clickable(enabled = linkState is UsernameLinkState.Present) { + Util.copyToClipboard(context, (linkState as UsernameLinkState.Present).link) scope.launch { snackbarHostState.showSnackbar(copyMessage) } } .padding(horizontal = 26.dp, vertical = 16.dp) + .alpha(if (linkState is UsernameLinkState.Present) 1.0f else 0.6f) ) { Image( - painter = painterResource(id = R.drawable.symbol_copy_android_24), + painter = painterResource(id = R.drawable.symbol_link_24), contentDescription = null, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) ) Text( - text = displayText, + text = when (linkState) { + is UsernameLinkState.Present -> linkState.link + is UsernameLinkState.NotSet -> stringResource(id = R.string.UsernameLinkSettings_link_not_set_label) + is UsernameLinkState.Resetting -> stringResource(id = R.string.UsernameLinkSettings_resetting_link_label) + }, modifier = Modifier.padding(start = 26.dp), maxLines = 1, overflow = TextOverflow.Ellipsis @@ -163,45 +198,68 @@ private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState: } } -@Preview(name = "Light Theme") @Composable -private fun ScreenPreviewLightTheme() { - SignalTheme(isDarkMode = false) { +private fun ResetLinkResultDialog(message: String, onDismiss: () -> Unit) { + Dialogs.SimpleMessageDialog( + message = message, + dismiss = stringResource(id = android.R.string.ok), + onDismiss = onDismiss + ) +} + +@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ScreenPreview() { + SignalTheme { Surface { UsernameLinkShareScreen( state = previewState(), snackbarHostState = SnackbarHostState(), scope = rememberCoroutineScope(), navController = NavController(LocalContext.current), - onShareBadge = {} + onShareBadge = {}, + onResetClicked = {}, + onLinkResultHandled = {} ) } } } -@Preview(name = "Dark Theme") +@Preview(name = "Light Theme", group = "LinkRow", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "LinkRow", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun ScreenPreviewDarkTheme() { - SignalTheme(isDarkMode = true) { +private fun LinkRowPreview() { + SignalTheme { Surface { - UsernameLinkShareScreen( - state = previewState(), - snackbarHostState = SnackbarHostState(), - scope = rememberCoroutineScope(), - navController = NavController(LocalContext.current), - onShareBadge = {} - ) + Column(modifier = Modifier.padding(8.dp)) { + LinkRow( + linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"), + snackbarHostState = SnackbarHostState(), + scope = rememberCoroutineScope() + ) + LinkRow( + linkState = UsernameLinkState.NotSet, + snackbarHostState = SnackbarHostState(), + scope = rememberCoroutineScope() + ) + LinkRow( + linkState = UsernameLinkState.Resetting, + snackbarHostState = SnackbarHostState(), + scope = rememberCoroutineScope() + ) + } } } } private fun previewState(): UsernameLinkSettingsState { - val link = UsernameUtil.generateLink("maya.45") + val link = "https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf" return UsernameLinkSettingsState( activeTab = ActiveTab.Code, - username = "maya.45", - usernameLink = link, - qrCodeData = QrCodeData.forData(link, 64), + username = "parker.42", + usernameLinkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"), + qrCodeState = QrCodeState.Present(QrCodeData.forData(link, 64)), qrCodeColorScheme = UsernameQrCodeColorScheme.Blue ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkState.kt new file mode 100644 index 0000000000..cc4c2874b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkState.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +sealed class UsernameLinkState { + + /** Link is set. */ + data class Present(val link: String) : UsernameLinkState() + + /** Link has not been set yet or otherwise does not exist. */ + object NotSet : UsernameLinkState() + + /** Link is in the process of being reset. */ + object Resetting : UsernameLinkState() +} 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 ccb3e73b93..fc4ecc0924 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 @@ -32,6 +32,7 @@ import org.signal.qr.QrScannerView import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist import org.thoughtcrime.securesms.util.CommunicationActions +import java.util.concurrent.TimeUnit /** * A screen that allows you to scan a QR code to start a chat. @@ -53,7 +54,11 @@ fun UsernameQrScanScreen( 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) + if (qrScanResult.username != null) { + QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled) + } else { + QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found_no_username), onDismiss = onQrResultHandled) + } } is QrScanResult.Success -> { CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null) @@ -70,7 +75,7 @@ fun UsernameQrScanScreen( AndroidView( factory = { context -> val view = QrScannerView(context) - disposables += view.qrData.distinctUntilChanged().subscribe { data -> + disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data -> onQrCodeScanned(data) } view diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index bd3be394fd..113765bdba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.migrations.BackupNotificationMigrationJob; import org.thoughtcrime.securesms.migrations.BlobStorageLocationMigrationJob; import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob; import org.thoughtcrime.securesms.migrations.ClearGlideCacheMigrationJob; +import org.thoughtcrime.securesms.migrations.CopyUsernameToSignalStoreMigrationJob; import org.thoughtcrime.securesms.migrations.DatabaseMigrationJob; import org.thoughtcrime.securesms.migrations.DeleteDeprecatedLogsMigrationJob; import org.thoughtcrime.securesms.migrations.DirectoryRefreshMigrationJob; @@ -231,6 +232,7 @@ public final class JobManagerFactories { put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory()); put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory()); put(ClearGlideCacheMigrationJob.KEY, new ClearGlideCacheMigrationJob.Factory()); + put(CopyUsernameToSignalStoreMigrationJob.KEY, new CopyUsernameToSignalStoreMigrationJob.Factory()); put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory()); put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory()); put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index 9f8823c2eb..b3d1e62c4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -301,7 +301,8 @@ public class RefreshOwnProfileJob extends BaseJob { .confirmUsername(localUsername, response); } catch (IOException e) { Log.d(TAG, "Failed to synchronize username.", e); - SignalStore.phoneNumberPrivacy().markUsernameOutOfSync(); + // TODO [greyson][usernames] Is this actually enough to trigger it? Shouldn't we wait until we know for sure, rather than have a network error? + SignalStore.account().setUsernameOutOfSync(true); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index 4125a315b4..86addb46ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -26,6 +26,9 @@ import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.push.ServiceId.PNI import org.whispersystems.signalservice.api.push.ServiceIds import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.api.push.UsernameLinkComponents +import org.whispersystems.signalservice.api.util.UuidUtil +import org.whispersystems.signalservice.api.util.toByteArray import java.security.SecureRandom internal class AccountValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) { @@ -64,6 +67,11 @@ internal class AccountValues internal constructor(store: KeyValueStore) : Signal private const val KEY_PNI_LAST_RESORT_KYBER_PREKEY_ID = "account.pni_last_resort_kyber_prekey_id" private const val KEY_PNI_LAST_RESORT_KYBER_PREKEY_ROTATION_TIME = "account.pni_last_resort_kyber_prekey_rotation_time" + private const val KEY_USERNAME = "account.username" + private const val KEY_USERNAME_LINK_ENTROPY = "account.username_link_entropy" + private const val KEY_USERNAME_LINK_SERVER_ID = "account.username_link_server_id" + private const val KEY_USERNAME_OUT_OF_SYNC = "phoneNumberPrivacy.usernameOutOfSync" + @VisibleForTesting const val KEY_E164 = "account.e164" @@ -100,7 +108,9 @@ internal class AccountValues internal constructor(store: KeyValueStore) : Signal KEY_ACI_IDENTITY_PUBLIC_KEY, KEY_ACI_IDENTITY_PRIVATE_KEY, KEY_PNI_IDENTITY_PUBLIC_KEY, - KEY_PNI_IDENTITY_PRIVATE_KEY + KEY_PNI_IDENTITY_PRIVATE_KEY, + KEY_USERNAME, + KEY_USERNAME_LINK_SERVER_ID ) } @@ -351,6 +361,36 @@ internal class AccountValues internal constructor(store: KeyValueStore) : Signal val isLinkedDevice: Boolean get() = !isPrimaryDevice + /** The local user's full username (nickname.discriminator), if set. */ + var username: String? by stringValue(KEY_USERNAME, null) + + /** The local user's username link components, if set. */ + var usernameLink: UsernameLinkComponents? + get() { + val entropy: ByteArray? = getBlob(KEY_USERNAME_LINK_ENTROPY, null) + val serverId: ByteArray? = getBlob(KEY_USERNAME_LINK_SERVER_ID, null) + + return if (entropy != null && serverId != null) { + val serverIdUuid = UuidUtil.parseOrThrow(serverId) + UsernameLinkComponents(entropy, serverIdUuid) + } else { + null + } + } + set(value) { + store + .beginWrite() + .putBlob(KEY_USERNAME_LINK_ENTROPY, value?.entropy) + .putBlob(KEY_USERNAME_LINK_SERVER_ID, value?.serverId?.toByteArray()) + .apply() + } + + /** + * There are some cases where our username may fall out of sync with the service. In particular, we may get a new value for our username from + * storage service but then find that it doesn't match what's on the service. + */ + var usernameOutOfSync: Boolean by booleanValue(KEY_USERNAME_OUT_OF_SYNC, false) + private fun clearLocalCredentials() { putString(KEY_SERVICE_PASSWORD, Util.getSecret(18)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PhoneNumberPrivacyValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PhoneNumberPrivacyValues.java index 94765d05f3..7849b338ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PhoneNumberPrivacyValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PhoneNumberPrivacyValues.java @@ -11,10 +11,9 @@ import java.util.List; public final class PhoneNumberPrivacyValues extends SignalStoreValues { - public static final String SHARING_MODE = "phoneNumberPrivacy.sharingMode"; - public static final String LISTING_MODE = "phoneNumberPrivacy.listingMode"; - public static final String LISTING_TIMESTAMP = "phoneNumberPrivacy.listingMode.timestamp"; - public static final String USERNAME_OUT_OF_SYNC = "phoneNumberPrivacy.usernameOutOfSync"; + public static final String SHARING_MODE = "phoneNumberPrivacy.sharingMode"; + public static final String LISTING_MODE = "phoneNumberPrivacy.listingMode"; + public static final String LISTING_TIMESTAMP = "phoneNumberPrivacy.listingMode.timestamp"; private static final Collection REGULAR_CERTIFICATE = Collections.singletonList(CertificateType.UUID_AND_E164); private static final Collection PRIVACY_CERTIFICATE = Collections.singletonList(CertificateType.UUID_ONLY); @@ -69,18 +68,6 @@ public final class PhoneNumberPrivacyValues extends SignalStoreValues { return getLong(LISTING_TIMESTAMP, 0); } - public void markUsernameOutOfSync() { - putBoolean(USERNAME_OUT_OF_SYNC, true); - } - - public void clearUsernameOutOfSync() { - putBoolean(USERNAME_OUT_OF_SYNC, false); - } - - public boolean isUsernameOutOfSync() { - return getBoolean(USERNAME_OUT_OF_SYNC, false); - } - /** * If you respect {@link #getPhoneNumberSharingMode}, then you will only ever need to fetch and store * these certificates types. diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index 79d97cdb47..80ba8a7419 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -136,9 +136,10 @@ public class ApplicationMigrations { static final int ATTACHMENT_CLEANUP_3 = 92; static final int EMOJI_SEARCH_INDEX_CHECK = 93; static final int IDENTITY_FIX = 94; + static final int COPY_USERNAME_TO_SIGNAL_STORE = 95; } - public static final int CURRENT_VERSION = 94; + public static final int CURRENT_VERSION = 95; /** * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call @@ -617,6 +618,10 @@ public class ApplicationMigrations { jobs.put(Version.IDENTITY_FIX, new IdentityTableCleanupMigrationJob()); } + if (lastSeenVersion < Version.COPY_USERNAME_TO_SIGNAL_STORE) { + jobs.put(Version.COPY_USERNAME_TO_SIGNAL_STORE, new CopyUsernameToSignalStoreMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/CopyUsernameToSignalStoreMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/CopyUsernameToSignalStoreMigrationJob.kt new file mode 100644 index 0000000000..81aef555ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/CopyUsernameToSignalStoreMigrationJob.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.migrations + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.storage.StorageSyncHelper + +/** + * Migration to copy any existing username to [SignalStore.account] + */ +internal class CopyUsernameToSignalStoreMigrationJob( + parameters: Parameters = Parameters.Builder().build() +) : MigrationJob(parameters) { + + companion object { + const val KEY = "CopyUsernameToSignalStore" + + val TAG = Log.tag(CopyUsernameToSignalStoreMigrationJob::class.java) + } + + override fun getFactoryKey(): String = KEY + + override fun isUiBlocking(): Boolean = false + + override fun performMigration() { + if (SignalStore.account().aci == null || SignalStore.account().pni == null) { + Log.i(TAG, "ACI/PNI are unset, skipping.") + return + } + + val self = Recipient.self() + + if (self.username.isEmpty) { + Log.i(TAG, "No username set, skipping.") + return + } + + SignalStore.account().username = self.username.get() + + // New fields in storage service, so we trigger a sync + SignalDatabase.recipients.markNeedsSync(self.id) + StorageSyncHelper.scheduleSyncForDataChange() + } + + override fun shouldRetry(e: Exception): Boolean = false + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): CopyUsernameToSignalStoreMigrationJob { + return CopyUsernameToSignalStoreMigrationJob(parameters) + } + } +} 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 f60bdb4b4e..d940c91a11 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 @@ -24,8 +24,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.snackbar.Snackbar; import org.signal.core.util.logging.Log; -import org.signal.libsignal.usernames.BaseUsernameException; -import org.signal.libsignal.usernames.Username; import org.thoughtcrime.securesms.AvatarPreviewActivity; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; @@ -47,7 +45,6 @@ import org.thoughtcrime.securesms.util.UsernameUtil; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; -import org.whispersystems.util.Base64UrlSafe; import java.util.Arrays; import java.util.Optional; @@ -247,7 +244,6 @@ public class ManageProfileFragment extends LoggingFragment { binding.manageProfileUsernameShare.setVisibility(View.GONE); } else { binding.manageProfileUsername.setText(username); - binding.manageProfileUsernameSubtitle.setText(UsernameUtil.generateLink(username)); binding.manageProfileUsernameShare.setVisibility(View.VISIBLE); } } @@ -318,7 +314,7 @@ public class ManageProfileFragment extends LoggingFragment { disposables.add(disposable); } - private void handleUsernameDeletionResult(@NonNull UsernameEditRepository.UsernameDeleteResult usernameDeleteResult) { + private void handleUsernameDeletionResult(@NonNull UsernameRepository.UsernameDeleteResult usernameDeleteResult) { switch (usernameDeleteResult) { case SUCCESS: Snackbar.make(requireView(), R.string.ManageProfileFragment__username_deleted, Snackbar.LENGTH_SHORT).show(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java index 2152b08149..7ea96fb323 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileViewModel.java @@ -50,7 +50,7 @@ class ManageProfileViewModel extends ViewModel { private final SingleLiveEvent events; private final RecipientForeverObserver observer; private final ManageProfileRepository repository; - private final UsernameEditRepository usernameEditRepository; + private final UsernameRepository usernameEditRepository; private final MutableLiveData> badge; private byte[] previousAvatar; @@ -63,7 +63,7 @@ class ManageProfileViewModel extends ViewModel { this.aboutEmoji = new MutableLiveData<>(); this.events = new SingleLiveEvent<>(); this.repository = new ManageProfileRepository(); - this.usernameEditRepository = new UsernameEditRepository(); + this.usernameEditRepository = new UsernameRepository(); this.badge = new DefaultValueLiveData<>(Optional.empty()); this.observer = this::onRecipientChanged; this.avatarState = LiveDataUtil.combineLatest(Recipient.self().live().getLiveData(), internalAvatarState, (self, state) -> new AvatarState(state, self)); @@ -104,7 +104,7 @@ class ManageProfileViewModel extends ViewModel { return events; } - public Single deleteUsername() { + public Single deleteUsername() { return usernameEditRepository.deleteUsername().observeOn(AndroidSchedulers.mainThread()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java deleted file mode 100644 index 7feb848e8f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java +++ /dev/null @@ -1,131 +0,0 @@ -package org.thoughtcrime.securesms.profiles.manage; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; - -import org.signal.core.util.Result; -import org.signal.core.util.logging.Log; -import org.signal.libsignal.usernames.BaseUsernameException; -import org.signal.libsignal.usernames.Username; -import org.thoughtcrime.securesms.database.SignalDatabase; -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.SignalServiceAccountManager; -import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException; -import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException; -import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException; -import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse; -import org.whispersystems.util.Base64UrlSafe; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -class UsernameEditRepository { - - private static final String TAG = Log.tag(UsernameEditRepository.class); - - private final SignalServiceAccountManager accountManager; - - UsernameEditRepository() { - this.accountManager = ApplicationDependencies.getSignalServiceAccountManager(); - } - - @NonNull Single> reserveUsername(@NonNull String nickname) { - return Single.fromCallable(() -> reserveUsernameInternal(nickname)).subscribeOn(Schedulers.io()); - } - - @NonNull Single confirmUsername(@NonNull UsernameState.Reserved reserved) { - return Single.fromCallable(() -> confirmUsernameInternal(reserved)).subscribeOn(Schedulers.io()); - } - - @NonNull Single deleteUsername() { - return Single.fromCallable(this::deleteUsernameInternal).subscribeOn(Schedulers.io()); - } - - @WorkerThread - private @NonNull Result reserveUsernameInternal(@NonNull String nickname) { - try { - List candidates = Username.generateCandidates(nickname, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH); - List hashes = new ArrayList<>(); - - for (String candidate : candidates) { - byte[] hash = Username.hash(candidate); - hashes.add(Base64UrlSafe.encodeBytesWithoutPadding(hash)); - } - - ReserveUsernameResponse response = accountManager.reserveUsername(hashes); - int hashIndex = hashes.indexOf(response.getUsernameHash()); - if (hashIndex == -1) { - Log.w(TAG, "[reserveUsername] The response hash could not be found in our set of hashes."); - return Result.failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR); - } - - Log.i(TAG, "[reserveUsername] Successfully reserved username."); - return Result.success(new UsernameState.Reserved(candidates.get(hashIndex), response)); - } catch (BaseUsernameException e) { - Log.w(TAG, "[reserveUsername] An error occurred while generating candidates."); - return Result.failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR); - } catch (UsernameTakenException e) { - Log.w(TAG, "[reserveUsername] Username taken."); - return Result.failure(UsernameSetResult.USERNAME_UNAVAILABLE); - } catch (UsernameMalformedException e) { - Log.w(TAG, "[reserveUsername] Username malformed."); - return Result.failure(UsernameSetResult.USERNAME_INVALID); - } catch (IOException e) { - Log.w(TAG, "[reserveUsername] Generic network exception.", e); - return Result.failure(UsernameSetResult.NETWORK_ERROR); - } - } - - @WorkerThread - private @NonNull UsernameSetResult confirmUsernameInternal(@NonNull UsernameState.Reserved reserved) { - try { - accountManager.confirmUsername(reserved.getUsername(), reserved.getReserveUsernameResponse()); - SignalDatabase.recipients().setUsername(Recipient.self().getId(), reserved.getUsername()); - SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync(); - Log.i(TAG, "[confirmUsername] Successfully reserved username."); - return UsernameSetResult.SUCCESS; - } catch (UsernameTakenException e) { - Log.w(TAG, "[confirmUsername] Username gone."); - return UsernameSetResult.USERNAME_UNAVAILABLE; - } catch (UsernameIsNotReservedException e) { - Log.w(TAG, "[confirmUsername] Username was not reserved."); - return UsernameSetResult.USERNAME_INVALID; - } catch (IOException e) { - Log.w(TAG, "[confirmUsername] Generic network exception.", e); - return UsernameSetResult.NETWORK_ERROR; - } - } - - @WorkerThread - private @NonNull UsernameDeleteResult deleteUsernameInternal() { - try { - accountManager.deleteUsername(); - SignalDatabase.recipients().setUsername(Recipient.self().getId(), null); - SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync(); - Log.i(TAG, "[deleteUsername] Successfully deleted the username."); - return UsernameDeleteResult.SUCCESS; - } catch (IOException e) { - Log.w(TAG, "[deleteUsername] Generic network exception.", e); - return UsernameDeleteResult.NETWORK_ERROR; - } - } - - enum UsernameSetResult { - SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR, CANDIDATE_GENERATION_ERROR - } - - enum UsernameDeleteResult { - SUCCESS, NETWORK_ERROR - } - - interface Callback { - void onComplete(E result); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java index 64db801637..307feddc81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java @@ -40,15 +40,15 @@ class UsernameEditViewModel extends ViewModel { private static final long NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS = 500; - private final PublishSubject events; - private final UsernameEditRepository repo; - private final RxStore uiState; + private final PublishSubject events; + private final UsernameRepository repo; + private final RxStore uiState; private final PublishProcessor nicknamePublisher; private final CompositeDisposable disposables; private final boolean isInRegistration; private UsernameEditViewModel(boolean isInRegistration) { - this.repo = new UsernameEditRepository(); + this.repo = new UsernameRepository(); this.uiState = new RxStore<>(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, Recipient.self().getUsername().map(UsernameState.Set::new) .orElse(UsernameState.NoUsername.INSTANCE)), Schedulers.computation()); this.events = PublishSubject.create(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt new file mode 100644 index 0000000000..c81c00710c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt @@ -0,0 +1,273 @@ +package org.thoughtcrime.securesms.profiles.manage + +import androidx.annotation.WorkerThread +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.Result +import org.signal.core.util.Result.Companion.failure +import org.signal.core.util.Result.Companion.success +import org.signal.core.util.logging.Log +import org.signal.libsignal.usernames.BaseUsernameException +import org.signal.libsignal.usernames.Username +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkResetResult +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.storage.StorageSyncHelper +import org.thoughtcrime.securesms.util.NetworkUtil +import org.thoughtcrime.securesms.util.UsernameUtil +import org.whispersystems.signalservice.api.SignalServiceAccountManager +import org.whispersystems.signalservice.api.push.ServiceId.ACI +import org.whispersystems.signalservice.api.push.UsernameLinkComponents +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException +import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException +import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException +import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException +import org.whispersystems.util.Base64UrlSafe +import java.io.IOException + +/** + * Performs various actions around usernames and username links. + */ +class UsernameRepository { + private val accountManager: SignalServiceAccountManager = ApplicationDependencies.getSignalServiceAccountManager() + + /** + * Given a nickname, this will temporarily reserve a matching discriminator that can later be confirmed via [confirmUsername]. + */ + fun reserveUsername(nickname: String): Single> { + return Single + .fromCallable { reserveUsernameInternal(nickname) } + .subscribeOn(Schedulers.io()) + } + + /** + * Given a reserved username (obtained via [reserveUsername]), this will confirm that reservation, assigning the user that username. + */ + fun confirmUsername(reserved: UsernameState.Reserved): Single { + return Single + .fromCallable { confirmUsernameInternal(reserved) } + .subscribeOn(Schedulers.io()) + } + + /** + * Deletes the username from the local user's account + */ + fun deleteUsername(): Single { + return Single + .fromCallable { deleteUsernameInternal() } + .subscribeOn(Schedulers.io()) + } + + /** + * Creates or rotates the username link for the local user. If successful, the [UsernameLinkComponents] will be returned. + * If it fails for any reason, the optional will be empty. + * + * The assumption here is that when the user clicks this button, they will either have a new link, or no link at all. + * This is to prevent indeterminate states where the network call fails but may have actually succeeded, that kind of thing. + * As such, it's recommended to block calling this method on a network check. + */ + fun createOrResetUsernameLink(): Single { + if (!NetworkUtil.isConnected(ApplicationDependencies.getApplication())) { + Log.w(TAG, "[createOrRotateUsernameLink] No network! Not making any changes.") + return Single.just(UsernameLinkResetResult.NetworkUnavailable) + } + + val usernameString = SignalStore.account().username + if (usernameString.isNullOrBlank()) { + Log.w(TAG, "[createOrRotateUsernameLink] No username set! Cannot rotate the link!") + return Single.just(UsernameLinkResetResult.UnexpectedError) + } + + val username = try { + Username(usernameString) + } catch (e: BaseUsernameException) { + Log.w(TAG, "[createOrRotateUsernameLink] Failed to parse our own username! Cannot rotate the link!") + return Single.just(UsernameLinkResetResult.UnexpectedError) + } + + return Single + .fromCallable { + try { + SignalStore.account().usernameLink = null + + Log.d(TAG, "[createOrRotateUsernameLink] Creating username link...") + val components = accountManager.createUsernameLink(username) + SignalStore.account().usernameLink = components + SignalDatabase.recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() + Log.d(TAG, "[createOrRotateUsernameLink] Username link created.") + + UsernameLinkResetResult.Success(components) + } catch (e: IOException) { + Log.w(TAG, "[createOrRotateUsernameLink] Failed to rotate the username!") + UsernameLinkResetResult.NetworkError + } + } + .subscribeOn(Schedulers.io()) + } + + /** + * Given a full username link, this will do the necessary parsing and network lookups to resolve it to a (username, ACI) pair. + */ + fun convertLinkToUsernameAndAci(url: String): Single { + val components: UsernameLinkComponents = UsernameUtil.parseLink(url) ?: return Single.just(UsernameLinkConversionResult.Invalid) + + return Single + .fromCallable { + var username: Username? = null + + try { + val encryptedUsername: ByteArray = accountManager.getEncryptedUsernameFromLinkServerId(components.serverId) + val link = Username.UsernameLink(components.entropy, encryptedUsername) + + username = Username.fromLink(link) + + val aci = accountManager.getAciByUsernameHash(UsernameUtil.hashUsernameToBase64(username.toString())) + + UsernameLinkConversionResult.Success(username, aci) + } catch (e: IOException) { + Log.w(TAG, "[convertLinkToUsername] Failed to lookup user.", e) + + if (e is NonSuccessfulResponseCodeException) { + when (e.code) { + 404 -> UsernameLinkConversionResult.NotFound(username) + 422 -> UsernameLinkConversionResult.Invalid + else -> UsernameLinkConversionResult.NetworkError + } + } else { + UsernameLinkConversionResult.NetworkError + } + } catch (e: BaseUsernameException) { + Log.w(TAG, "[convertLinkToUsername] Bad username conversion.", e) + UsernameLinkConversionResult.Invalid + } + } + .subscribeOn(Schedulers.io()) + } + + @WorkerThread + private fun reserveUsernameInternal(nickname: String): Result { + return try { + val candidates: List = Username.candidatesFrom(nickname, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH) + + val hashes: List = candidates + .map { Base64UrlSafe.encodeBytesWithoutPadding(it.hash) } + + val response = accountManager.reserveUsername(hashes) + + val hashIndex = hashes.indexOf(response.usernameHash) + if (hashIndex == -1) { + Log.w(TAG, "[reserveUsername] The response hash could not be found in our set of hashes.") + return failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR) + } + + Log.i(TAG, "[reserveUsername] Successfully reserved username.") + success(UsernameState.Reserved(candidates[hashIndex].username, response)) + } catch (e: BaseUsernameException) { + Log.w(TAG, "[reserveUsername] An error occurred while generating candidates.") + failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR) + } catch (e: UsernameTakenException) { + Log.w(TAG, "[reserveUsername] Username taken.") + failure(UsernameSetResult.USERNAME_UNAVAILABLE) + } catch (e: UsernameMalformedException) { + Log.w(TAG, "[reserveUsername] Username malformed.") + failure(UsernameSetResult.USERNAME_INVALID) + } catch (e: IOException) { + Log.w(TAG, "[reserveUsername] Generic network exception.", e) + failure(UsernameSetResult.NETWORK_ERROR) + } + } + + @WorkerThread + private fun confirmUsernameInternal(reserved: UsernameState.Reserved): UsernameSetResult { + return try { + val username = Username(reserved.username) + accountManager.confirmUsername(reserved.username, reserved.reserveUsernameResponse) + SignalStore.account().username = username.username + SignalStore.account().usernameLink = null + SignalDatabase.recipients.setUsername(Recipient.self().id, reserved.username) + SignalStore.account().usernameOutOfSync = false + Log.i(TAG, "[confirmUsername] Successfully confirmed username.") + + if (tryToSetUsernameLink(username)) { + Log.i(TAG, "[confirmUsername] Successfully confirmed username link.") + } else { + Log.w(TAG, "[confirmUsername] Failed to confirm a username link. We'll try again when the user goes to view their link.") + } + + UsernameSetResult.SUCCESS + } catch (e: UsernameTakenException) { + Log.w(TAG, "[confirmUsername] Username gone.") + UsernameSetResult.USERNAME_UNAVAILABLE + } catch (e: UsernameIsNotReservedException) { + Log.w(TAG, "[confirmUsername] Username was not reserved.") + UsernameSetResult.USERNAME_INVALID + } catch (e: BaseUsernameException) { + Log.w(TAG, "[confirmUsername] Username was not reserved.") + UsernameSetResult.USERNAME_INVALID + } catch (e: IOException) { + Log.w(TAG, "[confirmUsername] Generic network exception.", e) + UsernameSetResult.NETWORK_ERROR + } + } + + private fun tryToSetUsernameLink(username: Username): Boolean { + for (i in 0..2) { + try { + val linkComponents = accountManager.createUsernameLink(username) + SignalStore.account().usernameLink = linkComponents + return true + } catch (e: IOException) { + Log.w(TAG, "[tryToSetUsernameLink] Failed with IOException on attempt " + (i + 1) + "/3", e) + } + } + + return false + } + + @WorkerThread + private fun deleteUsernameInternal(): UsernameDeleteResult { + return try { + accountManager.deleteUsername() + SignalDatabase.recipients.setUsername(Recipient.self().id, null) + SignalStore.account().usernameOutOfSync = false + Log.i(TAG, "[deleteUsername] Successfully deleted the username.") + UsernameDeleteResult.SUCCESS + } catch (e: IOException) { + Log.w(TAG, "[deleteUsername] Generic network exception.", e) + UsernameDeleteResult.NETWORK_ERROR + } + } + + enum class UsernameSetResult { + SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR, CANDIDATE_GENERATION_ERROR + } + + enum class UsernameDeleteResult { + SUCCESS, NETWORK_ERROR + } + + internal interface Callback { + fun onComplete(result: E) + } + + sealed class UsernameLinkConversionResult { + /** Successfully converted. Contains the username. */ + data class Success(val username: Username, val aci: ACI) : UsernameLinkConversionResult() + + /** Failed to convert due to a network error. */ + object NetworkError : UsernameLinkConversionResult() + + /** Failed to convert because the link or contents were invalid. */ + object Invalid : UsernameLinkConversionResult() + + /** No user exists for the given link. */ + data class NotFound(val username: Username?) : UsernameLinkConversionResult() + } + + companion object { + private val TAG = Log.tag(UsernameRepository::class.java) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt index a4b2ebcee7..e6e225e420 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt @@ -17,7 +17,7 @@ sealed class UsernameState { object NoUsername : UsernameState() data class Reserved( - override val username: String, + public override val username: String, val reserveUsernameResponse: ReserveUsernameResponse ) : UsernameState() diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java index 187b9749d8..e9cb7047b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java @@ -128,8 +128,9 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor checkUsername(@Nullable String value) { - if (value == null) { - return Optional.of(InvalidReason.TOO_SHORT); - } else if (value.length() < MIN_LENGTH) { - return Optional.of(InvalidReason.TOO_SHORT); - } else if (value.length() > MAX_LENGTH) { - return Optional.of(InvalidReason.TOO_LONG); - } else if (DIGIT_START_PATTERN.matcher(value).matches()) { - return Optional.of(InvalidReason.STARTS_WITH_NUMBER); - } else if (!FULL_PATTERN.matcher(value).matches()) { - return Optional.of(InvalidReason.INVALID_CHARACTERS); - } else { - return Optional.empty(); - } - } - - @WorkerThread - public static @NonNull Optional fetchAciForUsername(@NonNull String username) { - Optional localId = SignalDatabase.recipients().getByUsername(username); - - if (localId.isPresent()) { - Recipient recipient = Recipient.resolved(localId.get()); - - if (recipient.getServiceId().isPresent()) { - Log.i(TAG, "Found username locally -- using associated UUID."); - return recipient.getServiceId(); - } else { - Log.w(TAG, "Found username locally, but it had no associated UUID! Clearing it."); - SignalDatabase.recipients().clearUsernameIfExists(username); - } - } - - Log.d(TAG, "No local user with this username. Searching remotely."); - try { - return fetchAciForUsernameHash(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))); - } catch (BaseUsernameException e) { - return Optional.empty(); - } - } - - /** - * 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 { - ACI aci = ApplicationDependencies.getSignalServiceAccountManager() - .getAciByUsernameHash(base64UrlSafeEncodedUsernameHash); - return Optional.ofNullable(aci); - } catch (IOException e) { - return Optional.empty(); - } - } - - 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/java/org/thoughtcrime/securesms/util/UsernameUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt new file mode 100644 index 0000000000..11c3bf22de --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.util + +import androidx.annotation.WorkerThread +import org.signal.core.util.logging.Log +import org.signal.libsignal.usernames.BaseUsernameException +import org.signal.libsignal.usernames.Username +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.Recipient +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.api.push.UsernameLinkComponents +import org.whispersystems.signalservice.api.util.UuidUtil +import org.whispersystems.signalservice.api.util.toByteArray +import org.whispersystems.util.Base64UrlSafe +import java.io.IOException +import java.util.Locale +import java.util.Optional +import java.util.UUID +import java.util.regex.Pattern + +object UsernameUtil { + private val TAG = Log.tag(UsernameUtil::class.java) + const val MIN_LENGTH = 3 + const val MAX_LENGTH = 32 + private val 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 val DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$") + private val URL_PATTERN = """(https://)?signal.me/?#eu/([a-zA-Z0-9+\-_/]+)""".toRegex() + private const val BASE_URL_SCHEMELESS = "signal.me/#eu/" + private const val BASE_URL = "https://$BASE_URL_SCHEMELESS" + + fun isValidUsernameForSearch(value: String): Boolean { + return value.isNotEmpty() && !DIGIT_START_PATTERN.matcher(value).matches() + } + + @JvmStatic + fun checkUsername(value: String?): Optional { + return if (value == null) { + Optional.of(InvalidReason.TOO_SHORT) + } else if (value.length < MIN_LENGTH) { + Optional.of(InvalidReason.TOO_SHORT) + } else if (value.length > MAX_LENGTH) { + Optional.of(InvalidReason.TOO_LONG) + } else if (DIGIT_START_PATTERN.matcher(value).matches()) { + Optional.of(InvalidReason.STARTS_WITH_NUMBER) + } else if (!FULL_PATTERN.matcher(value).matches()) { + Optional.of(InvalidReason.INVALID_CHARACTERS) + } else { + Optional.empty() + } + } + + @JvmStatic + @WorkerThread + fun fetchAciForUsername(username: String): Optional { + val localId = recipients.getByUsername(username) + + if (localId.isPresent) { + val recipient = Recipient.resolved(localId.get()) + if (recipient.serviceId.isPresent) { + Log.i(TAG, "Found username locally -- using associated UUID.") + return recipient.serviceId + } else { + Log.w(TAG, "Found username locally, but it had no associated UUID! Clearing it.") + recipients.clearUsernameIfExists(username) + } + } + + Log.d(TAG, "No local user with this username. Searching remotely.") + + return try { + fetchAciForUsernameHash(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))) + } catch (e: BaseUsernameException) { + Optional.empty() + } + } + + /** + * Hashes a username to a url-safe base64 string. + * @throws BaseUsernameException If the username is invalid and un-hashable. + */ + @Throws(BaseUsernameException::class) + fun hashUsernameToBase64(username: String?): String { + return Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username)) + } + + @JvmStatic + @WorkerThread + fun fetchAciForUsernameHash(base64UrlSafeEncodedUsernameHash: String): Optional { + return try { + val aci = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsernameHash(base64UrlSafeEncodedUsernameHash) + Optional.ofNullable(aci) + } catch (e: IOException) { + Log.w(TAG, "Failed to get ACI for username hash", e) + Optional.empty() + } + } + + /** + * Generates a username link from the provided [UsernameLinkComponents]. + */ + fun generateLink(components: UsernameLinkComponents): String { + val combined: ByteArray = components.entropy + components.serverId.toByteArray() + val base64 = Base64UrlSafe.encodeBytesWithoutPadding(combined) + return BASE_URL + base64 + } + + /** + * Parses out the [UsernameLinkComponents] from a link if possible, otherwise null. + * You need to make a separate network request to convert these components into a username. + */ + fun parseLink(url: String): UsernameLinkComponents? { + val match: MatchResult = URL_PATTERN.find(url) ?: return null + val path: String = match.groups[2]?.value ?: return null + val allBytes: ByteArray = Base64UrlSafe.decodePaddingAgnostic(path) + + if (allBytes.size != 48) { + return null + } + + val entropy: ByteArray = allBytes.slice(0 until 32).toByteArray() + val serverId: ByteArray = allBytes.slice(32 until allBytes.size).toByteArray() + val serverIdUuid: UUID = UuidUtil.parseOrNull(serverId) ?: return null + + return UsernameLinkComponents(entropy = entropy, serverId = serverIdUuid) + } + + enum class 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 66130e2e8d..078cb6fdaf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6106,6 +6106,16 @@ Username copied Link copied + + Link not set + + Resetting linkā€¦ + + Reset QR code? + + If you reset your QR code, your existing QR code and link will no longer work. + + Reset Reset @@ -6122,8 +6132,14 @@ The QR code was invalid. A user with username %1$s could not be found. + + This user could not be found. Experienced a network error. Please try again. + + You do not have network access. Your link was not reset. Try again later. + + A network error occurred while trying to reset your link. Try again later. @@ -6144,7 +6160,7 @@ Reject - + Approve diff --git a/core-ui/src/main/java/org/signal/core/ui/theme/SignalTheme.kt b/core-ui/src/main/java/org/signal/core/ui/theme/SignalTheme.kt index 70e0e1b9ef..1d0340993a 100644 --- a/core-ui/src/main/java/org/signal/core/ui/theme/SignalTheme.kt +++ b/core-ui/src/main/java/org/signal/core/ui/theme/SignalTheme.kt @@ -1,5 +1,6 @@ package org.signal.core.ui.theme +import android.content.res.Configuration import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -9,6 +10,7 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview @@ -168,7 +170,7 @@ private val darkColorScheme = darkColorScheme( @Composable fun SignalTheme( - isDarkMode: Boolean, + isDarkMode: Boolean = LocalContext.current.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES, content: @Composable () -> Unit ) { val extendedColors = if (isDarkMode) darkExtendedColors else lightExtendedColors diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index 915f179a42..722a2944ea 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -14,6 +14,9 @@ import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.logging.Log; import org.signal.libsignal.protocol.state.SignedPreKeyRecord; +import org.signal.libsignal.usernames.BaseUsernameException; +import org.signal.libsignal.usernames.Username; +import org.signal.libsignal.usernames.Username.UsernameLink; import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential; import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.whispersystems.signalservice.api.account.AccountAttributes; @@ -37,6 +40,7 @@ import org.whispersystems.signalservice.api.push.ServiceId.ACI; import org.whispersystems.signalservice.api.push.ServiceId.PNI; import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.ServiceIdType; +import org.whispersystems.signalservice.api.push.UsernameLinkComponents; import org.whispersystems.signalservice.api.push.exceptions.NoContentException; import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; @@ -81,6 +85,7 @@ import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider; import org.whispersystems.signalservice.internal.util.Util; import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper; import org.whispersystems.util.Base64; +import org.whispersystems.util.Base64UrlSafe; import java.io.IOException; import java.security.KeyStore; @@ -96,6 +101,7 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -786,6 +792,25 @@ public class SignalServiceAccountManager { this.pushServiceSocket.deleteUsername(); } + public UsernameLinkComponents createUsernameLink(Username username) throws IOException { + try { + UsernameLink link = username.generateLink(); + UUID serverId = this.pushServiceSocket.createUsernameLink(Base64UrlSafe.encodeBytes(link.getEncryptedUsername())); + + return new UsernameLinkComponents(link.getEntropy(), serverId); + } catch (BaseUsernameException e) { + throw new AssertionError(e); + } + } + + public void deleteUsernameLink() throws IOException { + this.pushServiceSocket.deleteUsernameLink(); + } + + public byte[] getEncryptedUsernameFromLinkServerId(UUID serverId) throws IOException { + return this.pushServiceSocket.getEncryptedUsernameFromLinkServerId(serverId); + } + public void deleteAccount() throws IOException { this.pushServiceSocket.deleteAccount(); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/UsernameLinkComponents.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/UsernameLinkComponents.kt new file mode 100644 index 0000000000..a800aa2253 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/UsernameLinkComponents.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.push + +import java.util.UUID + +/** + * Wrapper for passing around the two components of a username link: the entropy and serverId, which when combined together form the full link handle. + */ +data class UsernameLinkComponents( + val entropy: ByteArray, + val serverId: UUID +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UsernameLinkComponents + + if (!entropy.contentEquals(other.entropy)) return false + return serverId == other.serverId + } + + override fun hashCode(): Int { + var result = entropy.contentHashCode() + result = 31 * result + serverId.hashCode() + return result + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java index 84332a360e..4321929b77 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java @@ -335,6 +335,10 @@ public final class SignalAccountRecord implements SignalRecord { return proto.getUsername(); } + public @Nullable AccountRecord.UsernameLink getUsernameLink() { + return proto.getUsernameLink(); + } + public AccountRecord toProto() { return proto; } @@ -717,6 +721,16 @@ public final class SignalAccountRecord implements SignalRecord { return this; } + public Builder setUsernameLink(@Nullable AccountRecord.UsernameLink link) { + if (link == null) { + builder.clearUsernameLink(); + } else { + builder.setUsernameLink(link); + } + + return this; + } + private static AccountRecord.Builder parseUnknowns(byte[] serializedUnknowns) { try { return AccountRecord.parseFrom(serializedUnknowns).toBuilder(); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidExtensions.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidExtensions.kt new file mode 100644 index 0000000000..dd380cdeec --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidExtensions.kt @@ -0,0 +1,10 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.util + +import java.util.UUID + +fun UUID.toByteArray(): ByteArray = UuidUtil.toByteArray(this) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/GetUsernameFromLinkResponseBody.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/GetUsernameFromLinkResponseBody.kt new file mode 100644 index 0000000000..3a20e3533c --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/GetUsernameFromLinkResponseBody.kt @@ -0,0 +1,6 @@ +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonProperty + +/** Response body for looking up a username by link from the service. */ +data class GetUsernameFromLinkResponseBody(@JsonProperty val usernameLinkEncryptedValue: String) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 01a5b34fe5..00d2b79ed7 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -216,6 +216,8 @@ public class PushServiceSocket { private static final String MODIFY_USERNAME_PATH = "/v1/accounts/username_hash"; private static final String RESERVE_USERNAME_PATH = "/v1/accounts/username_hash/reserve"; private static final String CONFIRM_USERNAME_PATH = "/v1/accounts/username_hash/confirm"; + private static final String USERNAME_LINK_PATH = "/v1/accounts/username_link"; + private static final String USERNAME_FROM_LINK_PATH = "/v1/accounts/username_link/%s"; private static final String DELETE_ACCOUNT_PATH = "/v1/accounts/me"; private static final String CHANGE_NUMBER_PATH = "/v2/accounts/number"; private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s"; @@ -1090,6 +1092,31 @@ public class PushServiceSocket { makeServiceRequest(MODIFY_USERNAME_PATH, "DELETE", null); } + /** + * Creates a new username link for a given username. + * @param encryptedUsername URL-safe base64-encoded encrypted username + * @return The serverId for the generated link. + */ + public UUID createUsernameLink(String encryptedUsername) throws IOException { + String response = makeServiceRequest(USERNAME_LINK_PATH, "PUT", JsonUtil.toJson(new SetUsernameLinkRequestBody(encryptedUsername))); + SetUsernameLinkResponseBody parsed = JsonUtil.fromJson(response, SetUsernameLinkResponseBody.class); + + return parsed.getUsernameLinkHandle(); + } + + /** Deletes your active username link. */ + public void deleteUsernameLink() throws IOException { + makeServiceRequest(USERNAME_LINK_PATH, "DELETE", null); + } + + /** Given a link serverId (see {@link #createUsernameLink(String)}), this will return the encrypted username associate with the link. */ + public byte[] getEncryptedUsernameFromLinkServerId(UUID serverId) throws IOException { + String response = makeServiceRequestWithoutAuthentication(String.format(USERNAME_FROM_LINK_PATH, serverId.toString()), "GET", null); + GetUsernameFromLinkResponseBody parsed = JsonUtil.fromJson(response, GetUsernameFromLinkResponseBody.class); + + return Base64UrlSafe.decodePaddingAgnostic(parsed.getUsernameLinkEncryptedValue()); + } + public void deleteAccount() throws IOException { makeServiceRequest(DELETE_ACCOUNT_PATH, "DELETE", null); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt new file mode 100644 index 0000000000..3b89076ff2 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt @@ -0,0 +1,6 @@ +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonProperty + +/** Request body for setting a username link on the service. */ +data class SetUsernameLinkRequestBody(@JsonProperty val usernameLinkEncryptedValue: String) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkResponseBody.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkResponseBody.kt new file mode 100644 index 0000000000..a2d71b9177 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkResponseBody.kt @@ -0,0 +1,13 @@ +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import org.whispersystems.signalservice.internal.util.JsonUtil.UuidDeserializer +import java.util.UUID + +/** Response body for setting a username link on the service. */ +data class SetUsernameLinkResponseBody( + @JsonProperty + @JsonDeserialize(using = UuidDeserializer::class) + val usernameLinkHandle: UUID +) diff --git a/libsignal/service/src/main/proto/StorageService.proto b/libsignal/service/src/main/proto/StorageService.proto index 3c050ae778..b1e0406929 100644 --- a/libsignal/service/src/main/proto/StorageService.proto +++ b/libsignal/service/src/main/proto/StorageService.proto @@ -156,6 +156,24 @@ message AccountRecord { } } + message UsernameLink { + enum Color { + UNKNOWN = 0; + BLUE = 1; + WHITE = 2; + GREY = 3; + OLIVE = 4; + GREEN = 5; + ORANGE = 6; + PINK = 7; + PURPLE = 8; + } + + bytes entropy = 1; // 32 bytes of entropy used for encryption + bytes serverId = 2; // 16 bytes of encoded UUID provided by the server + Color color = 3; + } + bytes profileKey = 1; string givenName = 2; string familyName = 3; @@ -189,6 +207,8 @@ message AccountRecord { bool hasReadOnboardingStory = 31; bool hasSeenGroupStoryEducationSheet = 32; string username = 33; + bool hasCompletedUsernameOnboarding = 34; + UsernameLink usernameLink = 35; } message StoryDistributionListRecord {