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 @After
fun tearDown() { fun tearDown() {
InstrumentationApplicationDependencyProvider.clearHandlers() InstrumentationApplicationDependencyProvider.clearHandlers()
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync() SignalStore.account().usernameOutOfSync = false
} }
@Test @Test
@ -78,7 +78,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
// THEN // THEN
assertTrue(didReserve) assertTrue(didReserve)
assertTrue(didConfirm) assertTrue(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync) assertFalse(SignalStore.account().usernameOutOfSync)
} }
@Test @Test
@ -108,7 +108,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
// THEN // THEN
assertTrue(didReserve) assertTrue(didReserve)
assertTrue(didConfirm) assertTrue(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync) assertFalse(SignalStore.account().usernameOutOfSync)
} }
@Test @Test
@ -142,7 +142,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
// THEN // THEN
assertFalse(didReserve) assertFalse(didReserve)
assertFalse(didConfirm) assertFalse(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync) assertFalse(SignalStore.account().usernameOutOfSync)
} }
@Test @Test
@ -176,6 +176,6 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
// THEN // THEN
assertTrue(didReserve) assertTrue(didReserve)
assertFalse(didConfirm) 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 { companion object {
@JvmStatic @JvmStatic
fun isEligible(): Boolean { 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) findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
}, },
onQrButtonClicked = { onQrButtonClicked = {
if (Recipient.self().username.isPresent && Recipient.self().username.get().isNotEmpty()) { if (SignalStore.account().username != null) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment) findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
} else { } else {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment) findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment)

View file

@ -60,7 +60,7 @@ private fun DrawScope.drawQr(
deadzonePercent: Float, deadzonePercent: Float,
logo: ImageBitmap logo: ImageBitmap
) { ) {
val deadzonePaddingPercent = 0.07f val deadzonePaddingPercent = 0.045f
// We want an even number of dots on either side of the deadzone // We want an even number of dots on either side of the deadzone
val deadzoneRadius: Int = (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight -> val deadzoneRadius: Int = (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight ->

View file

@ -1,14 +1,23 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState 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.Box
import androidx.compose.foundation.layout.Column 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.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -20,16 +29,22 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.signal.core.ui.theme.SignalTheme import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ScreenshotController import org.thoughtcrime.securesms.compose.ScreenshotController
import org.thoughtcrime.securesms.compose.getScreenshotBounds 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. * Renders a QR code and username as a badge.
*/ */
@Composable @Composable
fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier, screenshotController: ScreenshotController? = null) { fun QrCodeBadge(
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor) data: QrCodeState,
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor) colorScheme: UsernameQrCodeColorScheme,
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f) username: String,
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White) 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 { var badgeBounds by remember {
mutableStateOf<Rect?>(null) mutableStateOf<Rect?>(null)
} }
screenshotController?.bind(LocalView.current, badgeBounds) screenshotController?.bind(LocalView.current, badgeBounds)
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxWidth()
.padding(horizontal = 59.dp, vertical = 24.dp)
.onGloballyPositioned { .onGloballyPositioned {
badgeBounds = it.getScreenshotBounds() badgeBounds = it.getScreenshotBounds()
}, },
@ -57,24 +78,32 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
shadowElevation = elevation.dp shadowElevation = elevation.dp
) { ) {
Column { Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(296.dp)
) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.padding( .padding(
top = 32.dp, top = 32.dp,
start = 40.dp, start = 40.dp,
end = 40.dp, end = 40.dp
bottom = 16.dp
) )
.aspectRatio(1f) .aspectRatio(1f)
.fillMaxWidth(), .fillMaxWidth(),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = Color.White color = Color.White
) { ) {
if (data != null) { if (data is QrCodeState.Present) {
QrCode( QrCode(
data = data, data = data.data,
modifier = Modifier.padding(16.dp), 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, foregroundColor = foregroundColor,
backgroundColor = Color.White backgroundColor = Color.White
) )
@ -85,40 +114,169 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern
.fillMaxHeight(), .fillMaxHeight(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator( if (data is QrCodeState.Loading) {
color = colorScheme.borderColor, CircularProgressIndicator(
modifier = Modifier.size(56.dp) 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( Row(
text = username, horizontalArrangement = Arrangement.Center,
color = textColor, verticalAlignment = Alignment.CenterVertically,
fontSize = 20.sp,
lineHeight = 26.sp,
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.padding( .padding(
start = 40.dp, start = 32.dp,
end = 40.dp, end = 32.dp,
bottom = 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 @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) { 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 { Surface {
QrCodeBadge( QrCodeBadge(
data = QrCodeData.forData("https://signal.org", 64), data = QrCodeState.Loading,
colorScheme = UsernameQrCodeColorScheme.Blue, colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42" 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 @Composable
private fun PreviewWithoutCode() { private fun PreviewNotSet() {
SignalTheme(isDarkMode = false) { SignalTheme {
Surface { Surface {
QrCodeBadge( QrCodeBadge(
data = null, data = QrCodeState.NotSet,
colorScheme = UsernameQrCodeColorScheme.Blue, colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42" username = "parker.42"
) )

View file

@ -38,7 +38,7 @@ class QrCodeData(
@WorkerThread @WorkerThread
fun forData(data: String, size: Int): QrCodeData { fun forData(data: String, size: Int): QrCodeData {
val qrCodeWriter = QRCodeWriter() 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 padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints)
val dimens = padded.enclosingRectangle 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( White(
borderColor = Color(0xFFFFFFFF), borderColor = Color(0xFFFFFFFF),
foregroundColor = Color(0xFF464852), foregroundColor = Color(0xFF000000),
key = "white" key = "white"
), ),
Grey( Grey(

View file

@ -65,12 +65,14 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
.padding(contentPadding) .padding(contentPadding)
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(), .fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
QrCodeBadge( QrCodeBadge(
data = state.qrCodeData, data = state.qrCodeData,
colorScheme = state.selectedColorScheme, colorScheme = state.selectedColorScheme,
username = state.username username = state.username,
modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp)
) )
ColorPicker( ColorPicker(
@ -160,7 +162,7 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
@Preview @Preview
@Composable @Composable
private fun ColorPickerItemPreview() { private fun PreviewColorPickerItem() {
SignalTheme(isDarkMode = false) { SignalTheme(isDarkMode = false) {
Surface { Surface {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -173,7 +175,7 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
@Preview @Preview
@Composable @Composable
private fun ColorPickerPreview() { private fun PreviewColorPicker() {
SignalTheme(isDarkMode = false) { SignalTheme(isDarkMode = false) {
Surface { Surface {
ColorPicker( ColorPicker(

View file

@ -1,12 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker
import kotlinx.collections.immutable.ImmutableList 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 import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
data class UsernameLinkQrColorPickerState( data class UsernameLinkQrColorPickerState(
val username: String, val username: String,
val qrCodeData: QrCodeData?, val qrCodeData: QrCodeState,
val colorSchemes: ImmutableList<UsernameQrCodeColorScheme>, val colorSchemes: ImmutableList<UsernameQrCodeColorScheme>,
val selectedColorScheme: 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.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.collections.immutable.toImmutableList 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.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.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.UsernameUtil import org.thoughtcrime.securesms.util.UsernameUtil
class UsernameLinkQrColorPickerViewModel : ViewModel() { class UsernameLinkQrColorPickerViewModel : ViewModel() {
private val username: String = Recipient.self().username.get()
private val _state = mutableStateOf( private val _state = mutableStateOf(
UsernameLinkQrColorPickerState( UsernameLinkQrColorPickerState(
username = username, username = SignalStore.account().username!!,
qrCodeData = null, qrCodeData = QrCodeState.Loading,
colorSchemes = UsernameQrCodeColorScheme.values().asList().toImmutableList(), colorSchemes = UsernameQrCodeColorScheme.values().asList().toImmutableList(),
selectedColorScheme = SignalStore.misc().usernameQrCodeColorScheme selectedColorScheme = SignalStore.misc().usernameQrCodeColorScheme
) )
@ -33,15 +35,23 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
private val disposable: CompositeDisposable = CompositeDisposable() private val disposable: CompositeDisposable = CompositeDisposable()
init { init {
disposable += Single val usernameLink = SignalStore.account().usernameLink
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(username), 64) }
.subscribeOn(Schedulers.io()) if (usernameLink != null) {
.observeOn(AndroidSchedulers.mainThread()) disposable += Single
.subscribe { qrData -> .fromCallable { QrCodeData.forData(UsernameUtil.generateLink(usernameLink), 64) }
_state.value = _state.value.copy( .subscribeOn(Schedulers.io())
qrCodeData = qrData .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() { override fun onCleared() {
@ -50,6 +60,11 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
fun onColorSelected(color: UsernameQrCodeColorScheme) { fun onColorSelected(color: UsernameQrCodeColorScheme) {
SignalStore.misc().usernameQrCodeColorScheme = color SignalStore.misc().usernameQrCodeColorScheme = color
SignalExecutors.BOUNDED.run {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
_state.value = _state.value.copy( _state.value = _state.value.copy(
selectedColorScheme = color selectedColorScheme = color
) )

View file

@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
sealed class QrScanResult { sealed class QrScanResult {
class Success(val recipient: Recipient) : QrScanResult() class Success(val recipient: Recipient) : QrScanResult()
class NotFound(val username: String) : QrScanResult() class NotFound(val username: String?) : QrScanResult()
object InvalidData : 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 package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
@ -17,7 +18,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -55,7 +56,6 @@ import org.thoughtcrime.securesms.providers.BlobProvider
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@OptIn( @OptIn(
ExperimentalMaterial3Api::class,
ExperimentalPermissionsApi::class ExperimentalPermissionsApi::class
) )
class UsernameLinkSettingsFragment : ComposeFragment() { class UsernameLinkSettingsFragment : ComposeFragment() {
@ -71,6 +71,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
val scope: CoroutineScope = rememberCoroutineScope() val scope: CoroutineScope = rememberCoroutineScope()
val navController: NavController by remember { mutableStateOf(findNavController()) } val navController: NavController by remember { mutableStateOf(findNavController()) }
var showResetDialog: Boolean by remember { mutableStateOf(false) }
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
@ -95,7 +96,9 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
onShareBadge = { onShareBadge = {
shareQrBadge(it) 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?) { 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 @Preview
@Composable @Composable
private fun AppBarPreview() { private fun PreviewAppBar() {
SignalTheme(isDarkMode = false) { SignalTheme {
Surface { Surface {
TopAppBarContent(activeTab = ActiveTab.Code) 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 @Preview
@Composable @Composable
fun PreviewAll() { private fun PreviewResetDialog() {
FragmentContent() SignalTheme {
Surface {
ResetDialog(onConfirm = {}, onDismiss = {})
}
}
} }
private fun shareQrBadge(badge: Bitmap) { private fun shareQrBadge(badge: Bitmap) {

View file

@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main 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 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( data class UsernameLinkSettingsState(
val activeTab: ActiveTab, val activeTab: ActiveTab,
val username: String, val username: String,
val usernameLink: String, val usernameLinkState: UsernameLinkState,
val qrCodeData: QrCodeData?, val qrCodeState: QrCodeState,
val qrCodeColorScheme: UsernameQrCodeColorScheme, val qrCodeColorScheme: UsernameQrCodeColorScheme,
val qrScanResult: QrScanResult? = null, val qrScanResult: QrScanResult? = null,
val usernameLinkResetResult: UsernameLinkResetResult? = null,
val indeterminateProgress: Boolean = false val indeterminateProgress: Boolean = false
) { ) {
enum class ActiveTab { 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.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.core.util.logging.Log 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.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.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NetworkUtil
import org.thoughtcrime.securesms.util.UsernameUtil import org.thoughtcrime.securesms.util.UsernameUtil
import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException import java.util.Optional
import java.io.IOException
class UsernameLinkSettingsViewModel : ViewModel() { class UsernameLinkSettingsViewModel : ViewModel() {
private val TAG = Log.tag(UsernameLinkSettingsViewModel::class.java) private val TAG = Log.tag(UsernameLinkSettingsViewModel::class.java)
private val username: BehaviorSubject<String> = BehaviorSubject.createDefault(Recipient.self().username.get())
private val _state = mutableStateOf( private val _state = mutableStateOf(
UsernameLinkSettingsState( UsernameLinkSettingsState(
activeTab = ActiveTab.Code, activeTab = ActiveTab.Code,
username = username.value!!, username = SignalStore.account().username!!,
usernameLink = UsernameUtil.generateLink(username.value!!), usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(UsernameUtil.generateLink(it)) } ?: UsernameLinkState.NotSet,
qrCodeData = null, qrCodeState = QrCodeState.Loading,
qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme
) )
) )
val state: State<UsernameLinkSettingsState> = _state val state: State<UsernameLinkSettingsState> = _state
private val disposable: CompositeDisposable = CompositeDisposable() private val disposable: CompositeDisposable = CompositeDisposable()
private val usernameLink: BehaviorSubject<Optional<UsernameLinkComponents>> = BehaviorSubject.createDefault(Optional.ofNullable(SignalStore.account().usernameLink))
private val usernameRepo: UsernameRepository = UsernameRepository()
init { init {
disposable += username disposable += usernameLink
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.map { UsernameUtil.generateLink(it) } .map { link -> link.map { UsernameUtil.generateLink(it) } }
.flatMapSingle { generateQrCodeData(it) } .flatMapSingle { generateQrCodeData(it) }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData -> .subscribe { qrData ->
_state.value = _state.value.copy( _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) { fun onQrCodeScanned(url: String) {
_state.value = _state.value.copy( _state.value = _state.value.copy(
indeterminateProgress = true indeterminateProgress = true
) )
disposable += Single disposable += usernameRepo.convertLinkToUsernameAndAci(url)
.fromCallable { .map { result ->
val username: String? = UsernameUtil.parseLink(url) when (result) {
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString()))
if (username == null) { is UsernameRepository.UsernameLinkConversionResult.Invalid -> QrScanResult.InvalidData
Log.w(TAG, "Failed to parse username from url") is UsernameRepository.UsernameLinkConversionResult.NotFound -> QrScanResult.NotFound(result.username?.toString())
return@fromCallable QrScanResult.InvalidData is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.NetworkError
}
return@fromCallable try {
val hashed: String = UsernameUtil.hashUsernameToBase64(username)
val aci: ACI = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsernameHash(hashed)
QrScanResult.Success(Recipient.externalUsername(aci, username))
} catch (e: BaseUsernameException) {
Log.w(TAG, "Invalid username", e)
QrScanResult.InvalidData
} catch (e: NonSuccessfulResponseCodeException) {
Log.w(TAG, "Non-successful response during username resolution", e)
if (e.code == 404) {
QrScanResult.NotFound(username)
} else {
QrScanResult.NetworkError
}
} catch (e: IOException) {
Log.w(TAG, "Network error during username resolution", e)
QrScanResult.NetworkError
} }
} }
.subscribeOn(Schedulers.io()) .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 { 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 package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.res.Configuration
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@ -19,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -31,14 +35,15 @@ import androidx.navigation.NavController
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.signal.core.ui.Buttons import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.theme.SignalTheme import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge 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.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.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.compose.ScreenshotController import org.thoughtcrime.securesms.compose.ScreenshotController
import org.thoughtcrime.securesms.util.UsernameUtil
import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.navigation.safeNavigate
@ -48,22 +53,43 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
@Composable @Composable
fun UsernameLinkShareScreen( fun UsernameLinkShareScreen(
state: UsernameLinkSettingsState, state: UsernameLinkSettingsState,
onLinkResultHandled: () -> Unit,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
scope: CoroutineScope, scope: CoroutineScope,
navController: NavController, navController: NavController,
onShareBadge: (Bitmap) -> Unit, onShareBadge: (Bitmap) -> Unit,
modifier: Modifier = Modifier, 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( Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier modifier = modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
val usernameCopiedString = stringResource(id = R.string.UsernameLinkSettings_username_copied_toast)
QrCodeBadge( QrCodeBadge(
data = state.qrCodeData, data = state.qrCodeState,
colorScheme = state.qrCodeColorScheme, colorScheme = state.qrCodeColorScheme,
username = state.username, 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( ButtonBar(
@ -76,16 +102,8 @@ fun UsernameLinkShareScreen(
onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) } onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) }
) )
CopyRow( LinkRow(
displayText = state.username, linkState = state.usernameLinkState,
copyMessage = stringResource(R.string.UsernameLinkSettings_username_copied_toast),
snackbarHostState = snackbarHostState,
scope = scope
)
CopyRow(
displayText = state.usernameLink,
copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast),
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
scope = scope scope = scope
) )
@ -94,7 +112,7 @@ fun UsernameLinkShareScreen(
text = stringResource(id = R.string.UsernameLinkSettings_qr_description), text = stringResource(id = R.string.UsernameLinkSettings_qr_description),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, 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 color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -104,7 +122,7 @@ fun UsernameLinkShareScreen(
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
Buttons.Small(onClick = { /*TODO*/ }) { Buttons.Small(onClick = onResetClicked) {
Text( Text(
text = stringResource(id = R.string.UsernameLinkSettings_reset_button_label) text = stringResource(id = R.string.UsernameLinkSettings_reset_button_label)
) )
@ -133,29 +151,46 @@ private fun ButtonBar(onShareClicked: () -> Unit, onColorClicked: () -> Unit) {
} }
@Composable @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 context = LocalContext.current
val copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background) .background(color = MaterialTheme.colorScheme.background)
.clickable { .padding(
Util.copyToClipboard(context, displayText) 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 { scope.launch {
snackbarHostState.showSnackbar(copyMessage) snackbarHostState.showSnackbar(copyMessage)
} }
} }
.padding(horizontal = 26.dp, vertical = 16.dp) .padding(horizontal = 26.dp, vertical = 16.dp)
.alpha(if (linkState is UsernameLinkState.Present) 1.0f else 0.6f)
) { ) {
Image( Image(
painter = painterResource(id = R.drawable.symbol_copy_android_24), painter = painterResource(id = R.drawable.symbol_link_24),
contentDescription = null, contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
) )
Text( 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), modifier = Modifier.padding(start = 26.dp),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@ -163,45 +198,68 @@ private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState:
} }
} }
@Preview(name = "Light Theme")
@Composable @Composable
private fun ScreenPreviewLightTheme() { private fun ResetLinkResultDialog(message: String, onDismiss: () -> Unit) {
SignalTheme(isDarkMode = false) { 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 { Surface {
UsernameLinkShareScreen( UsernameLinkShareScreen(
state = previewState(), state = previewState(),
snackbarHostState = SnackbarHostState(), snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope(), scope = rememberCoroutineScope(),
navController = NavController(LocalContext.current), 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 @Composable
private fun ScreenPreviewDarkTheme() { private fun LinkRowPreview() {
SignalTheme(isDarkMode = true) { SignalTheme {
Surface { Surface {
UsernameLinkShareScreen( Column(modifier = Modifier.padding(8.dp)) {
state = previewState(), LinkRow(
snackbarHostState = SnackbarHostState(), linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
scope = rememberCoroutineScope(), snackbarHostState = SnackbarHostState(),
navController = NavController(LocalContext.current), scope = rememberCoroutineScope()
onShareBadge = {} )
) LinkRow(
linkState = UsernameLinkState.NotSet,
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope()
)
LinkRow(
linkState = UsernameLinkState.Resetting,
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope()
)
}
} }
} }
} }
private fun previewState(): UsernameLinkSettingsState { private fun previewState(): UsernameLinkSettingsState {
val link = UsernameUtil.generateLink("maya.45") val link = "https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"
return UsernameLinkSettingsState( return UsernameLinkSettingsState(
activeTab = ActiveTab.Code, activeTab = ActiveTab.Code,
username = "maya.45", username = "parker.42",
usernameLink = link, usernameLinkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
qrCodeData = QrCodeData.forData(link, 64), qrCodeState = QrCodeState.Present(QrCodeData.forData(link, 64)),
qrCodeColorScheme = UsernameQrCodeColorScheme.Blue 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.R
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
import org.thoughtcrime.securesms.util.CommunicationActions 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. * 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) QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled)
} }
is QrScanResult.NotFound -> { 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 -> { is QrScanResult.Success -> {
CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null) CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null)
@ -70,7 +75,7 @@ fun UsernameQrScanScreen(
AndroidView( AndroidView(
factory = { context -> factory = { context ->
val view = QrScannerView(context) val view = QrScannerView(context)
disposables += view.qrData.distinctUntilChanged().subscribe { data -> disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data ->
onQrCodeScanned(data) onQrCodeScanned(data)
} }
view view

View file

@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.migrations.BackupNotificationMigrationJob;
import org.thoughtcrime.securesms.migrations.BlobStorageLocationMigrationJob; import org.thoughtcrime.securesms.migrations.BlobStorageLocationMigrationJob;
import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob; import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob;
import org.thoughtcrime.securesms.migrations.ClearGlideCacheMigrationJob; import org.thoughtcrime.securesms.migrations.ClearGlideCacheMigrationJob;
import org.thoughtcrime.securesms.migrations.CopyUsernameToSignalStoreMigrationJob;
import org.thoughtcrime.securesms.migrations.DatabaseMigrationJob; import org.thoughtcrime.securesms.migrations.DatabaseMigrationJob;
import org.thoughtcrime.securesms.migrations.DeleteDeprecatedLogsMigrationJob; import org.thoughtcrime.securesms.migrations.DeleteDeprecatedLogsMigrationJob;
import org.thoughtcrime.securesms.migrations.DirectoryRefreshMigrationJob; import org.thoughtcrime.securesms.migrations.DirectoryRefreshMigrationJob;
@ -231,6 +232,7 @@ public final class JobManagerFactories {
put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory()); put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory());
put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory()); put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory());
put(ClearGlideCacheMigrationJob.KEY, new ClearGlideCacheMigrationJob.Factory()); put(ClearGlideCacheMigrationJob.KEY, new ClearGlideCacheMigrationJob.Factory());
put(CopyUsernameToSignalStoreMigrationJob.KEY, new CopyUsernameToSignalStoreMigrationJob.Factory());
put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory()); put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory());
put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory()); put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory());
put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory()); put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory());

View file

@ -301,7 +301,8 @@ public class RefreshOwnProfileJob extends BaseJob {
.confirmUsername(localUsername, response); .confirmUsername(localUsername, response);
} catch (IOException e) { } catch (IOException e) {
Log.d(TAG, "Failed to synchronize username.", 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.ServiceId.PNI
import org.whispersystems.signalservice.api.push.ServiceIds import org.whispersystems.signalservice.api.push.ServiceIds
import org.whispersystems.signalservice.api.push.SignalServiceAddress 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 import java.security.SecureRandom
internal class AccountValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) { 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_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_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 @VisibleForTesting
const val KEY_E164 = "account.e164" 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_PUBLIC_KEY,
KEY_ACI_IDENTITY_PRIVATE_KEY, KEY_ACI_IDENTITY_PRIVATE_KEY,
KEY_PNI_IDENTITY_PUBLIC_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 val isLinkedDevice: Boolean
get() = !isPrimaryDevice 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() { private fun clearLocalCredentials() {
putString(KEY_SERVICE_PASSWORD, Util.getSecret(18)) putString(KEY_SERVICE_PASSWORD, Util.getSecret(18))

View file

@ -11,10 +11,9 @@ import java.util.List;
public final class PhoneNumberPrivacyValues extends SignalStoreValues { public final class PhoneNumberPrivacyValues extends SignalStoreValues {
public static final String SHARING_MODE = "phoneNumberPrivacy.sharingMode"; public static final String SHARING_MODE = "phoneNumberPrivacy.sharingMode";
public static final String LISTING_MODE = "phoneNumberPrivacy.listingMode"; public static final String LISTING_MODE = "phoneNumberPrivacy.listingMode";
public static final String LISTING_TIMESTAMP = "phoneNumberPrivacy.listingMode.timestamp"; public static final String LISTING_TIMESTAMP = "phoneNumberPrivacy.listingMode.timestamp";
public static final String USERNAME_OUT_OF_SYNC = "phoneNumberPrivacy.usernameOutOfSync";
private static final Collection<CertificateType> REGULAR_CERTIFICATE = Collections.singletonList(CertificateType.UUID_AND_E164); 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); 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); 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 * If you respect {@link #getPhoneNumberSharingMode}, then you will only ever need to fetch and store
* these certificates types. * these certificates types.

View file

@ -136,9 +136,10 @@ public class ApplicationMigrations {
static final int ATTACHMENT_CLEANUP_3 = 92; static final int ATTACHMENT_CLEANUP_3 = 92;
static final int EMOJI_SEARCH_INDEX_CHECK = 93; static final int EMOJI_SEARCH_INDEX_CHECK = 93;
static final int IDENTITY_FIX = 94; 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 * 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()); 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; 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 com.google.android.material.snackbar.Snackbar;
import org.signal.core.util.logging.Log; 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.AvatarPreviewActivity;
import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R; 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.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation; import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.util.Base64UrlSafe;
import java.util.Arrays; import java.util.Arrays;
import java.util.Optional; import java.util.Optional;
@ -247,7 +244,6 @@ public class ManageProfileFragment extends LoggingFragment {
binding.manageProfileUsernameShare.setVisibility(View.GONE); binding.manageProfileUsernameShare.setVisibility(View.GONE);
} else { } else {
binding.manageProfileUsername.setText(username); binding.manageProfileUsername.setText(username);
binding.manageProfileUsernameSubtitle.setText(UsernameUtil.generateLink(username));
binding.manageProfileUsernameShare.setVisibility(View.VISIBLE); binding.manageProfileUsernameShare.setVisibility(View.VISIBLE);
} }
} }
@ -318,7 +314,7 @@ public class ManageProfileFragment extends LoggingFragment {
disposables.add(disposable); disposables.add(disposable);
} }
private void handleUsernameDeletionResult(@NonNull UsernameEditRepository.UsernameDeleteResult usernameDeleteResult) { private void handleUsernameDeletionResult(@NonNull UsernameRepository.UsernameDeleteResult usernameDeleteResult) {
switch (usernameDeleteResult) { switch (usernameDeleteResult) {
case SUCCESS: case SUCCESS:
Snackbar.make(requireView(), R.string.ManageProfileFragment__username_deleted, Snackbar.LENGTH_SHORT).show(); 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 SingleLiveEvent<Event> events;
private final RecipientForeverObserver observer; private final RecipientForeverObserver observer;
private final ManageProfileRepository repository; private final ManageProfileRepository repository;
private final UsernameEditRepository usernameEditRepository; private final UsernameRepository usernameEditRepository;
private final MutableLiveData<Optional<Badge>> badge; private final MutableLiveData<Optional<Badge>> badge;
private byte[] previousAvatar; private byte[] previousAvatar;
@ -63,7 +63,7 @@ class ManageProfileViewModel extends ViewModel {
this.aboutEmoji = new MutableLiveData<>(); this.aboutEmoji = new MutableLiveData<>();
this.events = new SingleLiveEvent<>(); this.events = new SingleLiveEvent<>();
this.repository = new ManageProfileRepository(); this.repository = new ManageProfileRepository();
this.usernameEditRepository = new UsernameEditRepository(); this.usernameEditRepository = new UsernameRepository();
this.badge = new DefaultValueLiveData<>(Optional.empty()); this.badge = new DefaultValueLiveData<>(Optional.empty());
this.observer = this::onRecipientChanged; this.observer = this::onRecipientChanged;
this.avatarState = LiveDataUtil.combineLatest(Recipient.self().live().getLiveData(), internalAvatarState, (self, state) -> new AvatarState(state, self)); 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; return events;
} }
public Single<UsernameEditRepository.UsernameDeleteResult> deleteUsername() { public Single<UsernameRepository.UsernameDeleteResult> deleteUsername() {
return usernameEditRepository.deleteUsername().observeOn(AndroidSchedulers.mainThread()); 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 static final long NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS = 500;
private final PublishSubject<Event> events; private final PublishSubject<Event> events;
private final UsernameEditRepository repo; private final UsernameRepository repo;
private final RxStore<State> uiState; private final RxStore<State> uiState;
private final PublishProcessor<String> nicknamePublisher; private final PublishProcessor<String> nicknamePublisher;
private final CompositeDisposable disposables; private final CompositeDisposable disposables;
private final boolean isInRegistration; private final boolean isInRegistration;
private UsernameEditViewModel(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) 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()); .orElse(UsernameState.NoUsername.INSTANCE)), Schedulers.computation());
this.events = PublishSubject.create(); 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() object NoUsername : UsernameState()
data class Reserved( data class Reserved(
override val username: String, public override val username: String,
val reserveUsernameResponse: ReserveUsernameResponse val reserveUsernameResponse: ReserveUsernameResponse
) : UsernameState() ) : UsernameState()

View file

@ -128,8 +128,9 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
boolean hasReadOnboardingStory = remote.hasReadOnboardingStory() || remote.hasViewedOnboardingStory() || local.hasReadOnboardingStory() || local.hasViewedOnboardingStory() ; boolean hasReadOnboardingStory = remote.hasReadOnboardingStory() || remote.hasViewedOnboardingStory() || local.hasReadOnboardingStory() || local.hasViewedOnboardingStory() ;
boolean hasSeenGroupStoryEducation = remote.hasSeenGroupStoryEducationSheet() || local.hasSeenGroupStoryEducationSheet(); boolean hasSeenGroupStoryEducation = remote.hasSeenGroupStoryEducationSheet() || local.hasSeenGroupStoryEducationSheet();
String username = !StringUtil.isEmpty(remote.getUsername()) ? remote.getUsername() : local.getUsername(); 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); AccountRecord.UsernameLink usernameLink = remote.getUsernameLink() != null ? remote.getUsernameLink() : local.getUsernameLink();
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); 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) { if (matchesRemote) {
return remote; return remote;
@ -165,7 +166,8 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
.setStoriesDisabled(storiesDisabled) .setStoriesDisabled(storiesDisabled)
.setHasReadOnboardingStory(hasReadOnboardingStory) .setHasReadOnboardingStory(hasReadOnboardingStory)
.setHasSeenGroupStoryEducationSheet(hasSeenGroupStoryEducation) .setHasSeenGroupStoryEducationSheet(hasSeenGroupStoryEducation)
.setUsername(username); .setUsername(username)
.setUsernameLink(usernameLink);
if (!FeatureFlags.phoneNumberPrivacy() || !self.getPnpCapability().isSupported()) { if (!FeatureFlags.phoneNumberPrivacy() || !self.getPnpCapability().isSupported()) {
builder.setE164(e164); builder.setE164(e164);
@ -220,7 +222,8 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
boolean storiesDisabled, boolean storiesDisabled,
@NonNull OptionalBool storyViewReceiptsState, @NonNull OptionalBool storyViewReceiptsState,
boolean hasReadOnboardingStory, boolean hasReadOnboardingStory,
@Nullable String username) @Nullable String username,
@Nullable AccountRecord.UsernameLink usernameLink)
{ {
return Arrays.equals(contact.serializeUnknownFields(), unknownFields) && return Arrays.equals(contact.serializeUnknownFields(), unknownFields) &&
Objects.equals(contact.getGivenName().orElse(""), givenName) && Objects.equals(contact.getGivenName().orElse(""), givenName) &&
@ -251,6 +254,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
contact.isStoriesDisabled() == storiesDisabled && contact.isStoriesDisabled() == storiesDisabled &&
contact.getStoryViewReceiptsState().equals(storyViewReceiptsState) && contact.getStoryViewReceiptsState().equals(storyViewReceiptsState) &&
contact.hasReadOnboardingStory() == hasReadOnboardingStory && 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.Collectors;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import com.google.protobuf.ByteString;
import org.signal.core.util.SetUtil; import org.signal.core.util.SetUtil;
import org.signal.core.util.logging.Log; 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.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.RecipientRecord; 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.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util; 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.SignalAccountRecord;
import org.whispersystems.signalservice.api.storage.SignalContactRecord; 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.SignalStorageManifest;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord; import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.api.util.OptionalUtil; 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 org.whispersystems.signalservice.internal.storage.protos.OptionalBool;
import java.util.Collection; import java.util.Collection;
@ -159,6 +165,17 @@ public final class StorageSyncHelper {
account.setE164(self.requireE164()); 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()); return SignalStorageRecord.forAccount(account.build());
} }
@ -214,6 +231,16 @@ public final class StorageSyncHelper {
if (fetchProfile && update.getNew().getAvatarUrlPath().isPresent()) { if (fetchProfile && update.getNew().getAvatarUrlPath().isPresent()) {
ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(self, update.getNew().getAvatarUrlPath().get())); 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() { public static void scheduleSyncForDataChange() {

View file

@ -6,6 +6,7 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey; 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.GroupTable;
import org.thoughtcrime.securesms.database.IdentityTable; import org.thoughtcrime.securesms.database.IdentityTable;
import org.thoughtcrime.securesms.database.RecipientTable; 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) { private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientRecord recipient, byte[] rawStorageId) {
if (recipient.getAci() == null && recipient.getE164() == null) { if (recipient.getAci() == null && recipient.getE164() == null) {
throw new AssertionError("Must have either a UUID or a phone number!"); 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> <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 --> <!-- 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> <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 --> <!-- Button label for a button that will reset your username and give you a new link -->
<string name="UsernameLinkSettings_reset_button_label">Reset</string> <string name="UsernameLinkSettings_reset_button_label">Reset</string>
<!-- Button label for a button that indicates that the user is done changing the current setting --> <!-- 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> <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. --> <!-- 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> <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. --> <!-- 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> <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 --> <!-- PendingParticipantsView -->
<!-- Displayed in the popup card when a remote user attempts to join a call link --> <!-- Displayed in the popup card when a remote user attempts to join a call link -->
@ -6144,7 +6160,7 @@
</plurals> </plurals>
<!-- Content description for rejecting a user --> <!-- Content description for rejecting a user -->
<string name="PendingParticipantsBottomSheet__reject">Reject</string> <string name="PendingParticipantsBottomSheet__reject">Reject</string>
<!-- Content desccription for confirming a user --> <!-- Content description for confirming a user -->
<string name="PendingParticipantsBottomSheet__approve">Approve</string> <string name="PendingParticipantsBottomSheet__approve">Approve</string>
<!-- EOF --> <!-- EOF -->

View file

@ -1,5 +1,6 @@
package org.signal.core.ui.theme package org.signal.core.ui.theme
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -9,6 +10,7 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color 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.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -168,7 +170,7 @@ private val darkColorScheme = darkColorScheme(
@Composable @Composable
fun SignalTheme( 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 content: @Composable () -> Unit
) { ) {
val extendedColors = if (isDarkMode) darkExtendedColors else lightExtendedColors 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.ecc.ECPublicKey;
import org.signal.libsignal.protocol.logging.Log; import org.signal.libsignal.protocol.logging.Log;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord; 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.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.signalservice.api.account.AccountAttributes; 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.PNI;
import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceIdType; 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.NoContentException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; 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.util.Util;
import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper; import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper;
import org.whispersystems.util.Base64; import org.whispersystems.util.Base64;
import org.whispersystems.util.Base64UrlSafe;
import java.io.IOException; import java.io.IOException;
import java.security.KeyStore; import java.security.KeyStore;
@ -96,6 +101,7 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
@ -786,6 +792,25 @@ public class SignalServiceAccountManager {
this.pushServiceSocket.deleteUsername(); 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 { public void deleteAccount() throws IOException {
this.pushServiceSocket.deleteAccount(); 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(); return proto.getUsername();
} }
public @Nullable AccountRecord.UsernameLink getUsernameLink() {
return proto.getUsernameLink();
}
public AccountRecord toProto() { public AccountRecord toProto() {
return proto; return proto;
} }
@ -717,6 +721,16 @@ public final class SignalAccountRecord implements SignalRecord {
return this; 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) { private static AccountRecord.Builder parseUnknowns(byte[] serializedUnknowns) {
try { try {
return AccountRecord.parseFrom(serializedUnknowns).toBuilder(); 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 MODIFY_USERNAME_PATH = "/v1/accounts/username_hash";
private static final String RESERVE_USERNAME_PATH = "/v1/accounts/username_hash/reserve"; 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 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 DELETE_ACCOUNT_PATH = "/v1/accounts/me";
private static final String CHANGE_NUMBER_PATH = "/v2/accounts/number"; private static final String CHANGE_NUMBER_PATH = "/v2/accounts/number";
private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s"; private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s";
@ -1090,6 +1092,31 @@ public class PushServiceSocket {
makeServiceRequest(MODIFY_USERNAME_PATH, "DELETE", null); 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 { public void deleteAccount() throws IOException {
makeServiceRequest(DELETE_ACCOUNT_PATH, "DELETE", null); 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; bytes profileKey = 1;
string givenName = 2; string givenName = 2;
string familyName = 3; string familyName = 3;
@ -189,6 +207,8 @@ message AccountRecord {
bool hasReadOnboardingStory = 31; bool hasReadOnboardingStory = 31;
bool hasSeenGroupStoryEducationSheet = 32; bool hasSeenGroupStoryEducationSheet = 32;
string username = 33; string username = 33;
bool hasCompletedUsernameOnboarding = 34;
UsernameLink usernameLink = 35;
} }
message StoryDistributionListRecord { message StoryDistributionListRecord {