Add initial username link screen + QR code generation.

This commit is contained in:
Greyson Parrelli 2023-03-29 14:39:41 -04:00 committed by Alex Hart
parent e0c06615fb
commit 855e194baa
30 changed files with 1367 additions and 27 deletions

View file

@ -13,7 +13,7 @@ import com.google.zxing.common.BitMatrix;
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 @@ public class QrView extends SquareImageView {
}
public void setQrText(@Nullable String text) {
setQrBitmap(QrCode.create(text, foregroundColor, backgroundColor));
setQrBitmap(QrCodeUtil.create(text, foregroundColor, backgroundColor));
}
private void setQrBitmap(@Nullable Bitmap qrBitmap) {

View file

@ -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<BioPreference>() {
private class BioPreference(val recipient: Recipient, val onRowClicked: () -> Unit, val onQrButtonClicked: () -> Unit) : PreferenceModel<BioPreference>() {
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

View file

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

View file

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

View file

@ -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<Eye> {
val eyes: MutableList<Eye> = 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<Int, Int>,
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)
}
}
}

View file

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

View file

@ -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<UsernameQrCodeColorScheme>, 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 = {}
)
}
}
}
}

View file

@ -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<UsernameQrCodeColorScheme>,
val selectedColorScheme: UsernameQrCodeColorScheme
)

View file

@ -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<UsernameLinkQrColorPickerState> = _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
)
}
}

View file

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

View file

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

View file

@ -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<String> = 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<UsernameLinkSettingsState> = _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<QrCodeData> {
return Single.fromCallable {
QrCodeData.forData(url, 64)
}
}
}

View file

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

View file

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

View file

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.keyvalue;
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 final class MiscellaneousValues extends SignalStoreValues {
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());
}
}

View file

@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.profiles.manage.ManageProfileViewModel.AvatarS
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 @@ public class ManageProfileFragment extends LoggingFragment {
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);

View file

@ -15,12 +15,12 @@ import com.google.zxing.qrcode.QRCodeWriter;
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);

View file

@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.R;
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 @@ public class GroupLinkShareQrDialogFragment extends DialogFragment {
}
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);

View file

@ -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 class UsernameUtil {
}
}
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
}

View file

@ -54,7 +54,7 @@ import org.thoughtcrime.securesms.database.IdentityTable;
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 @@ public class VerifyDisplayFragment extends Fragment implements ViewTreeObserver.
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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M3.88 12c0 0.23 0.09 0.45 0.25 0.62l6.5 6.5c0.34 0.34 0.9 0.34 1.24 0 0.34-0.34 0.34-0.9 0-1.24l-5.13-5.13 1.76 0.13h11.25c0.48 0 0.88-0.4 0.88-0.88s-0.4-0.88-0.88-0.88H8.5l-1.76 0.13 5.13-5.13c0.34-0.34 0.34-0.9 0-1.24-0.34-0.34-0.9-0.34-1.24 0l-6.5 6.5C3.97 11.55 3.88 11.77 3.88 12Z"/>
</vector>

View file

@ -0,0 +1,24 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6 15.5C6 14.67 6.67 14 7.5 14S9 14.67 9 15.5 8.33 17 7.5 17 6 16.33 6 15.5Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M17.63 9.25c-0.83 0-1.5 0.67-1.5 1.5s0.67 1.5 1.5 1.5c0.82 0 1.5-0.67 1.5-1.5s-0.68-1.5-1.5-1.5Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M4.88 10.75c0-0.83 0.67-1.5 1.5-1.5 0.82 0 1.5 0.67 1.5 1.5s-0.68 1.5-1.5 1.5c-0.83 0-1.5-0.67-1.5-1.5Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M14.5 5.38c-0.83 0-1.5 0.67-1.5 1.5 0 0.82 0.67 1.5 1.5 1.5S16 7.7 16 6.87c0-0.83-0.67-1.5-1.5-1.5Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M8 6.88c0-0.83 0.67-1.5 1.5-1.5S11 6.05 11 6.88c0 0.82-0.67 1.5-1.5 1.5S8 7.7 8 6.87Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M12 1.13C6 1.13 1.12 5.99 1.12 12 1.13 18 6 22.87 12 22.88c1.23 0 2.24-1.05 2.19-2.28-0.02-0.65-0.33-1.13-0.52-1.4-0.01-0.04-0.03-0.06-0.05-0.08-0.18-0.3-0.27-0.47-0.27-0.75 0-0.73 0.5-1.28 1.23-1.28h2.05c3.36-0.01 6.25-2.38 6.25-6.25 0-2.59-1.38-5.02-3.38-6.78-2-1.77-4.7-2.93-7.5-2.93ZM2.87 12c0-5.04 4.09-9.13 9.13-9.13 2.33 0 4.63 0.98 6.34 2.5 1.73 1.52 2.79 3.5 2.79 5.47 0 2.8-2.01 4.49-4.5 4.5h-2.05c-1.8 0-2.98 1.44-2.98 3.03 0 0.8 0.32 1.33 0.55 1.7 0.25 0.37 0.29 0.45 0.3 0.58v0.02c0 0.24-0.21 0.46-0.45 0.45-5.04 0-9.13-4.08-9.13-9.12Z"/>
</vector>

View file

@ -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">
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/title"
@ -72,4 +73,19 @@
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/qr_button"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="24dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:icon="@drawable/symbol_qrcode_24"
app:iconSize="20dp"
app:iconTint="@color/core_black"
app:backgroundTint="@color/signal_light_colorSurface3"
style="@style/Widget.Signal.Button.Icon"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -10,6 +10,26 @@
android:name="org.thoughtcrime.securesms.components.settings.app.AppSettingsFragment"
android:label="app_settings_fragment"
tools:layout="@layout/dsl_settings_fragment">
<action
android:id="@+id/action_appSettingsFragment_to_usernameLinkSettingsFragment"
app:destination="@id/usernameLinkSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_usernameEducationFragment"
app:destination="@id/manageProfileActivity"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit">
<argument
android:name="start_at_username"
app:argType="boolean"
android:defaultValue="true"
/>
</action>
<action
android:id="@+id/action_appSettingsFragment_to_accountSettingsFragment"
app:destination="@id/accountSettingsFragment"
@ -781,6 +801,23 @@
</fragment>
<fragment
android:id="@+id/usernameLinkSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsFragment" >
<action
android:id="@+id/action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment"
app:destination="@id/usernameLinkQrColorPickerFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/usernameLinkQrColorPickerFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker.UsernameLinkQrColorPickerFragment" />
<include app:graph="@navigation/story_privacy_settings" />
</navigation>

View file

@ -5963,5 +5963,28 @@
<!-- Displayed in a text row, allowing the user to delete the call link -->
<string name="CallLinkDetailsFragment__delete_call_link">Delete call link</string>
<!-- Button label for the share button in the username link settings -->
<string name="UsernameLinkSettings_share_button_label">Share</string>
<!-- Button label for the color selector button in the username link settings -->
<string name="UsernameLinkSettings_color_button_label">Color</string>
<!-- Description text for QR code and links in the username link settings -->
<string name="UsernameLinkSettings_qr_description">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.</string>
<!-- Content of a toast that will show after the username is copied to the clipboard -->
<string name="UsernameLinkSettings_username_copied_toast">Username copied</string>
<!-- Content of a toast that will show after the username link is copied to the clipboard -->
<string name="UsernameLinkSettings_link_copied_toast">Link copied</string>
<!-- Button label for a button that will reset your username and give you a new link -->
<string name="UsernameLinkSettings_reset_button_label">Reset</string>
<!-- Button label for a button that indicates that the user is done changing the current setting -->
<string name="UsernameLinkSettings_done_button_label">Done</string>
<!-- Label for a tab that shows a screen to view your username QR code -->
<string name="UsernameLinkSettings_code_tab_name">Code</string>
<!-- Label for a tab that shows a screen to scan a QR code -->
<string name="UsernameLinkSettings_scan_tab_name">Scan</string>
<!-- Description text shown underneath the username QR code scanner -->
<string name="UsernameLinkSettings_qr_scan_description">Scan the QR Code on your contacts device.</string>
<!-- App bar title for the username QR code color picker screen -->
<string name="UsernameLinkSettings_color_picker_app_bar_title">Color</string>
<!-- EOF -->
</resources>

View file

@ -429,21 +429,25 @@
</attr>
</declare-styleable>
<style name="Widget.Signal.Button.Icon.Circular" parent="Widget.MaterialComponents.Button.Icon">
<item name="android:layout_width">48dp</item>
<item name="android:layout_height">48dp</item>
<item name="iconSize">24dp</item>
<style name="Widget.Signal.Button.Icon" parent="Widget.Material3.Button.IconButton">
<item name="android:elevation" tools:ignore="NewApi">0dp</item>
<item name="android:stateListAnimator" tools:ignore="NewApi">@null</item>
<item name="android:insetRight">0dp</item>
<item name="android:insetLeft">0dp</item>
<item name="android:insetTop">0dp</item>
<item name="android:insetBottom">0dp</item>
<item name="iconGravity">textStart</item>
</style>
<style name="Widget.Signal.Button.Icon.Circular" parent="Widget.Signal.Button.Icon">
<item name="android:layout_width">48dp</item>
<item name="android:layout_height">48dp</item>
<item name="iconSize">24dp</item>
<item name="android:elevation" tools:ignore="NewApi">0dp</item>
<item name="android:stateListAnimator" tools:ignore="NewApi">@null</item>
<item name="iconTint">@color/white</item>
<item name="iconTintMode">multiply</item>
<item name="iconPadding">0dp</item>
<item name="rippleColor">@color/core_ultramarine</item>
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.Signal.Button.Rounded</item>
</style>
<style name="Widget.Signal.Button.Icon.Circular.Small">

View file

@ -1,6 +1,8 @@
package org.signal.core.ui.theme
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
@ -9,28 +11,32 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
private val typography = Typography().run {
copy(
headlineLarge = headlineLarge.copy(
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = headlineMedium.copy(
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
titleLarge = titleLarge.copy(
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = titleMedium.copy(
fontFamily = FontFamily.SansSerif,
fontStyle = FontStyle.Normal,
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.0125.sp
letterSpacing = 0.0125.sp,
fontFamily = FontFamily.SansSerif,
fontStyle = FontStyle.Normal
),
titleSmall = titleSmall.copy(
fontSize = 16.sp,
@ -38,10 +44,12 @@ private val typography = Typography().run {
letterSpacing = 0.0125.sp
),
bodyLarge = bodyLarge.copy(
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = 0.0125.sp
),
bodyMedium = bodyMedium.copy(
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.0107.sp
),
@ -51,6 +59,7 @@ private val typography = Typography().run {
letterSpacing = 0.0192.sp
),
labelLarge = labelLarge.copy(
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.0107.sp
),
@ -60,6 +69,7 @@ private val typography = Typography().run {
letterSpacing = 0.0192.sp
),
labelSmall = labelSmall.copy(
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.025.sp
)
@ -172,6 +182,63 @@ fun SignalTheme(
}
}
@Preview(showBackground = true)
@Composable
private fun TypographyPreview() {
SignalTheme(isDarkMode = false) {
Column {
Text(
text = "Headline Small",
style = MaterialTheme.typography.headlineLarge
)
Text(
text = "Headline Small",
style = MaterialTheme.typography.headlineMedium
)
Text(
text = "Headline Small",
style = MaterialTheme.typography.headlineSmall
)
Text(
text = "Title Large",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Title Medium",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Title Small",
style = MaterialTheme.typography.titleSmall
)
Text(
text = "Body Large",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Body Medium",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Body Small",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Label Large",
style = MaterialTheme.typography.labelLarge
)
Text(
text = "Label Medium",
style = MaterialTheme.typography.labelMedium
)
Text(
text = "Label Small",
style = MaterialTheme.typography.labelSmall
)
}
}
}
object SignalTheme {
val colors: ExtendedColors
@Composable

View file

@ -139,6 +139,7 @@ dependencyResolutionManagement {
alias('lottie').to('com.airbnb.android:lottie:5.2.0')
alias('dnsjava').to('dnsjava:dnsjava:2.1.9')
alias('nanohttpd-webserver').to('org.nanohttpd:nanohttpd-webserver:2.3.1')
alias('kotlinx-collections-immutable').to('org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5')
// Can't use the newest version because it hits some weird NoClassDefFoundException
alias('jknack-handlebars').to('com.github.jknack:handlebars:4.0.7')

View file

@ -44,6 +44,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="30fd58f97339dde1f7f779b1b6a448c13f65102de46bacd5cc7849b762a4e7d2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.activity" name="activity-compose" version="1.5.1">
<artifact name="activity-compose-1.5.1.aar">
<sha256 value="8374138f15251cc3ed375425599e94a36038c05cf877f877281ae019b95b844a" origin="Generated by Gradle"/>
</artifact>
<artifact name="activity-compose-1.5.1.module">
<sha256 value="10a44d247a4555af19e3bb8e06b5f24c4ed72252c5feace8de244a35ece256f7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.activity" name="activity-ktx" version="1.5.1">
<artifact name="activity-ktx-1.5.1.aar">
<sha256 value="fd69a5ccb99244cb7c5224580a58e23238d10ed4086199a33e9bfc31c4e4834f" origin="Generated by Gradle"/>
@ -948,6 +956,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="01e413b73cbe38cb714dc5bdb21bd860931124c8e5f2369803f4aacc49081c9f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel-compose" version="2.5.1">
<artifact name="lifecycle-viewmodel-compose-2.5.1.aar">
<sha256 value="4b0f50cca837753d82942822b996c214f09c8c709524f1cc95e03facd96bf466" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-viewmodel-compose-2.5.1.module">
<sha256 value="9453b61679d8d5c53b8ea3d61444239cc0c9e49b796e204f48fd1f0332ca2686" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel-ktx" version="2.5.1">
<artifact name="lifecycle-viewmodel-ktx-2.5.1.aar">
<sha256 value="30eecb351d81f0c429e186e65a892a42ce1d5bc5c80420bfece4ae279333023d" origin="Generated by Gradle"/>
@ -1003,6 +1019,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="459b2d0214420b6df2edb448c8a7c226dbecdb98b797685eb3da5f0a3b0adcbd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigation" name="navigation-compose" version="2.5.3">
<artifact name="navigation-compose-2.5.3.aar">
<sha256 value="85b5fee5718aa0ce736f4e6a3aca64862a1a1093f619b3e173786712912118d4" origin="Generated by Gradle"/>
</artifact>
<artifact name="navigation-compose-2.5.3.module">
<sha256 value="a90d53ce3a46c56c87a866f118c4d7bf9cb90bb9a5c3e83ce92bb3cbfd805f14" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigation" name="navigation-fragment" version="2.5.3">
<artifact name="navigation-fragment-2.5.3.aar">
<sha256 value="8fd447ce032b1850bcded21855061d5dd209bf564dffb3a89451d0e642b26bec" origin="Generated by Gradle"/>