Update username link QR code styling.

This commit is contained in:
Greyson Parrelli
2023-07-06 11:33:16 -04:00
committed by Clark Chen
parent e1570e9512
commit 8372c699f7
5 changed files with 68 additions and 155 deletions

View File

@@ -131,7 +131,7 @@ class AppSettingsFragment : DSLSettingsFragment(
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity) findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
}, },
onQrButtonClicked = { onQrButtonClicked = {
if (Recipient.self().getUsername().isPresent()) { if (Recipient.self().username.isPresent && Recipient.self().username.get().isNotEmpty()) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment) findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
} else { } else {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment) findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment)

View File

@@ -1,27 +1,30 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.imageResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import kotlin.math.ceil
import kotlin.math.floor
/** /**
* Shows a QRCode that represents the provided data. Includes a Signal logo in the middle. * Shows a QRCode that represents the provided data. Includes a Signal logo in the middle.
@@ -32,7 +35,7 @@ fun QrCode(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
foregroundColor: Color = Color.Black, foregroundColor: Color = Color.Black,
backgroundColor: Color = Color.White, backgroundColor: Color = Color.White,
deadzonePercent: Float = 0.4f deadzonePercent: Float = 0.35f
) { ) {
val logo = ImageBitmap.imageResource(R.drawable.qrcode_logo) val logo = ImageBitmap.imageResource(R.drawable.qrcode_logo)
@@ -58,53 +61,65 @@ private fun DrawScope.drawQr(
deadzonePercent: Float, deadzonePercent: Float,
logo: ImageBitmap logo: ImageBitmap
) { ) {
val deadzonePaddingPercent = 0.07f
// We want an even number of dots on either side of the deadzone // We want an even number of dots on either side of the deadzone
val candidateDeadzoneWidth: Int = (data.width * deadzonePercent).toInt() val deadzoneRadius: Int = (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight ->
val deadzoneWidth: Int = if ((data.width - candidateDeadzoneWidth) % 2 == 0) { if ((data.height - candidateDeadzoneHeight) % 2 == 0) {
candidateDeadzoneWidth candidateDeadzoneHeight
} else { } else {
candidateDeadzoneWidth + 1 candidateDeadzoneHeight + 1
} }
} / 2
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 cellWidthPx: Float = size.width / data.width
val cellRadiusPx = cellWidthPx / 2 val cornerRadius = CornerRadius(7f, 7f)
val deadzone = Circle(center = IntOffset(data.width / 2, data.height / 2), radius = deadzoneRadius)
for (x in 0 until data.width) { for (x in 0 until data.width) {
for (y in 0 until data.height) { for (y in 0 until data.height) {
if (x < deadzoneStartX || x >= deadzoneEndX || y < deadzoneStartY || y >= deadzoneEndY) { val position = IntOffset(x, y)
drawCircle(
color = if (data.get(x, y)) foregroundColor else backgroundColor, if (data.get(position) && !deadzone.contains(position)) {
radius = cellRadiusPx, val filledAbove = IntOffset(x, y - 1).let { data.get(it) && !deadzone.contains(it) }
center = Offset(x * cellWidthPx + cellRadiusPx, y * cellWidthPx + cellRadiusPx) val filledBelow = IntOffset(x, y + 1).let { data.get(it) && !deadzone.contains(it) }
val filledLeft = IntOffset(x - 1, y).let { data.get(it) && !deadzone.contains(it) }
val filledRight = IntOffset(x + 1, y).let { data.get(it) && !deadzone.contains(it) }
val path = Path().apply {
addRoundRect(
RoundRect(
rect = Rect(
topLeft = Offset(floor(x * cellWidthPx), floor(y * cellWidthPx - 1)),
bottomRight = Offset(ceil((x + 1) * cellWidthPx), ceil((y + 1) * cellWidthPx + 1))
),
topLeft = if (filledAbove || filledLeft) CornerRadius.Zero else cornerRadius,
topRight = if (filledAbove || filledRight) CornerRadius.Zero else cornerRadius,
bottomLeft = if (filledBelow || filledLeft) CornerRadius.Zero else cornerRadius,
bottomRight = if (filledBelow || filledRight) CornerRadius.Zero else cornerRadius
)
)
}
drawPath(
path = path,
color = if (data.get(position)) foregroundColor else backgroundColor
) )
} }
} }
} }
// Logo border // Logo border
val deadzonePaddingPercent = 0.03f
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2 val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
drawCircle( drawCircle(
color = foregroundColor, color = foregroundColor,
radius = logoBorderRadiusPx, radius = logoBorderRadiusPx,
style = Stroke(width = 4.dp.toPx()), style = Stroke(width = cellWidthPx * 0.75f),
center = this.center center = this.center
) )
// Logo // Logo
val logoWidthPx = ((deadzonePercent / 2) * size.width).toInt() val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt()
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt() val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
drawImage( drawImage(
image = logo, image = logo,
@@ -112,43 +127,6 @@ private fun DrawScope.drawQr(
dstSize = IntSize(logoWidthPx, logoWidthPx), dstSize = IntSize(logoWidthPx, logoWidthPx),
colorFilter = ColorFilter.tint(foregroundColor) 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 @Preview
@@ -157,8 +135,17 @@ private fun Preview() {
Surface { Surface {
QrCode( QrCode(
data = QrCodeData.forData("https://signal.org", 64), data = QrCodeData.forData("https://signal.org", 64),
modifier = Modifier.size(200.dp), modifier = Modifier.size(350.dp)
deadzonePercent = 0.3f
) )
} }
} }
private data class Circle(
val center: IntOffset,
val radius: Int
) {
fun contains(position: IntOffset): Boolean {
val diff = center - position
return diff.x * diff.x + diff.y * diff.y < radius * radius
}
}

View File

@@ -74,7 +74,7 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern
if (data != null) { if (data != null) {
QrCode( QrCode(
data = data, data = data,
modifier = Modifier.padding(20.dp), modifier = Modifier.padding(16.dp),
foregroundColor = foregroundColor, foregroundColor = foregroundColor,
backgroundColor = Color.White backgroundColor = Color.White
) )

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.compose.ui.unit.IntOffset
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
@@ -17,93 +18,18 @@ class QrCodeData(
private val bits: BitSet 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. * Returns true if the bit in the QR code is "on" for the specified position, false if it is "off" or out of bounds.
*/ */
fun eyes(): List<Eye> { fun get(position: IntOffset): Boolean {
val eyes: MutableList<Eye> = mutableListOf() val (x, y) = position
return if (x < 0 || y < 0 || x >= width || y >= height) {
val size: Int = getPossibleEyeSize() false
} else {
// Top left bits.get(y * width + x)
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 { companion object {
/** /**

View File

@@ -70,7 +70,7 @@ fun UsernameQrScanScreen(
AndroidView( AndroidView(
factory = { context -> factory = { context ->
val view = QrScannerView(context) val view = QrScannerView(context)
disposables += view.qrData.subscribe { data -> disposables += view.qrData.distinctUntilChanged().subscribe { data ->
onQrCodeScanned(data) onQrCodeScanned(data)
} }
view view