Update to the new username link spec.
This commit is contained in:
parent
a6dd4345ab
commit
8a93814bac
47 changed files with 1283 additions and 463 deletions
|
@ -32,7 +32,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
|||
@After
|
||||
fun tearDown() {
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync()
|
||||
SignalStore.account().usernameOutOfSync = false
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -78,7 +78,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
|||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertTrue(didConfirm)
|
||||
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
assertFalse(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -108,7 +108,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
|||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertTrue(didConfirm)
|
||||
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
assertFalse(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -142,7 +142,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
|||
// THEN
|
||||
assertFalse(didReserve)
|
||||
assertFalse(didConfirm)
|
||||
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
assertFalse(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -176,6 +176,6 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
|||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertFalse(didConfirm)
|
||||
assertTrue(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
assertTrue(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ class UsernameOutOfSyncReminder : Reminder(R.string.UsernameOutOfSyncReminder__s
|
|||
companion object {
|
||||
@JvmStatic
|
||||
fun isEligible(): Boolean {
|
||||
return FeatureFlags.usernames() && SignalStore.phoneNumberPrivacy().isUsernameOutOfSync
|
||||
return FeatureFlags.usernames() && SignalStore.account().usernameOutOfSync
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -131,7 +131,7 @@ class AppSettingsFragment : DSLSettingsFragment(
|
|||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
|
||||
},
|
||||
onQrButtonClicked = {
|
||||
if (Recipient.self().username.isPresent && Recipient.self().username.get().isNotEmpty()) {
|
||||
if (SignalStore.account().username != null) {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
|
||||
} else {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment)
|
||||
|
|
|
@ -60,7 +60,7 @@ private fun DrawScope.drawQr(
|
|||
deadzonePercent: Float,
|
||||
logo: ImageBitmap
|
||||
) {
|
||||
val deadzonePaddingPercent = 0.07f
|
||||
val deadzonePaddingPercent = 0.045f
|
||||
|
||||
// We want an even number of dots on either side of the deadzone
|
||||
val deadzoneRadius: Int = (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight ->
|
||||
|
|
|
@ -1,14 +1,23 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
|
@ -20,16 +29,22 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ScreenshotController
|
||||
import org.thoughtcrime.securesms.compose.getScreenshotBounds
|
||||
|
||||
|
@ -37,19 +52,25 @@ import org.thoughtcrime.securesms.compose.getScreenshotBounds
|
|||
* Renders a QR code and username as a badge.
|
||||
*/
|
||||
@Composable
|
||||
fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier, screenshotController: ScreenshotController? = null) {
|
||||
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor)
|
||||
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor)
|
||||
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f)
|
||||
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White)
|
||||
fun QrCodeBadge(
|
||||
data: QrCodeState,
|
||||
colorScheme: UsernameQrCodeColorScheme,
|
||||
username: String,
|
||||
modifier: Modifier = Modifier,
|
||||
screenshotController: ScreenshotController? = null,
|
||||
usernameCopyable: Boolean = false,
|
||||
onClick: (() -> Unit) = {}
|
||||
) {
|
||||
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor, label = "border")
|
||||
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor, label = "foreground")
|
||||
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f, label = "elevation")
|
||||
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White, label = "textColor")
|
||||
var badgeBounds by remember {
|
||||
mutableStateOf<Rect?>(null)
|
||||
}
|
||||
screenshotController?.bind(LocalView.current, badgeBounds)
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 59.dp, vertical = 24.dp)
|
||||
.onGloballyPositioned {
|
||||
badgeBounds = it.getScreenshotBounds()
|
||||
},
|
||||
|
@ -57,24 +78,32 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern
|
|||
shape = RoundedCornerShape(24.dp),
|
||||
shadowElevation = elevation.dp
|
||||
) {
|
||||
Column {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(296.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = 32.dp,
|
||||
start = 40.dp,
|
||||
end = 40.dp,
|
||||
bottom = 16.dp
|
||||
end = 40.dp
|
||||
)
|
||||
.aspectRatio(1f)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White
|
||||
) {
|
||||
if (data != null) {
|
||||
if (data is QrCodeState.Present) {
|
||||
QrCode(
|
||||
data = data,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
data = data.data,
|
||||
modifier = Modifier
|
||||
.border(
|
||||
width = if (colorScheme == UsernameQrCodeColorScheme.White) 2.dp else 0.dp,
|
||||
color = Color(0xFFE9E9E9),
|
||||
shape = RoundedCornerShape(size = 12.dp)
|
||||
)
|
||||
.padding(16.dp),
|
||||
foregroundColor = foregroundColor,
|
||||
backgroundColor = Color.White
|
||||
)
|
||||
|
@ -85,40 +114,169 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern
|
|||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = colorScheme.borderColor,
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
if (data is QrCodeState.Loading) {
|
||||
CircularProgressIndicator(
|
||||
color = colorScheme.borderColor,
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
} else if (data is QrCodeState.NotSet) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.symbol_error_circle_24),
|
||||
contentDescription = stringResource(id = R.string.UsernameLinkSettings_link_not_set_label),
|
||||
colorFilter = ColorFilter.tint(colorResource(R.color.core_grey_25)),
|
||||
modifier = Modifier
|
||||
.width(28.dp)
|
||||
.height(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = username,
|
||||
color = textColor,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
textAlign = TextAlign.Center,
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = 40.dp,
|
||||
end = 40.dp,
|
||||
bottom = 32.dp
|
||||
start = 32.dp,
|
||||
end = 32.dp,
|
||||
top = 8.dp,
|
||||
bottom = 28.dp
|
||||
)
|
||||
)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(
|
||||
enabled = usernameCopyable,
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
if (usernameCopyable) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.symbol_copy_android_24),
|
||||
contentDescription = null,
|
||||
colorFilter = if (colorScheme == UsernameQrCodeColorScheme.White) {
|
||||
ColorFilter.tint(Color.Black)
|
||||
} else {
|
||||
ColorFilter.tint(Color.White)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = username,
|
||||
color = textColor,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(name = "Light Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewWithCode() {
|
||||
private fun PreviewWithCodeShort() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Column {
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42",
|
||||
usernameCopyable = false
|
||||
)
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42",
|
||||
usernameCopyable = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(group = "LongName")
|
||||
@Composable
|
||||
private fun PreviewWithCodeLong() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Column {
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "TheAmazingSpiderMan.42",
|
||||
usernameCopyable = false
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "TheAmazingSpiderMan.42",
|
||||
usernameCopyable = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(group = "Colors", heightDp = 1500)
|
||||
@Composable
|
||||
private fun PreviewAllColorsP1() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
Column {
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Blue)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.White)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Green)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Grey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(group = "Colors", heightDp = 1500)
|
||||
@Composable
|
||||
private fun PreviewAllColorsP2() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
Column {
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Pink)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Orange)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Purple)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SampleCode(colorScheme = UsernameQrCodeColorScheme.Tan)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SampleCode(colorScheme: UsernameQrCodeColorScheme) {
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdf", 64)),
|
||||
colorScheme = colorScheme,
|
||||
username = "parker.42"
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "Loading", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "Loading", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewLoading() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
QrCodeBadge(
|
||||
data = QrCodeData.forData("https://signal.org", 64),
|
||||
data = QrCodeState.Loading,
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42"
|
||||
)
|
||||
|
@ -126,13 +284,14 @@ private fun PreviewWithCode() {
|
|||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(name = "Light Theme", group = "NotSet", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "NotSet", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewWithoutCode() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
private fun PreviewNotSet() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
QrCodeBadge(
|
||||
data = null,
|
||||
data = QrCodeState.NotSet,
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42"
|
||||
)
|
||||
|
|
|
@ -38,7 +38,7 @@ class QrCodeData(
|
|||
@WorkerThread
|
||||
fun forData(data: String, size: Int): QrCodeData {
|
||||
val qrCodeWriter = QRCodeWriter()
|
||||
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H.toString())
|
||||
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q.toString())
|
||||
|
||||
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val dimens = padded.enclosingRectangle
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -17,7 +17,7 @@ enum class UsernameQrCodeColorScheme(
|
|||
),
|
||||
White(
|
||||
borderColor = Color(0xFFFFFFFF),
|
||||
foregroundColor = Color(0xFF464852),
|
||||
foregroundColor = Color(0xFF000000),
|
||||
key = "white"
|
||||
),
|
||||
Grey(
|
||||
|
|
|
@ -65,12 +65,14 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
|
|||
.padding(contentPadding)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
QrCodeBadge(
|
||||
data = state.qrCodeData,
|
||||
colorScheme = state.selectedColorScheme,
|
||||
username = state.username
|
||||
username = state.username,
|
||||
modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp)
|
||||
)
|
||||
|
||||
ColorPicker(
|
||||
|
@ -160,7 +162,7 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
|
|||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ColorPickerItemPreview() {
|
||||
private fun PreviewColorPickerItem() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
@ -173,7 +175,7 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
|
|||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ColorPickerPreview() {
|
||||
private fun PreviewColorPicker() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
ColorPicker(
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
|
||||
data class UsernameLinkQrColorPickerState(
|
||||
val username: String,
|
||||
val qrCodeData: QrCodeData?,
|
||||
val qrCodeData: QrCodeState,
|
||||
val colorSchemes: ImmutableList<UsernameQrCodeColorScheme>,
|
||||
val selectedColorScheme: UsernameQrCodeColorScheme
|
||||
)
|
||||
|
|
|
@ -9,20 +9,22 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
|||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
|
||||
class UsernameLinkQrColorPickerViewModel : ViewModel() {
|
||||
|
||||
private val username: String = Recipient.self().username.get()
|
||||
|
||||
private val _state = mutableStateOf(
|
||||
UsernameLinkQrColorPickerState(
|
||||
username = username,
|
||||
qrCodeData = null,
|
||||
username = SignalStore.account().username!!,
|
||||
qrCodeData = QrCodeState.Loading,
|
||||
colorSchemes = UsernameQrCodeColorScheme.values().asList().toImmutableList(),
|
||||
selectedColorScheme = SignalStore.misc().usernameQrCodeColorScheme
|
||||
)
|
||||
|
@ -33,15 +35,23 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
|
|||
private val disposable: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
init {
|
||||
disposable += Single
|
||||
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(username), 64) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { qrData ->
|
||||
_state.value = _state.value.copy(
|
||||
qrCodeData = qrData
|
||||
)
|
||||
}
|
||||
val usernameLink = SignalStore.account().usernameLink
|
||||
|
||||
if (usernameLink != null) {
|
||||
disposable += Single
|
||||
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(usernameLink), 64) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { qrData ->
|
||||
_state.value = _state.value.copy(
|
||||
qrCodeData = QrCodeState.Present(qrData)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.value = _state.value.copy(
|
||||
qrCodeData = QrCodeState.NotSet
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
@ -50,6 +60,11 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
|
|||
|
||||
fun onColorSelected(color: UsernameQrCodeColorScheme) {
|
||||
SignalStore.misc().usernameQrCodeColorScheme = color
|
||||
SignalExecutors.BOUNDED.run {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
selectedColorScheme = color
|
||||
)
|
||||
|
|
|
@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
|||
sealed class QrScanResult {
|
||||
class Success(val recipient: Recipient) : QrScanResult()
|
||||
|
||||
class NotFound(val username: String) : QrScanResult()
|
||||
class NotFound(val username: String?) : QrScanResult()
|
||||
|
||||
object InvalidData : QrScanResult()
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
|
@ -17,7 +18,6 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
|
@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
@ -55,7 +56,6 @@ import org.thoughtcrime.securesms.providers.BlobProvider
|
|||
import java.io.ByteArrayOutputStream
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalPermissionsApi::class
|
||||
)
|
||||
class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
|
@ -71,6 +71,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
|||
val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
|
||||
val scope: CoroutineScope = rememberCoroutineScope()
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
var showResetDialog: Boolean by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
|
@ -95,7 +96,9 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
|||
onShareBadge = {
|
||||
shareQrBadge(it)
|
||||
},
|
||||
screenshotController = screenshotController
|
||||
screenshotController = screenshotController,
|
||||
onResetClicked = { showResetDialog = true },
|
||||
onLinkResultHandled = { viewModel.onUsernameLinkResetResultHandled() }
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -114,6 +117,16 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showResetDialog) {
|
||||
ResetDialog(
|
||||
onConfirm = {
|
||||
viewModel.onUsernameLinkReset()
|
||||
showResetDialog = false
|
||||
},
|
||||
onDismiss = { showResetDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
@ -182,20 +195,43 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_title),
|
||||
body = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_body),
|
||||
confirm = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_confirm_button),
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AppBarPreview() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
private fun PreviewAppBar() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
TopAppBarContent(activeTab = ActiveTab.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewAll() {
|
||||
FragmentContent()
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewAll() {
|
||||
FragmentContent()
|
||||
private fun PreviewResetDialog() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
ResetDialog(onConfirm = {}, onDismiss = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareQrBadge(badge: Bitmap) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
|
||||
/**
|
||||
|
@ -9,10 +9,11 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.Username
|
|||
data class UsernameLinkSettingsState(
|
||||
val activeTab: ActiveTab,
|
||||
val username: String,
|
||||
val usernameLink: String,
|
||||
val qrCodeData: QrCodeData?,
|
||||
val usernameLinkState: UsernameLinkState,
|
||||
val qrCodeState: QrCodeState,
|
||||
val qrCodeColorScheme: UsernameQrCodeColorScheme,
|
||||
val qrScanResult: QrScanResult? = null,
|
||||
val usernameLinkResetResult: UsernameLinkResetResult? = null,
|
||||
val indeterminateProgress: Boolean = false
|
||||
) {
|
||||
enum class ActiveTab {
|
||||
|
|
|
@ -10,46 +10,46 @@ import io.reactivex.rxjava3.kotlin.plusAssign
|
|||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.NetworkUtil
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import java.io.IOException
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import java.util.Optional
|
||||
|
||||
class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
|
||||
private val TAG = Log.tag(UsernameLinkSettingsViewModel::class.java)
|
||||
|
||||
private val username: BehaviorSubject<String> = BehaviorSubject.createDefault(Recipient.self().username.get())
|
||||
|
||||
private val _state = mutableStateOf(
|
||||
UsernameLinkSettingsState(
|
||||
activeTab = ActiveTab.Code,
|
||||
username = username.value!!,
|
||||
usernameLink = UsernameUtil.generateLink(username.value!!),
|
||||
qrCodeData = null,
|
||||
username = SignalStore.account().username!!,
|
||||
usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(UsernameUtil.generateLink(it)) } ?: UsernameLinkState.NotSet,
|
||||
qrCodeState = QrCodeState.Loading,
|
||||
qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme
|
||||
)
|
||||
)
|
||||
|
||||
val state: State<UsernameLinkSettingsState> = _state
|
||||
|
||||
private val disposable: CompositeDisposable = CompositeDisposable()
|
||||
private val usernameLink: BehaviorSubject<Optional<UsernameLinkComponents>> = BehaviorSubject.createDefault(Optional.ofNullable(SignalStore.account().usernameLink))
|
||||
private val usernameRepo: UsernameRepository = UsernameRepository()
|
||||
|
||||
init {
|
||||
disposable += username
|
||||
disposable += usernameLink
|
||||
.observeOn(Schedulers.io())
|
||||
.map { UsernameUtil.generateLink(it) }
|
||||
.map { link -> link.map { UsernameUtil.generateLink(it) } }
|
||||
.flatMapSingle { generateQrCodeData(it) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { qrData ->
|
||||
_state.value = _state.value.copy(
|
||||
qrCodeData = qrData
|
||||
qrCodeState = if (qrData.isPresent) QrCodeState.Present(qrData.get()) else QrCodeState.NotSet
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -70,37 +70,70 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
|||
)
|
||||
}
|
||||
|
||||
fun onUsernameLinkReset() {
|
||||
if (!NetworkUtil.isConnected(ApplicationDependencies.getApplication())) {
|
||||
_state.value = _state.value.copy(
|
||||
usernameLinkResetResult = UsernameLinkResetResult.NetworkUnavailable
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val currentValue = _state.value
|
||||
val previousQrValue: QrCodeData? = if (currentValue.qrCodeState is QrCodeState.Present) {
|
||||
currentValue.qrCodeState.data
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
usernameLinkState = UsernameLinkState.Resetting,
|
||||
qrCodeState = QrCodeState.Loading
|
||||
)
|
||||
|
||||
disposable += usernameRepo.createOrResetUsernameLink()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { result ->
|
||||
val components: Optional<UsernameLinkComponents> = when (result) {
|
||||
is UsernameLinkResetResult.Success -> Optional.of(result.components)
|
||||
is UsernameLinkResetResult.NetworkError -> Optional.empty()
|
||||
else -> { usernameLink.value ?: Optional.empty() }
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
usernameLinkState = if (components.isPresent) {
|
||||
val link = UsernameUtil.generateLink(components.get())
|
||||
UsernameLinkState.Present(link)
|
||||
} else {
|
||||
UsernameLinkState.NotSet
|
||||
},
|
||||
usernameLinkResetResult = result,
|
||||
qrCodeState = if (components.isPresent && previousQrValue != null) {
|
||||
QrCodeState.Present(previousQrValue)
|
||||
} else {
|
||||
QrCodeState.NotSet
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onUsernameLinkResetResultHandled() {
|
||||
_state.value = _state.value.copy(
|
||||
usernameLinkResetResult = null
|
||||
)
|
||||
}
|
||||
|
||||
fun onQrCodeScanned(url: String) {
|
||||
_state.value = _state.value.copy(
|
||||
indeterminateProgress = true
|
||||
)
|
||||
|
||||
disposable += Single
|
||||
.fromCallable {
|
||||
val username: String? = UsernameUtil.parseLink(url)
|
||||
|
||||
if (username == null) {
|
||||
Log.w(TAG, "Failed to parse username from url")
|
||||
return@fromCallable QrScanResult.InvalidData
|
||||
}
|
||||
|
||||
return@fromCallable try {
|
||||
val hashed: String = UsernameUtil.hashUsernameToBase64(username)
|
||||
val aci: ACI = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsernameHash(hashed)
|
||||
QrScanResult.Success(Recipient.externalUsername(aci, username))
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "Invalid username", e)
|
||||
QrScanResult.InvalidData
|
||||
} catch (e: NonSuccessfulResponseCodeException) {
|
||||
Log.w(TAG, "Non-successful response during username resolution", e)
|
||||
if (e.code == 404) {
|
||||
QrScanResult.NotFound(username)
|
||||
} else {
|
||||
QrScanResult.NetworkError
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Network error during username resolution", e)
|
||||
QrScanResult.NetworkError
|
||||
disposable += usernameRepo.convertLinkToUsernameAndAci(url)
|
||||
.map { result ->
|
||||
when (result) {
|
||||
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString()))
|
||||
is UsernameRepository.UsernameLinkConversionResult.Invalid -> QrScanResult.InvalidData
|
||||
is UsernameRepository.UsernameLinkConversionResult.NotFound -> QrScanResult.NotFound(result.username?.toString())
|
||||
is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.NetworkError
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
|
@ -119,9 +152,9 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
|||
)
|
||||
}
|
||||
|
||||
private fun generateQrCodeData(url: String): Single<QrCodeData> {
|
||||
private fun generateQrCodeData(url: Optional<String>): Single<Optional<QrCodeData>> {
|
||||
return Single.fromCallable {
|
||||
QrCodeData.forData(url, 64)
|
||||
url.map { QrCodeData.forData(it, 64) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
|
@ -19,6 +22,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
|
@ -31,14 +35,15 @@ import androidx.navigation.NavController
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.compose.ScreenshotController
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
|
@ -48,22 +53,43 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
|||
@Composable
|
||||
fun UsernameLinkShareScreen(
|
||||
state: UsernameLinkSettingsState,
|
||||
onLinkResultHandled: () -> Unit,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
scope: CoroutineScope,
|
||||
navController: NavController,
|
||||
onShareBadge: (Bitmap) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
screenshotController: ScreenshotController? = null
|
||||
screenshotController: ScreenshotController? = null,
|
||||
onResetClicked: () -> Unit
|
||||
) {
|
||||
when (state.usernameLinkResetResult) {
|
||||
UsernameLinkResetResult.NetworkUnavailable -> {
|
||||
ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_unavailable), onDismiss = onLinkResultHandled)
|
||||
}
|
||||
UsernameLinkResetResult.NetworkError -> {
|
||||
ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_error), onDismiss = onLinkResultHandled)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
val usernameCopiedString = stringResource(id = R.string.UsernameLinkSettings_username_copied_toast)
|
||||
QrCodeBadge(
|
||||
data = state.qrCodeData,
|
||||
data = state.qrCodeState,
|
||||
colorScheme = state.qrCodeColorScheme,
|
||||
username = state.username,
|
||||
screenshotController = screenshotController
|
||||
screenshotController = screenshotController,
|
||||
usernameCopyable = true,
|
||||
modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(usernameCopiedString)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ButtonBar(
|
||||
|
@ -76,16 +102,8 @@ fun UsernameLinkShareScreen(
|
|||
onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) }
|
||||
)
|
||||
|
||||
CopyRow(
|
||||
displayText = state.username,
|
||||
copyMessage = stringResource(R.string.UsernameLinkSettings_username_copied_toast),
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope
|
||||
)
|
||||
|
||||
CopyRow(
|
||||
displayText = state.usernameLink,
|
||||
copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast),
|
||||
LinkRow(
|
||||
linkState = state.usernameLinkState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope
|
||||
)
|
||||
|
@ -94,7 +112,7 @@ fun UsernameLinkShareScreen(
|
|||
text = stringResource(id = R.string.UsernameLinkSettings_qr_description),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp),
|
||||
modifier = Modifier.padding(bottom = 19.dp, start = 43.dp, end = 43.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
|
@ -104,7 +122,7 @@ fun UsernameLinkShareScreen(
|
|||
.padding(bottom = 24.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Buttons.Small(onClick = { /*TODO*/ }) {
|
||||
Buttons.Small(onClick = onResetClicked) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.UsernameLinkSettings_reset_button_label)
|
||||
)
|
||||
|
@ -133,29 +151,46 @@ private fun ButtonBar(onShareClicked: () -> Unit, onColorClicked: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
|
||||
private fun LinkRow(linkState: UsernameLinkState, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
|
||||
val context = LocalContext.current
|
||||
val copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = MaterialTheme.colorScheme.background)
|
||||
.clickable {
|
||||
Util.copyToClipboard(context, displayText)
|
||||
.padding(
|
||||
top = 32.dp,
|
||||
bottom = 24.dp,
|
||||
start = 24.dp,
|
||||
end = 24.dp
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.clickable(enabled = linkState is UsernameLinkState.Present) {
|
||||
Util.copyToClipboard(context, (linkState as UsernameLinkState.Present).link)
|
||||
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(copyMessage)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 26.dp, vertical = 16.dp)
|
||||
.alpha(if (linkState is UsernameLinkState.Present) 1.0f else 0.6f)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.symbol_copy_android_24),
|
||||
painter = painterResource(id = R.drawable.symbol_link_24),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = displayText,
|
||||
text = when (linkState) {
|
||||
is UsernameLinkState.Present -> linkState.link
|
||||
is UsernameLinkState.NotSet -> stringResource(id = R.string.UsernameLinkSettings_link_not_set_label)
|
||||
is UsernameLinkState.Resetting -> stringResource(id = R.string.UsernameLinkSettings_resetting_link_label)
|
||||
},
|
||||
modifier = Modifier.padding(start = 26.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
|
@ -163,45 +198,68 @@ private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState:
|
|||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme")
|
||||
@Composable
|
||||
private fun ScreenPreviewLightTheme() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
private fun ResetLinkResultDialog(message: String, onDismiss: () -> Unit) {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
dismiss = stringResource(id = android.R.string.ok),
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun ScreenPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
UsernameLinkShareScreen(
|
||||
state = previewState(),
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope(),
|
||||
navController = NavController(LocalContext.current),
|
||||
onShareBadge = {}
|
||||
onShareBadge = {},
|
||||
onResetClicked = {},
|
||||
onLinkResultHandled = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Dark Theme")
|
||||
@Preview(name = "Light Theme", group = "LinkRow", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "LinkRow", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun ScreenPreviewDarkTheme() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
private fun LinkRowPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
UsernameLinkShareScreen(
|
||||
state = previewState(),
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope(),
|
||||
navController = NavController(LocalContext.current),
|
||||
onShareBadge = {}
|
||||
)
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
LinkRow(
|
||||
linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope()
|
||||
)
|
||||
LinkRow(
|
||||
linkState = UsernameLinkState.NotSet,
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope()
|
||||
)
|
||||
LinkRow(
|
||||
linkState = UsernameLinkState.Resetting,
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun previewState(): UsernameLinkSettingsState {
|
||||
val link = UsernameUtil.generateLink("maya.45")
|
||||
val link = "https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"
|
||||
return UsernameLinkSettingsState(
|
||||
activeTab = ActiveTab.Code,
|
||||
username = "maya.45",
|
||||
usernameLink = link,
|
||||
qrCodeData = QrCodeData.forData(link, 64),
|
||||
username = "parker.42",
|
||||
usernameLinkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
|
||||
qrCodeState = QrCodeState.Present(QrCodeData.forData(link, 64)),
|
||||
qrCodeColorScheme = UsernameQrCodeColorScheme.Blue
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -32,6 +32,7 @@ import org.signal.qr.QrScannerView
|
|||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* A screen that allows you to scan a QR code to start a chat.
|
||||
|
@ -53,7 +54,11 @@ fun UsernameQrScanScreen(
|
|||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled)
|
||||
}
|
||||
is QrScanResult.NotFound -> {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled)
|
||||
if (qrScanResult.username != null) {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled)
|
||||
} else {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found_no_username), onDismiss = onQrResultHandled)
|
||||
}
|
||||
}
|
||||
is QrScanResult.Success -> {
|
||||
CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null)
|
||||
|
@ -70,7 +75,7 @@ fun UsernameQrScanScreen(
|
|||
AndroidView(
|
||||
factory = { context ->
|
||||
val view = QrScannerView(context)
|
||||
disposables += view.qrData.distinctUntilChanged().subscribe { data ->
|
||||
disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data ->
|
||||
onQrCodeScanned(data)
|
||||
}
|
||||
view
|
||||
|
|
|
@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.migrations.BackupNotificationMigrationJob;
|
|||
import org.thoughtcrime.securesms.migrations.BlobStorageLocationMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.ClearGlideCacheMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.CopyUsernameToSignalStoreMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.DatabaseMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.DeleteDeprecatedLogsMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.DirectoryRefreshMigrationJob;
|
||||
|
@ -231,6 +232,7 @@ public final class JobManagerFactories {
|
|||
put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory());
|
||||
put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory());
|
||||
put(ClearGlideCacheMigrationJob.KEY, new ClearGlideCacheMigrationJob.Factory());
|
||||
put(CopyUsernameToSignalStoreMigrationJob.KEY, new CopyUsernameToSignalStoreMigrationJob.Factory());
|
||||
put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory());
|
||||
put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory());
|
||||
put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory());
|
||||
|
|
|
@ -301,7 +301,8 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||
.confirmUsername(localUsername, response);
|
||||
} catch (IOException e) {
|
||||
Log.d(TAG, "Failed to synchronize username.", e);
|
||||
SignalStore.phoneNumberPrivacy().markUsernameOutOfSync();
|
||||
// TODO [greyson][usernames] Is this actually enough to trigger it? Shouldn't we wait until we know for sure, rather than have a network error?
|
||||
SignalStore.account().setUsernameOutOfSync(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,9 @@ import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
|||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceIds
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import java.security.SecureRandom
|
||||
|
||||
internal class AccountValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
|
@ -64,6 +67,11 @@ internal class AccountValues internal constructor(store: KeyValueStore) : Signal
|
|||
private const val KEY_PNI_LAST_RESORT_KYBER_PREKEY_ID = "account.pni_last_resort_kyber_prekey_id"
|
||||
private const val KEY_PNI_LAST_RESORT_KYBER_PREKEY_ROTATION_TIME = "account.pni_last_resort_kyber_prekey_rotation_time"
|
||||
|
||||
private const val KEY_USERNAME = "account.username"
|
||||
private const val KEY_USERNAME_LINK_ENTROPY = "account.username_link_entropy"
|
||||
private const val KEY_USERNAME_LINK_SERVER_ID = "account.username_link_server_id"
|
||||
private const val KEY_USERNAME_OUT_OF_SYNC = "phoneNumberPrivacy.usernameOutOfSync"
|
||||
|
||||
@VisibleForTesting
|
||||
const val KEY_E164 = "account.e164"
|
||||
|
||||
|
@ -100,7 +108,9 @@ internal class AccountValues internal constructor(store: KeyValueStore) : Signal
|
|||
KEY_ACI_IDENTITY_PUBLIC_KEY,
|
||||
KEY_ACI_IDENTITY_PRIVATE_KEY,
|
||||
KEY_PNI_IDENTITY_PUBLIC_KEY,
|
||||
KEY_PNI_IDENTITY_PRIVATE_KEY
|
||||
KEY_PNI_IDENTITY_PRIVATE_KEY,
|
||||
KEY_USERNAME,
|
||||
KEY_USERNAME_LINK_SERVER_ID
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -351,6 +361,36 @@ internal class AccountValues internal constructor(store: KeyValueStore) : Signal
|
|||
val isLinkedDevice: Boolean
|
||||
get() = !isPrimaryDevice
|
||||
|
||||
/** The local user's full username (nickname.discriminator), if set. */
|
||||
var username: String? by stringValue(KEY_USERNAME, null)
|
||||
|
||||
/** The local user's username link components, if set. */
|
||||
var usernameLink: UsernameLinkComponents?
|
||||
get() {
|
||||
val entropy: ByteArray? = getBlob(KEY_USERNAME_LINK_ENTROPY, null)
|
||||
val serverId: ByteArray? = getBlob(KEY_USERNAME_LINK_SERVER_ID, null)
|
||||
|
||||
return if (entropy != null && serverId != null) {
|
||||
val serverIdUuid = UuidUtil.parseOrThrow(serverId)
|
||||
UsernameLinkComponents(entropy, serverIdUuid)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
set(value) {
|
||||
store
|
||||
.beginWrite()
|
||||
.putBlob(KEY_USERNAME_LINK_ENTROPY, value?.entropy)
|
||||
.putBlob(KEY_USERNAME_LINK_SERVER_ID, value?.serverId?.toByteArray())
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* There are some cases where our username may fall out of sync with the service. In particular, we may get a new value for our username from
|
||||
* storage service but then find that it doesn't match what's on the service.
|
||||
*/
|
||||
var usernameOutOfSync: Boolean by booleanValue(KEY_USERNAME_OUT_OF_SYNC, false)
|
||||
|
||||
private fun clearLocalCredentials() {
|
||||
putString(KEY_SERVICE_PASSWORD, Util.getSecret(18))
|
||||
|
||||
|
|
|
@ -11,10 +11,9 @@ import java.util.List;
|
|||
|
||||
public final class PhoneNumberPrivacyValues extends SignalStoreValues {
|
||||
|
||||
public static final String SHARING_MODE = "phoneNumberPrivacy.sharingMode";
|
||||
public static final String LISTING_MODE = "phoneNumberPrivacy.listingMode";
|
||||
public static final String LISTING_TIMESTAMP = "phoneNumberPrivacy.listingMode.timestamp";
|
||||
public static final String USERNAME_OUT_OF_SYNC = "phoneNumberPrivacy.usernameOutOfSync";
|
||||
public static final String SHARING_MODE = "phoneNumberPrivacy.sharingMode";
|
||||
public static final String LISTING_MODE = "phoneNumberPrivacy.listingMode";
|
||||
public static final String LISTING_TIMESTAMP = "phoneNumberPrivacy.listingMode.timestamp";
|
||||
|
||||
private static final Collection<CertificateType> REGULAR_CERTIFICATE = Collections.singletonList(CertificateType.UUID_AND_E164);
|
||||
private static final Collection<CertificateType> PRIVACY_CERTIFICATE = Collections.singletonList(CertificateType.UUID_ONLY);
|
||||
|
@ -69,18 +68,6 @@ public final class PhoneNumberPrivacyValues extends SignalStoreValues {
|
|||
return getLong(LISTING_TIMESTAMP, 0);
|
||||
}
|
||||
|
||||
public void markUsernameOutOfSync() {
|
||||
putBoolean(USERNAME_OUT_OF_SYNC, true);
|
||||
}
|
||||
|
||||
public void clearUsernameOutOfSync() {
|
||||
putBoolean(USERNAME_OUT_OF_SYNC, false);
|
||||
}
|
||||
|
||||
public boolean isUsernameOutOfSync() {
|
||||
return getBoolean(USERNAME_OUT_OF_SYNC, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* If you respect {@link #getPhoneNumberSharingMode}, then you will only ever need to fetch and store
|
||||
* these certificates types.
|
||||
|
|
|
@ -136,9 +136,10 @@ public class ApplicationMigrations {
|
|||
static final int ATTACHMENT_CLEANUP_3 = 92;
|
||||
static final int EMOJI_SEARCH_INDEX_CHECK = 93;
|
||||
static final int IDENTITY_FIX = 94;
|
||||
static final int COPY_USERNAME_TO_SIGNAL_STORE = 95;
|
||||
}
|
||||
|
||||
public static final int CURRENT_VERSION = 94;
|
||||
public static final int CURRENT_VERSION = 95;
|
||||
|
||||
/**
|
||||
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
|
||||
|
@ -617,6 +618,10 @@ public class ApplicationMigrations {
|
|||
jobs.put(Version.IDENTITY_FIX, new IdentityTableCleanupMigrationJob());
|
||||
}
|
||||
|
||||
if (lastSeenVersion < Version.COPY_USERNAME_TO_SIGNAL_STORE) {
|
||||
jobs.put(Version.COPY_USERNAME_TO_SIGNAL_STORE, new CopyUsernameToSignalStoreMigrationJob());
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,8 +24,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
|||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.thoughtcrime.securesms.AvatarPreviewActivity;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
@ -47,7 +45,6 @@ import org.thoughtcrime.securesms.util.UsernameUtil;
|
|||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
import org.whispersystems.util.Base64UrlSafe;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
@ -247,7 +244,6 @@ public class ManageProfileFragment extends LoggingFragment {
|
|||
binding.manageProfileUsernameShare.setVisibility(View.GONE);
|
||||
} else {
|
||||
binding.manageProfileUsername.setText(username);
|
||||
binding.manageProfileUsernameSubtitle.setText(UsernameUtil.generateLink(username));
|
||||
binding.manageProfileUsernameShare.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
@ -318,7 +314,7 @@ public class ManageProfileFragment extends LoggingFragment {
|
|||
disposables.add(disposable);
|
||||
}
|
||||
|
||||
private void handleUsernameDeletionResult(@NonNull UsernameEditRepository.UsernameDeleteResult usernameDeleteResult) {
|
||||
private void handleUsernameDeletionResult(@NonNull UsernameRepository.UsernameDeleteResult usernameDeleteResult) {
|
||||
switch (usernameDeleteResult) {
|
||||
case SUCCESS:
|
||||
Snackbar.make(requireView(), R.string.ManageProfileFragment__username_deleted, Snackbar.LENGTH_SHORT).show();
|
||||
|
|
|
@ -50,7 +50,7 @@ class ManageProfileViewModel extends ViewModel {
|
|||
private final SingleLiveEvent<Event> events;
|
||||
private final RecipientForeverObserver observer;
|
||||
private final ManageProfileRepository repository;
|
||||
private final UsernameEditRepository usernameEditRepository;
|
||||
private final UsernameRepository usernameEditRepository;
|
||||
private final MutableLiveData<Optional<Badge>> badge;
|
||||
|
||||
private byte[] previousAvatar;
|
||||
|
@ -63,7 +63,7 @@ class ManageProfileViewModel extends ViewModel {
|
|||
this.aboutEmoji = new MutableLiveData<>();
|
||||
this.events = new SingleLiveEvent<>();
|
||||
this.repository = new ManageProfileRepository();
|
||||
this.usernameEditRepository = new UsernameEditRepository();
|
||||
this.usernameEditRepository = new UsernameRepository();
|
||||
this.badge = new DefaultValueLiveData<>(Optional.empty());
|
||||
this.observer = this::onRecipientChanged;
|
||||
this.avatarState = LiveDataUtil.combineLatest(Recipient.self().live().getLiveData(), internalAvatarState, (self, state) -> new AvatarState(state, self));
|
||||
|
@ -104,7 +104,7 @@ class ManageProfileViewModel extends ViewModel {
|
|||
return events;
|
||||
}
|
||||
|
||||
public Single<UsernameEditRepository.UsernameDeleteResult> deleteUsername() {
|
||||
public Single<UsernameRepository.UsernameDeleteResult> deleteUsername() {
|
||||
return usernameEditRepository.deleteUsername().observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -40,15 +40,15 @@ class UsernameEditViewModel extends ViewModel {
|
|||
|
||||
private static final long NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS = 500;
|
||||
|
||||
private final PublishSubject<Event> events;
|
||||
private final UsernameEditRepository repo;
|
||||
private final RxStore<State> uiState;
|
||||
private final PublishSubject<Event> events;
|
||||
private final UsernameRepository repo;
|
||||
private final RxStore<State> uiState;
|
||||
private final PublishProcessor<String> nicknamePublisher;
|
||||
private final CompositeDisposable disposables;
|
||||
private final boolean isInRegistration;
|
||||
|
||||
private UsernameEditViewModel(boolean isInRegistration) {
|
||||
this.repo = new UsernameEditRepository();
|
||||
this.repo = new UsernameRepository();
|
||||
this.uiState = new RxStore<>(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, Recipient.self().getUsername().<UsernameState>map(UsernameState.Set::new)
|
||||
.orElse(UsernameState.NoUsername.INSTANCE)), Schedulers.computation());
|
||||
this.events = PublishSubject.create();
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -17,7 +17,7 @@ sealed class UsernameState {
|
|||
object NoUsername : UsernameState()
|
||||
|
||||
data class Reserved(
|
||||
override val username: String,
|
||||
public override val username: String,
|
||||
val reserveUsernameResponse: ReserveUsernameResponse
|
||||
) : UsernameState()
|
||||
|
||||
|
|
|
@ -128,8 +128,9 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
|||
boolean hasReadOnboardingStory = remote.hasReadOnboardingStory() || remote.hasViewedOnboardingStory() || local.hasReadOnboardingStory() || local.hasViewedOnboardingStory() ;
|
||||
boolean hasSeenGroupStoryEducation = remote.hasSeenGroupStoryEducationSheet() || local.hasSeenGroupStoryEducationSheet();
|
||||
String username = !StringUtil.isEmpty(remote.getUsername()) ? remote.getUsername() : local.getUsername();
|
||||
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory, username);
|
||||
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory, username);
|
||||
AccountRecord.UsernameLink usernameLink = remote.getUsernameLink() != null ? remote.getUsernameLink() : local.getUsernameLink();
|
||||
boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory, username, usernameLink);
|
||||
boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsState, hasReadOnboardingStory, username, usernameLink);
|
||||
|
||||
if (matchesRemote) {
|
||||
return remote;
|
||||
|
@ -165,7 +166,8 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
|||
.setStoriesDisabled(storiesDisabled)
|
||||
.setHasReadOnboardingStory(hasReadOnboardingStory)
|
||||
.setHasSeenGroupStoryEducationSheet(hasSeenGroupStoryEducation)
|
||||
.setUsername(username);
|
||||
.setUsername(username)
|
||||
.setUsernameLink(usernameLink);
|
||||
|
||||
if (!FeatureFlags.phoneNumberPrivacy() || !self.getPnpCapability().isSupported()) {
|
||||
builder.setE164(e164);
|
||||
|
@ -220,7 +222,8 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
|||
boolean storiesDisabled,
|
||||
@NonNull OptionalBool storyViewReceiptsState,
|
||||
boolean hasReadOnboardingStory,
|
||||
@Nullable String username)
|
||||
@Nullable String username,
|
||||
@Nullable AccountRecord.UsernameLink usernameLink)
|
||||
{
|
||||
return Arrays.equals(contact.serializeUnknownFields(), unknownFields) &&
|
||||
Objects.equals(contact.getGivenName().orElse(""), givenName) &&
|
||||
|
@ -251,6 +254,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor<Signal
|
|||
contact.isStoriesDisabled() == storiesDisabled &&
|
||||
contact.getStoryViewReceiptsState().equals(storyViewReceiptsState) &&
|
||||
contact.hasReadOnboardingStory() == hasReadOnboardingStory &&
|
||||
Objects.equals(contact.getUsername(), username);
|
||||
Objects.equals(contact.getUsername(), username) &&
|
||||
Objects.equals(contact.getUsernameLink(), usernameLink);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,9 +8,11 @@ import androidx.annotation.VisibleForTesting;
|
|||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.signal.core.util.SetUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.RecipientRecord;
|
||||
|
@ -26,12 +28,16 @@ import org.thoughtcrime.securesms.util.Base64;
|
|||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
|
||||
import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
|
||||
import org.whispersystems.signalservice.api.storage.SignalRecord;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
|
||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.OptionalBool;
|
||||
|
||||
import java.util.Collection;
|
||||
|
@ -159,6 +165,17 @@ public final class StorageSyncHelper {
|
|||
account.setE164(self.requireE164());
|
||||
}
|
||||
|
||||
UsernameLinkComponents linkComponents = SignalStore.account().getUsernameLink();
|
||||
if (linkComponents != null) {
|
||||
account.setUsernameLink(AccountRecord.UsernameLink.newBuilder()
|
||||
.setEntropy(ByteString.copyFrom(linkComponents.getEntropy()))
|
||||
.setServerId(UuidUtil.toByteString(linkComponents.getServerId()))
|
||||
.setColor(StorageSyncModels.localToRemoteUsernameColor(SignalStore.misc().getUsernameQrCodeColorScheme()))
|
||||
.build());
|
||||
} else {
|
||||
account.setUsernameLink(null);
|
||||
}
|
||||
|
||||
return SignalStorageRecord.forAccount(account.build());
|
||||
}
|
||||
|
||||
|
@ -214,6 +231,16 @@ public final class StorageSyncHelper {
|
|||
if (fetchProfile && update.getNew().getAvatarUrlPath().isPresent()) {
|
||||
ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(self, update.getNew().getAvatarUrlPath().get()));
|
||||
}
|
||||
|
||||
if (update.getNew().getUsernameLink() != null) {
|
||||
SignalStore.account().setUsernameLink(
|
||||
new UsernameLinkComponents(
|
||||
update.getNew().getUsernameLink().getEntropy().toByteArray(),
|
||||
UuidUtil.parseOrThrow(update.getNew().getUsernameLink().getServerId().toByteArray())
|
||||
)
|
||||
);
|
||||
SignalStore.misc().setUsernameQrCodeColorScheme(StorageSyncModels.remoteToLocalUsernameColor(update.getNew().getUsernameLink().getColor()));
|
||||
}
|
||||
}
|
||||
|
||||
public static void scheduleSyncForDataChange() {
|
||||
|
|
|
@ -6,6 +6,7 @@ import androidx.annotation.Nullable;
|
|||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme;
|
||||
import org.thoughtcrime.securesms.database.GroupTable;
|
||||
import org.thoughtcrime.securesms.database.IdentityTable;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
|
@ -100,6 +101,34 @@ public final class StorageSyncModels {
|
|||
}
|
||||
}
|
||||
|
||||
public static @NonNull AccountRecord.UsernameLink.Color localToRemoteUsernameColor(UsernameQrCodeColorScheme local) {
|
||||
switch (local) {
|
||||
case Blue: return AccountRecord.UsernameLink.Color.BLUE;
|
||||
case White: return AccountRecord.UsernameLink.Color.WHITE;
|
||||
case Grey: return AccountRecord.UsernameLink.Color.GREY;
|
||||
case Tan: return AccountRecord.UsernameLink.Color.OLIVE;
|
||||
case Green: return AccountRecord.UsernameLink.Color.GREEN;
|
||||
case Orange: return AccountRecord.UsernameLink.Color.ORANGE;
|
||||
case Pink: return AccountRecord.UsernameLink.Color.PINK;
|
||||
case Purple: return AccountRecord.UsernameLink.Color.PURPLE;
|
||||
default: return AccountRecord.UsernameLink.Color.BLUE;
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull UsernameQrCodeColorScheme remoteToLocalUsernameColor(AccountRecord.UsernameLink.Color remote) {
|
||||
switch (remote) {
|
||||
case BLUE: return UsernameQrCodeColorScheme.Blue;
|
||||
case WHITE: return UsernameQrCodeColorScheme.White;
|
||||
case GREY: return UsernameQrCodeColorScheme.Grey;
|
||||
case OLIVE: return UsernameQrCodeColorScheme.Tan;
|
||||
case GREEN: return UsernameQrCodeColorScheme.Green;
|
||||
case ORANGE: return UsernameQrCodeColorScheme.Orange;
|
||||
case PINK: return UsernameQrCodeColorScheme.Pink;
|
||||
case PURPLE: return UsernameQrCodeColorScheme.Purple;
|
||||
default: return UsernameQrCodeColorScheme.Blue;
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientRecord recipient, byte[] rawStorageId) {
|
||||
if (recipient.getAci() == null && recipient.getE164() == null) {
|
||||
throw new AssertionError("Must have either a UUID or a phone number!");
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -6106,6 +6106,16 @@
|
|||
<string name="UsernameLinkSettings_username_copied_toast">Username copied</string>
|
||||
<!-- Content of a toast that will show after the username link is copied to the clipboard -->
|
||||
<string name="UsernameLinkSettings_link_copied_toast">Link copied</string>
|
||||
<!-- Content of a text field that is shown when the user has not yet set a username link -->
|
||||
<string name="UsernameLinkSettings_link_not_set_label">Link not set</string>
|
||||
<!-- Content of a text field that is shown when the user is actively resetting the username link and waiting for the operation to finish -->
|
||||
<string name="UsernameLinkSettings_resetting_link_label">Resetting link…</string>
|
||||
<!-- Title of a dialog prompting the user to confirm whether they would like to reset their username link and QR code -->
|
||||
<string name="UsernameLinkSettings_reset_link_dialog_title">Reset QR code?</string>
|
||||
<!-- Body of a dialog prompting the user to confirm whether they would like to reset their username link and QR code -->
|
||||
<string name="UsernameLinkSettings_reset_link_dialog_body">If you reset your QR code, your existing QR code and link will no longer work.</string>
|
||||
<!-- Label for the confirmation button on a dialog prompting the user to confirm whether they would like to reset their username link and QR code -->
|
||||
<string name="UsernameLinkSettings_reset_link_dialog_confirm_button">Reset</string>
|
||||
<!-- Button label for a button that will reset your username and give you a new link -->
|
||||
<string name="UsernameLinkSettings_reset_button_label">Reset</string>
|
||||
<!-- Button label for a button that indicates that the user is done changing the current setting -->
|
||||
|
@ -6122,8 +6132,14 @@
|
|||
<string name="UsernameLinkSettings_qr_result_invalid">The QR code was invalid.</string>
|
||||
<!-- Body of a dialog that is displayed when the username we looked up could not be found. -->
|
||||
<string name="UsernameLinkSettings_qr_result_not_found">A user with username %1$s could not be found.</string>
|
||||
<!-- Body of a dialog that is displayed when the username we looked up could not be found and we also could not parse the username. -->
|
||||
<string name="UsernameLinkSettings_qr_result_not_found_no_username">This user could not be found.</string>
|
||||
<!-- Body of a dialog that is displayed when we experienced a network error when looking up a username. -->
|
||||
<string name="UsernameLinkSettings_qr_result_network_error">Experienced a network error. Please try again.</string>
|
||||
<!-- Body of a dialog that is displayed when we failed to reset your username link because you had no internet. -->
|
||||
<string name="UsernameLinkSettings_reset_link_result_network_unavailable">You do not have network access. Your link was not reset. Try again later.</string>
|
||||
<!-- Body of a dialog that is displayed when we failed to reset your username link because of a transient network issue. -->
|
||||
<string name="UsernameLinkSettings_reset_link_result_network_error">A network error occurred while trying to reset your link. Try again later.</string>
|
||||
|
||||
<!-- PendingParticipantsView -->
|
||||
<!-- Displayed in the popup card when a remote user attempts to join a call link -->
|
||||
|
@ -6144,7 +6160,7 @@
|
|||
</plurals>
|
||||
<!-- Content description for rejecting a user -->
|
||||
<string name="PendingParticipantsBottomSheet__reject">Reject</string>
|
||||
<!-- Content desccription for confirming a user -->
|
||||
<!-- Content description for confirming a user -->
|
||||
<string name="PendingParticipantsBottomSheet__approve">Approve</string>
|
||||
|
||||
<!-- EOF -->
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.signal.core.ui.theme
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
@ -9,6 +10,7 @@ import androidx.compose.material3.lightColorScheme
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
@ -168,7 +170,7 @@ private val darkColorScheme = darkColorScheme(
|
|||
|
||||
@Composable
|
||||
fun SignalTheme(
|
||||
isDarkMode: Boolean,
|
||||
isDarkMode: Boolean = LocalContext.current.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val extendedColors = if (isDarkMode) darkExtendedColors else lightExtendedColors
|
||||
|
|
|
@ -14,6 +14,9 @@ import org.signal.libsignal.protocol.InvalidKeyException;
|
|||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.protocol.logging.Log;
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.signal.libsignal.usernames.Username.UsernameLink;
|
||||
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
|
@ -37,6 +40,7 @@ import org.whispersystems.signalservice.api.push.ServiceId.ACI;
|
|||
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NoContentException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
|
@ -81,6 +85,7 @@ import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider;
|
|||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper;
|
||||
import org.whispersystems.util.Base64;
|
||||
import org.whispersystems.util.Base64UrlSafe;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyStore;
|
||||
|
@ -96,6 +101,7 @@ import java.util.Locale;
|
|||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
@ -786,6 +792,25 @@ public class SignalServiceAccountManager {
|
|||
this.pushServiceSocket.deleteUsername();
|
||||
}
|
||||
|
||||
public UsernameLinkComponents createUsernameLink(Username username) throws IOException {
|
||||
try {
|
||||
UsernameLink link = username.generateLink();
|
||||
UUID serverId = this.pushServiceSocket.createUsernameLink(Base64UrlSafe.encodeBytes(link.getEncryptedUsername()));
|
||||
|
||||
return new UsernameLinkComponents(link.getEntropy(), serverId);
|
||||
} catch (BaseUsernameException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteUsernameLink() throws IOException {
|
||||
this.pushServiceSocket.deleteUsernameLink();
|
||||
}
|
||||
|
||||
public byte[] getEncryptedUsernameFromLinkServerId(UUID serverId) throws IOException {
|
||||
return this.pushServiceSocket.getEncryptedUsernameFromLinkServerId(serverId);
|
||||
}
|
||||
|
||||
public void deleteAccount() throws IOException {
|
||||
this.pushServiceSocket.deleteAccount();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -335,6 +335,10 @@ public final class SignalAccountRecord implements SignalRecord {
|
|||
return proto.getUsername();
|
||||
}
|
||||
|
||||
public @Nullable AccountRecord.UsernameLink getUsernameLink() {
|
||||
return proto.getUsernameLink();
|
||||
}
|
||||
|
||||
public AccountRecord toProto() {
|
||||
return proto;
|
||||
}
|
||||
|
@ -717,6 +721,16 @@ public final class SignalAccountRecord implements SignalRecord {
|
|||
return this;
|
||||
}
|
||||
|
||||
public Builder setUsernameLink(@Nullable AccountRecord.UsernameLink link) {
|
||||
if (link == null) {
|
||||
builder.clearUsernameLink();
|
||||
} else {
|
||||
builder.setUsernameLink(link);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private static AccountRecord.Builder parseUnknowns(byte[] serializedUnknowns) {
|
||||
try {
|
||||
return AccountRecord.parseFrom(serializedUnknowns).toBuilder();
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -216,6 +216,8 @@ public class PushServiceSocket {
|
|||
private static final String MODIFY_USERNAME_PATH = "/v1/accounts/username_hash";
|
||||
private static final String RESERVE_USERNAME_PATH = "/v1/accounts/username_hash/reserve";
|
||||
private static final String CONFIRM_USERNAME_PATH = "/v1/accounts/username_hash/confirm";
|
||||
private static final String USERNAME_LINK_PATH = "/v1/accounts/username_link";
|
||||
private static final String USERNAME_FROM_LINK_PATH = "/v1/accounts/username_link/%s";
|
||||
private static final String DELETE_ACCOUNT_PATH = "/v1/accounts/me";
|
||||
private static final String CHANGE_NUMBER_PATH = "/v2/accounts/number";
|
||||
private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s";
|
||||
|
@ -1090,6 +1092,31 @@ public class PushServiceSocket {
|
|||
makeServiceRequest(MODIFY_USERNAME_PATH, "DELETE", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new username link for a given username.
|
||||
* @param encryptedUsername URL-safe base64-encoded encrypted username
|
||||
* @return The serverId for the generated link.
|
||||
*/
|
||||
public UUID createUsernameLink(String encryptedUsername) throws IOException {
|
||||
String response = makeServiceRequest(USERNAME_LINK_PATH, "PUT", JsonUtil.toJson(new SetUsernameLinkRequestBody(encryptedUsername)));
|
||||
SetUsernameLinkResponseBody parsed = JsonUtil.fromJson(response, SetUsernameLinkResponseBody.class);
|
||||
|
||||
return parsed.getUsernameLinkHandle();
|
||||
}
|
||||
|
||||
/** Deletes your active username link. */
|
||||
public void deleteUsernameLink() throws IOException {
|
||||
makeServiceRequest(USERNAME_LINK_PATH, "DELETE", null);
|
||||
}
|
||||
|
||||
/** Given a link serverId (see {@link #createUsernameLink(String)}), this will return the encrypted username associate with the link. */
|
||||
public byte[] getEncryptedUsernameFromLinkServerId(UUID serverId) throws IOException {
|
||||
String response = makeServiceRequestWithoutAuthentication(String.format(USERNAME_FROM_LINK_PATH, serverId.toString()), "GET", null);
|
||||
GetUsernameFromLinkResponseBody parsed = JsonUtil.fromJson(response, GetUsernameFromLinkResponseBody.class);
|
||||
|
||||
return Base64UrlSafe.decodePaddingAgnostic(parsed.getUsernameLinkEncryptedValue());
|
||||
}
|
||||
|
||||
public void deleteAccount() throws IOException {
|
||||
makeServiceRequest(DELETE_ACCOUNT_PATH, "DELETE", null);
|
||||
}
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
)
|
|
@ -156,6 +156,24 @@ message AccountRecord {
|
|||
}
|
||||
}
|
||||
|
||||
message UsernameLink {
|
||||
enum Color {
|
||||
UNKNOWN = 0;
|
||||
BLUE = 1;
|
||||
WHITE = 2;
|
||||
GREY = 3;
|
||||
OLIVE = 4;
|
||||
GREEN = 5;
|
||||
ORANGE = 6;
|
||||
PINK = 7;
|
||||
PURPLE = 8;
|
||||
}
|
||||
|
||||
bytes entropy = 1; // 32 bytes of entropy used for encryption
|
||||
bytes serverId = 2; // 16 bytes of encoded UUID provided by the server
|
||||
Color color = 3;
|
||||
}
|
||||
|
||||
bytes profileKey = 1;
|
||||
string givenName = 2;
|
||||
string familyName = 3;
|
||||
|
@ -189,6 +207,8 @@ message AccountRecord {
|
|||
bool hasReadOnboardingStory = 31;
|
||||
bool hasSeenGroupStoryEducationSheet = 32;
|
||||
string username = 33;
|
||||
bool hasCompletedUsernameOnboarding = 34;
|
||||
UsernameLink usernameLink = 35;
|
||||
}
|
||||
|
||||
message StoryDistributionListRecord {
|
||||
|
|
Loading…
Add table
Reference in a new issue