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