Update to the new username link spec.

This commit is contained in:
Greyson Parrelli
2023-08-25 09:33:57 -04:00
parent a6dd4345ab
commit 8a93814bac
47 changed files with 1283 additions and 463 deletions

View File

@@ -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
}
}
}

View File

@@ -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)

View File

@@ -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 ->

View File

@@ -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<Rect?>(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"
)

View File

@@ -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

View File

@@ -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()
}

View File

@@ -17,7 +17,7 @@ enum class UsernameQrCodeColorScheme(
),
White(
borderColor = Color(0xFFFFFFFF),
foregroundColor = Color(0xFF464852),
foregroundColor = Color(0xFF000000),
key = "white"
),
Grey(

View File

@@ -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(

View File

@@ -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<UsernameQrCodeColorScheme>,
val selectedColorScheme: UsernameQrCodeColorScheme
)

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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<String> = 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<UsernameLinkSettingsState> = _state
private val disposable: CompositeDisposable = CompositeDisposable()
private val usernameLink: BehaviorSubject<Optional<UsernameLinkComponents>> = 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<UsernameLinkComponents> = 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<QrCodeData> {
private fun generateQrCodeData(url: Optional<String>): Single<Optional<QrCodeData>> {
return Single.fromCallable {
QrCodeData.forData(url, 64)
url.map { QrCodeData.forData(it, 64) }
}
}
}

View File

@@ -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
)
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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());

View File

@@ -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);
}
}

View File

@@ -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))

View File

@@ -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<CertificateType> REGULAR_CERTIFICATE = Collections.singletonList(CertificateType.UUID_AND_E164);
private static final Collection<CertificateType> 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.

View File

@@ -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;
}

View File

@@ -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<CopyUsernameToSignalStoreMigrationJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): CopyUsernameToSignalStoreMigrationJob {
return CopyUsernameToSignalStoreMigrationJob(parameters)
}
}
}

View File

@@ -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();

View File

@@ -50,7 +50,7 @@ class ManageProfileViewModel extends ViewModel {
private final SingleLiveEvent<Event> events;
private final RecipientForeverObserver observer;
private final ManageProfileRepository repository;
private final UsernameEditRepository usernameEditRepository;
private final UsernameRepository usernameEditRepository;
private final MutableLiveData<Optional<Badge>> 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<UsernameEditRepository.UsernameDeleteResult> deleteUsername() {
public Single<UsernameRepository.UsernameDeleteResult> deleteUsername() {
return usernameEditRepository.deleteUsername().observeOn(AndroidSchedulers.mainThread());
}

View File

@@ -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<Result<UsernameState.Reserved, UsernameSetResult>> reserveUsername(@NonNull String nickname) {
return Single.fromCallable(() -> reserveUsernameInternal(nickname)).subscribeOn(Schedulers.io());
}
@NonNull Single<UsernameSetResult> confirmUsername(@NonNull UsernameState.Reserved reserved) {
return Single.fromCallable(() -> confirmUsernameInternal(reserved)).subscribeOn(Schedulers.io());
}
@NonNull Single<UsernameDeleteResult> deleteUsername() {
return Single.fromCallable(this::deleteUsernameInternal).subscribeOn(Schedulers.io());
}
@WorkerThread
private @NonNull Result<UsernameState.Reserved, UsernameSetResult> reserveUsernameInternal(@NonNull String nickname) {
try {
List<String> candidates = Username.generateCandidates(nickname, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH);
List<String> 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<E> {
void onComplete(E result);
}
}

View File

@@ -40,15 +40,15 @@ class UsernameEditViewModel extends ViewModel {
private static final long NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS = 500;
private final PublishSubject<Event> events;
private final UsernameEditRepository repo;
private final RxStore<State> uiState;
private final PublishSubject<Event> events;
private final UsernameRepository repo;
private final RxStore<State> uiState;
private final PublishProcessor<String> 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().<UsernameState>map(UsernameState.Set::new)
.orElse(UsernameState.NoUsername.INSTANCE)), Schedulers.computation());
this.events = PublishSubject.create();

View File

@@ -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<Result<UsernameState.Reserved, UsernameSetResult>> {
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<UsernameSetResult> {
return Single
.fromCallable { confirmUsernameInternal(reserved) }
.subscribeOn(Schedulers.io())
}
/**
* Deletes the username from the local user's account
*/
fun deleteUsername(): Single<UsernameDeleteResult> {
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<UsernameLinkResetResult> {
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<UsernameLinkConversionResult> {
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<UsernameState.Reserved, UsernameSetResult> {
return try {
val candidates: List<Username> = Username.candidatesFrom(nickname, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH)
val hashes: List<String> = 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<E> {
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)
}
}

View File

@@ -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()

View File

@@ -128,8 +128,9 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
boolean hasReadOnboardingStory = remote.hasReadOnboardingStory() || remote.hasViewedOnboardingStory() || local.hasReadOnboardingStory() || local.hasViewedOnboardingStory() ;
boolean hasSeenGroupStoryEducation = remote.hasSeenGroupStoryEducationSheet() || local.hasSeenGroupStoryEducationSheet();
String username = !StringUtil.isEmpty(remote.getUsername()) ? remote.getUsername() : local.getUsername();
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory, username);
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory, username);
AccountRecord.UsernameLink usernameLink = remote.getUsernameLink() != null ? remote.getUsernameLink() : local.getUsernameLink();
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory, username, usernameLink);
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory, username, usernameLink);
if (matchesRemote) {
return remote;
@@ -165,7 +166,8 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
.setStoriesDisabled(storiesDisabled)
.setHasReadOnboardingStory(hasReadOnboardingStory)
.setHasSeenGroupStoryEducationSheet(hasSeenGroupStoryEducation)
.setUsername(username);
.setUsername(username)
.setUsernameLink(usernameLink);
if (!FeatureFlags.phoneNumberPrivacy() || !self.getPnpCapability().isSupported()) {
builder.setE164(e164);
@@ -220,7 +222,8 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
boolean storiesDisabled,
@NonNull OptionalBool storyViewReceiptsState,
boolean hasReadOnboardingStory,
@Nullable String username)
@Nullable String username,
@Nullable AccountRecord.UsernameLink usernameLink)
{
return Arrays.equals(contact.serializeUnknownFields(), unknownFields) &&
Objects.equals(contact.getGivenName().orElse(""), givenName) &&
@@ -251,6 +254,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
contact.isStoriesDisabled() == storiesDisabled &&
contact.getStoryViewReceiptsState().equals(storyViewReceiptsState) &&
contact.hasReadOnboardingStory() == hasReadOnboardingStory &&
Objects.equals(contact.getUsername(), username);
Objects.equals(contact.getUsername(), username) &&
Objects.equals(contact.getUsernameLink(), usernameLink);
}
}

View File

@@ -8,9 +8,11 @@ import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.protobuf.ByteString;
import org.signal.core.util.SetUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.RecipientRecord;
@@ -26,12 +28,16 @@ import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalRecord;
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.api.util.OptionalUtil;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord;
import org.whispersystems.signalservice.internal.storage.protos.OptionalBool;
import java.util.Collection;
@@ -159,6 +165,17 @@ public final class StorageSyncHelper {
account.setE164(self.requireE164());
}
UsernameLinkComponents linkComponents = SignalStore.account().getUsernameLink();
if (linkComponents != null) {
account.setUsernameLink(AccountRecord.UsernameLink.newBuilder()
.setEntropy(ByteString.copyFrom(linkComponents.getEntropy()))
.setServerId(UuidUtil.toByteString(linkComponents.getServerId()))
.setColor(StorageSyncModels.localToRemoteUsernameColor(SignalStore.misc().getUsernameQrCodeColorScheme()))
.build());
} else {
account.setUsernameLink(null);
}
return SignalStorageRecord.forAccount(account.build());
}
@@ -214,6 +231,16 @@ public final class StorageSyncHelper {
if (fetchProfile && update.getNew().getAvatarUrlPath().isPresent()) {
ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(self, update.getNew().getAvatarUrlPath().get()));
}
if (update.getNew().getUsernameLink() != null) {
SignalStore.account().setUsernameLink(
new UsernameLinkComponents(
update.getNew().getUsernameLink().getEntropy().toByteArray(),
UuidUtil.parseOrThrow(update.getNew().getUsernameLink().getServerId().toByteArray())
)
);
SignalStore.misc().setUsernameQrCodeColorScheme(StorageSyncModels.remoteToLocalUsernameColor(update.getNew().getUsernameLink().getColor()));
}
}
public static void scheduleSyncForDataChange() {

View File

@@ -6,6 +6,7 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme;
import org.thoughtcrime.securesms.database.GroupTable;
import org.thoughtcrime.securesms.database.IdentityTable;
import org.thoughtcrime.securesms.database.RecipientTable;
@@ -100,6 +101,34 @@ public final class StorageSyncModels {
}
}
public static @NonNull AccountRecord.UsernameLink.Color localToRemoteUsernameColor(UsernameQrCodeColorScheme local) {
switch (local) {
case Blue: return AccountRecord.UsernameLink.Color.BLUE;
case White: return AccountRecord.UsernameLink.Color.WHITE;
case Grey: return AccountRecord.UsernameLink.Color.GREY;
case Tan: return AccountRecord.UsernameLink.Color.OLIVE;
case Green: return AccountRecord.UsernameLink.Color.GREEN;
case Orange: return AccountRecord.UsernameLink.Color.ORANGE;
case Pink: return AccountRecord.UsernameLink.Color.PINK;
case Purple: return AccountRecord.UsernameLink.Color.PURPLE;
default: return AccountRecord.UsernameLink.Color.BLUE;
}
}
public static @NonNull UsernameQrCodeColorScheme remoteToLocalUsernameColor(AccountRecord.UsernameLink.Color remote) {
switch (remote) {
case BLUE: return UsernameQrCodeColorScheme.Blue;
case WHITE: return UsernameQrCodeColorScheme.White;
case GREY: return UsernameQrCodeColorScheme.Grey;
case OLIVE: return UsernameQrCodeColorScheme.Tan;
case GREEN: return UsernameQrCodeColorScheme.Green;
case ORANGE: return UsernameQrCodeColorScheme.Orange;
case PINK: return UsernameQrCodeColorScheme.Pink;
case PURPLE: return UsernameQrCodeColorScheme.Purple;
default: return UsernameQrCodeColorScheme.Blue;
}
}
private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientRecord recipient, byte[] rawStorageId) {
if (recipient.getAci() == null && recipient.getE164() == null) {
throw new AssertionError("Must have either a UUID or a phone number!");

View File

@@ -1,135 +0,0 @@
package org.thoughtcrime.securesms.util;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.util.Base64UrlSafe;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class UsernameUtil {
private static final String TAG = Log.tag(UsernameUtil.class);
public static final int MIN_LENGTH = 3;
public static final int MAX_LENGTH = 32;
private static final Pattern FULL_PATTERN = Pattern.compile(String.format(Locale.US, "^[a-zA-Z_][a-zA-Z0-9_]{%d,%d}$", MIN_LENGTH - 1, MAX_LENGTH - 1), Pattern.CASE_INSENSITIVE);
private static final Pattern DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$");
private static final Pattern URL_PATTERN = Pattern.compile("(https://)?signal.me/#u/([a-zA-Z0-9+/]*={0,2})");
private static final String BASE_URL_SCHEMELESS = "signal.me/#u/";
private static final String BASE_URL = "https://" + BASE_URL_SCHEMELESS;
public static boolean isValidUsernameForSearch(@Nullable String value) {
return !TextUtils.isEmpty(value) && !DIGIT_START_PATTERN.matcher(value).matches();
}
public static Optional<InvalidReason> 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<ServiceId> fetchAciForUsername(@NonNull String username) {
Optional<RecipientId> 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<ServiceId> 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
}
}

View File

@@ -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<InvalidReason> {
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<ServiceId> {
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<ServiceId> {
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
}
}