diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt index bf4dcb8cf0..f47808257f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt @@ -53,12 +53,12 @@ fun QrCode( } } -private fun DrawScope.drawQr( +fun DrawScope.drawQr( data: QrCodeData, foregroundColor: Color, backgroundColor: Color, deadzonePercent: Float, - logo: ImageBitmap + logo: ImageBitmap? ) { val deadzonePaddingPercent = 0.045f @@ -120,12 +120,14 @@ private fun DrawScope.drawQr( // Logo val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * 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) - ) + if (logo != null) { + drawImage( + image = logo, + dstOffset = IntOffset(logoOffsetPx, logoOffsetPx), + dstSize = IntSize(logoWidthPx, logoWidthPx), + colorFilter = ColorFilter.tint(foregroundColor) + ) + } } @Preview diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt index 50b804b8a8..58225dd33f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt @@ -24,17 +24,12 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -45,8 +40,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.signal.core.ui.theme.SignalTheme import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.compose.ScreenshotController -import org.thoughtcrime.securesms.compose.getScreenshotBounds /** * Renders a QR code and username as a badge. @@ -57,23 +50,16 @@ fun QrCodeBadge( colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier, - screenshotController: ScreenshotController? = null, usernameCopyable: Boolean = false, onClick: (() -> Unit) = {} ) { val borderColor by animateColorAsState(targetValue = colorScheme.borderColor, label = "border") val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor, label = "foreground") val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f, label = "elevation") - val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White, label = "textColor") - var badgeBounds by remember { - mutableStateOf(null) - } - screenshotController?.bind(LocalView.current, badgeBounds) + val textColor by animateColorAsState(targetValue = colorScheme.textColor, label = "textColor") + Surface( - modifier = modifier - .onGloballyPositioned { - badgeBounds = it.getScreenshotBounds() - }, + modifier = modifier, color = borderColor, shape = RoundedCornerShape(24.dp), shadowElevation = elevation.dp @@ -99,8 +85,8 @@ fun QrCodeBadge( data = data.data, modifier = Modifier .border( - width = if (colorScheme == UsernameQrCodeColorScheme.White) 2.dp else 0.dp, - color = Color(0xFFE9E9E9), + width = 2.dp, + color = colorScheme.outlineColor, shape = RoundedCornerShape(size = 12.dp) ) .padding(16.dp), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt index 7419e67310..41ac2988f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/UsernameQrCodeColorScheme.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.graphics.Color enum class UsernameQrCodeColorScheme( val borderColor: Color, val foregroundColor: Color, + val textColor: Color = Color.White, + val outlineColor: Color = Color.Transparent, private val key: String ) { Blue( @@ -18,6 +20,8 @@ enum class UsernameQrCodeColorScheme( White( borderColor = Color(0xFFFFFFFF), foregroundColor = Color(0xFF000000), + textColor = Color.Black, + outlineColor = Color(0xFFE9E9E9), key = "white" ), Grey( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt index e82fb6a71a..0a4db77325 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt @@ -114,9 +114,8 @@ class UsernameLinkSettingsFragment : ComposeFragment() { modifier = Modifier.padding(contentPadding), navController = navController, onShareBadge = { - shareQrBadge(it) + shareQrBadge(viewModel.generateQrCodeImage()) }, - screenshotController = screenshotController, onResetClicked = { showResetDialog = true }, onLinkResultHandled = { viewModel.onUsernameLinkResetResultHandled() } ) @@ -278,7 +277,11 @@ class UsernameLinkSettingsFragment : ComposeFragment() { } } - private fun shareQrBadge(badge: Bitmap) { + private fun shareQrBadge(badge: Bitmap?) { + if (badge == null) { + return + } + try { ByteArrayOutputStream().use { byteArrayOutputStream -> badge.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt index 84e31fa1e0..08f83327b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt @@ -1,7 +1,23 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Typeface +import android.os.Build import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection import androidx.lifecycle.ViewModel import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single @@ -10,8 +26,10 @@ import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.BehaviorSubject import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.drawQr import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -165,4 +183,106 @@ class UsernameLinkSettingsViewModel : ViewModel() { url.map { QrCodeData.forData(it, 64) } } } + + /** + * Fun fact: there's no way to draw a composable to a bitmap. You'd think there would be, but there isn't. You can "screenshot" it if it's 100% on-screen, + * but if it's partially offscreen you're SOL. So, we get to go through the fun process of re-drawing the QR badge to an image for sharing ourselves. + * + * Sizes were picked arbitrarily. + * + * I hate this as much as you do. + */ + fun generateQrCodeImage(): Bitmap? { + val state: UsernameLinkSettingsState = _state.value + + if (state.qrCodeState !is QrCodeState.Present) { + Log.w(TAG, "Invalid state to generate QR code! ${state.qrCodeState.javaClass.simpleName}") + return null + } + + val qrCodeData: QrCodeData = state.qrCodeState.data + + val width = 480 + val height = 525 + val qrSize = 300f + val qrPadding = 25f + val borderSizeX = 64f + val borderSizeY = 52f + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { + eraseColor(Color.TRANSPARENT) + } + + val androidCanvas = android.graphics.Canvas(bitmap) + val composeCanvas = Canvas(androidCanvas) + val canvasDrawScope = CanvasDrawScope() + + // Draw the background + androidCanvas.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 30f, 30f, Paint().apply { color = state.qrCodeColorScheme.borderColor.toArgb() }) + androidCanvas.drawRoundRect(borderSizeX, borderSizeY, borderSizeX + qrSize + qrPadding * 2, borderSizeY + qrSize + qrPadding * 2, 15f, 15f, Paint().apply { color = Color.WHITE }) + androidCanvas.drawRoundRect( + borderSizeX, + borderSizeY, + borderSizeX + qrSize + qrPadding * 2, + borderSizeY + qrSize + qrPadding * 2, + 15f, + 15f, + Paint().apply { + color = state.qrCodeColorScheme.outlineColor.toArgb() + style = Paint.Style.STROKE + strokeWidth = 4f + } + ) + + // Draw the QR code + composeCanvas.translate((width / 2) - (qrSize / 2), 80f) + canvasDrawScope.draw( + density = object : Density { + override val density: Float = 1f + override val fontScale: Float = 1f + }, + layoutDirection = LayoutDirection.Ltr, + canvas = composeCanvas, + size = Size(qrSize, qrSize) + ) { + drawQr( + data = qrCodeData, + foregroundColor = state.qrCodeColorScheme.foregroundColor, + backgroundColor = state.qrCodeColorScheme.borderColor, + deadzonePercent = 0.35f, + logo = null + ) + } + composeCanvas.translate(-90f, -80f) + + // Draw the signal logo -- unfortunately can't have the normal QR code drawing handle it because it requires a composable ImageBitmap + BitmapFactory.decodeResource(ApplicationDependencies.getApplication().resources, R.drawable.qrcode_logo).also { logoBitmap -> + val tintedPaint = Paint().apply { + colorFilter = PorterDuffColorFilter(state.qrCodeColorScheme.foregroundColor.toArgb(), PorterDuff.Mode.SRC_IN) + } + val sourceRect = Rect(0, 0, logoBitmap.width, logoBitmap.height) + val destRect = RectF(210f, 200f, 270f, 260f) + androidCanvas.drawBitmap(logoBitmap, sourceRect, destRect, tintedPaint) + } + + // Draw the text + val textPaint = Paint().apply { + color = state.qrCodeColorScheme.textColor.toArgb() + textSize = 34f + typeface = if (Build.VERSION.SDK_INT < 26) { + Typeface.DEFAULT_BOLD + } else { + Typeface.Builder("") + .setFallback("sans-serif") + .setWeight(600) + .build() + } + } + val textBounds = Rect() + textPaint.getTextBounds(state.username, 0, state.username.length, textBounds) + + androidCanvas.drawText(state.username, (width / 2f) - (textBounds.width() / 2f), 465f, textPaint) + + return bitmap + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt index 3342fec4dd..f57a136fc7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main import android.content.res.Configuration -import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -43,7 +42,6 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeDa import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab -import org.thoughtcrime.securesms.compose.ScreenshotController import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -57,9 +55,8 @@ fun UsernameLinkShareScreen( snackbarHostState: SnackbarHostState, scope: CoroutineScope, navController: NavController, - onShareBadge: (Bitmap) -> Unit, + onShareBadge: () -> Unit, modifier: Modifier = Modifier, - screenshotController: ScreenshotController? = null, onResetClicked: () -> Unit ) { when (state.usernameLinkResetResult) { @@ -82,7 +79,6 @@ fun UsernameLinkShareScreen( data = state.qrCodeState, colorScheme = state.qrCodeColorScheme, username = state.username, - screenshotController = screenshotController, usernameCopyable = true, modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp), onClick = { @@ -93,12 +89,7 @@ fun UsernameLinkShareScreen( ) ButtonBar( - onShareClicked = { - val badgeBitmap = screenshotController?.screenshot() - if (badgeBitmap != null) { - onShareBadge.invoke(badgeBitmap) - } - }, + onShareClicked = onShareBadge, onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) } )