From 96333b616b70b50f380e12ae85fa303ae3fcde05 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 9 Nov 2023 10:10:42 -0500 Subject: [PATCH] Add username link share sheet. --- ...dRoundedCornerBottomSheetDialogFragment.kt | 4 +- .../settings/app/usernamelinks/QrCodeBadge.kt | 4 +- .../main/UsernameLinkSettingsFragment.kt | 26 ++- .../main/UsernameLinkSettingsViewModel.kt | 9 + .../main/UsernameLinkShareBottomSheet.kt | 180 ++++++++++++++++++ .../main/UsernameLinkShareScreen.kt | 32 ++-- .../res/navigation/username_link_settings.xml | 8 + app/src/main/res/values/strings.xml | 7 + 8 files changed, 242 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareBottomSheet.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt index 8669b422e2..cdf94596fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt @@ -42,7 +42,9 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr override fun onResume() { super.onResume() - WindowUtil.initializeScreenshotSecurity(requireContext(), dialog!!.window!!) + dialog?.window?.let { window -> + WindowUtil.initializeScreenshotSecurity(requireContext(), window) + } } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 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 58225dd33f..96269226cc 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 @@ -51,7 +51,7 @@ fun QrCodeBadge( username: String, modifier: Modifier = Modifier, usernameCopyable: Boolean = false, - onClick: (() -> Unit) = {} + onClick: ((String) -> Unit) = {} ) { val borderColor by animateColorAsState(targetValue = colorScheme.borderColor, label = "border") val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor, label = "foreground") @@ -132,7 +132,7 @@ fun QrCodeBadge( .clip(RoundedCornerShape(8.dp)) .clickable( enabled = usernameCopyable, - onClick = onClick + onClick = { onClick(username) } ) .padding(8.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 0a4db77325..d5d5b88b84 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 @@ -30,6 +30,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,6 +44,7 @@ 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.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.fragment.findNavController @@ -59,19 +61,24 @@ 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 +import java.util.UUID -@OptIn( - ExperimentalPermissionsApi::class -) +@OptIn(ExperimentalPermissionsApi::class) class UsernameLinkSettingsFragment : ComposeFragment() { private val viewModel: UsernameLinkSettingsViewModel by viewModels() private val disposables: LifecycleDisposable = LifecycleDisposable() - private val screenshotController = ScreenshotController() + override fun onStart() { + super.onStart() + setFragmentResultListener(UsernameLinkShareBottomSheet.REQUEST_KEY) { key, bundle -> + if (bundle.getBoolean(UsernameLinkShareBottomSheet.KEY_COPY)) { + viewModel.onLinkCopied() + } + } + } @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -83,6 +90,15 @@ class UsernameLinkSettingsFragment : ComposeFragment() { var showResetDialog: Boolean by remember { mutableStateOf(false) } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA) + val linkCopiedEvent: UUID? by viewModel.linkCopiedEvent + + val linkCopiedString = stringResource(R.string.UsernameLinkSettings_link_copied_toast) + + LaunchedEffect(linkCopiedEvent) { + if (linkCopiedEvent != null) { + snackbarHostState.showSnackbar(linkCopiedString) + } + } Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, 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 c2452b23c3..2dad371ae9 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 @@ -10,6 +10,7 @@ import android.graphics.Rect import android.graphics.RectF import android.graphics.Typeface import android.os.Build +import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.geometry.Size @@ -39,6 +40,7 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.NetworkUtil import org.whispersystems.signalservice.api.push.UsernameLinkComponents import java.util.Optional +import java.util.UUID class UsernameLinkSettingsViewModel : ViewModel() { @@ -58,6 +60,9 @@ class UsernameLinkSettingsViewModel : ViewModel() { private val disposable: CompositeDisposable = CompositeDisposable() private val usernameLink: BehaviorSubject> = BehaviorSubject.createDefault(Optional.ofNullable(SignalStore.account().usernameLink)) + private val _linkCopiedEvent: MutableState = mutableStateOf(null) + val linkCopiedEvent: State get() = _linkCopiedEvent + init { disposable += usernameLink .observeOn(Schedulers.io()) @@ -177,6 +182,10 @@ class UsernameLinkSettingsViewModel : ViewModel() { ) } + fun onLinkCopied() { + _linkCopiedEvent.value = UUID.randomUUID() + } + private fun generateQrCodeData(url: Optional): Single> { return Single.fromCallable { url.map { QrCodeData.forData(it, 64) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareBottomSheet.kt new file mode 100644 index 0000000000..f3f46b681a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareBottomSheet.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import android.content.Intent +import android.content.res.Configuration +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.setFragmentResult +import kotlinx.coroutines.CoroutineScope +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.Util + +class UsernameLinkShareBottomSheet : ComposeBottomSheetDialogFragment() { + + companion object { + const val REQUEST_KEY = "link_share_bottom_sheet" + const val KEY_COPY = "copy" + + @JvmStatic + fun show(fragmentManager: FragmentManager) { + CallLinkIncomingRequestSheet().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + + @Composable + override fun SheetContent() { + val scope = rememberCoroutineScope() + val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } + + Content( + usernameLink = SignalStore.account().usernameLink?.toLink() ?: "", + scope = scope, + snackbarHostState = snackbarHostState, + dismissDialog = { didCopy -> + setFragmentResult(REQUEST_KEY, bundleOf(KEY_COPY to didCopy)) + dismiss() + } + ) + } +} + +@Composable +private fun Content( + usernameLink: String, + scope: CoroutineScope, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + dismissDialog: (Boolean) -> Unit = {} +) { + val context = LocalContext.current + val usernameCopiedString = stringResource(id = R.string.UsernameLinkSettings_username_copied_toast) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + BottomSheets.Handle() + + Text( + text = stringResource(R.string.UsernameLinkShareBottomSheet_title), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 41.dp, vertical = 24.dp) + ) + Text( + text = usernameLink, + modifier = Modifier + .padding(horizontal = 24.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + shape = RoundedCornerShape(12.dp) + ) + .padding(all = 16.dp) + ) + ButtonRow( + icon = painterResource(R.drawable.symbol_copy_android_24), + text = stringResource(R.string.UsernameLinkShareBottomSheet_copy_link), + modifier = Modifier.padding(top = 12.dp), + onClick = { + Util.copyToClipboard(context, usernameLink) + dismissDialog(true) + } + ) + ButtonRow( + icon = painterResource(R.drawable.symbol_share_android_24), + text = stringResource(R.string.UsernameLinkShareBottomSheet_share), + modifier = Modifier.padding(bottom = 12.dp), + onClick = { + dismissDialog(false) + + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, usernameLink) + } + + context.startActivity(Intent.createChooser(sendIntent, null)) + } + ) + } +} + +@Composable +private fun ButtonRow(icon: Painter, text: String, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + Icon( + painter = icon, + contentDescription = text, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 16.dp) + ) + Text( + text = text, + modifier = Modifier + .padding(vertical = 16.dp) + ) + } +} + +@Preview(name = "Light Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + SignalTheme { + Surface { + Content( + usernameLink = "https://signal.me#eufzLWmFFUYAOqnVJ4Zlt0KqXf87r59FC1hZ3r7WipjKvgzMBg7DBlY5DB5hQTjsw0", + scope = rememberCoroutineScope() + ) + } + } +} + +@Preview(name = "Light Theme", group = "button row", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "button row", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ButtonRowPreview() { + SignalTheme { + Surface { + ButtonRow(icon = painterResource(R.drawable.symbol_share_android_24), text = "Share") + } + } +} 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 c4e27c878e..903b114239 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 @@ -59,6 +59,8 @@ fun UsernameLinkShareScreen( modifier: Modifier = Modifier, onResetClicked: () -> Unit ) { + val context = LocalContext.current + when (state.usernameLinkResetResult) { UsernameLinkResetResult.NetworkUnavailable -> { ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_unavailable), onDismiss = onLinkResultHandled) @@ -81,7 +83,8 @@ fun UsernameLinkShareScreen( username = state.username, usernameCopyable = true, modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp), - onClick = { + onClick = { username -> + Util.copyToClipboard(context, username) scope.launch { snackbarHostState.showSnackbar(usernameCopiedString) } @@ -95,8 +98,9 @@ fun UsernameLinkShareScreen( LinkRow( linkState = state.usernameLinkState, - snackbarHostState = snackbarHostState, - scope = scope + onClick = { + navController.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkShareBottomSheet()) + } ) Text( @@ -142,9 +146,7 @@ private fun ButtonBar(onShareClicked: () -> Unit, onColorClicked: () -> Unit) { } @Composable -private fun LinkRow(linkState: UsernameLinkState, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { - val context = LocalContext.current - val copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast) +private fun LinkRow(linkState: UsernameLinkState, onClick: () -> Unit = {}) { Row( modifier = Modifier .fillMaxWidth() @@ -161,11 +163,7 @@ private fun LinkRow(linkState: UsernameLinkState, snackbarHostState: SnackbarHos shape = RoundedCornerShape(12.dp) ) .clickable(enabled = linkState is UsernameLinkState.Present) { - Util.copyToClipboard(context, (linkState as UsernameLinkState.Present).link) - - scope.launch { - snackbarHostState.showSnackbar(copyMessage) - } + onClick() } .padding(horizontal = 26.dp, vertical = 16.dp) .alpha(if (linkState is UsernameLinkState.Present) 1.0f else 0.6f) @@ -225,19 +223,13 @@ private fun LinkRowPreview() { Surface { Column(modifier = Modifier.padding(8.dp)) { LinkRow( - linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"), - snackbarHostState = SnackbarHostState(), - scope = rememberCoroutineScope() + linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf") ) LinkRow( - linkState = UsernameLinkState.NotSet, - snackbarHostState = SnackbarHostState(), - scope = rememberCoroutineScope() + linkState = UsernameLinkState.NotSet ) LinkRow( - linkState = UsernameLinkState.Resetting, - snackbarHostState = SnackbarHostState(), - scope = rememberCoroutineScope() + linkState = UsernameLinkState.Resetting ) } } diff --git a/app/src/main/res/navigation/username_link_settings.xml b/app/src/main/res/navigation/username_link_settings.xml index e2154c2b06..1ad99e7297 100644 --- a/app/src/main/res/navigation/username_link_settings.xml +++ b/app/src/main/res/navigation/username_link_settings.xml @@ -20,10 +20,18 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bad82c1d15..9b6e058621 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6401,6 +6401,13 @@ A network error occurred while trying to reset your link. Try again later. + + Anyone with this link can view your username and start a chat with you. Only share it with people you trust. + + Copy link + + Share + Would like to join…