diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index c9aaf77766..43b045c83a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -890,11 +890,11 @@ public final class ContactSelectionListFragment extends LoggingFragment { return ContactSearchConfiguration.build(builder -> { builder.setQuery(contactSearchState.getQuery()); - if (newConversationCallback != null) { + if (newConversationCallback != null && !hasQuery) { builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode()); } - if (findByCallback != null) { + if (findByCallback != null && !hasQuery) { builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode()); builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode()); } @@ -913,10 +913,11 @@ public final class ContactSelectionListFragment extends LoggingFragment { )); } + boolean hideHeader = newCallCallback != null || (newConversationCallback != null && !hasQuery); builder.addSection(new ContactSearchConfiguration.Section.Individuals( includeSelf, transportType, - newCallCallback == null && findByCallback == null, + !hideHeader, null, !hideLetterHeaders() )); @@ -944,7 +945,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { builder.username(newRowMode); } - if (newCallCallback != null || newConversationCallback != null) { + if ((newCallCallback != null || newConversationCallback != null) && !hasQuery) { addMoreSection(builder); builder.withEmptyState(emptyBuilder -> { emptyBuilder.addSection(ContactSearchConfiguration.Section.Empty.INSTANCE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt index 3e39b3a0e4..62a04d2324 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt @@ -398,15 +398,22 @@ open class ContactSearchAdapter( override fun bind(model: UnknownRecipientModel) { checkbox.visible = displayCheckBox checkbox.isSelected = false - name.setText( - when (model.data.mode) { - ContactSearchConfiguration.NewRowMode.NEW_CALL -> R.string.contact_selection_list__new_call - ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> R.string.contact_selection_list__unknown_contact - ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block - ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group - } - ) - number.text = model.data.query + val nameText = when (model.data.mode) { + ContactSearchConfiguration.NewRowMode.NEW_CALL -> R.string.contact_selection_list__new_call + ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> -1 + ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block + ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group + } + + if (nameText > 0) { + name.setText(nameText) + number.text = model.data.query + number.visible = true + } else { + name.text = model.data.query + number.visible = false + } + itemView.setOnClickListener { onClick.onClicked(itemView, model.data, false) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/compose/Permissions.kt b/app/src/main/java/org/thoughtcrime/securesms/permissions/compose/Permissions.kt new file mode 100644 index 0000000000..c2b5169c5c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/compose/Permissions.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.permissions.compose + +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.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import org.signal.core.ui.Dialogs +import org.thoughtcrime.securesms.R + +/** + * Dialogs and state management for permissions requests in compose screens. + */ +object Permissions { + + interface Controller { + fun request() + } + + private enum class RequestState { + NONE, + RATIONALE, + SYSTEM + } + + @Composable + fun cameraPermissionHandler( + rationale: String, + onPermissionGranted: () -> Unit + ): Controller { + return permissionHandler( + permission = android.Manifest.permission.CAMERA, + icon = painterResource(id = R.drawable.symbol_camera_24), + rationale = rationale, + onPermissionGranted = onPermissionGranted + ) + } + + /** + * Generic permissions rationale dialog and state management for single permissions. + */ + @OptIn(ExperimentalPermissionsApi::class) + @Composable + fun permissionHandler( + permission: String, + icon: Painter, + rationale: String, + onPermissionGranted: () -> Unit + ): Controller { + var requestState by remember { + mutableStateOf(RequestState.NONE) + } + + val permissionState = rememberPermissionState(permission = permission) { + if (it && requestState == RequestState.SYSTEM) { + onPermissionGranted() + } + } + + if (requestState == RequestState.RATIONALE) { + Dialogs.PermissionRationaleDialog( + icon = icon, + rationale = rationale, + confirm = stringResource(id = R.string.Permissions_continue), + dismiss = stringResource(id = R.string.Permissions_not_now), + onConfirm = { + requestState = RequestState.SYSTEM + permissionState.launchPermissionRequest() + }, + onDismiss = { + requestState = RequestState.NONE + } + ) + } + + return object : Controller { + override fun request() { + if (permissionState.status.isGranted) { + requestState = RequestState.NONE + onPermissionGranted() + } else { + requestState = RequestState.RATIONALE + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java index b758118fb7..e65d3cbb72 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java @@ -4,6 +4,8 @@ import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.DialogInterface; import android.content.Intent; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.text.SpannableStringBuilder; @@ -185,25 +187,20 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF : recipient.getDisplayName(requireContext()); fullName.setVisibility(TextUtils.isEmpty(name) ? View.GONE : View.VISIBLE); SpannableStringBuilder nameBuilder = new SpannableStringBuilder(name); - boolean appendedToName = false; if (recipient.showVerified()) { - appendedToName = true; - SpanUtil.appendCenteredImageSpan(nameBuilder, ContextUtil.requireDrawable(requireContext(), R.drawable.ic_official_28), 28, 28); + SpanUtil.appendCenteredImageSpanWithoutSpace(nameBuilder, new ColorDrawable(Color.TRANSPARENT), 8, 8); + SpanUtil.appendCenteredImageSpanWithoutSpace(nameBuilder, ContextUtil.requireDrawable(requireContext(), R.drawable.ic_official_28), 28, 28); } else if (recipient.isSystemContact()) { - appendedToName = true; Drawable drawable = ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_person_circle_24); drawable.setTint(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurface)); - SpanUtil.appendCenteredImageSpan(nameBuilder, drawable, 24, 24); + SpanUtil.appendCenteredImageSpanWithoutSpace(nameBuilder, new ColorDrawable(Color.TRANSPARENT), 8, 8); + SpanUtil.appendCenteredImageSpanWithoutSpace(nameBuilder, drawable, 24, 24); } if (!recipient.isSelf() && recipient.isIndividual()) { Drawable drawable = ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_chevron_right_24); drawable.setBounds(0, 0, (int) DimensionUnit.DP.toPixels(24), (int) DimensionUnit.DP.toPixels(24)); drawable.setTint(ContextCompat.getColor(requireContext(), R.color.signal_colorOutline)); - - if (!appendedToName) { - nameBuilder.append(" "); - } nameBuilder.append(SpanUtil.buildCenteredImageSpan(drawable)); fullName.setText(nameBuilder); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt index cd97a9f779..576ef8089c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt @@ -66,6 +66,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.dialog import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.google.accompanist.permissions.ExperimentalPermissionsApi import kotlinx.coroutines.launch import org.signal.core.ui.Animations.navHostSlideInTransition import org.signal.core.ui.Animations.navHostSlideOutTransition @@ -81,6 +82,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameQrScannerActivity import org.thoughtcrime.securesms.invites.InviteActions +import org.thoughtcrime.securesms.permissions.compose.Permissions import org.thoughtcrime.securesms.phonenumbers.PhoneNumberVisualTransformation import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -103,6 +105,7 @@ class FindByActivity : PassphraseRequiredActivity() { FindByViewModel(FindByMode.valueOf(intent.getStringExtra(MODE)!!)) } + @OptIn(ExperimentalPermissionsApi::class) override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { val qrScanLauncher: ActivityResultLauncher = registerForActivityResult(UsernameQrScannerActivity.Contract()) { recipientId -> if (recipientId != null) { @@ -136,6 +139,14 @@ class FindByActivity : PassphraseRequiredActivity() { navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24) ) { val context = LocalContext.current + + val cameraPermissionController = Permissions.cameraPermissionHandler( + rationale = stringResource(id = R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs_access_to_the_camera), + onPermissionGranted = { + qrScanLauncher.launch(Unit) + } + ) + Content( paddingValues = it, state = state, @@ -157,7 +168,7 @@ class FindByActivity : PassphraseRequiredActivity() { navController.navigate("select-country-prefix") }, onQrCodeScanClicked = { - qrScanLauncher.launch(Unit) + cameraPermissionController.request() } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java index f97c23b6da..3a78a7bc7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java @@ -147,6 +147,11 @@ public final class SpanUtil { builder.append(" ").append(SpanUtil.buildCenteredImageSpan(drawable)); } + public static void appendCenteredImageSpanWithoutSpace(@NonNull SpannableStringBuilder builder, @NonNull Drawable drawable, int width, int height) { + drawable.setBounds(0, 0, ViewUtil.dpToPx(width), ViewUtil.dpToPx(height)); + builder.append(SpanUtil.buildCenteredImageSpan(drawable)); + } + public static CharSequence learnMore(@NonNull Context context, @ColorInt int color, @NonNull View.OnClickListener onLearnMoreClicked) diff --git a/app/src/main/res/layout/contact_search_unknown_item.xml b/app/src/main/res/layout/contact_search_unknown_item.xml index 62626a9e1c..a807b3231a 100644 --- a/app/src/main/res/layout/contact_search_unknown_item.xml +++ b/app/src/main/res/layout/contact_search_unknown_item.xml @@ -22,7 +22,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle" - app:srcCompat="@drawable/ic_search_24" + app:srcCompat="@drawable/symbol_search_24" app:tint="@color/signal_colorOnSecondaryContainer" tools:ignore="UnusedAttribute" /> diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt index 2defa929d0..1cc5756642 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -1,17 +1,34 @@ package org.signal.core.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow 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.material3.AlertDialogDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import org.signal.core.ui.Dialogs.PermissionRationaleDialog import org.signal.core.ui.Dialogs.SimpleAlertDialog import org.signal.core.ui.Dialogs.SimpleMessageDialog @@ -31,7 +48,11 @@ object Dialogs { ) { androidx.compose.material3.AlertDialog( onDismissRequest = onDismiss, - title = if (title == null) null else { { Text(text = title) } }, + title = if (title == null) { + null + } else { + { Text(text = title) } + }, text = { Text(text = message) }, confirmButton = { TextButton(onClick = { @@ -105,6 +126,82 @@ object Dialogs { .size(100.dp) ) } + + @OptIn(ExperimentalLayoutApi::class) + @Composable + fun PermissionRationaleDialog( + icon: Painter, + rationale: String, + confirm: String, + dismiss: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit + ) { + Dialog( + onDismissRequest = onDismiss + ) { + Surface( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = AlertDialogDefaults.shape + ) + .clip(AlertDialogDefaults.shape) + ) { + Column { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.primary) + .padding(40.dp) + ) { + Icon( + painter = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + Text( + text = rationale, + modifier = Modifier + .padding(top = 20.dp) + .padding(horizontal = 20.dp) + ) + + FlowRow( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + TextButton(onClick = onDismiss) { + Text(text = dismiss) + } + + TextButton(onClick = onConfirm) { + Text(text = confirm) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun PermissionRationaleDialogPreview() { + Previews.Preview { + PermissionRationaleDialog( + icon = painterResource(id = android.R.drawable.ic_menu_camera), + rationale = "This is rationale text about why we need permission.", + confirm = "Continue", + dismiss = "Not now", + onConfirm = {}, + onDismiss = {} + ) + } } @Preview