diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java b/app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java index 1bcd9ebab84..abd9492e204 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java @@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.SquareImageView; -import org.thoughtcrime.securesms.qr.QrCode; +import org.thoughtcrime.securesms.qr.QrCodeUtil; /** * Generates a bitmap asynchronously for the supplied {@link BitMatrix} data and displays it. @@ -59,7 +59,7 @@ private void init(@Nullable AttributeSet attrs) { } public void setQrText(@Nullable String text) { - setQrBitmap(QrCode.create(text, foregroundColor, backgroundColor)); + setQrBitmap(QrCodeUtil.create(text, foregroundColor, backgroundColor)); } private void setQrBitmap(@Nullable Bitmap qrBitmap) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index df8ab77cfc9..5ed18119b30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -47,9 +47,19 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men private fun getConfiguration(state: AppSettingsState): DSLConfiguration { return configure { customPref( - BioPreference(state.self) { - findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity) - } + BioPreference( + recipient = state.self, + onRowClicked = { + findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity) + }, + onQrButtonClicked = { + if (Recipient.self().getUsername().isPresent()) { + findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment) + } else { + findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment) + } + } + ) ) clickPref( @@ -216,7 +226,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men } } - private class BioPreference(val recipient: Recipient, val onClick: () -> Unit) : PreferenceModel() { + private class BioPreference(val recipient: Recipient, val onRowClicked: () -> Unit, val onQrButtonClicked: () -> Unit) : PreferenceModel() { override fun areContentsTheSame(newItem: BioPreference): Boolean { return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient) } @@ -231,11 +241,12 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon) private val aboutView: TextView = itemView.findViewById(R.id.about) private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge) + private val qrButton: View = itemView.findViewById(R.id.qr_button) override fun bind(model: BioPreference) { super.bind(model) - itemView.setOnClickListener { model.onClick() } + itemView.setOnClickListener { model.onRowClicked() } titleView.text = model.recipient.profileName.toString() summaryView.text = PhoneNumberFormatter.prettyPrint(model.recipient.requireE164()) @@ -246,6 +257,14 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men summaryView.visibility = View.VISIBLE avatarView.visibility = View.VISIBLE + if (FeatureFlags.usernames()) { + qrButton.visibility = View.VISIBLE + qrButton.isClickable = true + qrButton.setOnClickListener { model.onQrButtonClicked() } + } else { + qrButton.visibility = View.GONE + } + if (model.recipient.combinedAboutAndEmoji != null) { aboutView.text = model.recipient.combinedAboutAndEmoji aboutView.visibility = View.VISIBLE diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt new file mode 100644 index 00000000000..2f479d4e2e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt @@ -0,0 +1,165 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.imageResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.R + +/** + * Shows a QRCode that represents the provided data. Includes a Signal logo in the middle. + */ +@Composable +fun QrCode( + data: QrCodeData, + modifier: Modifier = Modifier, + foregroundColor: Color = Color.Black, + backgroundColor: Color = Color.White, + deadzonePercent: Float = 0.4f +) { + val logo = ImageBitmap.imageResource(R.drawable.qrcode_logo) + + Column( + modifier = modifier + .drawBehind { + drawQr( + data = data, + foregroundColor = foregroundColor, + backgroundColor = backgroundColor, + deadzonePercent = deadzonePercent, + logo = logo + ) + } + ) { + } +} + +private fun DrawScope.drawQr( + data: QrCodeData, + foregroundColor: Color, + backgroundColor: Color, + deadzonePercent: Float, + logo: ImageBitmap +) { + // We want an even number of dots on either side of the deadzone + val candidateDeadzoneWidth: Int = (data.width * deadzonePercent).toInt() + val deadzoneWidth: Int = if ((data.width - candidateDeadzoneWidth) % 2 == 0) { + candidateDeadzoneWidth + } else { + candidateDeadzoneWidth + 1 + } + + val candidateDeadzoneHeight: Int = (data.height * deadzonePercent).toInt() + val deadzoneHeight: Int = if ((data.height - candidateDeadzoneHeight) % 2 == 0) { + candidateDeadzoneHeight + } else { + candidateDeadzoneHeight + 1 + } + + val deadzoneStartX: Int = (data.width - deadzoneWidth) / 2 + val deadzoneEndX: Int = deadzoneStartX + deadzoneWidth + val deadzoneStartY: Int = (data.height - deadzoneHeight) / 2 + val deadzoneEndY: Int = deadzoneStartY + deadzoneHeight + + val cellWidthPx: Float = size.width / data.width + val cellRadiusPx = cellWidthPx / 2 + + for (x in 0 until data.width) { + for (y in 0 until data.height) { + if (x < deadzoneStartX || x >= deadzoneEndX || y < deadzoneStartY || y >= deadzoneEndY) { + drawCircle( + color = if (data.get(x, y)) foregroundColor else backgroundColor, + radius = cellRadiusPx, + center = Offset(x * cellWidthPx + cellRadiusPx, y * cellWidthPx + cellRadiusPx) + ) + } + } + } + + // Logo border + val deadzonePaddingPercent = 0.02f + val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2 + drawCircle( + color = foregroundColor, + radius = logoBorderRadiusPx, + style = Stroke(width = cellWidthPx * 0.7f), + center = this.center + ) + + // Logo + val logoWidthPx = ((deadzonePercent / 2) * size.width).toInt() + val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt() + drawImage( + image = logo, + dstOffset = IntOffset(logoOffsetPx, logoOffsetPx), + dstSize = IntSize(logoWidthPx, logoWidthPx), + colorFilter = ColorFilter.tint(foregroundColor) + ) + + for (eye in data.eyes()) { + val strokeWidth = cellWidthPx + + // Clear the already-drawn dots + drawRect( + color = backgroundColor, + topLeft = Offset( + x = eye.position.first * cellWidthPx, + y = eye.position.second * cellWidthPx + ), + size = Size(eye.size * cellWidthPx + cellRadiusPx, eye.size * cellWidthPx) + ) + + // Outer square + drawRoundRect( + color = foregroundColor, + topLeft = Offset( + x = eye.position.first * cellWidthPx + strokeWidth / 2, + y = eye.position.second * cellWidthPx + strokeWidth / 2 + ), + size = Size((eye.size - 1) * cellWidthPx, (eye.size - 1) * cellWidthPx), + cornerRadius = CornerRadius(cellRadiusPx * 2, cellRadiusPx * 2), + style = Stroke(width = strokeWidth) + ) + + // Inner square + drawRoundRect( + color = foregroundColor, + topLeft = Offset( + x = (eye.position.first + 2) * cellWidthPx, + y = (eye.position.second + 2) * cellWidthPx + ), + size = Size((eye.size - 4) * cellWidthPx, (eye.size - 4) * cellWidthPx), + cornerRadius = CornerRadius(cellRadiusPx, cellRadiusPx) + ) + } +} + +@Preview +@Composable +private fun Preview() { + Surface { + QrCode( + data = QrCodeData.forData("https://signal.org", 64), + modifier = Modifier + .width(100.dp) + .height(100.dp), + deadzonePercent = 0.3f + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt new file mode 100644 index 00000000000..48585a5da91 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.core.ui.theme.SignalTheme + +/** + * Renders a QR code and username as a badge. + */ +@Composable +fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier) { + val borderColor by animateColorAsState(targetValue = colorScheme.borderColor) + val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor) + val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f) + val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White) + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 59.dp, vertical = 24.dp), + color = borderColor, + shape = RoundedCornerShape(24.dp), + shadowElevation = elevation.dp + ) { + Column { + Surface( + modifier = Modifier + .padding( + top = 32.dp, + start = 40.dp, + end = 40.dp, + bottom = 16.dp + ) + .aspectRatio(1f) + .fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = Color.White + ) { + if (data != null) { + QrCode( + data = data, + modifier = Modifier.padding(20.dp), + foregroundColor = foregroundColor, + backgroundColor = Color.White + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colorScheme.borderColor, + modifier = Modifier.size(56.dp) + ) + } + } + } + + Text( + text = username, + color = textColor, + fontSize = 20.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.W600, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 40.dp, + end = 40.dp, + bottom = 32.dp + ) + ) + } + } +} + +@Preview +@Composable +private fun PreviewWithCode() { + SignalTheme(isDarkMode = false) { + Surface { + QrCodeBadge( + data = QrCodeData.forData("https://signal.org", 64), + colorScheme = UsernameQrCodeColorScheme.Blue, + username = "parker.42" + ) + } + } +} + +@Preview +@Composable +private fun PreviewWithoutCode() { + SignalTheme(isDarkMode = false) { + Surface { + QrCodeBadge( + data = null, + colorScheme = UsernameQrCodeColorScheme.Blue, + username = "parker.42" + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt new file mode 100644 index 00000000000..ef013ed792f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt @@ -0,0 +1,138 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks + +import androidx.annotation.WorkerThread +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import java.util.BitSet + +/** + * Efficient representation of raw QR code data. Stored as an X/Y grid of points, where (0, 0) is the top left corner. + * X increases as you move right, and Y increases as you go down. + */ +class QrCodeData( + val width: Int, + val height: Int, + private val bits: BitSet +) { + + fun get(x: Int, y: Int): Boolean { + return bits.get(y * width + x) + } + + /** + * Returns the position of the "eyes" of the QR code -- the big squares in the three corners. + */ + fun eyes(): List { + val eyes: MutableList = mutableListOf() + + val size: Int = getPossibleEyeSize() + + // Top left + if ( + horizontalLineExists(0, 0, size) && + horizontalLineExists(0, size - 1, size) && + verticalLineExists(0, 0, size) && + verticalLineExists(size - 1, 0, size) + ) { + eyes += Eye( + position = 0 to 0, + size = size + ) + } + + // Bottom left + if ( + horizontalLineExists(0, height - size, size) && + horizontalLineExists(0, size - 1, size) && + verticalLineExists(0, height - size, size) && + verticalLineExists(size - 1, height - size, size) + ) { + eyes += Eye( + position = 0 to height - size, + size = size + ) + } + + // Top right + if ( + horizontalLineExists(width - size, 0, size) && + horizontalLineExists(width - size, size - 1, size) && + verticalLineExists(width - size, 0, size) && + verticalLineExists(width - 1, 0, size) + ) { + eyes += Eye( + position = width - size to 0, + size = size + ) + } + + return eyes + } + + private fun getPossibleEyeSize(): Int { + var x = 0 + + while (get(x, 0)) { + x++ + } + + return x + } + + private fun horizontalLineExists(x: Int, y: Int, length: Int): Boolean { + for (p in x until x + length) { + if (!get(p, y)) { + return false + } + } + return true + } + + private fun verticalLineExists(x: Int, y: Int, length: Int): Boolean { + for (p in y until y + length) { + if (!get(x, p)) { + return false + } + } + return true + } + + data class Eye( + val position: Pair, + val size: Int + ) + + companion object { + + /** + * Converts the provided string data into a QR representation. + */ + @WorkerThread + fun forData(data: String, size: Int): QrCodeData { + val qrCodeWriter = QRCodeWriter() + val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H.toString()) + + val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints) + val dimens = padded.enclosingRectangle + val xStart = dimens[0] + val yStart = dimens[1] + val width = dimens[2] + val height = dimens[3] + val bitSet = BitSet(width * height) + + for (x in xStart until xStart + width) { + for (y in yStart until yStart + height) { + if (padded.get(x, y)) { + val destX = x - xStart + val destY = y - yStart + bitSet.set(destY * width + destX) + } + } + } + + return QrCodeData(width, height, bitSet) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt new file mode 100644 index 00000000000..f58634fc861 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks + +import androidx.compose.ui.graphics.Color + +/** + * A set of color schemes for sharing QR codes. + */ +enum class UsernameQrCodeColorScheme( + val borderColor: Color, + val foregroundColor: Color, + private val key: String +) { + Blue( + borderColor = Color(0xFF506ECD), + foregroundColor = Color(0xFF2449C0), + key = "blue" + ), + White( + borderColor = Color(0xFFFFFFFF), + foregroundColor = Color(0xFF464852), + key = "white" + ), + Grey( + borderColor = Color(0xFF6A6C74), + foregroundColor = Color(0xFF464852), + key = "grey" + ), + Tan( + borderColor = Color(0xFFBBB29A), + foregroundColor = Color(0xFF73694F), + key = "tan" + ), + Green( + borderColor = Color(0xFF97AA89), + foregroundColor = Color(0xFF55733F), + key = "green" + ), + Orange( + borderColor = Color(0xFFDE7134), + foregroundColor = Color(0xFFDA6C2E), + key = "orange" + ), + Pink( + borderColor = Color(0xFFEA7B9D), + foregroundColor = Color(0xFFBB617B), + key = "pink" + ), + Purple( + borderColor = Color(0xFF9E7BE9), + foregroundColor = Color(0xFF7651C5), + key = "purple" + ); + + fun serialize(): String { + return key + } + + companion object { + /** + * Returns the [UsernameQrCodeColorScheme] based on the serialized string. If no match is found, the default of [Blue] is returned. + */ + @JvmStatic + fun deserialize(serialized: String?): UsernameQrCodeColorScheme { + return values().firstOrNull { it.key == serialized } ?: Blue + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerFragment.kt new file mode 100644 index 00000000000..014d78643a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerFragment.kt @@ -0,0 +1,187 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.signal.core.ui.Buttons +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme +import org.thoughtcrime.securesms.compose.ComposeFragment + +/** + * Gives the user the ability to change the color of their shareable username QR code with a live preview. + */ +@OptIn(ExperimentalMaterial3Api::class) +class UsernameLinkQrColorPickerFragment : ComposeFragment() { + + val viewModel: UsernameLinkQrColorPickerViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val state: UsernameLinkQrColorPickerState by viewModel.state + val navController: NavController by remember { mutableStateOf(findNavController()) } + + Scaffold( + topBar = { TopAppBarContent(onBackClicked = { navController.popBackStack() }) } + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxWidth() + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween + ) { + QrCodeBadge( + data = state.qrCodeData, + colorScheme = state.selectedColorScheme, + username = state.username + ) + + ColorPicker( + colors = state.colorSchemes, + selected = state.selectedColorScheme, + onSelectionChanged = { color -> viewModel.onColorSelected(color) } + ) + + Row( + modifier = Modifier + .weight(1f, false) + .fillMaxWidth() + .padding(end = 24.dp), + horizontalArrangement = Arrangement.End + ) { + Buttons.MediumTonal(onClick = { navController.popBackStack() }) { + Text(stringResource(R.string.UsernameLinkSettings_done_button_label)) + } + } + } + } + } + + @Composable + private fun TopAppBarContent(onBackClicked: () -> Unit) { + TopAppBar( + title = { + Text(stringResource(R.string.UsernameLinkSettings_color_picker_app_bar_title)) + }, + navigationIcon = { + IconButton(onClick = onBackClicked) { + Image(painter = painterResource(R.drawable.symbol_arrow_left_24), contentDescription = null) + } + } + ) + } + + @Composable + private fun ColorPicker(colors: ImmutableList, selected: UsernameQrCodeColorScheme, onSelectionChanged: (UsernameQrCodeColorScheme) -> Unit) { + LazyVerticalGrid( + modifier = Modifier.padding(horizontal = 30.dp), + columns = GridCells.Adaptive(minSize = 88.dp) + ) { + colors.forEach { color -> + item(key = color.serialize()) { + ColorPickerItem( + color = color, + selected = color == selected, + onClick = { + onSelectionChanged(color) + } + ) + } + } + } + } + + @Composable + private fun ColorPickerItem(color: UsernameQrCodeColorScheme, selected: Boolean, onClick: () -> Unit) { + val outerBorderColor by animateColorAsState(targetValue = if (selected) MaterialTheme.colorScheme.onBackground else Color.Transparent) + val colorCircleSize by animateFloatAsState(targetValue = if (selected) 44f else 56f) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 13.dp) + .border(width = 2.dp, color = outerBorderColor, shape = CircleShape) + .size(56.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Surface( + onClick = onClick, + modifier = Modifier + .border(width = 2.dp, color = Color.Black.copy(alpha = 0.12f), shape = CircleShape) + .size(colorCircleSize.dp), + shape = CircleShape, + color = color.borderColor, + content = {} + ) + } + } + } + + @Preview + @Composable + private fun ColorPickerItemPreview() { + SignalTheme(isDarkMode = false) { + Surface { + Row(verticalAlignment = Alignment.CenterVertically) { + ColorPickerItem(color = UsernameQrCodeColorScheme.Blue, selected = false, onClick = {}) + ColorPickerItem(color = UsernameQrCodeColorScheme.Blue, selected = true, onClick = {}) + } + } + } + } + + @Preview + @Composable + private fun ColorPickerPreview() { + SignalTheme(isDarkMode = false) { + Surface { + ColorPicker( + colors = UsernameQrCodeColorScheme.values().toList().toImmutableList(), + selected = UsernameQrCodeColorScheme.Blue, + onSelectionChanged = {} + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerState.kt new file mode 100644 index 00000000000..688af12810a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerState.kt @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker + +import kotlinx.collections.immutable.ImmutableList +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme + +data class UsernameLinkQrColorPickerState( + val username: String, + val qrCodeData: QrCodeData?, + val colorSchemes: ImmutableList, + val selectedColorScheme: UsernameQrCodeColorScheme +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt new file mode 100644 index 00000000000..560fcd8b99e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.collections.immutable.toImmutableList +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.UsernameUtil + +class UsernameLinkQrColorPickerViewModel : ViewModel() { + + private val username: String = Recipient.self().username.get() + + private val _state = mutableStateOf( + UsernameLinkQrColorPickerState( + username = username, + qrCodeData = null, + colorSchemes = UsernameQrCodeColorScheme.values().asList().toImmutableList(), + selectedColorScheme = SignalStore.misc().usernameQrCodeColorScheme + ) + ) + + val state: State = _state + + private val disposable: CompositeDisposable = CompositeDisposable() + + init { + disposable += Single + .fromCallable { QrCodeData.forData(UsernameUtil.generateLink(username), 64) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { qrData -> + _state.value = _state.value.copy( + qrCodeData = qrData + ) + } + } + + override fun onCleared() { + disposable.clear() + } + + fun onColorSelected(color: UsernameQrCodeColorScheme) { + SignalStore.misc().usernameQrCodeColorScheme = color + _state.value = _state.value.copy( + selectedColorScheme = color + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt new file mode 100644 index 00000000000..262c664594d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.tooling.preview.Preview +import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.CoroutineScope +import org.thoughtcrime.securesms.compose.ComposeFragment + +@OptIn(ExperimentalMaterial3Api::class) +class UsernameLinkSettingsFragment : ComposeFragment() { + + val viewModel: UsernameLinkSettingsViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state + val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } + val scope: CoroutineScope = rememberCoroutineScope() + val navController: NavController by remember { mutableStateOf(findNavController()) } + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + ) { contentPadding -> + UsernameLinkShareScreen( + state = state, + snackbarHostState = snackbarHostState, + scope = scope, + contentPadding = contentPadding, + navController = navController + ) + } + } + + override fun onResume() { + super.onResume() + viewModel.onResume() + } + + @Preview + @Composable + fun PreviewAll() { + FragmentContent() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsState.kt new file mode 100644 index 00000000000..4636ac696e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsState.kt @@ -0,0 +1,14 @@ +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.UsernameQrCodeColorScheme + +/** + * Represents the UI state of the [UsernameLinkSettingsFragment]. + */ +data class UsernameLinkSettingsState( + val username: String, + val usernameLink: String, + val qrCodeData: QrCodeData?, + val qrCodeColorScheme: UsernameQrCodeColorScheme +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt new file mode 100644 index 00000000000..6aedf807770 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.UsernameUtil + +class UsernameLinkSettingsViewModel : ViewModel() { + + private val username: BehaviorSubject = BehaviorSubject.createDefault(Recipient.self().username.get()) + + private val _state = mutableStateOf( + UsernameLinkSettingsState( + username = username.value!!, + usernameLink = UsernameUtil.generateLink(username.value!!), + qrCodeData = null, + qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme + ) + ) + + val state: State = _state + + private val disposable: CompositeDisposable = CompositeDisposable() + + init { + disposable += username + .observeOn(Schedulers.io()) + .map { UsernameUtil.generateLink(it) } + .flatMapSingle { generateQrCodeData(it) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { qrData -> + _state.value = _state.value.copy( + qrCodeData = qrData + ) + } + } + + override fun onCleared() { + disposable.clear() + } + + fun onResume() { + _state.value = _state.value.copy( + qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme + ) + } + + private fun generateQrCodeData(url: String): Single { + return Single.fromCallable { + QrCodeData.forData(url, 64) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt new file mode 100644 index 00000000000..3ee94a29bab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt @@ -0,0 +1,194 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.signal.core.ui.Buttons +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme +import org.thoughtcrime.securesms.util.UsernameUtil +import org.thoughtcrime.securesms.util.Util +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * A screen that shows all the data around your username link and how to share it, including a QR code. + */ +@Composable +fun UsernameLinkShareScreen( + state: UsernameLinkSettingsState, + snackbarHostState: SnackbarHostState, + scope: CoroutineScope, + navController: NavController, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp) +) { + Column( + modifier = modifier + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + ) { + QrCodeBadge( + data = state.qrCodeData, + colorScheme = state.qrCodeColorScheme, + username = state.username + ) + + ButtonBar( + onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) } + ) + + CopyRow( + displayText = state.username, + copyMessage = stringResource(R.string.UsernameLinkSettings_username_copied_toast), + snackbarHostState = snackbarHostState, + scope = scope + ) + + CopyRow( + displayText = state.usernameLink, + copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast), + snackbarHostState = snackbarHostState, + scope = scope + ) + + Text( + text = stringResource(id = R.string.UsernameLinkSettings_qr_description), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.Center + ) { + Buttons.Small(onClick = { /*TODO*/ }) { + Text( + text = stringResource(id = R.string.UsernameLinkSettings_reset_button_label) + ) + } + } + } +} + +@Composable +private fun ButtonBar(onColorClicked: () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(space = 32.dp, alignment = Alignment.CenterHorizontally), + modifier = Modifier.fillMaxWidth() + ) { + Buttons.ActionButton( + onClick = {}, + iconResId = R.drawable.symbol_share_android_24, + labelResId = R.string.UsernameLinkSettings_share_button_label + ) + Buttons.ActionButton( + onClick = onColorClicked, + iconResId = R.drawable.symbol_color_24, + labelResId = R.string.UsernameLinkSettings_color_button_label + ) + } +} + +@Composable +private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { + val context = LocalContext.current + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.background) + .clickable { + Util.copyToClipboard(context, displayText) + + scope.launch { + snackbarHostState.showSnackbar(copyMessage) + } + } + .padding(horizontal = 26.dp, vertical = 16.dp) + ) { + Image( + painter = painterResource(id = R.drawable.symbol_copy_android_24), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) + ) + + Text( + text = displayText, + modifier = Modifier.padding(start = 26.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Preview(name = "Light Theme") +@Composable +private fun ScreenPreviewLightTheme() { + SignalTheme(isDarkMode = false) { + Surface { + UsernameLinkShareScreen( + state = previewState(), + snackbarHostState = SnackbarHostState(), + scope = rememberCoroutineScope(), + navController = NavController(LocalContext.current) + ) + } + } +} + +@Preview(name = "Dark Theme") +@Composable +private fun ScreenPreviewDarkTheme() { + SignalTheme(isDarkMode = true) { + Surface { + UsernameLinkShareScreen( + state = previewState(), + snackbarHostState = SnackbarHostState(), + scope = rememberCoroutineScope(), + navController = NavController(LocalContext.current) + ) + } + } +} + +private fun previewState(): UsernameLinkSettingsState { + val link = UsernameUtil.generateLink("maya.45") + return UsernameLinkSettingsState( + username = "maya.45", + usernameLink = link, + qrCodeData = QrCodeData.forData(link, 64), + qrCodeColorScheme = UsernameQrCodeColorScheme.Blue + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt new file mode 100644 index 00000000000..5cad84d08ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * A screen that allows you to scan a QR code to start a chat. + */ +@Composable +fun UsernameQrScanScreen(modifier: Modifier = Modifier) { + // TODO + Text(text = "QR Scanner Placeholder") +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java index 8615c696f39..dbadde95e8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java @@ -3,6 +3,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme; import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata; import org.thoughtcrime.securesms.jobmanager.impl.ChangeNumberConstraintObserver; @@ -30,6 +31,7 @@ public final class MiscellaneousValues extends SignalStoreValues { private static final String PNI_INITIALIZED_DEVICES = "misc.pni_initialized_devices"; private static final String SMS_PHASE_1_START_MS = "misc.sms_export.phase_1_start.3"; private static final String LINKED_DEVICES_REMINDER = "misc.linked_devices_reminder"; + private static final String USERNAME_QR_CODE_COLOR = "mis.username_qr_color_scheme"; MiscellaneousValues(@NonNull KeyValueStore store) { super(store); @@ -252,4 +254,15 @@ public void setShouldShowLinkedDevicesReminder(boolean value) { public boolean getShouldShowLinkedDevicesReminder() { return getBoolean(LINKED_DEVICES_REMINDER, false); } + + /** The color the user saved for rendering their shareable username QR code. */ + public @NonNull UsernameQrCodeColorScheme getUsernameQrCodeColorScheme() { + String serialized = getString(USERNAME_QR_CODE_COLOR, null); + return UsernameQrCodeColorScheme.deserialize(serialized); + } + + public void setUsernameQrCodeColorScheme(@NonNull UsernameQrCodeColorScheme color) { + putString(USERNAME_QR_CODE_COLOR, color.serialize()); + } + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java index ebd07cc18c3..a45886afdd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java @@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.signal.core.util.concurrent.LifecycleDisposable; import org.thoughtcrime.securesms.util.NameUtil; +import org.thoughtcrime.securesms.util.UsernameUtil; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; @@ -248,7 +249,7 @@ private void presentUsername(@Nullable String username) { binding.manageProfileUsername.setText(username); try { - binding.manageProfileUsernameSubtitle.setText(getString(R.string.signal_me_username_url_no_scheme, Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username)))); + binding.manageProfileUsernameSubtitle.setText(UsernameUtil.generateLink(username)); } catch (BaseUsernameException e) { Log.w(TAG, "Could not format username link", e); binding.manageProfileUsernameSubtitle.setText(R.string.ManageProfileFragment_your_username); diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java b/app/src/main/java/org/thoughtcrime/securesms/qr/QrCodeUtil.java similarity index 94% rename from app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java rename to app/src/main/java/org/thoughtcrime/securesms/qr/QrCodeUtil.java index 42a6be5aefa..2180367c9a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java +++ b/app/src/main/java/org/thoughtcrime/securesms/qr/QrCodeUtil.java @@ -15,12 +15,12 @@ import org.signal.core.util.logging.Log; import org.signal.core.util.Stopwatch; -public final class QrCode { +public final class QrCodeUtil { - private QrCode() { + private QrCodeUtil() { } - public static final String TAG = Log.tag(QrCode.class); + public static final String TAG = Log.tag(QrCodeUtil.class); public static @NonNull Bitmap create(@Nullable String data) { return create(data, Color.BLACK, Color.TRANSPARENT); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrDialogFragment.java index e3cf416bf7c..eb4bcd351ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrDialogFragment.java @@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.components.qr.QrView; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.qr.QrCode; +import org.thoughtcrime.securesms.qr.QrCodeUtil; import org.thoughtcrime.securesms.util.BottomSheetUtil; import org.thoughtcrime.securesms.util.ThemeUtil; @@ -123,7 +123,7 @@ private void presentUrl(@Nullable String url) { } private static Uri createTemporaryPng(@Nullable String url) throws IOException { - Bitmap qrBitmap = QrCode.create(url, Color.BLACK, Color.WHITE); + Bitmap qrBitmap = QrCodeUtil.create(url, Color.BLACK, Color.WHITE); try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { qrBitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java index c5ea4438675..781687ca55d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java @@ -32,6 +32,10 @@ public class UsernameUtil { 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 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(); } @@ -87,6 +91,13 @@ public static Optional checkUsername(@Nullable String value) { } } + public static String generateLink(String username) throws BaseUsernameException { + byte[] hash = Username.hash(username); + String base64 = Base64UrlSafe.encodeBytesWithoutPadding(hash); + + return BASE_URL + base64; + } + public enum InvalidReason { TOO_SHORT, TOO_LONG, INVALID_CHARACTERS, STARTS_WITH_NUMBER } diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.java b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.java index 553eab8c356..af3d8a1a6da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.java @@ -54,7 +54,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.qr.QrCode; +import org.thoughtcrime.securesms.qr.QrCodeUtil; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -408,7 +408,7 @@ private void setFingerprintViews(Fingerprint fingerprint, boolean animate) { byte[] qrCodeData = fingerprint.getScannableFingerprint().getSerialized(); String qrCodeString = new String(qrCodeData, Charset.forName("ISO-8859-1")); - Bitmap qrCodeBitmap = QrCode.create(qrCodeString); + Bitmap qrCodeBitmap = QrCodeUtil.create(qrCodeString); qrCode.setImageBitmap(qrCodeBitmap); diff --git a/app/src/main/res/drawable/qrcode_logo.png b/app/src/main/res/drawable/qrcode_logo.png new file mode 100644 index 00000000000..1c3d57989c0 Binary files /dev/null and b/app/src/main/res/drawable/qrcode_logo.png differ diff --git a/app/src/main/res/drawable/symbol_arrow_left_24.xml b/app/src/main/res/drawable/symbol_arrow_left_24.xml new file mode 100644 index 00000000000..38e24ba8ef0 --- /dev/null +++ b/app/src/main/res/drawable/symbol_arrow_left_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/symbol_color_24.xml b/app/src/main/res/drawable/symbol_color_24.xml new file mode 100644 index 00000000000..b3683b1dd55 --- /dev/null +++ b/app/src/main/res/drawable/symbol_color_24.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/layout/bio_preference_item.xml b/app/src/main/res/layout/bio_preference_item.xml index 1c62471c15b..ef3c36d31fb 100644 --- a/app/src/main/res/layout/bio_preference_item.xml +++ b/app/src/main/res/layout/bio_preference_item.xml @@ -35,12 +35,13 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="24dp" - android:layout_marginEnd="24dp" + android:layout_marginEnd="12dp" android:orientation="vertical" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@id/qr_button" app:layout_constraintStart_toEndOf="@id/icon" - app:layout_constraintTop_toTopOf="parent"> + app:layout_constraintTop_toTopOf="parent" + app:layout_goneMarginEnd="24dp"> + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index af98baebb9a..59487218010 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -10,6 +10,26 @@ android:name="org.thoughtcrime.securesms.components.settings.app.AppSettingsFragment" android:label="app_settings_fragment" tools:layout="@layout/dsl_settings_fragment"> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b890bc3e380..2451339ed31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5963,5 +5963,28 @@ Delete call link + + Share + + Color + + Only share your QR code and link with people you trust. When shared others will be able to see your username and start a chat with you. + + Username copied + + Link copied + + Reset + + Done + + Code + + Scan + + Scan the QR Code on your contact’s device. + + Color + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 32574ab0fe1..0c59b39b6ac 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -429,21 +429,25 @@ - + +