Skip to content

Commit 855e194

Browse files
greyson-signalalex-signal
authored andcommitted
Add initial username link screen + QR code generation.
1 parent e0c0661 commit 855e194

30 files changed

+1367
-27
lines changed

‎app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import org.thoughtcrime.securesms.R;
1515
import org.thoughtcrime.securesms.components.SquareImageView;
16-
import org.thoughtcrime.securesms.qr.QrCode;
16+
import org.thoughtcrime.securesms.qr.QrCodeUtil;
1717

1818
/**
1919
* Generates a bitmap asynchronously for the supplied {@link BitMatrix} data and displays it.
@@ -59,7 +59,7 @@ private void init(@Nullable AttributeSet attrs) {
5959
}
6060

6161
public void setQrText(@Nullable String text) {
62-
setQrBitmap(QrCode.create(text, foregroundColor, backgroundColor));
62+
setQrBitmap(QrCodeUtil.create(text, foregroundColor, backgroundColor));
6363
}
6464

6565
private void setQrBitmap(@Nullable Bitmap qrBitmap) {

‎app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt

+24-5
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,19 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
4747
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
4848
return configure {
4949
customPref(
50-
BioPreference(state.self) {
51-
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
52-
}
50+
BioPreference(
51+
recipient = state.self,
52+
onRowClicked = {
53+
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
54+
},
55+
onQrButtonClicked = {
56+
if (Recipient.self().getUsername().isPresent()) {
57+
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
58+
} else {
59+
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment)
60+
}
61+
}
62+
)
5363
)
5464

5565
clickPref(
@@ -216,7 +226,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
216226
}
217227
}
218228

219-
private class BioPreference(val recipient: Recipient, val onClick: () -> Unit) : PreferenceModel<BioPreference>() {
229+
private class BioPreference(val recipient: Recipient, val onRowClicked: () -> Unit, val onQrButtonClicked: () -> Unit) : PreferenceModel<BioPreference>() {
220230
override fun areContentsTheSame(newItem: BioPreference): Boolean {
221231
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
222232
}
@@ -231,11 +241,12 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
231241
private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon)
232242
private val aboutView: TextView = itemView.findViewById(R.id.about)
233243
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
244+
private val qrButton: View = itemView.findViewById(R.id.qr_button)
234245

235246
override fun bind(model: BioPreference) {
236247
super.bind(model)
237248

238-
itemView.setOnClickListener { model.onClick() }
249+
itemView.setOnClickListener { model.onRowClicked() }
239250

240251
titleView.text = model.recipient.profileName.toString()
241252
summaryView.text = PhoneNumberFormatter.prettyPrint(model.recipient.requireE164())
@@ -246,6 +257,14 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
246257
summaryView.visibility = View.VISIBLE
247258
avatarView.visibility = View.VISIBLE
248259

260+
if (FeatureFlags.usernames()) {
261+
qrButton.visibility = View.VISIBLE
262+
qrButton.isClickable = true
263+
qrButton.setOnClickListener { model.onQrButtonClicked() }
264+
} else {
265+
qrButton.visibility = View.GONE
266+
}
267+
249268
if (model.recipient.combinedAboutAndEmoji != null) {
250269
aboutView.text = model.recipient.combinedAboutAndEmoji
251270
aboutView.visibility = View.VISIBLE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
2+
3+
import androidx.compose.foundation.layout.Column
4+
import androidx.compose.foundation.layout.height
5+
import androidx.compose.foundation.layout.width
6+
import androidx.compose.material3.Surface
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.ui.Modifier
9+
import androidx.compose.ui.draw.drawBehind
10+
import androidx.compose.ui.geometry.CornerRadius
11+
import androidx.compose.ui.geometry.Offset
12+
import androidx.compose.ui.geometry.Size
13+
import androidx.compose.ui.graphics.Color
14+
import androidx.compose.ui.graphics.ColorFilter
15+
import androidx.compose.ui.graphics.ImageBitmap
16+
import androidx.compose.ui.graphics.drawscope.DrawScope
17+
import androidx.compose.ui.graphics.drawscope.Stroke
18+
import androidx.compose.ui.res.imageResource
19+
import androidx.compose.ui.tooling.preview.Preview
20+
import androidx.compose.ui.unit.IntOffset
21+
import androidx.compose.ui.unit.IntSize
22+
import androidx.compose.ui.unit.dp
23+
import org.thoughtcrime.securesms.R
24+
25+
/**
26+
* Shows a QRCode that represents the provided data. Includes a Signal logo in the middle.
27+
*/
28+
@Composable
29+
fun QrCode(
30+
data: QrCodeData,
31+
modifier: Modifier = Modifier,
32+
foregroundColor: Color = Color.Black,
33+
backgroundColor: Color = Color.White,
34+
deadzonePercent: Float = 0.4f
35+
) {
36+
val logo = ImageBitmap.imageResource(R.drawable.qrcode_logo)
37+
38+
Column(
39+
modifier = modifier
40+
.drawBehind {
41+
drawQr(
42+
data = data,
43+
foregroundColor = foregroundColor,
44+
backgroundColor = backgroundColor,
45+
deadzonePercent = deadzonePercent,
46+
logo = logo
47+
)
48+
}
49+
) {
50+
}
51+
}
52+
53+
private fun DrawScope.drawQr(
54+
data: QrCodeData,
55+
foregroundColor: Color,
56+
backgroundColor: Color,
57+
deadzonePercent: Float,
58+
logo: ImageBitmap
59+
) {
60+
// We want an even number of dots on either side of the deadzone
61+
val candidateDeadzoneWidth: Int = (data.width * deadzonePercent).toInt()
62+
val deadzoneWidth: Int = if ((data.width - candidateDeadzoneWidth) % 2 == 0) {
63+
candidateDeadzoneWidth
64+
} else {
65+
candidateDeadzoneWidth + 1
66+
}
67+
68+
val candidateDeadzoneHeight: Int = (data.height * deadzonePercent).toInt()
69+
val deadzoneHeight: Int = if ((data.height - candidateDeadzoneHeight) % 2 == 0) {
70+
candidateDeadzoneHeight
71+
} else {
72+
candidateDeadzoneHeight + 1
73+
}
74+
75+
val deadzoneStartX: Int = (data.width - deadzoneWidth) / 2
76+
val deadzoneEndX: Int = deadzoneStartX + deadzoneWidth
77+
val deadzoneStartY: Int = (data.height - deadzoneHeight) / 2
78+
val deadzoneEndY: Int = deadzoneStartY + deadzoneHeight
79+
80+
val cellWidthPx: Float = size.width / data.width
81+
val cellRadiusPx = cellWidthPx / 2
82+
83+
for (x in 0 until data.width) {
84+
for (y in 0 until data.height) {
85+
if (x < deadzoneStartX || x >= deadzoneEndX || y < deadzoneStartY || y >= deadzoneEndY) {
86+
drawCircle(
87+
color = if (data.get(x, y)) foregroundColor else backgroundColor,
88+
radius = cellRadiusPx,
89+
center = Offset(x * cellWidthPx + cellRadiusPx, y * cellWidthPx + cellRadiusPx)
90+
)
91+
}
92+
}
93+
}
94+
95+
// Logo border
96+
val deadzonePaddingPercent = 0.02f
97+
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
98+
drawCircle(
99+
color = foregroundColor,
100+
radius = logoBorderRadiusPx,
101+
style = Stroke(width = cellWidthPx * 0.7f),
102+
center = this.center
103+
)
104+
105+
// Logo
106+
val logoWidthPx = ((deadzonePercent / 2) * size.width).toInt()
107+
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
108+
drawImage(
109+
image = logo,
110+
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
111+
dstSize = IntSize(logoWidthPx, logoWidthPx),
112+
colorFilter = ColorFilter.tint(foregroundColor)
113+
)
114+
115+
for (eye in data.eyes()) {
116+
val strokeWidth = cellWidthPx
117+
118+
// Clear the already-drawn dots
119+
drawRect(
120+
color = backgroundColor,
121+
topLeft = Offset(
122+
x = eye.position.first * cellWidthPx,
123+
y = eye.position.second * cellWidthPx
124+
),
125+
size = Size(eye.size * cellWidthPx + cellRadiusPx, eye.size * cellWidthPx)
126+
)
127+
128+
// Outer square
129+
drawRoundRect(
130+
color = foregroundColor,
131+
topLeft = Offset(
132+
x = eye.position.first * cellWidthPx + strokeWidth / 2,
133+
y = eye.position.second * cellWidthPx + strokeWidth / 2
134+
),
135+
size = Size((eye.size - 1) * cellWidthPx, (eye.size - 1) * cellWidthPx),
136+
cornerRadius = CornerRadius(cellRadiusPx * 2, cellRadiusPx * 2),
137+
style = Stroke(width = strokeWidth)
138+
)
139+
140+
// Inner square
141+
drawRoundRect(
142+
color = foregroundColor,
143+
topLeft = Offset(
144+
x = (eye.position.first + 2) * cellWidthPx,
145+
y = (eye.position.second + 2) * cellWidthPx
146+
),
147+
size = Size((eye.size - 4) * cellWidthPx, (eye.size - 4) * cellWidthPx),
148+
cornerRadius = CornerRadius(cellRadiusPx, cellRadiusPx)
149+
)
150+
}
151+
}
152+
153+
@Preview
154+
@Composable
155+
private fun Preview() {
156+
Surface {
157+
QrCode(
158+
data = QrCodeData.forData("https://signal.org", 64),
159+
modifier = Modifier
160+
.width(100.dp)
161+
.height(100.dp),
162+
deadzonePercent = 0.3f
163+
)
164+
}
165+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
2+
3+
import androidx.compose.animation.animateColorAsState
4+
import androidx.compose.animation.core.animateFloatAsState
5+
import androidx.compose.foundation.layout.Box
6+
import androidx.compose.foundation.layout.Column
7+
import androidx.compose.foundation.layout.aspectRatio
8+
import androidx.compose.foundation.layout.fillMaxHeight
9+
import androidx.compose.foundation.layout.fillMaxWidth
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.layout.size
12+
import androidx.compose.foundation.shape.RoundedCornerShape
13+
import androidx.compose.material3.CircularProgressIndicator
14+
import androidx.compose.material3.Surface
15+
import androidx.compose.material3.Text
16+
import androidx.compose.runtime.Composable
17+
import androidx.compose.runtime.getValue
18+
import androidx.compose.ui.Alignment
19+
import androidx.compose.ui.Modifier
20+
import androidx.compose.ui.graphics.Color
21+
import androidx.compose.ui.text.font.FontWeight
22+
import androidx.compose.ui.text.style.TextAlign
23+
import androidx.compose.ui.tooling.preview.Preview
24+
import androidx.compose.ui.unit.dp
25+
import androidx.compose.ui.unit.sp
26+
import org.signal.core.ui.theme.SignalTheme
27+
28+
/**
29+
* Renders a QR code and username as a badge.
30+
*/
31+
@Composable
32+
fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier) {
33+
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor)
34+
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor)
35+
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f)
36+
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White)
37+
38+
Surface(
39+
modifier = modifier
40+
.fillMaxWidth()
41+
.padding(horizontal = 59.dp, vertical = 24.dp),
42+
color = borderColor,
43+
shape = RoundedCornerShape(24.dp),
44+
shadowElevation = elevation.dp
45+
) {
46+
Column {
47+
Surface(
48+
modifier = Modifier
49+
.padding(
50+
top = 32.dp,
51+
start = 40.dp,
52+
end = 40.dp,
53+
bottom = 16.dp
54+
)
55+
.aspectRatio(1f)
56+
.fillMaxWidth(),
57+
shape = RoundedCornerShape(12.dp),
58+
color = Color.White
59+
) {
60+
if (data != null) {
61+
QrCode(
62+
data = data,
63+
modifier = Modifier.padding(20.dp),
64+
foregroundColor = foregroundColor,
65+
backgroundColor = Color.White
66+
)
67+
} else {
68+
Box(
69+
modifier = Modifier
70+
.fillMaxWidth()
71+
.fillMaxHeight(),
72+
contentAlignment = Alignment.Center
73+
) {
74+
CircularProgressIndicator(
75+
color = colorScheme.borderColor,
76+
modifier = Modifier.size(56.dp)
77+
)
78+
}
79+
}
80+
}
81+
82+
Text(
83+
text = username,
84+
color = textColor,
85+
fontSize = 20.sp,
86+
lineHeight = 26.sp,
87+
fontWeight = FontWeight.W600,
88+
textAlign = TextAlign.Center,
89+
modifier = Modifier
90+
.fillMaxWidth()
91+
.padding(
92+
start = 40.dp,
93+
end = 40.dp,
94+
bottom = 32.dp
95+
)
96+
)
97+
}
98+
}
99+
}
100+
101+
@Preview
102+
@Composable
103+
private fun PreviewWithCode() {
104+
SignalTheme(isDarkMode = false) {
105+
Surface {
106+
QrCodeBadge(
107+
data = QrCodeData.forData("https://signal.org", 64),
108+
colorScheme = UsernameQrCodeColorScheme.Blue,
109+
username = "parker.42"
110+
)
111+
}
112+
}
113+
}
114+
115+
@Preview
116+
@Composable
117+
private fun PreviewWithoutCode() {
118+
SignalTheme(isDarkMode = false) {
119+
Surface {
120+
QrCodeBadge(
121+
data = null,
122+
colorScheme = UsernameQrCodeColorScheme.Blue,
123+
username = "parker.42"
124+
)
125+
}
126+
}
127+
}

0 commit comments

Comments
 (0)
X