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

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

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

View file

@ -6106,6 +6106,16 @@
<string name="UsernameLinkSettings_username_copied_toast">Username copied</string>
<!-- Content of a toast that will show after the username link is copied to the clipboard -->
<string name="UsernameLinkSettings_link_copied_toast">Link copied</string>
<!-- Content of a text field that is shown when the user has not yet set a username link -->
<string name="UsernameLinkSettings_link_not_set_label">Link not set</string>
<!-- Content of a text field that is shown when the user is actively resetting the username link and waiting for the operation to finish -->
<string name="UsernameLinkSettings_resetting_link_label">Resetting link…</string>
<!-- Title of a dialog prompting the user to confirm whether they would like to reset their username link and QR code -->
<string name="UsernameLinkSettings_reset_link_dialog_title">Reset QR code?</string>
<!-- Body of a dialog prompting the user to confirm whether they would like to reset their username link and QR code -->
<string name="UsernameLinkSettings_reset_link_dialog_body">If you reset your QR code, your existing QR code and link will no longer work.</string>
<!-- Label for the confirmation button on a dialog prompting the user to confirm whether they would like to reset their username link and QR code -->
<string name="UsernameLinkSettings_reset_link_dialog_confirm_button">Reset</string>
<!-- Button label for a button that will reset your username and give you a new link -->
<string name="UsernameLinkSettings_reset_button_label">Reset</string>
<!-- Button label for a button that indicates that the user is done changing the current setting -->
@ -6122,8 +6132,14 @@
<string name="UsernameLinkSettings_qr_result_invalid">The QR code was invalid.</string>
<!-- Body of a dialog that is displayed when the username we looked up could not be found. -->
<string name="UsernameLinkSettings_qr_result_not_found">A user with username %1$s could not be found.</string>
<!-- Body of a dialog that is displayed when the username we looked up could not be found and we also could not parse the username. -->
<string name="UsernameLinkSettings_qr_result_not_found_no_username">This user could not be found.</string>
<!-- Body of a dialog that is displayed when we experienced a network error when looking up a username. -->
<string name="UsernameLinkSettings_qr_result_network_error">Experienced a network error. Please try again.</string>
<!-- Body of a dialog that is displayed when we failed to reset your username link because you had no internet. -->
<string name="UsernameLinkSettings_reset_link_result_network_unavailable">You do not have network access. Your link was not reset. Try again later.</string>
<!-- Body of a dialog that is displayed when we failed to reset your username link because of a transient network issue. -->
<string name="UsernameLinkSettings_reset_link_result_network_error">A network error occurred while trying to reset your link. Try again later.</string>
<!-- PendingParticipantsView -->
<!-- Displayed in the popup card when a remote user attempts to join a call link -->
@ -6144,7 +6160,7 @@
</plurals>
<!-- Content description for rejecting a user -->
<string name="PendingParticipantsBottomSheet__reject">Reject</string>
<!-- Content desccription for confirming a user -->
<!-- Content description for confirming a user -->
<string name="PendingParticipantsBottomSheet__approve">Approve</string>
<!-- EOF -->

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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