From a3e36d2453c9cb6b981693bdd38225020a1c0c99 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 29 Aug 2023 16:48:46 -0300 Subject: [PATCH] Update target API to 33 --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 6 + .../avatar/picker/AvatarPickerFragment.kt | 3 +- .../NotificationsSettingsFragment.kt | 24 ++ .../NotificationsSettingsState.kt | 2 + .../NotificationsSettingsViewModel.kt | 47 ++- .../components/settings/models/Banner.kt | 44 +++ .../v2/ConversationActivityResultContracts.kt | 3 +- .../v2/keyboard/AttachmentKeyboardFragment.kt | 4 +- .../DeviceTransferSetupFragment.java | 8 +- .../DeviceTransferSetupViewModel.java | 2 +- .../mediasend/v2/MediaSelectionNavigator.kt | 3 +- .../securesms/megaphone/Megaphones.java | 14 +- .../securesms/mms/AttachmentManager.java | 3 +- .../TurnOnNotificationsBottomSheet.kt | 153 +++++++++ .../securesms/permissions/PermissionCompat.kt | 37 +++ .../securesms/permissions/Permissions.java | 8 +- .../fragments/GrantPermissionsFragment.kt | 303 ++++++++++++++++++ .../fragments/WelcomeFragment.java | 160 ++++----- .../fragments/WelcomePermissions.kt | 53 +++ .../securesms/util/StorageUtil.java | 11 +- .../ChatWallpaperSelectionFragment.java | 3 +- .../crop/WallpaperImageSelectionActivity.java | 3 - .../illustration_toggle_switch.xml | 12 + .../drawable/illustration_toggle_switch.xml | 12 + .../main/res/drawable/permission_contact.xml | 20 ++ app/src/main/res/drawable/permission_file.xml | 19 ++ .../res/drawable/permission_notification.xml | 22 ++ .../main/res/drawable/permission_phone.xml | 16 + app/src/main/res/layout/dsl_banner.xml | 51 +++ app/src/main/res/navigation/registration.xml | 43 +++ app/src/main/res/values/light_colors.xml | 1 + app/src/main/res/values/strings.xml | 42 +++ .../fragments/WelcomePermissionsTest.kt | 97 ++++++ constants.gradle.kts | 2 +- dependencies.gradle | 2 +- .../org/signal/devicetransfer/WifiDirect.java | 17 +- gradle/verification-metadata.xml | 188 +++++++---- 38 files changed, 1236 insertions(+), 203 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Banner.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/notifications/TurnOnNotificationsBottomSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionCompat.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registration/fragments/GrantPermissionsFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomePermissions.kt create mode 100644 app/src/main/res/drawable-night/illustration_toggle_switch.xml create mode 100644 app/src/main/res/drawable/illustration_toggle_switch.xml create mode 100644 app/src/main/res/drawable/permission_contact.xml create mode 100644 app/src/main/res/drawable/permission_file.xml create mode 100644 app/src/main/res/drawable/permission_notification.xml create mode 100644 app/src/main/res/drawable/permission_phone.xml create mode 100644 app/src/main/res/layout/dsl_banner.xml create mode 100644 app/src/test/java/org/thoughtcrime/securesms/registration/fragments/WelcomePermissionsTest.kt diff --git a/app/build.gradle b/app/build.gradle index 7b24bc1906..53ebaf3047 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -596,6 +596,7 @@ dependencies { testImplementation testLibs.robolectric.shadows.multidex testImplementation (testLibs.bouncycastle.bcprov.jdk15on) { version { strictly "1.70" } } // Used by roboelectric testImplementation (testLibs.bouncycastle.bcpkix.jdk15on) { version { strictly "1.70" } } // Used by roboelectric + testImplementation testLibs.conscrypt.openjdk.uber // Used by robolectric testImplementation testLibs.hamcrest.hamcrest testImplementation testLibs.mockk diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ad0e20c978..c6d3b5f0d6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,8 @@ + + @@ -94,6 +96,10 @@ + + + + = store.stateLiveData + fun refresh() { + store.update { getState() } + } + fun setMessageNotificationsEnabled(enabled: Boolean) { SignalStore.settings().isMessageNotificationsEnabled = enabled - store.update { getState() } + refresh() } fun setMessageNotificationsSound(sound: Uri?) { val messageSound = sound ?: Uri.EMPTY SignalStore.settings().messageNotificationSound = messageSound NotificationChannels.getInstance().updateMessageRingtone(messageSound) - store.update { getState() } + refresh() } fun setMessageNotificationVibration(enabled: Boolean) { SignalStore.settings().isMessageVibrateEnabled = enabled NotificationChannels.getInstance().updateMessageVibrate(enabled) - store.update { getState() } + refresh() } fun setMessageNotificationLedColor(color: String) { SignalStore.settings().messageLedColor = color NotificationChannels.getInstance().updateMessagesLedColor(color) - store.update { getState() } + refresh() } fun setMessageNotificationLedBlink(blink: String) { SignalStore.settings().messageLedBlinkPattern = blink - store.update { getState() } + refresh() } fun setMessageNotificationInChatSoundsEnabled(enabled: Boolean) { SignalStore.settings().isMessageNotificationsInChatSoundsEnabled = enabled - store.update { getState() } + refresh() } fun setMessageRepeatAlerts(repeats: Int) { SignalStore.settings().messageNotificationsRepeatAlerts = repeats - store.update { getState() } + refresh() } fun setMessageNotificationPrivacy(preference: String) { SignalStore.settings().messageNotificationsPrivacy = NotificationPrivacyPreference(preference) - store.update { getState() } + refresh() } fun setMessageNotificationPriority(priority: Int) { sharedPreferences.edit().putString(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF, priority.toString()).apply() - store.update { getState() } + refresh() } fun setCallNotificationsEnabled(enabled: Boolean) { SignalStore.settings().isCallNotificationsEnabled = enabled - store.update { getState() } + refresh() } fun setCallRingtone(ringtone: Uri?) { SignalStore.settings().callRingtone = ringtone ?: Uri.EMPTY - store.update { getState() } + refresh() } fun setCallVibrateEnabled(enabled: Boolean) { SignalStore.settings().isCallVibrateEnabled = enabled - store.update { getState() } + refresh() } fun setNotifyWhenContactJoinsSignal(enabled: Boolean) { SignalStore.settings().isNotifyWhenContactJoinsSignal = enabled - store.update { getState() } + refresh() } private fun getState(): NotificationsSettingsState = NotificationsSettingsState( messageNotificationsState = MessageNotificationsState( - notificationsEnabled = SignalStore.settings().isMessageNotificationsEnabled, + notificationsEnabled = SignalStore.settings().isMessageNotificationsEnabled && canEnableNotifications(), + canEnableNotifications = canEnableNotifications(), sound = SignalStore.settings().messageNotificationSound, vibrateEnabled = SignalStore.settings().isMessageVibrateEnabled, ledColor = SignalStore.settings().messageLedColor, @@ -109,13 +115,24 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer troubleshootNotifications = SlowNotificationHeuristics.isPotentiallyCausedByBatteryOptimizations() && SlowNotificationHeuristics.isHavingDelayedNotifications() ), callNotificationsState = CallNotificationsState( - notificationsEnabled = SignalStore.settings().isCallNotificationsEnabled, + notificationsEnabled = SignalStore.settings().isCallNotificationsEnabled && canEnableNotifications(), + canEnableNotifications = canEnableNotifications(), ringtone = SignalStore.settings().callRingtone, vibrateEnabled = SignalStore.settings().isCallVibrateEnabled ), notifyWhenContactJoinsSignal = SignalStore.settings().isNotifyWhenContactJoinsSignal ) + private fun canEnableNotifications(): Boolean { + val areNotificationsDisabledBySystem = Build.VERSION.SDK_INT >= 26 && ( + !NotificationChannels.getInstance().isMessageChannelEnabled || + !NotificationChannels.getInstance().isMessagesChannelGroupEnabled || + !NotificationChannels.getInstance().areNotificationsEnabled() + ) + + return !areNotificationsDisabledBySystem + } + class Factory(private val sharedPreferences: SharedPreferences) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return requireNotNull(modelClass.cast(NotificationsSettingsViewModel(sharedPreferences))) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Banner.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Banner.kt new file mode 100644 index 0000000000..4fa3527e49 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Banner.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.models + +import androidx.annotation.StringRes +import org.thoughtcrime.securesms.databinding.DslBannerBinding +import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory +import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel + +/** + * Displays a banner to notify the user of certain state or action that needs to be taken. + */ +object Banner { + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, DslBannerBinding::inflate)) + } + + class Model( + @StringRes val textId: Int, + @StringRes val actionId: Int, + val onClick: () -> Unit + ) : MappingModel { + override fun areItemsTheSame(newItem: Model): Boolean { + return true + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return textId == newItem.textId && actionId == newItem.actionId + } + } + + private class ViewHolder(binding: DslBannerBinding) : BindingViewHolder(binding) { + override fun bind(model: Model) { + binding.bannerText.setText(model.textId) + binding.bannerAction.setText(model.actionId) + binding.bannerAction.setOnClickListener { model.onClick() } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt index 05bba72eb1..e38cadc7b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt @@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.maps.PlacePickerActivity import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity +import org.thoughtcrime.securesms.permissions.PermissionCompat import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.recipients.RecipientId @@ -72,7 +73,7 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat fun launchGallery(recipientId: RecipientId, text: CharSequence?, isReply: Boolean) { Permissions .with(fragment) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .request(*PermissionCompat.forImagesAndVideos()) .ifNecessary() .withPermanentDenialDialog(fragment.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) .onAllGranted { mediaGalleryLauncher.launch(MediaSelectionInput(emptyList(), recipientId, text, isReply)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt index f386ce1f6e..8092447173 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt @@ -5,7 +5,6 @@ package org.thoughtcrime.securesms.conversation.v2.keyboard -import android.Manifest import android.os.Bundle import android.view.View import androidx.core.os.bundleOf @@ -23,6 +22,7 @@ import org.thoughtcrime.securesms.conversation.AttachmentKeyboardButton import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.permissions.PermissionCompat import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.recipients.Recipient import java.util.function.Predicate @@ -93,7 +93,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_ override fun onAttachmentPermissionsRequested() { Permissions.with(requireParentFragment()) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .request(*PermissionCompat.forImagesAndVideos()) .onAllGranted { viewModel.refreshRecentMedia() } .withPermanentDenialDialog(getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) .execute() diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupFragment.java index ba9cd3f1c2..2a7ec0b2de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupFragment.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.devicetransfer; -import android.Manifest; import android.content.ActivityNotFoundException; import android.content.Intent; import android.location.LocationManager; @@ -16,7 +15,6 @@ import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.Group; import androidx.core.content.ContextCompat; import androidx.lifecycle.ViewModelProvider; @@ -100,7 +98,7 @@ public abstract class DeviceTransferSetupFragment extends LoggingFragment { case INITIAL: status.setText(""); case PERMISSIONS_CHECK: - requestLocationPermission(); + requestRequiredPermission(); break; case PERMISSIONS_DENIED: error.setText(getErrorTextForStep(step)); @@ -280,9 +278,9 @@ public abstract class DeviceTransferSetupFragment extends LoggingFragment { super.onDestroyView(); } - private void requestLocationPermission() { + private void requestRequiredPermission() { Permissions.with(this) - .request(Manifest.permission.ACCESS_FINE_LOCATION) + .request(WifiDirect.requiredPermission()) .ifNecessary() .withRationaleDialog(getString(getErrorTextForStep(SetupStep.PERMISSIONS_DENIED)), false, R.drawable.ic_location_on_white_24dp) .withPermanentDenialDialog(getString(getErrorTextForStep(SetupStep.PERMISSIONS_DENIED))) diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupViewModel.java index e240b539bc..1f01667756 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupViewModel.java @@ -78,7 +78,7 @@ public final class DeviceTransferSetupViewModel extends ViewModel { public void onWifiDirectUnavailable(WifiDirect.AvailableStatus availability) { Log.i(TAG, "Wifi Direct unavailable: " + availability); - if (availability == WifiDirect.AvailableStatus.FINE_LOCATION_PERMISSION_NOT_GRANTED) { + if (availability == WifiDirect.AvailableStatus.REQUIRED_PERMISSION_NOT_GRANTED) { store.update(s -> s.updateStep(SetupStep.PERMISSIONS_CHECK)); } else { store.update(s -> s.updateStep(SetupStep.WIFI_DIRECT_UNAVAILABLE)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionNavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionNavigator.kt index f0ad76c318..c55efd9858 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionNavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionNavigator.kt @@ -5,6 +5,7 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.navigation.NavController import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.permissions.PermissionCompat import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -46,7 +47,7 @@ class MediaSelectionNavigator( onGranted: () -> Unit ) { Permissions.with(this) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .request(*PermissionCompat.forImagesAndVideos()) .ifNecessary() .withPermanentDenialDialog(getString(R.string.AttachmentKeyboard_Signal_needs_permission_to_show_your_photos_and_videos)) .onAllGranted(onGranted) diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index 07a91fb5a9..8143d6f3a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity; import org.thoughtcrime.securesms.lock.v2.SvrMigrationActivity; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.notifications.TurnOnNotificationsBottomSheet; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity; import org.thoughtcrime.securesms.recipients.Recipient; @@ -232,17 +233,8 @@ public final class Megaphones { .setBody(R.string.NotificationsMegaphone_never_miss_a_message) .setImage(R.drawable.megaphone_notifications_64) .setActionButton(R.string.NotificationsMegaphone_turn_on, (megaphone, controller) -> { - if (Build.VERSION.SDK_INT >= 26 && !NotificationChannels.getInstance().isMessageChannelEnabled()) { - Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); - intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.getInstance().getMessagesChannel()); - intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()); - controller.onMegaphoneNavigationRequested(intent); - } else if (Build.VERSION.SDK_INT >= 26 && - (!NotificationChannels.getInstance().areNotificationsEnabled() || !NotificationChannels.getInstance().isMessagesChannelGroupEnabled())) - { - Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS); - intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()); - controller.onMegaphoneNavigationRequested(intent); + if (Build.VERSION.SDK_INT >= 26) { + controller.onMegaphoneDialogFragmentRequested(new TurnOnNotificationsBottomSheet()); } else { controller.onMegaphoneNavigationRequested(AppSettingsActivity.notifications(context)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index da3f71f1f1..3ae596337b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -67,6 +67,7 @@ import org.thoughtcrime.securesms.payments.create.CreatePaymentFragmentArgs; import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity; import org.thoughtcrime.securesms.payments.preferences.RecipientHasNotEnabledPaymentsDialog; import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable; +import org.thoughtcrime.securesms.permissions.PermissionCompat; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider; @@ -407,7 +408,7 @@ public class AttachmentManager { public static void selectGallery(Fragment fragment, int requestCode, @NonNull Recipient recipient, @NonNull CharSequence body, @NonNull MessageSendType messageSendType, boolean hasQuote) { Permissions.with(fragment) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .request(PermissionCompat.forImagesAndVideos()) .ifNecessary() .withPermanentDenialDialog(fragment.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) .onAllGranted(() -> fragment.startActivityForResult(MediaSelectionActivity.gallery(fragment.requireContext(), messageSendType, Collections.emptyList(), recipient.getId(), body, hasQuote), requestCode)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/TurnOnNotificationsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/TurnOnNotificationsBottomSheet.kt new file mode 100644 index 0000000000..025ca41a4b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/TurnOnNotificationsBottomSheet.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.notifications + +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Buttons +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment + +private const val PLACEHOLDER = "__TOGGLE_PLACEHOLDER__" + +/** + * Sheet explaining how to turn on notifications and providing an action to do so. + */ +class TurnOnNotificationsBottomSheet : ComposeBottomSheetDialogFragment() { + + @Composable + override fun SheetContent() { + TurnOnNotificationsSheetContent(this::goToSystemNotificationSettings) + } + + private fun goToSystemNotificationSettings() { + if (Build.VERSION.SDK_INT >= 26 && !NotificationChannels.getInstance().isMessageChannelEnabled) { + val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.getInstance().messagesChannel) + intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) + startActivity(intent) + } else if (Build.VERSION.SDK_INT >= 26 && (!NotificationChannels.getInstance().areNotificationsEnabled() || !NotificationChannels.getInstance().isMessagesChannelGroupEnabled)) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) + startActivity(intent) + } else { + startActivity(AppSettingsActivity.notifications(requireContext())) + } + + dismissAllowingStateLoss() + } +} + +@Preview +@Composable +private fun TurnOnNotificationsSheetContentPreview() { + SignalTheme(isDarkMode = false) { + Surface { + TurnOnNotificationsSheetContent {} + } + } +} + +@Composable +private fun TurnOnNotificationsSheetContent( + onGoToSettingsClicked: () -> Unit +) { + Column( + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) + .padding(bottom = 32.dp) + ) { + BottomSheets.Handle( + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Text( + text = stringResource(R.string.TurnOnNotificationsBottomSheet__turn_on_notifications), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 12.dp, top = 10.dp) + ) + + Text( + text = stringResource(R.string.TurnOnNotificationsBottomSheet__to_receive_notifications), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 32.dp) + ) + + Text( + text = stringResource(R.string.TurnOnNotificationsBottomSheet__1_tap_settings_below), + modifier = Modifier.padding(bottom = 32.dp) + ) + + val step2String = stringResource(id = R.string.TurnOnNotificationsBottomSheet__2_s_turn_on_notifications, PLACEHOLDER) + val (step2Text, step2InlineContent) = remember(step2String) { + val parts = step2String.split(PLACEHOLDER) + val annotatedString = buildAnnotatedString { + append(parts[0]) + appendInlineContent("toggle") + append(parts[1]) + } + + val inlineContentMap = mapOf( + "toggle" to InlineTextContent(Placeholder(36.sp, 22.sp, PlaceholderVerticalAlign.Center)) { + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.illustration_toggle_switch), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } + ) + + annotatedString to inlineContentMap + } + + Text( + text = step2Text, + inlineContent = step2InlineContent, + modifier = Modifier.padding(bottom = 32.dp) + ) + + Buttons.LargeTonal( + onClick = onGoToSettingsClicked, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth(1f) + ) { + Text(text = stringResource(id = R.string.TurnOnNotificationsBottomSheet__settings)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionCompat.kt b/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionCompat.kt new file mode 100644 index 0000000000..34ab560d2a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionCompat.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.permissions + +import android.Manifest +import android.os.Build + +/** + * Compatibility object for requesting specific permissions that have become more + * granular as the APIs have evolved. + */ +object PermissionCompat { + @JvmStatic + fun forImages(): Array { + return if (Build.VERSION.SDK_INT >= 33) { + arrayOf(Manifest.permission.READ_MEDIA_IMAGES) + } else { + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + private fun forVideos(): Array { + return if (Build.VERSION.SDK_INT >= 33) { + arrayOf(Manifest.permission.READ_MEDIA_VIDEO) + } else { + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + @JvmStatic + fun forImagesAndVideos(): Array { + return setOf(*(forImages() + forVideos())).toTypedArray() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java index 6139261b8a..7a2cfe864d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java @@ -49,6 +49,10 @@ public class Permissions { return new PermissionsBuilder(new FragmentPermissionObject(fragment)); } + public static boolean isRuntimePermissionsRequired() { + return Build.VERSION.SDK_INT >= 23; + } + public static class PermissionsBuilder { private final PermissionObject permissionObject; @@ -239,13 +243,13 @@ public class Permissions { } public static boolean hasAny(@NonNull Context context, String... permissions) { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || + return !isRuntimePermissionsRequired() || Stream.of(permissions).anyMatch(permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED); } public static boolean hasAll(@NonNull Context context, String... permissions) { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || + return !isRuntimePermissionsRequired() || Stream.of(permissions).allMatch(permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/GrantPermissionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/GrantPermissionsFragment.kt new file mode 100644 index 0000000000..94ccdbdab8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/GrantPermissionsFragment.kt @@ -0,0 +1,303 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.fragments + +import android.os.Build +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.navArgs +import org.signal.core.ui.Buttons +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel +import org.thoughtcrime.securesms.util.BackupUtil + +/** + * Fragment displayed during registration which allows a user to read through + * what permissions are granted to Signal and why, and a means to either skip + * granting those permissions or continue to grant via system dialogs. + */ +class GrantPermissionsFragment : ComposeFragment() { + + private val args by navArgs() + private val viewModel by activityViewModels() + private val isSearchingForBackup = mutableStateOf(false) + + @Composable + override fun FragmentContent() { + val isSearchingForBackup by this.isSearchingForBackup + + GrantPermissionsScreen( + deviceBuildVersion = Build.VERSION.SDK_INT, + isSearchingForBackup = isSearchingForBackup, + isBackupSelectionRequired = BackupUtil.isUserSelectionRequired(LocalContext.current), + onNextClicked = this::onNextClicked, + onNotNowClicked = this::onNotNowClicked + ) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + + private fun onNextClicked() { + when (args.welcomeAction) { + WelcomeAction.CONTINUE -> { + WelcomeFragment.continueClicked( + this, + viewModel, + { isSearchingForBackup.value = true }, + { isSearchingForBackup.value = false }, + GrantPermissionsFragmentDirections.actionSkipRestore(), + GrantPermissionsFragmentDirections.actionRestore() + ) + } + + WelcomeAction.RESTORE_BACKUP -> { + WelcomeFragment.restoreFromBackupClicked( + this, + viewModel, + GrantPermissionsFragmentDirections.actionTransferOrRestore() + ) + } + } + } + + private fun onNotNowClicked() { + when (args.welcomeAction) { + WelcomeAction.CONTINUE -> { + WelcomeFragment.gatherInformationAndContinue( + this, + viewModel, + { isSearchingForBackup.value = true }, + { isSearchingForBackup.value = false }, + GrantPermissionsFragmentDirections.actionSkipRestore(), + GrantPermissionsFragmentDirections.actionRestore() + ) + } + + WelcomeAction.RESTORE_BACKUP -> { + WelcomeFragment.gatherInformationAndChooseBackup( + this, + viewModel, + GrantPermissionsFragmentDirections.actionTransferOrRestore() + ) + } + } + } + + /** + * Which welcome action the user selected which prompted this + * screen. + */ + enum class WelcomeAction { + CONTINUE, + RESTORE_BACKUP + } +} + +@Preview +@Composable +fun GrantPermissionsScreenPreview() { + SignalTheme(isDarkMode = false) { + GrantPermissionsScreen( + deviceBuildVersion = 33, + isBackupSelectionRequired = true, + isSearchingForBackup = true, + {}, + {} + ) + } +} + +@Composable +fun GrantPermissionsScreen( + deviceBuildVersion: Int, + isBackupSelectionRequired: Boolean, + isSearchingForBackup: Boolean, + onNextClicked: () -> Unit, + onNotNowClicked: () -> Unit +) { + Surface { + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(top = 40.dp, bottom = 24.dp) + ) { + LazyColumn( + modifier = Modifier.weight(1f) + ) { + item { + Text( + text = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions), + style = MaterialTheme.typography.headlineMedium + ) + } + + item { + Text( + text = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp, bottom = 41.dp) + ) + } + + if (deviceBuildVersion >= 33) { + item { + PermissionRow( + imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification), + title = stringResource(id = R.string.GrantPermissionsFragment__notifications), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when) + ) + } + } + + item { + PermissionRow( + imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact), + title = stringResource(id = R.string.GrantPermissionsFragment__contacts), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know) + ) + } + + if (deviceBuildVersion < 29 || !isBackupSelectionRequired) { + item { + PermissionRow( + imageVector = ImageVector.vectorResource(id = R.drawable.permission_file), + title = stringResource(id = R.string.GrantPermissionsFragment__storage), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files) + ) + } + } + + item { + PermissionRow( + imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone), + title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier) + ) + } + } + + Row { + TextButton(onClick = onNotNowClicked) { + Text( + text = stringResource(id = R.string.GrantPermissionsFragment__not_now) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + if (isSearchingForBackup) { + Box { + NextButton( + isSearchingForBackup = true, + onNextClicked = onNextClicked + ) + + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } else { + NextButton( + isSearchingForBackup = false, + onNextClicked = onNextClicked + ) + } + } + } + } +} + +@Preview +@Composable +fun PermissionRowPreview() { + PermissionRow( + imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification), + title = stringResource(id = R.string.GrantPermissionsFragment__notifications), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when) + ) +} + +@Composable +fun PermissionRow( + imageVector: ImageVector, + title: String, + subtitle: String +) { + Row(modifier = Modifier.padding(bottom = 32.dp)) { + Image( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.size(16.dp)) + + Column { + Text( + text = title, + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.size(32.dp)) + } +} + +@Composable +fun NextButton( + isSearchingForBackup: Boolean, + onNextClicked: () -> Unit +) { + val alpha = if (isSearchingForBackup) { + 0f + } else { + 1f + } + + Buttons.LargeTonal( + onClick = onNextClicked, + enabled = !isSearchingForBackup, + modifier = Modifier.alpha(alpha) + ) { + Text( + text = stringResource(id = R.string.GrantPermissionsFragment__next) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java index 9a369fad54..d2cfd34d60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java @@ -14,11 +14,11 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.annotation.StringRes; +import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.ActivityNavigator; +import androidx.navigation.NavDirections; import androidx.navigation.Navigation; import androidx.navigation.fragment.NavHostFragment; @@ -49,29 +49,6 @@ public final class WelcomeFragment extends LoggingFragment { private static final String TAG = Log.tag(WelcomeFragment.class); - private static final String[] PERMISSIONS = { Manifest.permission.WRITE_CONTACTS, - Manifest.permission.READ_CONTACTS, - Manifest.permission.WRITE_EXTERNAL_STORAGE, - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.READ_PHONE_STATE }; - @RequiresApi(26) - private static final String[] PERMISSIONS_API_26 = { Manifest.permission.WRITE_CONTACTS, - Manifest.permission.READ_CONTACTS, - Manifest.permission.WRITE_EXTERNAL_STORAGE, - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.READ_PHONE_STATE, - Manifest.permission.READ_PHONE_NUMBERS }; - @RequiresApi(26) - private static final String[] PERMISSIONS_API_29 = { Manifest.permission.WRITE_CONTACTS, - Manifest.permission.READ_CONTACTS, - Manifest.permission.READ_PHONE_STATE, - Manifest.permission.READ_PHONE_NUMBERS }; - - private static final @StringRes int RATIONALE = R.string.RegistrationActivity_signal_needs_access_to_your_contacts_and_media_in_order_to_connect_with_friends; - private static final @StringRes int RATIONALE_API_29 = R.string.RegistrationActivity_signal_needs_access_to_your_contacts_in_order_to_connect_with_friends; - private static final int[] HEADERS = { R.drawable.ic_contacts_white_48dp, R.drawable.ic_folder_white_48dp }; - private static final int[] HEADERS_API_29 = { R.drawable.ic_contacts_white_48dp }; - private CircularProgressMaterialButton continueButton; private RegistrationViewModel viewModel; @@ -97,7 +74,7 @@ public final class WelcomeFragment extends LoggingFragment { return; } - initializeNumber(); + initializeNumber(requireContext(), viewModel); Log.i(TAG, "Skipping restore because this is a reregistration."); viewModel.setWelcomeSkippedOnRestore(); @@ -109,10 +86,10 @@ public final class WelcomeFragment extends LoggingFragment { setDebugLogSubmitMultiTapView(view.findViewById(R.id.title)); continueButton = view.findViewById(R.id.welcome_continue_button); - continueButton.setOnClickListener(this::continueClicked); + continueButton.setOnClickListener(v -> onContinueClicked()); Button restoreFromBackup = view.findViewById(R.id.welcome_transfer_or_restore); - restoreFromBackup.setOnClickListener(this::restoreFromBackupClicked); + restoreFromBackup.setOnClickListener(v -> onRestoreFromBackupClicked()); TextView welcomeTermsButton = view.findViewById(R.id.welcome_terms_button); welcomeTermsButton.setOnClickListener(v -> onTermsClicked()); @@ -139,70 +116,116 @@ public final class WelcomeFragment extends LoggingFragment { } } - private void continueClicked(@NonNull View view) { - boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext()); + private void onContinueClicked() { + if (Permissions.isRuntimePermissionsRequired()) { + NavHostFragment.findNavController(this) + .navigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(GrantPermissionsFragment.WelcomeAction.CONTINUE)); + } else { + gatherInformationAndContinue( + this, + viewModel, + () -> continueButton.setSpinning(), + () -> continueButton.cancelSpinning(), + WelcomeFragmentDirections.actionSkipRestore(), + WelcomeFragmentDirections.actionRestore() + ); + } + } - Permissions.with(this) - .request(getContinuePermissions(isUserSelectionRequired)) + private void onRestoreFromBackupClicked() { + if (Permissions.isRuntimePermissionsRequired()) { + NavHostFragment.findNavController(this) + .navigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(GrantPermissionsFragment.WelcomeAction.RESTORE_BACKUP)); + } else { + gatherInformationAndChooseBackup(this, viewModel, WelcomeFragmentDirections.actionTransferOrRestore()); + } + } + + static void continueClicked(@NonNull Fragment fragment, + @NonNull RegistrationViewModel viewModel, + @NonNull Runnable onSearchForBackupStarted, + @NonNull Runnable onSearchForBackupFinished, + @NonNull NavDirections actionSkipRestore, + @NonNull NavDirections actionRestore) + { + boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(fragment.requireContext()); + + Permissions.with(fragment) + .request(WelcomePermissions.getWelcomePermissions(isUserSelectionRequired)) .ifNecessary() - .withRationaleDialog(getString(getContinueRationale(isUserSelectionRequired)), getContinueHeaders(isUserSelectionRequired)) - .onAnyResult(() -> gatherInformationAndContinue(continueButton)) + .onAnyResult(() -> gatherInformationAndContinue(fragment, + viewModel, + onSearchForBackupStarted, + onSearchForBackupFinished, + actionSkipRestore, + actionRestore)) .execute(); } - private void restoreFromBackupClicked(@NonNull View view) { - boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext()); + static void restoreFromBackupClicked(@NonNull Fragment fragment, + @NonNull RegistrationViewModel viewModel, + @NonNull NavDirections actionTransferOrRestore) + { + boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(fragment.requireContext()); - Permissions.with(this) - .request(getContinuePermissions(isUserSelectionRequired)) + Permissions.with(fragment) + .request(WelcomePermissions.getWelcomePermissions(isUserSelectionRequired)) .ifNecessary() - .withRationaleDialog(getString(getContinueRationale(isUserSelectionRequired)), getContinueHeaders(isUserSelectionRequired)) - .onAnyResult(() -> gatherInformationAndChooseBackup(continueButton)) + .onAnyResult(() -> gatherInformationAndChooseBackup(fragment, viewModel, actionTransferOrRestore)) .execute(); } - private void gatherInformationAndContinue(@NonNull View view) { - continueButton.setSpinning(); + static void gatherInformationAndContinue( + @NonNull Fragment fragment, + @NonNull RegistrationViewModel viewModel, + @NonNull Runnable onSearchForBackupStarted, + @NonNull Runnable onSearchForBackupFinished, + @NonNull NavDirections actionSkipRestore, + @NonNull NavDirections actionRestore + ) { + onSearchForBackupStarted.run(); RestoreBackupFragment.searchForBackup(backup -> { - Context context = getContext(); + Context context = fragment.getContext(); if (context == null) { Log.i(TAG, "No context on fragment, must have navigated away."); return; } - TextSecurePreferences.setHasSeenWelcomeScreen(requireContext(), true); + TextSecurePreferences.setHasSeenWelcomeScreen(fragment.requireContext(), true); - initializeNumber(); + initializeNumber(fragment.requireContext(), viewModel); - continueButton.cancelSpinning(); + onSearchForBackupFinished.run(); if (backup == null) { Log.i(TAG, "Skipping backup. No backup found, or no permission to look."); - SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), - WelcomeFragmentDirections.actionSkipRestore()); + SafeNavigation.safeNavigate(NavHostFragment.findNavController(fragment), + actionSkipRestore); } else { - SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), - WelcomeFragmentDirections.actionRestore()); + SafeNavigation.safeNavigate(NavHostFragment.findNavController(fragment), + actionRestore); } }); } - private void gatherInformationAndChooseBackup(@NonNull View view) { - TextSecurePreferences.setHasSeenWelcomeScreen(requireContext(), true); + static void gatherInformationAndChooseBackup(@NonNull Fragment fragment, + @NonNull RegistrationViewModel viewModel, + @NonNull NavDirections actionTransferOrRestore) { + TextSecurePreferences.setHasSeenWelcomeScreen(fragment.requireContext(), true); - initializeNumber(); + initializeNumber(fragment.requireContext(), viewModel); - SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), - WelcomeFragmentDirections.actionTransferOrRestore()); + SafeNavigation.safeNavigate(NavHostFragment.findNavController(fragment), + actionTransferOrRestore); } @SuppressLint("MissingPermission") - private void initializeNumber() { + private static void initializeNumber(@NonNull Context context, @NonNull RegistrationViewModel viewModel) { Optional localNumber = Optional.empty(); - if (Permissions.hasAll(requireContext(), Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) { - localNumber = Util.getDeviceNumber(requireContext()); + if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) { + localNumber = Util.getDeviceNumber(context); } else { Log.i(TAG, "No phone permission"); } @@ -215,7 +238,7 @@ public final class WelcomeFragment extends LoggingFragment { viewModel.onNumberDetected(phoneNumber.getCountryCode(), nationalNumber); } else { Log.i(TAG, "No number detected"); - Optional simCountryIso = Util.getSimCountryIso(requireContext()); + Optional simCountryIso = Util.getSimCountryIso(context); if (simCountryIso.isPresent() && !TextUtils.isEmpty(simCountryIso.get())) { viewModel.onNumberDetected(PhoneNumberUtil.getInstance().getCountryCodeForRegion(simCountryIso.get()), ""); @@ -232,23 +255,4 @@ public final class WelcomeFragment extends LoggingFragment { !viewModel.isReregister() && !SignalStore.settings().isBackupEnabled(); } - - @SuppressLint("NewApi") - private static String[] getContinuePermissions(boolean isUserSelectionRequired) { - if (isUserSelectionRequired) { - return PERMISSIONS_API_29; - } else if (Build.VERSION.SDK_INT >= 26) { - return PERMISSIONS_API_26; - } else { - return PERMISSIONS; - } - } - - private static @StringRes int getContinueRationale(boolean isUserSelectionRequired) { - return isUserSelectionRequired ? RATIONALE_API_29 : RATIONALE; - } - - private static int[] getContinueHeaders(boolean isUserSelectionRequired) { - return isUserSelectionRequired ? HEADERS_API_29 : HEADERS; - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomePermissions.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomePermissions.kt new file mode 100644 index 0000000000..0151f4c0d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomePermissions.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.fragments + +import android.Manifest +import android.os.Build + +/** + * Handles welcome permissions instead of having to do weird giant if statements. + */ +object WelcomePermissions { + private enum class Permissions { + POST_NOTIFICATIONS { + override fun getPermissions(isUserBackupSelectionRequired: Boolean): List { + return if (Build.VERSION.SDK_INT >= 33) { + listOf(Manifest.permission.POST_NOTIFICATIONS) + } else { + emptyList() + } + } + }, + CONTACTS { + override fun getPermissions(isUserBackupSelectionRequired: Boolean): List { + return listOf(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS) + } + }, + STORAGE { + override fun getPermissions(isUserBackupSelectionRequired: Boolean): List { + return if (Build.VERSION.SDK_INT < 29 || !isUserBackupSelectionRequired) { + listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + } else { + emptyList() + } + } + }, + PHONE { + override fun getPermissions(isUserBackupSelectionRequired: Boolean): List { + return listOf(Manifest.permission.READ_PHONE_STATE) + + (if (Build.VERSION.SDK_INT >= 26) listOf(Manifest.permission.READ_PHONE_NUMBERS) else emptyList()) + } + }; + + abstract fun getPermissions(isUserBackupSelectionRequired: Boolean): List + } + + @JvmStatic + fun getWelcomePermissions(isUserBackupSelectionRequired: Boolean): Array { + return Permissions.values().map { it.getPermissions(isUserBackupSelectionRequired) }.flatten().toTypedArray() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java index 47a5b15e39..12468aa3fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.NoExternalStorageException; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.permissions.PermissionCompat; import org.thoughtcrime.securesms.permissions.Permissions; import java.io.File; @@ -82,10 +83,6 @@ public class StorageUtil { } } - public static File getBackupCacheDirectory(Context context) { - return context.getExternalCacheDir(); - } - private static File getSignalStorageDir() throws NoExternalStorageException { final File storage = Environment.getExternalStorageDirectory(); @@ -108,17 +105,13 @@ public class StorageUtil { return storage.canWrite(); } - public static File getLegacyBackupDirectory() throws NoExternalStorageException { - return getSignalStorageDir(); - } - public static boolean canWriteToMediaStore() { return Build.VERSION.SDK_INT > 28 || Permissions.hasAll(ApplicationDependencies.getApplication(), Manifest.permission.WRITE_EXTERNAL_STORAGE); } public static boolean canReadFromMediaStore() { - return Permissions.hasAll(ApplicationDependencies.getApplication(), Manifest.permission.READ_EXTERNAL_STORAGE); + return Permissions.hasAll(ApplicationDependencies.getApplication(), PermissionCompat.forImagesAndVideos()); } public static @NonNull Uri getVideoUri() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java index 6c630ccd3f..c8ad4ed300 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java @@ -18,6 +18,7 @@ import androidx.navigation.Navigation; import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.permissions.PermissionCompat; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.wallpaper.crop.WallpaperImageSelectionActivity; @@ -76,7 +77,7 @@ public class ChatWallpaperSelectionFragment extends Fragment { private void askForPermissionIfNeededAndLaunchPhotoSelection() { Permissions.with(this) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .request(PermissionCompat.forImages()) .ifNecessary() .onAllGranted(() -> { startActivityForResult(WallpaperImageSelectionActivity.getIntent(requireContext(), viewModel.getRecipientId()), CHOOSE_WALLPAPER); diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperImageSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperImageSelectionActivity.java index 27bf8674ab..e56da74048 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperImageSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperImageSelectionActivity.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.wallpaper.crop; -import android.Manifest; import android.content.Context; import android.content.Intent; import android.os.Bundle; @@ -8,7 +7,6 @@ import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresPermission; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatDelegate; @@ -23,7 +21,6 @@ public final class WallpaperImageSelectionActivity extends AppCompatActivity private static final String EXTRA_RECIPIENT_ID = "RECIPIENT_ID"; private static final int CROP = 901; - @RequiresPermission(Manifest.permission.READ_EXTERNAL_STORAGE) public static Intent getIntent(@NonNull Context context, @Nullable RecipientId recipientId) { diff --git a/app/src/main/res/drawable-night/illustration_toggle_switch.xml b/app/src/main/res/drawable-night/illustration_toggle_switch.xml new file mode 100644 index 0000000000..dd20938ce9 --- /dev/null +++ b/app/src/main/res/drawable-night/illustration_toggle_switch.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/illustration_toggle_switch.xml b/app/src/main/res/drawable/illustration_toggle_switch.xml new file mode 100644 index 0000000000..19959c794d --- /dev/null +++ b/app/src/main/res/drawable/illustration_toggle_switch.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/permission_contact.xml b/app/src/main/res/drawable/permission_contact.xml new file mode 100644 index 0000000000..b1ddbdc3c6 --- /dev/null +++ b/app/src/main/res/drawable/permission_contact.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/permission_file.xml b/app/src/main/res/drawable/permission_file.xml new file mode 100644 index 0000000000..b77c5117b8 --- /dev/null +++ b/app/src/main/res/drawable/permission_file.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/permission_notification.xml b/app/src/main/res/drawable/permission_notification.xml new file mode 100644 index 0000000000..3b8a4d4291 --- /dev/null +++ b/app/src/main/res/drawable/permission_notification.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/drawable/permission_phone.xml b/app/src/main/res/drawable/permission_phone.xml new file mode 100644 index 0000000000..ebbc747288 --- /dev/null +++ b/app/src/main/res/drawable/permission_phone.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/layout/dsl_banner.xml b/app/src/main/res/layout/dsl_banner.xml new file mode 100644 index 0000000000..66bfe29f73 --- /dev/null +++ b/app/src/main/res/layout/dsl_banner.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/registration.xml b/app/src/main/res/navigation/registration.xml index 4f78929cbf..d6504db8d0 100644 --- a/app/src/main/res/navigation/registration.xml +++ b/app/src/main/res/navigation/registration.xml @@ -43,6 +43,49 @@ app:popEnterAnim="@anim/nav_default_pop_enter_anim" app:popExitAnim="@anim/nav_default_pop_exit_anim" /> + + + + + + + + + + + + + + @color/signal_colorNeutralVariant @color/signal_colorError @color/signal_colorNeutralInverse + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d6a60a2ef..d8c0770c3f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2246,6 +2246,18 @@ Turn off contact joined Signal notifications? You can enable them again in Signal > Settings > Notifications. + + + Turn on notifications + + To receive notifications for new messages: + + 1. Tap “Settings” below + + 2. %1$s Turn on notifications + + Settings + Messages Calls @@ -3117,6 +3129,32 @@ Update now + + + Not now + + Next + + Allow permissions + + To help you message people you know, Signal will request these permissions. + + Notifications + + Get notified when new messages arrive. + + Contacts + + Find people you know. Your contacts are encrypted and not visible to the Signal service. + + Phone calls + + Make registering easier and enable additional calling features. + + Storage + + Send photos, videos and files from your device. + Security setup @@ -4991,6 +5029,10 @@ Failed to open picker. + + To enable notifications, Signal needs permission to display them. + + Turn on Signal Release Notes & News diff --git a/app/src/test/java/org/thoughtcrime/securesms/registration/fragments/WelcomePermissionsTest.kt b/app/src/test/java/org/thoughtcrime/securesms/registration/fragments/WelcomePermissionsTest.kt new file mode 100644 index 0000000000..bec7e0083a --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/registration/fragments/WelcomePermissionsTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.fragments + +import android.Manifest +import android.app.Application +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@Ignore("Causing OOM errors.") +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class WelcomePermissionsTest { + @Test + @Config(sdk = [33]) + fun givenApi33_whenIGetWelcomePermissions_thenIExpectPostNotifications() { + val result = WelcomePermissions.getWelcomePermissions(true) + + assertTrue(Manifest.permission.POST_NOTIFICATIONS in result) + } + + @Test + @Config(sdk = [23, 26, 29]) + fun givenApiUnder33_whenIGetWelcomePermissions_thenIExpectNoPostNotifications() { + val result = WelcomePermissions.getWelcomePermissions(true) + + assertFalse(Manifest.permission.POST_NOTIFICATIONS in result) + } + + @Test + @Config(sdk = [23, 26, 29, 33]) + fun givenAnyApi_whenIGetWelcomePermissions_thenIExpectContacts() { + val result = WelcomePermissions.getWelcomePermissions(true) + + assertTrue(Manifest.permission.WRITE_CONTACTS in result) + assertTrue(Manifest.permission.READ_CONTACTS in result) + } + + @Test + @Config(sdk = [23, 26, 29, 33]) + fun givenAnyApi_whenIGetWelcomePermissions_thenIExpectReadPhoneState() { + val result = WelcomePermissions.getWelcomePermissions(true) + + assertTrue(Manifest.permission.READ_PHONE_STATE in result) + } + + @Test + @Config(sdk = [26, 29, 33]) + fun givenApi26Plus_whenIGetWelcomePermissions_thenIExpectReadPhoneNumbers() { + val result = WelcomePermissions.getWelcomePermissions(true) + + assertTrue(Manifest.permission.READ_PHONE_NUMBERS in result) + } + + @Test + @Config(sdk = [23]) + fun givenApiUnder26_whenIGetWelcomePermissions_thenIExpectNoReadPhoneNumbers() { + val result = WelcomePermissions.getWelcomePermissions(true) + + assertFalse(Manifest.permission.READ_PHONE_NUMBERS in result) + } + + @Test + @Config(sdk = [23, 26]) + fun givenApiUnder29_whenIGetWelcomePermissions_thenIExpectPhoneStorage() { + val result = WelcomePermissions.getWelcomePermissions(true) + + assertTrue(Manifest.permission.WRITE_EXTERNAL_STORAGE in result) + assertTrue(Manifest.permission.READ_EXTERNAL_STORAGE in result) + } + + @Test + @Config(sdk = [29, 33]) + fun givenApi29Plus_whenIGetWelcomePermissionsAndSelectionNotRequired_thenIExpectPhoneStorage() { + val result = WelcomePermissions.getWelcomePermissions(false) + + assertTrue(Manifest.permission.WRITE_EXTERNAL_STORAGE in result) + assertTrue(Manifest.permission.READ_EXTERNAL_STORAGE in result) + } + + @Test + @Config(sdk = [29, 33]) + fun givenApi29Plus_whenIGetWelcomePermissionsAndSelectionRequired_thenIExpectNoPhoneStorage() { + val result = WelcomePermissions.getWelcomePermissions(true) + + assertFalse(Manifest.permission.WRITE_EXTERNAL_STORAGE in result) + assertFalse(Manifest.permission.READ_EXTERNAL_STORAGE in result) + } +} diff --git a/constants.gradle.kts b/constants.gradle.kts index 5bebdd47f7..3c9af2ae4e 100644 --- a/constants.gradle.kts +++ b/constants.gradle.kts @@ -1,6 +1,6 @@ val signalBuildToolsVersion by extra("34.0.0") val signalCompileSdkVersion by extra("android-34") -val signalTargetSdkVersion by extra(31) +val signalTargetSdkVersion by extra(33) val signalMinSdkVersion by extra(21) val signalJavaVersion by extra(JavaVersion.VERSION_17) val signalKotlinJvmTarget by extra("17") \ No newline at end of file diff --git a/dependencies.gradle b/dependencies.gradle index 86dce7edc0..7c0b976ac8 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -172,7 +172,7 @@ dependencyResolutionManagement { testLibs { version('androidx-test', '1.4.0') version('androidx-test-ext-junit', '1.1.1') - version('robolectric', '4.8.1') + version('robolectric', '4.10.3') library('junit-junit', 'junit:junit:4.13.2') library('androidx-test-core', 'androidx.test', 'core').versionRef('androidx-test') diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java index 9226c63e6d..780595ffac 100644 --- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java @@ -65,6 +65,14 @@ public final class WifiDirect { private WifiP2pDnsSdServiceRequest serviceRequest; private final HandlerThread wifiDirectCallbacksHandler; + public static @NonNull String requiredPermission() { + if (Build.VERSION.SDK_INT >= 33) { + return Manifest.permission.NEARBY_WIFI_DEVICES; + } else { + return Manifest.permission.ACCESS_FINE_LOCATION; + } + } + /** * Determine the ability to use WiFi Direct by checking if the device supports WiFi Direct * and the appropriate permissions have been granted. @@ -81,9 +89,12 @@ public final class WifiDirect { return AvailableStatus.WIFI_MANAGER_NOT_AVAILABLE; } - if (Build.VERSION.SDK_INT >= 23 && context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT >= 33 && context.checkSelfPermission(Manifest.permission.NEARBY_WIFI_DEVICES) != PackageManager.PERMISSION_GRANTED) { + Log.i(TAG, "Nearby Wifi permission required"); + return AvailableStatus.REQUIRED_PERMISSION_NOT_GRANTED; + } else if (Build.VERSION.SDK_INT >= 23 && context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { Log.i(TAG, "Fine location permission required"); - return AvailableStatus.FINE_LOCATION_PERMISSION_NOT_GRANTED; + return AvailableStatus.REQUIRED_PERMISSION_NOT_GRANTED; } return Build.VERSION.SDK_INT <= 23 || wifiManager.isP2pSupported() ? AvailableStatus.AVAILABLE @@ -464,7 +475,7 @@ public final class WifiDirect { public enum AvailableStatus { FEATURE_NOT_AVAILABLE, WIFI_MANAGER_NOT_AVAILABLE, - FINE_LOCATION_PERMISSION_NOT_GRANTED, + REQUIRED_PERMISSION_NOT_GRANTED, WIFI_DIRECT_NOT_AVAILABLE, AVAILABLE } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index e210e93807..0e3227f1b1 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1538,6 +1538,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -1558,6 +1563,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -1588,6 +1598,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -2606,6 +2621,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -2970,6 +2990,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4229,6 +4254,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -5051,6 +5081,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -5061,11 +5096,21 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + @@ -5076,118 +5121,127 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - - - - + + + - - - + + + - - + + - - - - - - + + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + + + + + +