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 48585a5da9..2de4296ba6 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 @@ -15,30 +15,44 @@ 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.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalView 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 +import org.thoughtcrime.securesms.compose.ScreenshotController +import org.thoughtcrime.securesms.compose.getScreenshotBounds /** * Renders a QR code and username as a badge. */ @Composable -fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier) { +fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier, screenshotController: ScreenshotController? = null) { 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) - + var badgeBounds by remember { + mutableStateOf(null) + } + screenshotController?.bind(LocalView.current, badgeBounds) Surface( modifier = modifier .fillMaxWidth() - .padding(horizontal = 59.dp, vertical = 24.dp), + .padding(horizontal = 59.dp, vertical = 24.dp) + .onGloballyPositioned { + badgeBounds = it.getScreenshotBounds() + }, color = borderColor, shape = RoundedCornerShape(24.dp), shadowElevation = elevation.dp 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 f23080e58f..6b4c7ae81e 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 @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main +import android.content.Intent +import android.graphics.Bitmap import android.os.Bundle import android.view.View import androidx.compose.animation.AnimatedVisibility @@ -32,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.app.ShareCompat import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.fragment.findNavController @@ -47,6 +50,9 @@ import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.compose.ScreenshotController +import org.thoughtcrime.securesms.providers.BlobProvider +import java.io.ByteArrayOutputStream @OptIn( ExperimentalMaterial3Api::class, @@ -57,6 +63,8 @@ class UsernameLinkSettingsFragment : ComposeFragment() { private val viewModel: UsernameLinkSettingsViewModel by viewModels() private val disposables: LifecycleDisposable = LifecycleDisposable() + private val screenshotController = ScreenshotController() + @Composable override fun FragmentContent() { val state by viewModel.state @@ -83,7 +91,11 @@ class UsernameLinkSettingsFragment : ComposeFragment() { snackbarHostState = snackbarHostState, scope = scope, modifier = Modifier.padding(contentPadding), - navController = navController + navController = navController, + onShareBadge = { + shareQrBadge(it) + }, + screenshotController = screenshotController ) } @@ -185,4 +197,29 @@ class UsernameLinkSettingsFragment : ComposeFragment() { fun PreviewAll() { FragmentContent() } + + private fun shareQrBadge(badge: Bitmap) { + try { + ByteArrayOutputStream().use { byteArrayOutputStream -> + badge.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream) + byteArrayOutputStream.flush() + val bytes = byteArrayOutputStream.toByteArray() + val shareUri = BlobProvider.getInstance() + .forData(bytes) + .withMimeType("image/png") + .withFileName("SignalGroupQr.png") + .createForSingleSessionInMemory() + + val intent = ShareCompat.IntentBuilder.from(requireActivity()) + .setType("image/png") + .setStream(shareUri) + .createChooserIntent() + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + startActivity(intent) + } + } finally { + badge.recycle() + } + } } 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 77e60ad1f6..a59d47d13b 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,5 +1,6 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main +import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -36,6 +37,7 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBa import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData 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.UsernameUtil import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -49,7 +51,9 @@ fun UsernameLinkShareScreen( snackbarHostState: SnackbarHostState, scope: CoroutineScope, navController: NavController, - modifier: Modifier = Modifier + onShareBadge: (Bitmap) -> Unit, + modifier: Modifier = Modifier, + screenshotController: ScreenshotController? = null ) { Column( modifier = modifier @@ -58,10 +62,17 @@ fun UsernameLinkShareScreen( QrCodeBadge( data = state.qrCodeData, colorScheme = state.qrCodeColorScheme, - username = state.username + username = state.username, + screenshotController = screenshotController ) ButtonBar( + onShareClicked = { + val badgeBitmap = screenshotController?.screenshot() + if (badgeBitmap != null) { + onShareBadge.invoke(badgeBitmap) + } + }, onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) } ) @@ -103,13 +114,13 @@ fun UsernameLinkShareScreen( } @Composable -private fun ButtonBar(onColorClicked: () -> Unit) { +private fun ButtonBar(onShareClicked: () -> Unit, onColorClicked: () -> Unit) { Row( horizontalArrangement = Arrangement.spacedBy(space = 32.dp, alignment = Alignment.CenterHorizontally), modifier = Modifier.fillMaxWidth() ) { Buttons.ActionButton( - onClick = {}, + onClick = onShareClicked, iconResId = R.drawable.symbol_share_android_24, labelResId = R.string.UsernameLinkSettings_share_button_label ) @@ -161,7 +172,8 @@ private fun ScreenPreviewLightTheme() { state = previewState(), snackbarHostState = SnackbarHostState(), scope = rememberCoroutineScope(), - navController = NavController(LocalContext.current) + navController = NavController(LocalContext.current), + onShareBadge = {} ) } } @@ -176,7 +188,8 @@ private fun ScreenPreviewDarkTheme() { state = previewState(), snackbarHostState = SnackbarHostState(), scope = rememberCoroutineScope(), - navController = NavController(LocalContext.current) + navController = NavController(LocalContext.current), + onShareBadge = {} ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/compose/ScreenshotController.kt b/app/src/main/java/org/thoughtcrime/securesms/compose/ScreenshotController.kt new file mode 100644 index 0000000000..ae65716b1d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/compose/ScreenshotController.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.compose + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.View +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.boundsInWindow + +/** + * Helper class for screenshotting compose views. + * + * You need to call bind from the compose, passing in the + * LocalView.current view with bounds fetched from when the + * composable is globally positioned. + * + * See QrCodeBadge.kt for an example + */ +class ScreenshotController { + private var screenshotCallback: (() -> Bitmap?)? = null + + fun bind(view: View, bounds: Rect?) { + if (bounds == null) { + screenshotCallback = null + return + } + screenshotCallback = { + val bitmap = Bitmap.createBitmap( + bounds.width.toInt(), + bounds.height.toInt(), + Bitmap.Config.ARGB_8888 + ) + + if (Build.VERSION.SDK_INT >= 26) { + PixelCopy.request( + (view.context as Activity).window, + android.graphics.Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()), + bitmap, + {}, + Handler(Looper.getMainLooper()) + ) + } else { + val canvas = Canvas(bitmap) + .apply { + translate(-bounds.left, -bounds.top) + } + view.draw(canvas) + } + + bitmap + } + } + + fun screenshot(): Bitmap? { + return screenshotCallback?.invoke() + } +} + +fun LayoutCoordinates.getScreenshotBounds(): Rect { + return if (Build.VERSION.SDK_INT >= 26) { + this.boundsInWindow() + } else { + this.boundsInRoot() + } +}