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
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
-
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
-
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+