Skip to content

Commit

Permalink
Add initial username link screen + QR code generation.
Browse files Browse the repository at this point in the history
  • Loading branch information
greyson-signal authored and alex-signal committed May 5, 2023
1 parent e0c0661 commit 855e194
Show file tree
Hide file tree
Showing 30 changed files with 1,367 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
Expand All @@ -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())
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
}
Loading

0 comments on commit 855e194

Please sign in to comment.