From da2ee33dff378b7910b388dd169153fa403f6e79 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 24 Jun 2021 13:52:54 -0300 Subject: [PATCH] Refactor conversation settings screens into a single fragment with new UI. --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 15 +- .../transitions/CircleAvatarTransition.kt | 97 +++ .../transitions/WipeDownTransition.kt | 56 ++ .../securesms/components/AvatarImageView.java | 138 +++- .../securesms/components/FromTextView.java | 16 +- .../securesms/components/ThumbnailView.java | 18 + .../settings/DSLSettingsActivity.kt | 3 +- .../components/settings/DSLSettingsAdapter.kt | 39 +- .../settings/DSLSettingsFragment.kt | 63 +- .../components/settings/DSLSettingsIcon.kt | 42 + .../settings/app/AppSettingsFragment.kt | 21 +- .../NotificationsSettingsFragment.kt | 2 +- .../app/privacy/PrivacySettingsFragment.kt | 2 +- .../ConversationSettingsActivity.kt | 90 ++ .../conversation/ConversationSettingsEvent.kt | 38 + .../ConversationSettingsFragment.kt | 772 ++++++++++++++++++ .../ConversationSettingsRepository.kt | 203 +++++ .../conversation/ConversationSettingsState.kt | 92 +++ .../ConversationSettingsViewModel.kt | 454 ++++++++++ .../conversation/GroupAddMembersResult.kt | 15 + .../conversation/GroupCapacityResult.kt | 46 ++ .../permissions/PermissionsSettingsEvents.kt | 7 + .../PermissionsSettingsFragment.kt | 86 ++ .../PermissionsSettingsRepository.kt | 45 + .../permissions/PermissionsSettingsState.kt | 7 + .../PermissionsSettingsViewModel.kt | 66 ++ .../preferences/AvatarPreference.kt | 45 + .../preferences/BioTextPreference.kt | 91 +++ .../preferences/ButtonStripPreference.kt | 105 +++ .../preferences/GroupDescriptionPreference.kt | 65 ++ .../preferences/InternalPreference.kt | 67 ++ .../preferences/LargeIconClickPreference.kt | 32 + .../preferences/LegacyGroupPreference.kt | 67 ++ .../preferences/RecipientPreference.kt | 63 ++ .../preferences/SharedMediaPreference.kt | 43 + .../conversation/preferences/Utils.kt | 20 + .../SoundsAndNotificationsSettingsFragment.kt | 118 +++ ...oundsAndNotificationsSettingsRepository.kt | 37 + .../SoundsAndNotificationsSettingsState.kt | 13 + ...SoundsAndNotificationsSettingsViewModel.kt | 51 ++ .../securesms/components/settings/dsl.kt | 62 +- .../conversation/ConversationActivity.java | 40 +- .../conversation/ConversationIntents.java | 21 +- .../conversation/ConversationTitleView.java | 19 +- .../securesms/groups/ui/LeaveGroupDialog.java | 5 +- .../ui/addmembers/AddMembersActivity.java | 24 + .../ui/managegroup/ManageGroupActivity.java | 61 -- .../ui/managegroup/ManageGroupFragment.java | 521 ------------ .../ui/managegroup/ManageGroupRepository.java | 233 ------ .../ui/managegroup/ManageGroupViewModel.java | 468 ----------- ...ientSettingsCoordinatorLayoutBehavior.java | 130 --- .../RecipientBottomSheetDialogFragment.java | 63 +- .../bottomsheet/RecipientDialogViewModel.java | 6 +- .../ManageRecipientActivity.java | 72 -- .../ManageRecipientFragment.java | 416 ---------- .../ManageRecipientRepository.java | 115 --- .../ManageRecipientViewModel.java | 361 -------- .../DynamicConversationSettingsTheme.java | 12 + .../ic_volume_off_grey600_18dp.webp | Bin 314 -> 0 bytes .../ic_volume_off_grey600_18dp.webp | Bin 224 -> 0 bytes .../drawable-night/ic_bell_disabled_16.xml | 9 + .../drawable-night/ic_bell_disabled_24.xml | 9 + .../drawable-night/ic_safety_number_24.xml | 9 + .../main/res/drawable-night/ic_speaker_24.xml | 9 + .../res/drawable-night/ic_wallpaper_24.xml | 9 + .../drawable-v21/selectable_icon_button.xml | 11 + ...ble_recipient_bottom_sheet_icon_button.xml | 11 + .../ic_volume_off_grey600_18dp.webp | Bin 364 -> 0 bytes .../ic_volume_off_grey600_18dp.webp | Bin 512 -> 0 bytes .../ic_volume_off_grey600_18dp.webp | Bin 644 -> 0 bytes app/src/main/res/drawable/add_to_a_group.xml | 10 + .../main/res/drawable/ic_bell_disabled_16.xml | 9 + .../main/res/drawable/ic_bell_disabled_24.xml | 9 + app/src/main/res/drawable/ic_link_16.xml | 9 + .../main/res/drawable/ic_safety_number_24.xml | 9 + app/src/main/res/drawable/ic_speaker_24.xml | 9 + app/src/main/res/drawable/ic_wallpaper_24.xml | 29 + .../res/drawable/icon_button_squircle.xml | 6 + ...ient_bottom_sheet_icon_button_squircle.xml | 6 + .../res/drawable/selectable_icon_button.xml | 15 + ...ble_recipient_bottom_sheet_icon_button.xml | 15 + app/src/main/res/drawable/show_more.xml | 10 + ...sation_settings_avatar_preference_item.xml | 12 + ...versation_settings_bio_preference_item.xml | 54 ++ .../conversation_settings_button_strip.xml | 137 ++++ .../layout/conversation_settings_fragment.xml | 20 + ..._settings_group_description_preference.xml | 15 + ...versation_settings_internal_preference.xml | 26 + ...ation_settings_legacy_group_preference.xml | 15 + .../conversation_settings_shared_media.xml | 5 + .../layout/conversation_settings_toolbar.xml | 59 ++ .../main/res/layout/dsl_settings_activity.xml | 4 +- .../main/res/layout/group_manage_activity.xml | 15 - .../main/res/layout/group_manage_fragment.xml | 733 ----------------- .../res/layout/group_recipient_list_item.xml | 32 +- .../res/layout/large_icon_preference_item.xml | 58 ++ .../res/layout/recipient_bottom_sheet.xml | 142 ++-- .../res/layout/recipient_manage_activity.xml | 13 - .../res/layout/recipient_manage_fragment.xml | 639 --------------- .../recipient_preference_photo_rail.xml | 21 +- .../recipient_preference_photo_rail_item.xml | 24 +- ...fragment.xml => conversation_settings.xml} | 0 .../res/menu/manage_recipient_fragment.xml | 10 - .../res/navigation/conversation_settings.xml | 102 +++ .../conversation_settings_exit_transition.xml | 4 + .../transparent_window_wipe_transition.xml | 6 + app/src/main/res/values-night/dark_colors.xml | 2 + app/src/main/res/values-night/dark_themes.xml | 2 + app/src/main/res/values-sw360dp/dimens.xml | 4 + app/src/main/res/values-v21/themes.xml | 20 + app/src/main/res/values/arrays.xml | 10 + app/src/main/res/values/attrs.xml | 2 + app/src/main/res/values/dimens.xml | 4 + app/src/main/res/values/light_colors.xml | 2 + app/src/main/res/values/light_themes.xml | 2 + app/src/main/res/values/strings.xml | 70 +- app/src/main/res/values/text_styles.xml | 5 + app/src/main/res/values/themes.xml | 12 + .../conversation/GroupCapacityResultTest.kt | 103 +++ build.gradle | 1 + 121 files changed, 4394 insertions(+), 4076 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleAvatarTransition.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/animation/transitions/WipeDownTransition.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/GroupAddMembersResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/GroupCapacityResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsEvents.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/BioTextPreference.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/ButtonStripPreference.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/GroupDescriptionPreference.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/InternalPreference.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LargeIconClickPreference.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LegacyGroupPreference.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/RecipientPreference.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/SharedMediaPreference.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/Utils.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupActivity.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSettingsCoordinatorLayoutBehavior.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientActivity.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/DynamicConversationSettingsTheme.java delete mode 100644 app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.webp delete mode 100644 app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.webp create mode 100644 app/src/main/res/drawable-night/ic_bell_disabled_16.xml create mode 100644 app/src/main/res/drawable-night/ic_bell_disabled_24.xml create mode 100644 app/src/main/res/drawable-night/ic_safety_number_24.xml create mode 100644 app/src/main/res/drawable-night/ic_speaker_24.xml create mode 100644 app/src/main/res/drawable-night/ic_wallpaper_24.xml create mode 100644 app/src/main/res/drawable-v21/selectable_icon_button.xml create mode 100644 app/src/main/res/drawable-v21/selectable_recipient_bottom_sheet_icon_button.xml delete mode 100644 app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.webp delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.webp delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.webp create mode 100644 app/src/main/res/drawable/add_to_a_group.xml create mode 100644 app/src/main/res/drawable/ic_bell_disabled_16.xml create mode 100644 app/src/main/res/drawable/ic_bell_disabled_24.xml create mode 100644 app/src/main/res/drawable/ic_link_16.xml create mode 100644 app/src/main/res/drawable/ic_safety_number_24.xml create mode 100644 app/src/main/res/drawable/ic_speaker_24.xml create mode 100644 app/src/main/res/drawable/ic_wallpaper_24.xml create mode 100644 app/src/main/res/drawable/icon_button_squircle.xml create mode 100644 app/src/main/res/drawable/recipient_bottom_sheet_icon_button_squircle.xml create mode 100644 app/src/main/res/drawable/selectable_icon_button.xml create mode 100644 app/src/main/res/drawable/selectable_recipient_bottom_sheet_icon_button.xml create mode 100644 app/src/main/res/drawable/show_more.xml create mode 100644 app/src/main/res/layout/conversation_settings_avatar_preference_item.xml create mode 100644 app/src/main/res/layout/conversation_settings_bio_preference_item.xml create mode 100644 app/src/main/res/layout/conversation_settings_button_strip.xml create mode 100644 app/src/main/res/layout/conversation_settings_fragment.xml create mode 100644 app/src/main/res/layout/conversation_settings_group_description_preference.xml create mode 100644 app/src/main/res/layout/conversation_settings_internal_preference.xml create mode 100644 app/src/main/res/layout/conversation_settings_legacy_group_preference.xml create mode 100644 app/src/main/res/layout/conversation_settings_shared_media.xml create mode 100644 app/src/main/res/layout/conversation_settings_toolbar.xml delete mode 100644 app/src/main/res/layout/group_manage_activity.xml delete mode 100644 app/src/main/res/layout/group_manage_fragment.xml create mode 100644 app/src/main/res/layout/large_icon_preference_item.xml delete mode 100644 app/src/main/res/layout/recipient_manage_activity.xml delete mode 100644 app/src/main/res/layout/recipient_manage_fragment.xml rename app/src/main/res/menu/{manage_group_fragment.xml => conversation_settings.xml} (100%) delete mode 100644 app/src/main/res/menu/manage_recipient_fragment.xml create mode 100644 app/src/main/res/navigation/conversation_settings.xml create mode 100644 app/src/main/res/transition/conversation_settings_exit_transition.xml create mode 100644 app/src/main/res/transition/transparent_window_wipe_transition.xml create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupCapacityResultTest.kt diff --git a/app/build.gradle b/app/build.gradle index 52bcba900..3b1d28c15 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,6 +11,8 @@ apply plugin: 'witness' apply plugin: 'org.jlleitschuh.gradle.ktlint' apply from: 'translations.gradle' apply from: 'witness-verifications.gradle' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'app.cash.exhaustive' repositories { maven { @@ -75,6 +77,7 @@ android { useLibrary 'org.apache.http.legacy' kotlinOptions { + jvmTarget = "1.8" freeCompilerArgs = ["-Xallow-result-return-type"] } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9dbb93c53..6fa0306e6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -308,14 +308,6 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode" android:theme="@style/Theme.Signal.DayNight.NoActionBar" /> - - - - + + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleAvatarTransition.kt b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleAvatarTransition.kt new file mode 100644 index 000000000..1fda6fe7d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/CircleAvatarTransition.kt @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.animation.transitions + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.animation.PropertyValuesHolder +import android.animation.TypeEvaluator +import android.content.Context +import android.transition.Transition +import android.transition.TransitionValues +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import android.view.animation.Interpolator +import androidx.annotation.RequiresApi +import org.thoughtcrime.securesms.components.AvatarImageView + +private const val POSITION_ON_SCREEN = "signal.circleavatartransition.positiononscreen" +private const val WIDTH = "signal.circleavatartransition.width" +private const val HEIGHT = "signal.circleavatartransition.height" + +/** + * Custom transition for Circular avatars, because once you have multiple things animating stuff was getting broken and weird. + */ +@RequiresApi(21) +class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) { + override fun captureStartValues(transitionValues: TransitionValues) { + captureValues(transitionValues) + } + + override fun captureEndValues(transitionValues: TransitionValues) { + captureValues(transitionValues) + } + + private fun captureValues(transitionValues: TransitionValues) { + val view: View = transitionValues.view + + if (view is AvatarImageView) { + val topLeft = intArrayOf(0, 0) + view.getLocationOnScreen(topLeft) + transitionValues.values[POSITION_ON_SCREEN] = topLeft + transitionValues.values[WIDTH] = view.measuredWidth + transitionValues.values[HEIGHT] = view.measuredHeight + } + } + + override fun createAnimator(sceneRoot: ViewGroup, startValues: TransitionValues?, endValues: TransitionValues?): Animator? { + if (startValues == null || endValues == null) { + return null + } + + val view: View = endValues.view + if (view !is AvatarImageView || view.transitionName != "avatar") { + return null + } + + val startCoords: IntArray = startValues.values[POSITION_ON_SCREEN] as? IntArray ?: intArrayOf(0, 0).apply { view.getLocationOnScreen(this) } + val endCoords: IntArray = endValues.values[POSITION_ON_SCREEN] as? IntArray ?: intArrayOf(0, 0).apply { view.getLocationOnScreen(this) } + + val startWidth: Int = startValues.values[WIDTH] as? Int ?: view.measuredWidth + val endWidth: Int = endValues.values[WIDTH] as? Int ?: view.measuredWidth + + val startHeight: Int = startValues.values[HEIGHT] as? Int ?: view.measuredHeight + val endHeight: Int = endValues.values[HEIGHT] as? Int ?: view.measuredHeight + + val startHeightOffset = (endHeight - startHeight) / 2f + val startWidthOffset = (endWidth - startWidth) / 2f + + val translateXHolder = PropertyValuesHolder.ofFloat("translationX", startCoords[0] - endCoords[0] - startWidthOffset, 0f).apply { + setEvaluator(FloatInterpolatorEvaluator(DecelerateInterpolator())) + } + val translateYHolder = PropertyValuesHolder.ofFloat("translationY", startCoords[1] - endCoords[1] - startHeightOffset, 0f).apply { + setEvaluator(FloatInterpolatorEvaluator(AccelerateInterpolator())) + } + + val widthRatio = startWidth.toFloat() / endWidth + val scaleXHolder = PropertyValuesHolder.ofFloat("scaleX", widthRatio, 1f) + + val heightRatio = startHeight.toFloat() / endHeight + val scaleYHolder = PropertyValuesHolder.ofFloat("scaleY", heightRatio, 1f) + + return ObjectAnimator.ofPropertyValuesHolder(view, translateXHolder, translateYHolder, scaleXHolder, scaleYHolder) + } + + private class FloatInterpolatorEvaluator( + private val interpolator: Interpolator + ) : TypeEvaluator { + + override fun evaluate(fraction: Float, startValue: Float, endValue: Float): Float { + val interpolatedFraction = interpolator.getInterpolation(fraction) + val delta = endValue - startValue + + return delta * interpolatedFraction + startValue + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/WipeDownTransition.kt b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/WipeDownTransition.kt new file mode 100644 index 000000000..bc1fb2bcc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/transitions/WipeDownTransition.kt @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.animation.transitions + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.animation.RectEvaluator +import android.content.Context +import android.graphics.Rect +import android.transition.Transition +import android.transition.TransitionValues +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import androidx.annotation.RequiresApi +import androidx.fragment.app.FragmentContainerView + +private const val BOUNDS = "signal.wipedowntransition.bottom" + +/** + * WipeDownTransition will animate the bottom position of a view such that it "wipes" down the screen to a final position. + */ +@RequiresApi(21) +class WipeDownTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) { + override fun captureStartValues(transitionValues: TransitionValues) { + captureValues(transitionValues) + } + + override fun captureEndValues(transitionValues: TransitionValues) { + captureValues(transitionValues) + } + + private fun captureValues(transitionValues: TransitionValues) { + val view: View = transitionValues.view + + if (view is ViewGroup) { + val rect = Rect() + view.getLocalVisibleRect(rect) + transitionValues.values[BOUNDS] = rect + } + } + + override fun createAnimator(sceneRoot: ViewGroup, startValues: TransitionValues?, endValues: TransitionValues?): Animator? { + if (startValues == null || endValues == null) { + return null + } + + val view: View = endValues.view + if (view !is FragmentContainerView) { + return null + } + + val startBottom: Rect = startValues.values[BOUNDS] as? Rect ?: Rect().apply { view.getLocalVisibleRect(this) } + val endBottom: Rect = endValues.values[BOUNDS] as? Rect ?: Rect().apply { view.getLocalVisibleRect(this) } + + return ObjectAnimator.ofObject(view, "clipBounds", RectEvaluator(), startBottom, endBottom) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index db0ab2350..8025dc393 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -6,12 +6,15 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; +import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import androidx.annotation.DrawableRes; +import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.Px; import androidx.appcompat.widget.AppCompatImageView; import androidx.fragment.app.FragmentActivity; @@ -20,21 +23,23 @@ import com.bumptech.glide.load.Transformation; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.bitmap.CircleCrop; import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy; +import com.bumptech.glide.request.target.SimpleTarget; +import com.bumptech.glide.request.transition.Transition; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; import org.thoughtcrime.securesms.conversation.colors.AvatarColor; import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity; import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequest; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; -import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity; import org.thoughtcrime.securesms.util.AvatarUtil; import org.thoughtcrime.securesms.util.BlurTransformation; import org.thoughtcrime.securesms.util.ThemeUtil; @@ -74,6 +79,7 @@ public final class AvatarImageView extends AppCompatImageView { private Recipient.FallbackPhotoProvider fallbackPhotoProvider; private boolean blurred; private ChatColors chatColors; + private FixedSizeTarget fixedSizeTarget; private @Nullable RecipientContactPhoto recipientContactPhoto; private @NonNull Drawable unknownRecipientDrawable; @@ -93,8 +99,8 @@ public final class AvatarImageView extends AppCompatImageView { if (attrs != null) { TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AvatarImageView, 0, 0); - inverted = typedArray.getBoolean(R.styleable.AvatarImageView_inverted, false); - size = typedArray.getInt(R.styleable.AvatarImageView_fallbackImageSize, SIZE_LARGE); + inverted = typedArray.getBoolean(R.styleable.AvatarImageView_inverted, false); + size = typedArray.getInt(R.styleable.AvatarImageView_fallbackImageSize, SIZE_LARGE); typedArray.recycle(); } @@ -105,6 +111,11 @@ public final class AvatarImageView extends AppCompatImageView { chatColors = null; } + @Override + public void setClipBounds(Rect clipBounds) { + super.setClipBounds(clipBounds); + } + @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); @@ -148,6 +159,10 @@ public final class AvatarImageView extends AppCompatImageView { } } + public AvatarOptions.Builder buildOptions() { + return new AvatarOptions.Builder(this); + } + /** * Shows self as the note to self icon. */ @@ -167,11 +182,22 @@ public final class AvatarImageView extends AppCompatImageView { } public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar) { + setAvatar(requestManager, recipient, new AvatarOptions.Builder(this) + .withUseSelfProfileAvatar(useSelfProfileAvatar) + .withQuickContactEnabled(quickContactEnabled) + .build()); + } + + private void setAvatar(@Nullable Recipient recipient, @NonNull AvatarOptions avatarOptions) { + setAvatar(GlideApp.with(this), recipient, avatarOptions); + } + + private void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, @NonNull AvatarOptions avatarOptions) { if (recipient != null) { - RecipientContactPhoto photo = (recipient.isSelf() && useSelfProfileAvatar) ? new RecipientContactPhoto(recipient, - new ProfileContactPhoto(Recipient.self(), - Recipient.self().getProfileAvatar())) - : new RecipientContactPhoto(recipient); + RecipientContactPhoto photo = (recipient.isSelf() && avatarOptions.useSelfProfileAvatar) ? new RecipientContactPhoto(recipient, + new ProfileContactPhoto(Recipient.self(), + Recipient.self().getProfileAvatar())) + : new RecipientContactPhoto(recipient); boolean shouldBlur = recipient.shouldBlurAvatar(); ChatColors chatColors = recipient.getChatColors(); @@ -184,6 +210,10 @@ public final class AvatarImageView extends AppCompatImageView { Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider) : photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider); + if (fixedSizeTarget != null) { + requestManager.clear(fixedSizeTarget); + } + if (photo.contactPhoto != null) { List> transforms = new ArrayList<>(); @@ -193,19 +223,26 @@ public final class AvatarImageView extends AppCompatImageView { transforms.add(new CircleCrop()); blurred = shouldBlur; - requestManager.load(photo.contactPhoto) - .fallback(fallbackContactPhotoDrawable) - .error(fallbackContactPhotoDrawable) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .downsample(DownsampleStrategy.CENTER_INSIDE) - .transform(new MultiTransformation<>(transforms)) - .into(this); + GlideRequest request = requestManager.load(photo.contactPhoto) + .fallback(fallbackContactPhotoDrawable) + .error(fallbackContactPhotoDrawable) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .downsample(DownsampleStrategy.CENTER_INSIDE) + .transform(new MultiTransformation<>(transforms)); + + if (avatarOptions.fixedSize > 0) { + fixedSizeTarget = new FixedSizeTarget(avatarOptions.fixedSize); + request.into(fixedSizeTarget); + } else { + request.into(this); + } + } else { setImageDrawable(fallbackContactPhotoDrawable); } } - setAvatarClickHandler(recipient, quickContactEnabled); + setAvatarClickHandler(recipient, avatarOptions.quickContactEnabled); } else { recipientContactPhoto = null; requestManager.clear(this); @@ -225,15 +262,15 @@ public final class AvatarImageView extends AppCompatImageView { super.setOnClickListener(v -> { Context context = getContext(); if (recipient.isPushGroup()) { - context.startActivity(ManageGroupActivity.newIntent(context, recipient.requireGroupId().requirePush()), - ManageGroupActivity.createTransitionBundle(context, this)); + context.startActivity(ConversationSettingsActivity.forGroup(context, recipient.requireGroupId().requirePush()), + ConversationSettingsActivity.createTransitionBundle(context, this)); } else { if (context instanceof FragmentActivity) { RecipientBottomSheetDialogFragment.create(recipient.getId(), null) .show(((FragmentActivity) context).getSupportFragmentManager(), "BOTTOM"); } else { - context.startActivity(ManageRecipientActivity.newIntent(context, recipient.getId()), - ManageRecipientActivity.createTransitionBundle(context, this)); + context.startActivity(ConversationSettingsActivity.forRecipient(context, recipient.getId()), + ConversationSettingsActivity.createTransitionBundle(context, this)); } } }); @@ -294,4 +331,65 @@ public final class AvatarImageView extends AppCompatImageView { Objects.equals(other.contactPhoto, contactPhoto); } } + + private final class FixedSizeTarget extends SimpleTarget { + + FixedSizeTarget(int size) { + super(size, size); + } + + @Override + public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { + setImageDrawable(resource); + } + } + + public static final class AvatarOptions { + + private final boolean quickContactEnabled; + private final boolean useSelfProfileAvatar; + private final int fixedSize; + + private AvatarOptions(@NonNull Builder builder) { + this.quickContactEnabled = builder.quickContactEnabled; + this.useSelfProfileAvatar = builder.useSelfProfileAvatar; + this.fixedSize = builder.fixedSize; + } + + public static final class Builder { + + private final AvatarImageView avatarImageView; + + private boolean quickContactEnabled = false; + private boolean useSelfProfileAvatar = false; + private int fixedSize = -1; + + private Builder(@NonNull AvatarImageView avatarImageView) { + this.avatarImageView = avatarImageView; + } + + public @NonNull Builder withQuickContactEnabled(boolean quickContactEnabled) { + this.quickContactEnabled = quickContactEnabled; + return this; + } + + public @NonNull Builder withUseSelfProfileAvatar(boolean useSelfProfileAvatar) { + this.useSelfProfileAvatar = useSelfProfileAvatar; + return this; + } + + public @NonNull Builder withFixedSize(@Px @IntRange(from = 1) int fixedSize) { + this.fixedSize = fixedSize; + return this; + } + + public AvatarOptions build() { + return new AvatarOptions(this); + } + + public void load(@Nullable Recipient recipient) { + avatarImageView.setAvatar(recipient, build()); + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java index e6b8b2eb4..218bfa42a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java @@ -1,7 +1,10 @@ package org.thoughtcrime.securesms.components; import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; import android.graphics.Typeface; +import android.graphics.drawable.Drawable; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; @@ -9,11 +12,15 @@ import android.text.style.StyleSpan; import android.util.AttributeSet; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Objects; public class FromTextView extends EmojiTextView { @@ -66,9 +73,16 @@ public class FromTextView extends EmojiTextView { setText(builder); if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0); - else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off_grey600_18dp, 0, 0, 0); + else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(getMuted(), null, null, null); else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); } + private Drawable getMuted() { + Drawable mutedDrawable = Objects.requireNonNull(ContextCompat.getDrawable(getContext(), R.drawable.ic_bell_disabled_16)); + mutedDrawable.setBounds(0, 0, ViewUtil.dpToPx(18), ViewUtil.dpToPx(18)); + mutedDrawable.setColorFilter(new PorterDuffColorFilter(ContextCompat.getColor(getContext(), R.color.signal_icon_tint_secondary), PorterDuff.Mode.SRC_IN)); + + return mutedDrawable; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java index 8874598ba..753604dce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -3,6 +3,12 @@ package org.thoughtcrime.securesms.components; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Bitmap; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RoundRectShape; +import android.graphics.drawable.shapes.Shape; import android.net.Uri; import android.util.AttributeSet; import android.view.View; @@ -12,6 +18,7 @@ import android.widget.ImageView; import androidx.annotation.NonNull; import androidx.annotation.UiThread; +import androidx.core.content.ContextCompat; import com.bumptech.glide.RequestBuilder; import com.bumptech.glide.load.engine.DiskCacheStrategy; @@ -41,6 +48,7 @@ import org.thoughtcrime.securesms.util.views.Stub; import org.thoughtcrime.securesms.video.VideoPlayer; import org.whispersystems.libsignal.util.guava.Optional; +import java.util.Arrays; import java.util.Collections; import java.util.Locale; import java.util.Objects; @@ -93,6 +101,7 @@ public class ThumbnailView extends FrameLayout { this.blurhash = findViewById(R.id.thumbnail_blurhash); this.playOverlay = findViewById(R.id.play_overlay); this.captionIcon = findViewById(R.id.thumbnail_caption_icon); + super.setOnClickListener(new ThumbnailClickDispatcher()); if (attrs != null) { @@ -103,9 +112,18 @@ public class ThumbnailView extends FrameLayout { bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0); radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius)); fit = typedArray.getInt(R.styleable.ThumbnailView_thumbnail_fit, 0) == 1 ? new FitCenter() : new CenterCrop(); + + int transparentOverlayColor = typedArray.getColor(R.styleable.ThumbnailView_transparent_overlay_color, -1); + if (transparentOverlayColor > 0) { + image.setColorFilter(new PorterDuffColorFilter(transparentOverlayColor, PorterDuff.Mode.SRC_ATOP)); + } else { + image.setColorFilter(null); + } + typedArray.recycle(); } else { radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius); + image.setColorFilter(null); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsActivity.kt index 2ba0a5331..136427f88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsActivity.kt @@ -8,10 +8,11 @@ import androidx.navigation.fragment.NavHostFragment import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme +import org.thoughtcrime.securesms.util.DynamicTheme open class DSLSettingsActivity : PassphraseRequiredActivity() { - private val dynamicTheme = DynamicNoActionBarTheme() + protected open val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme() protected lateinit var navController: NavController private set diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt index dd10151d1..bea5a85f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt @@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.MappingAdapter import org.thoughtcrime.securesms.util.MappingViewHolder import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.visible class DSLSettingsAdapter : MappingAdapter() { init { @@ -42,13 +43,9 @@ abstract class PreferenceViewHolder>(itemView: View) : Ma it.isEnabled = model.isEnabled } - if (model.iconId != -1) { - iconView.setImageResource(model.iconId) - iconView.visibility = View.VISIBLE - } else { - iconView.setImageDrawable(null) - iconView.visibility = View.GONE - } + val icon = model.icon?.resolve(context) + iconView.setImageDrawable(icon) + iconView.visible = icon != null val title = model.title?.resolve(context) if (title != null) { @@ -93,13 +90,31 @@ class RadioListPreferenceViewHolder(itemView: View) : PreferenceViewHolder - model.onSelected(which) - dialog.dismiss() + if (model.confirmAction) { + selection = which + } else { + model.onSelected(which) + dialog.dismiss() + } } - .show() + + if (model.confirmAction) { + builder + .setPositiveButton(android.R.string.ok) { dialog, _ -> + model.onSelected(selection) + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + .show() + } else { + builder.show() + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt index a29b6431e..6a82f1ebe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt @@ -14,19 +14,21 @@ import androidx.recyclerview.widget.RecyclerView import org.thoughtcrime.securesms.R abstract class DSLSettingsFragment( - @StringRes private val titleId: Int, + @StringRes private val titleId: Int = -1, @MenuRes private val menuId: Int = -1, @LayoutRes layoutId: Int = R.layout.dsl_settings_fragment ) : Fragment(layoutId) { private lateinit var recyclerView: RecyclerView - private lateinit var toolbarShadowHelper: ToolbarShadowHelper + private lateinit var scrollAnimationHelper: OnScrollAnimationHelper override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val toolbar: Toolbar = view.findViewById(R.id.toolbar) val toolbarShadow: View = view.findViewById(R.id.toolbar_shadow) - toolbar.setTitle(titleId) + if (titleId != -1) { + toolbar.setTitle(titleId) + } toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() @@ -39,18 +41,22 @@ abstract class DSLSettingsFragment( recyclerView = view.findViewById(R.id.recycler) recyclerView.edgeEffectFactory = EdgeEffectFactory() - toolbarShadowHelper = ToolbarShadowHelper(toolbarShadow) + scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow) val adapter = DSLSettingsAdapter() recyclerView.adapter = adapter - recyclerView.addOnScrollListener(toolbarShadowHelper) + recyclerView.addOnScrollListener(scrollAnimationHelper) bindAdapter(adapter) } override fun onResume() { super.onResume() - toolbarShadowHelper.onScrolled(recyclerView, 0, 0) + scrollAnimationHelper.onScrolled(recyclerView, 0, 0) + } + + protected open fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper { + return ToolbarShadowAnimationHelper(toolbarShadow) } abstract fun bindAdapter(adapter: DSLSettingsAdapter) @@ -66,31 +72,54 @@ abstract class DSLSettingsFragment( } } - class ToolbarShadowHelper(private val toolbarShadow: View) : RecyclerView.OnScrollListener() { + abstract class OnScrollAnimationHelper : RecyclerView.OnScrollListener() { + private var lastAnimationState = AnimationState.NONE - private var lastAnimationState = ToolbarAnimationState.NONE + protected open val duration: Long = 250L override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - val newAnimationState = - if (recyclerView.canScrollVertically(-1)) ToolbarAnimationState.SHOW else ToolbarAnimationState.HIDE + val newAnimationState = getAnimationState(recyclerView) if (newAnimationState == lastAnimationState) { return } when (newAnimationState) { - ToolbarAnimationState.NONE -> throw AssertionError() - ToolbarAnimationState.HIDE -> toolbarShadow.animate().alpha(0f) - ToolbarAnimationState.SHOW -> toolbarShadow.animate().alpha(1f) + AnimationState.NONE -> throw AssertionError() + AnimationState.HIDE -> hide() + AnimationState.SHOW -> show() } lastAnimationState = newAnimationState } + + protected open fun getAnimationState(recyclerView: RecyclerView): AnimationState { + return if (recyclerView.canScrollVertically(-1)) AnimationState.SHOW else AnimationState.HIDE + } + + protected abstract fun show() + + protected abstract fun hide() + + enum class AnimationState { + NONE, + HIDE, + SHOW + } } - private enum class ToolbarAnimationState { - NONE, - HIDE, - SHOW + open class ToolbarShadowAnimationHelper(private val toolbarShadow: View) : OnScrollAnimationHelper() { + + override fun show() { + toolbarShadow.animate() + .setDuration(duration) + .alpha(1f) + } + + override fun hide() { + toolbarShadow.animate() + .setDuration(duration) + .alpha(0f) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt new file mode 100644 index 000000000..81ba8622e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsIcon.kt @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.components.settings + +import android.content.Context +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.Drawable +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import org.thoughtcrime.securesms.R + +const val NO_TINT = -1 + +sealed class DSLSettingsIcon { + + private data class FromResource( + @DrawableRes private val iconId: Int, + @ColorRes private val iconTintId: Int + ) : DSLSettingsIcon() { + override fun resolve(context: Context) = requireNotNull(ContextCompat.getDrawable(context, iconId)).apply { + if (iconTintId != NO_TINT) { + colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, iconTintId), PorterDuff.Mode.SRC_IN) + } + } + } + + private data class FromDrawable( + private val drawable: Drawable + ) : DSLSettingsIcon() { + override fun resolve(context: Context): Drawable = drawable + } + + abstract fun resolve(context: Context): Drawable + + companion object { + @JvmStatic + fun from(@DrawableRes iconId: Int, @ColorRes iconTintId: Int = R.color.signal_icon_tint_primary): DSLSettingsIcon = FromResource(iconId, iconTintId) + + @JvmStatic + fun from(drawable: Drawable): DSLSettingsIcon = FromDrawable(drawable) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index 2a60bcd7c..b4860bf02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder @@ -44,7 +45,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men clickPref( title = DSLSettingsText.from(R.string.AccountSettingsFragment__account), - iconId = R.drawable.ic_profile_circle_24, + icon = DSLSettingsIcon.from(R.drawable.ic_profile_circle_24), onClick = { Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_accountSettingsFragment) } @@ -52,7 +53,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men clickPref( title = DSLSettingsText.from(R.string.preferences__linked_devices), - iconId = R.drawable.ic_linked_devices_24, + icon = DSLSettingsIcon.from(R.drawable.ic_linked_devices_24), onClick = { Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_deviceActivity) } @@ -72,7 +73,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men clickPref( title = DSLSettingsText.from(R.string.preferences__appearance), - iconId = R.drawable.ic_appearance_24, + icon = DSLSettingsIcon.from(R.drawable.ic_appearance_24), onClick = { Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment) } @@ -80,7 +81,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men clickPref( title = DSLSettingsText.from(R.string.preferences_chats__chats), - iconId = R.drawable.ic_message_tinted_bitmap_24, + icon = DSLSettingsIcon.from(R.drawable.ic_message_tinted_bitmap_24), onClick = { Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment) } @@ -88,7 +89,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men clickPref( title = DSLSettingsText.from(R.string.preferences__notifications), - iconId = R.drawable.ic_bell_24, + icon = DSLSettingsIcon.from(R.drawable.ic_bell_24), onClick = { Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment) } @@ -96,7 +97,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men clickPref( title = DSLSettingsText.from(R.string.preferences__privacy), - iconId = R.drawable.ic_lock_24, + icon = DSLSettingsIcon.from(R.drawable.ic_lock_24), onClick = { Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_privacySettingsFragment) } @@ -104,7 +105,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men clickPref( title = DSLSettingsText.from(R.string.preferences__data_and_storage), - iconId = R.drawable.ic_archive_24dp, + icon = DSLSettingsIcon.from(R.drawable.ic_archive_24dp), onClick = { Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment) } @@ -114,7 +115,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men clickPref( title = DSLSettingsText.from(R.string.preferences__help), - iconId = R.drawable.ic_help_24, + icon = DSLSettingsIcon.from(R.drawable.ic_help_24), onClick = { Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_helpSettingsFragment) } @@ -122,7 +123,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men clickPref( title = DSLSettingsText.from(R.string.AppSettingsFragment__invite_your_friends), - iconId = R.drawable.ic_invite_24, + icon = DSLSettingsIcon.from(R.drawable.ic_invite_24), onClick = { Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_inviteActivity) } @@ -130,7 +131,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men externalLinkPref( title = DSLSettingsText.from(R.string.preferences__donate_to_signal), - iconId = R.drawable.ic_heart_24, + icon = DSLSettingsIcon.from(R.drawable.ic_heart_24), linkId = R.string.donate_url ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsFragment.kt index f9aa9f59b..52f47a29e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsFragment.kt @@ -289,7 +289,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__ val radioListPreference: RadioListPreference ) : PreferenceModel( title = radioListPreference.title, - iconId = radioListPreference.iconId, + icon = radioListPreference.icon, summary = radioListPreference.summary ) { override fun areContentsTheSame(newItem: LedColorPreference): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt index 947d3fb42..50d524648 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt @@ -430,7 +430,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac ) : PreferenceModel( title = clickPreference.title, summary = clickPreference.summary, - iconId = clickPreference.iconId, + icon = clickPreference.icon, isEnabled = clickPreference.isEnabled ) { override fun areContentsTheSame(newItem: ValueClickPreference): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt new file mode 100644 index 000000000..d200a50d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.components.settings.conversation + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.core.app.ActivityCompat +import androidx.core.app.ActivityOptionsCompat +import androidx.core.util.Pair +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.ParcelableGroupId +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.DynamicConversationSettingsTheme +import org.thoughtcrime.securesms.util.DynamicTheme + +class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.Callback { + + override val dynamicTheme: DynamicTheme = DynamicConversationSettingsTheme() + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + ActivityCompat.postponeEnterTransition(this) + super.onCreate(savedInstanceState, ready) + } + + override fun onContentWillRender() { + ActivityCompat.startPostponedEnterTransition(this) + } + + override fun finish() { + super.finish() + overridePendingTransition(0, R.anim.fade_out) + } + + companion object { + + @JvmStatic + fun createTransitionBundle(context: Context, avatar: View, windowContent: View): Bundle? { + return if (context is Activity) { + ActivityOptionsCompat.makeSceneTransitionAnimation( + context, + Pair.create(avatar, "avatar"), + Pair.create(windowContent, "window_content") + ).toBundle() + } else { + null + } + } + + @JvmStatic + fun createTransitionBundle(context: Context, avatar: View): Bundle? { + return if (context is Activity) { + ActivityOptionsCompat.makeSceneTransitionAnimation( + context, + avatar, + "avatar", + ).toBundle() + } else { + null + } + } + + @JvmStatic + fun forGroup(context: Context, groupId: GroupId): Intent { + val startBundle = ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(groupId)) + .build() + .toBundle() + + return getIntent(context) + .putExtra(ARG_START_BUNDLE, startBundle) + } + + @JvmStatic + fun forRecipient(context: Context, recipientId: RecipientId): Intent { + val startBundle = ConversationSettingsFragmentArgs.Builder(recipientId, null) + .build() + .toBundle() + + return getIntent(context) + .putExtra(ARG_START_BUNDLE, startBundle) + } + + private fun getIntent(context: Context): Intent { + return Intent(context, ConversationSettingsActivity::class.java) + .putExtra(ARG_NAV_GRAPH, R.navigation.conversation_settings) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt new file mode 100644 index 000000000..749afc8eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.components.settings.conversation + +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +sealed class ConversationSettingsEvent { + class AddToAGroup( + val recipientId: RecipientId, + val groupMembership: List + ) : ConversationSettingsEvent() + + class AddMembersToGroup( + val groupId: GroupId, + val selectionWarning: Int, + val selectionLimit: Int, + val groupMembersWithoutSelf: List + ) : ConversationSettingsEvent() + + object ShowGroupHardLimitDialog : ConversationSettingsEvent() + + class ShowAddMembersToGroupError( + val failureReason: GroupChangeFailureReason + ) : ConversationSettingsEvent() + + class ShowGroupInvitesSentDialog( + val invitesSentTo: List + ) : ConversationSettingsEvent() + + class ShowMembersAdded( + val membersAddedCount: Int + ) : ConversationSettingsEvent() + + class InitiateGroupMigration( + val recipientId: RecipientId + ) : ConversationSettingsEvent() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt new file mode 100644 index 000000000..411f893e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -0,0 +1,772 @@ +package org.thoughtcrime.securesms.components.settings.conversation + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.Rect +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat +import androidx.core.view.doOnPreDraw +import androidx.fragment.app.viewModels +import androidx.navigation.Navigation +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import app.cash.exhaustive.Exhaustive +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import org.thoughtcrime.securesms.AvatarPreviewActivity +import org.thoughtcrime.securesms.BlockUnblockDialog +import org.thoughtcrime.securesms.InviteActivity +import org.thoughtcrime.securesms.MediaPreviewActivity +import org.thoughtcrime.securesms.MuteDialog +import org.thoughtcrime.securesms.PushContactSelectionActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.VerifyIdentityActivity +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.NO_TINT +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.components.settings.conversation.preferences.AvatarPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.BioTextPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.GroupDescriptionPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.InternalPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.groups.ParcelableGroupId +import org.thoughtcrime.securesms.groups.ui.GroupErrors +import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog +import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog +import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity +import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity +import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment +import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment +import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity +import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientExporter +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment +import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.ShareableGroupLinkDialogFragment +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.ContextUtil +import org.thoughtcrime.securesms.util.ExpirationUtil +import org.thoughtcrime.securesms.util.ThemeUtil +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity + +private const val REQUEST_CODE_VIEW_CONTACT = 1 +private const val REQUEST_CODE_ADD_CONTACT = 2 +private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3 +private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4 + +class ConversationSettingsFragment : DSLSettingsFragment( + layoutId = R.layout.conversation_settings_fragment, + menuId = R.menu.conversation_settings +) { + + private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) } + private val blockIcon by lazy { + ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24).apply { + colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN) + } + } + + private val unblockIcon by lazy { + ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24) + } + + private val leaveIcon by lazy { + ContextUtil.requireDrawable(requireContext(), R.drawable.ic_leave_tinted_24).apply { + colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN) + } + } + + private val viewModel by viewModels( + factoryProducer = { + val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments()) + val groupId = args.groupId as? ParcelableGroupId + + ConversationSettingsViewModel.Factory( + recipientId = args.recipientId, + groupId = ParcelableGroupId.get(groupId), + repository = ConversationSettingsRepository(requireContext()) + ) + } + ) + + private lateinit var callback: Callback + + private lateinit var toolbar: Toolbar + private lateinit var toolbarAvatar: AvatarImageView + private lateinit var toolbarTitle: TextView + private lateinit var toolbarBackground: View + + private val navController get() = Navigation.findNavController(requireView()) + + override fun onAttach(context: Context) { + super.onAttach(context) + + callback = context as Callback + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + toolbar = view.findViewById(R.id.toolbar) + toolbarAvatar = view.findViewById(R.id.toolbar_avatar) + toolbarTitle = view.findViewById(R.id.toolbar_title) + toolbarBackground = view.findViewById(R.id.toolbar_background) + + super.onViewCreated(view, savedInstanceState) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + REQUEST_CODE_ADD_MEMBERS_TO_GROUP -> if (data != null) { + val selected: List = requireNotNull(data.getParcelableArrayListExtra(PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS)) + val progress: SimpleProgressDialog.DismissibleDialog = SimpleProgressDialog.showDelayed(requireContext()) + + viewModel.onAddToGroupComplete(selected) { + progress.dismiss() + } + } + REQUEST_CODE_RETURN_FROM_MEDIA -> viewModel.refreshSharedMedia() + REQUEST_CODE_ADD_CONTACT -> viewModel.refreshRecipient() + REQUEST_CODE_VIEW_CONTACT -> viewModel.refreshRecipient() + } + } + + override fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper { + return ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatar, toolbarTitle, toolbarBackground, toolbarShadow) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return if (item.itemId == R.id.action_edit) { + val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments()) + val groupId = args.groupId as ParcelableGroupId + + startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(ParcelableGroupId.get(groupId)))) + true + } else { + super.onOptionsItemSelected(item) + } + } + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + BioTextPreference.register(adapter) + AvatarPreference.register(adapter) + ButtonStripPreference.register(adapter) + LargeIconClickPreference.register(adapter) + SharedMediaPreference.register(adapter) + RecipientPreference.register(adapter) + InternalPreference.register(adapter) + GroupDescriptionPreference.register(adapter) + LegacyGroupPreference.register(adapter) + + viewModel.state.observe(viewLifecycleOwner) { state -> + + if (state.recipient != Recipient.UNKNOWN) { + toolbarAvatar.buildOptions() + .withQuickContactEnabled(false) + .withUseSelfProfileAvatar(false) + .withFixedSize(ViewUtil.dpToPx(80)) + .load(state.recipient) + + state.withRecipientSettingsState { + toolbarTitle.text = state.recipient.getDisplayName(requireContext()) + } + + state.withGroupSettingsState { + toolbarTitle.text = it.groupTitle + toolbar.menu.findItem(R.id.action_edit).isVisible = it.canEditGroupAttributes + } + } + + adapter.submitList(getConfiguration(state).toMappingModelList()) + + if (state.isLoaded) { + (requireView().parent as? ViewGroup)?.doOnPreDraw { + callback.onContentWillRender() + } + } + } + + viewModel.events.observe(viewLifecycleOwner) { event -> + @Exhaustive + when (event) { + is ConversationSettingsEvent.AddToAGroup -> handleAddToAGroup(event) + is ConversationSettingsEvent.AddMembersToGroup -> handleAddMembersToGroup(event) + ConversationSettingsEvent.ShowGroupHardLimitDialog -> showGroupHardLimitDialog() + is ConversationSettingsEvent.ShowAddMembersToGroupError -> showAddMembersToGroupError(event) + is ConversationSettingsEvent.ShowGroupInvitesSentDialog -> showGroupInvitesSentDialog(event) + is ConversationSettingsEvent.ShowMembersAdded -> showMembersAdded(event) + is ConversationSettingsEvent.InitiateGroupMigration -> GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(parentFragmentManager, event.recipientId) + } + } + } + + private fun getConfiguration(state: ConversationSettingsState): DSLConfiguration { + return configure { + if (state.recipient == Recipient.UNKNOWN) { + return@configure + } + + customPref( + AvatarPreference.Model( + recipient = state.recipient, + onAvatarClick = { avatar -> + requireActivity().apply { + startActivity( + AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id), + AvatarPreviewActivity.createTransitionBundle(this, avatar) + ) + } + } + ) + ) + + state.withRecipientSettingsState { + customPref(BioTextPreference.RecipientModel(recipient = state.recipient)) + } + + state.withGroupSettingsState { groupState -> + + val groupMembershipDescription = if (groupState.groupId.isV1) { + String.format("%s · %s", groupState.membershipCountDescription, getString(R.string.ManageGroupActivity_legacy_group)) + } else if (!groupState.canEditGroupAttributes && groupState.groupDescription.isNullOrEmpty()) { + groupState.membershipCountDescription + } else { + null + } + + customPref( + BioTextPreference.GroupModel( + groupTitle = groupState.groupTitle, + groupMembershipDescription = groupMembershipDescription + ) + ) + + if (groupState.groupId.isV2) { + customPref( + GroupDescriptionPreference.Model( + groupId = groupState.groupId, + groupDescription = groupState.groupDescription, + descriptionShouldLinkify = groupState.groupDescriptionShouldLinkify, + canEditGroupAttributes = groupState.canEditGroupAttributes, + onEditGroupDescription = { + startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), groupState.groupId)) + }, + onViewGroupDescription = { + GroupDescriptionDialog.show(childFragmentManager, groupState.groupId, null, groupState.groupDescriptionShouldLinkify) + } + ) + ) + } else if (groupState.legacyGroupState != LegacyGroupPreference.State.NONE) { + customPref( + LegacyGroupPreference.Model( + state = groupState.legacyGroupState, + onLearnMoreClick = { GroupsLearnMoreBottomSheetDialogFragment.show(parentFragmentManager) }, + onUpgradeClick = { viewModel.initiateGroupUpgrade() }, + onMmsWarningClick = { startActivity(Intent(requireContext(), InviteActivity::class.java)) } + ) + ) + } + } + + state.withRecipientSettingsState { recipientState -> + if (recipientState.displayInternalRecipientDetails) { + customPref( + InternalPreference.Model( + recipient = state.recipient, + onDisableProfileSharingClick = { + viewModel.disableProfileSharing() + } + ) + ) + } + } + + customPref( + ButtonStripPreference.Model( + state = state.buttonStripState, + onVideoClick = { + CommunicationActions.startVideoCall(requireActivity(), state.recipient) + }, + onAudioClick = { + CommunicationActions.startVoiceCall(requireActivity(), state.recipient) + }, + onMuteClick = { + if (!state.buttonStripState.isMuted) { + MuteDialog.show(requireContext(), viewModel::setMuteUntil) + } else { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(state.recipient.muteUntil.formatMutedUntil(requireContext())) + .setPositiveButton(R.string.ConversationSettingsFragment__unmute) { dialog, _ -> + viewModel.unmute() + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } + .show() + } + }, + onSearchClick = { + val intent = ConversationIntents.createBuilder(requireContext(), state.recipient.id, state.threadId) + .withSearchOpen(true) + .build() + + startActivity(intent) + requireActivity().finish() + } + ) + ) + + dividerPref() + + val summary = DSLSettingsText.from(formatDisappearingMessagesLifespan(state.disappearingMessagesLifespan)) + val icon = if (state.disappearingMessagesLifespan <= 0) { + R.drawable.ic_update_timer_disabled_16 + } else { + R.drawable.ic_update_timer_16 + } + + var enabled = true + state.withGroupSettingsState { + enabled = it.canEditGroupAttributes + } + + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__disappearing_messages), + summary = summary, + icon = DSLSettingsIcon.from(icon), + isEnabled = enabled, + onClick = { + val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToAppSettingsExpireTimer() + .setInitialValue(state.disappearingMessagesLifespan) + .setRecipientId(state.recipient.id) + .setForResultMode(false) + + navController.navigate(action) + } + ) + + clickPref( + title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper), + icon = DSLSettingsIcon.from(R.drawable.ic_wallpaper_24), + onClick = { + startActivity(ChatWallpaperActivity.createIntent(requireContext(), state.recipient.id)) + } + ) + + if (!state.recipient.isSelf) { + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications), + icon = DSLSettingsIcon.from(R.drawable.ic_speaker_24), + onClick = { + val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id) + + navController.navigate(action) + } + ) + } + + state.withRecipientSettingsState { recipientState -> + when (recipientState.contactLinkState) { + ContactLinkState.OPEN -> { + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__contact_details), + icon = DSLSettingsIcon.from(R.drawable.ic_profile_circle_24), + onClick = { + startActivityForResult(Intent(Intent.ACTION_VIEW, state.recipient.contactUri), REQUEST_CODE_VIEW_CONTACT) + } + ) + } + ContactLinkState.ADD -> { + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_as_a_contact), + icon = DSLSettingsIcon.from(R.drawable.ic_plus_24), + onClick = { + startActivityForResult(RecipientExporter.export(state.recipient).asAddContactIntent(), REQUEST_CODE_ADD_CONTACT) + } + ) + } + ContactLinkState.NONE -> { + } + } + + if (recipientState.identityRecord != null) { + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__view_safety_number), + icon = DSLSettingsIcon.from(R.drawable.ic_safety_number_24), + onClick = { + startActivity(VerifyIdentityActivity.newIntent(requireActivity(), recipientState.identityRecord)) + } + ) + } + } + + if (state.sharedMedia != null && state.sharedMedia.count > 0) { + dividerPref() + + sectionHeaderPref(R.string.recipient_preference_activity__shared_media) + + customPref( + SharedMediaPreference.Model( + mediaCursor = state.sharedMedia, + onMediaRecordClick = { mediaRecord, isLtr -> + startActivityForResult( + MediaPreviewActivity.intentFromMediaRecord(requireContext(), mediaRecord, isLtr), + REQUEST_CODE_RETURN_FROM_MEDIA + ) + } + ) + ) + + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all), + onClick = { + startActivity(MediaOverviewActivity.forThread(requireContext(), state.threadId)) + } + ) + } + + state.withRecipientSettingsState { groupState -> + if (groupState.selfHasGroups) { + + dividerPref() + + val groupsInCommonCount = groupState.allGroupsInCommon.size + sectionHeaderPref( + DSLSettingsText.from( + if (groupsInCommonCount == 0) { + getString(R.string.ManageRecipientActivity_no_groups_in_common) + } else { + resources.getQuantityString( + R.plurals.ManageRecipientActivity_d_groups_in_common, + groupsInCommonCount, + groupsInCommonCount + ) + } + ) + ) + + customPref( + LargeIconClickPreference.Model( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_to_a_group), + icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT), + onClick = { + viewModel.onAddToGroup() + } + ) + ) + + for (group in groupState.groupsInCommon) { + customPref( + RecipientPreference.Model( + recipient = group, + onClick = { + CommunicationActions.startConversation(requireActivity(), group, null) + requireActivity().finish() + } + ) + ) + } + + if (groupState.canShowMoreGroupsInCommon) { + customPref( + LargeIconClickPreference.Model( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all), + icon = DSLSettingsIcon.from(R.drawable.show_more, NO_TINT), + onClick = { + viewModel.revealAllMembers() + } + ) + ) + } + } + } + + state.withGroupSettingsState { groupState -> + val memberCount = groupState.allMembers.size + + if (groupState.canAddToGroup || memberCount > 0) { + dividerPref() + + sectionHeaderPref(DSLSettingsText.from(resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount))) + } + + if (groupState.canAddToGroup) { + customPref( + LargeIconClickPreference.Model( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members), + icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT), + onClick = { + viewModel.onAddToGroup() + } + ) + ) + } + + for (member in groupState.members) { + customPref( + RecipientPreference.Model( + recipient = member.member, + isAdmin = member.isAdmin, + onClick = { + RecipientBottomSheetDialogFragment.create(member.member.id, groupState.groupId).show(parentFragmentManager, "BOTTOM") + } + ) + ) + } + + if (groupState.canShowMoreGroupMembers) { + customPref( + LargeIconClickPreference.Model( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all), + icon = DSLSettingsIcon.from(R.drawable.show_more, NO_TINT), + onClick = { + viewModel.revealAllMembers() + } + ) + ) + } + + if (state.recipient.isPushV2Group) { + dividerPref() + + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_link), + summary = DSLSettingsText.from(if (groupState.groupLinkEnabled) R.string.preferences_on else R.string.preferences_off), + icon = DSLSettingsIcon.from(R.drawable.ic_link_16), + onClick = { + ShareableGroupLinkDialogFragment.create(groupState.groupId.requireV2()).show(parentFragmentManager, "DIALOG") + } + ) + + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites), + icon = DSLSettingsIcon.from(R.drawable.ic_update_group_add_16), + onClick = { + startActivity(ManagePendingAndRequestingMembersActivity.newIntent(requireContext(), groupState.groupId.requireV2())) + } + ) + + if (groupState.isSelfAdmin) { + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__permissions), + icon = DSLSettingsIcon.from(R.drawable.ic_lock_24), + onClick = { + val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(ParcelableGroupId.from(groupState.groupId)) + navController.navigate(action) + } + ) + } + } + + if (groupState.canLeave) { + dividerPref() + + clickPref( + title = DSLSettingsText.from(R.string.conversation__menu_leave_group, alertTint), + icon = DSLSettingsIcon.from(leaveIcon), + onClick = { + LeaveGroupDialog.handleLeavePushGroup(requireActivity(), groupState.groupId.requirePush(), null) + } + ) + } + } + + if (state.canModifyBlockedState) { + state.withRecipientSettingsState { + dividerPref() + } + + state.withGroupSettingsState { + if (!it.canLeave) { + dividerPref() + } + } + + val isBlocked = state.recipient.isBlocked + val isGroup = state.recipient.isPushGroup + + val title = when { + isBlocked && isGroup -> R.string.ConversationSettingsFragment__unblock_group + isBlocked -> R.string.ConversationSettingsFragment__unblock + isGroup -> R.string.ConversationSettingsFragment__block_group + else -> R.string.ConversationSettingsFragment__block + } + + val titleTint = if (isBlocked) null else alertTint + val blockUnblockIcon = if (isBlocked) unblockIcon else blockIcon + + clickPref( + title = DSLSettingsText.from(title, titleTint), + icon = DSLSettingsIcon.from(blockUnblockIcon), + onClick = { + if (state.recipient.isBlocked) { + BlockUnblockDialog.showUnblockFor(requireContext(), viewLifecycleOwner.lifecycle, state.recipient) { + viewModel.unblock() + } + } else { + BlockUnblockDialog.showBlockFor(requireContext(), viewLifecycleOwner.lifecycle, state.recipient) { + viewModel.block() + } + } + } + ) + } + } + } + + private fun formatDisappearingMessagesLifespan(disappearingMessagesLifespan: Int): String { + return if (disappearingMessagesLifespan <= 0) { + getString(R.string.preferences_off) + } else { + ExpirationUtil.getExpirationDisplayValue(requireContext(), disappearingMessagesLifespan) + } + } + + private fun handleAddToAGroup(addToAGroup: ConversationSettingsEvent.AddToAGroup) { + startActivity(AddToGroupsActivity.newIntent(requireContext(), addToAGroup.recipientId, addToAGroup.groupMembership)) + } + + private fun handleAddMembersToGroup(addMembersToGroup: ConversationSettingsEvent.AddMembersToGroup) { + startActivityForResult( + AddMembersActivity.createIntent( + requireContext(), + addMembersToGroup.groupId, + ContactsCursorLoader.DisplayMode.FLAG_PUSH, + addMembersToGroup.selectionWarning, + addMembersToGroup.selectionLimit, + addMembersToGroup.groupMembersWithoutSelf + ), + REQUEST_CODE_ADD_MEMBERS_TO_GROUP + ) + } + + private fun showGroupHardLimitDialog() { + GroupLimitDialog.showHardLimitMessage(requireContext()) + } + + private fun showAddMembersToGroupError(showAddMembersToGroupError: ConversationSettingsEvent.ShowAddMembersToGroupError) { + Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(showAddMembersToGroupError.failureReason), Toast.LENGTH_LONG).show() + } + + private fun showGroupInvitesSentDialog(showGroupInvitesSentDialog: ConversationSettingsEvent.ShowGroupInvitesSentDialog) { + GroupInviteSentDialog.showInvitesSent(requireContext(), showGroupInvitesSentDialog.invitesSentTo) + } + + private fun showMembersAdded(showMembersAdded: ConversationSettingsEvent.ShowMembersAdded) { + val string = resources.getQuantityString( + R.plurals.ManageGroupActivity_added, + showMembersAdded.membersAddedCount, + showMembersAdded.membersAddedCount + ) + + Snackbar.make(requireView(), string, Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show() + } + + private class ConversationSettingsOnUserScrolledAnimationHelper( + private val toolbarAvatar: View, + private val toolbarTitle: View, + private val toolbarBackground: View, + toolbarShadow: View + ) : ToolbarShadowAnimationHelper(toolbarShadow) { + + override val duration: Long = 200L + + private val actionBarSize = ThemeUtil.getThemedDimen(toolbarShadow.context, R.attr.actionBarSize) + private val rect = Rect() + + override fun getAnimationState(recyclerView: RecyclerView): AnimationState { + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + + // If first visible item position is 0 + // If less than actionbarsize is visible + // SHOW + // else + // HIDE + // else + // HIDE + + return if (layoutManager.findFirstVisibleItemPosition() == 0) { + val firstChild = requireNotNull(layoutManager.getChildAt(0)) + firstChild.getDrawingRect(rect) + + if (rect.height() <= actionBarSize) { + AnimationState.SHOW + } else { + AnimationState.HIDE + } + } else { + AnimationState.SHOW + } + } + + override fun show() { + super.show() + + toolbarAvatar + .animate() + .setDuration(duration) + .translationY(0f) + .alpha(1f) + + toolbarTitle + .animate() + .setDuration(duration) + .translationY(0f) + .alpha(1f) + + toolbarBackground + .animate() + .setDuration(duration) + .alpha(1f) + } + + override fun hide() { + super.hide() + + toolbarAvatar + .animate() + .setDuration(duration) + .translationY(ViewUtil.dpToPx(56).toFloat()) + .alpha(0f) + + toolbarTitle + .animate() + .setDuration(duration) + .translationY(ViewUtil.dpToPx(56).toFloat()) + .alpha(0f) + + toolbarBackground + .animate() + .setDuration(duration) + .alpha(0f) + } + } + + interface Callback { + fun onContentWillRender() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt new file mode 100644 index 000000000..ffd6213bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt @@ -0,0 +1,203 @@ +package org.thoughtcrime.securesms.components.settings.conversation + +import android.content.Context +import android.database.Cursor +import androidx.annotation.WorkerThread +import androidx.lifecycle.LiveData +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.signal.storageservice.protos.groups.local.DecryptedGroup +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember +import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.IdentityDatabase +import org.thoughtcrime.securesms.database.MediaDatabase +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupManager +import org.thoughtcrime.securesms.groups.GroupProtoUtil +import org.thoughtcrime.securesms.groups.LiveGroup +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientUtil +import org.thoughtcrime.securesms.util.FeatureFlags +import java.io.IOException + +private val TAG = Log.tag(ConversationSettingsRepository::class.java) + +class ConversationSettingsRepository( + private val context: Context +) { + + @WorkerThread + fun getThreadMedia(threadId: Long): Cursor { + return DatabaseFactory.getMediaDatabase(context).getGalleryMediaForThread(threadId, MediaDatabase.Sorting.Newest) + } + + fun getThreadId(recipientId: RecipientId, consumer: (Long) -> Unit) { + SignalExecutors.BOUNDED.execute { + consumer(DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipientId)) + } + } + + fun getThreadId(groupId: GroupId, consumer: (Long) -> Unit) { + SignalExecutors.BOUNDED.execute { + val recipientId = Recipient.externalGroupExact(context, groupId).id + consumer(DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipientId)) + } + } + + fun isInternalRecipientDetailsEnabled(): Boolean = SignalStore.internalValues().recipientDetails() + + fun hasGroups(consumer: (Boolean) -> Unit) { + SignalExecutors.BOUNDED.execute { consumer(DatabaseFactory.getGroupDatabase(context).activeGroupCount > 0) } + } + + fun getIdentity(recipientId: RecipientId, consumer: (IdentityDatabase.IdentityRecord?) -> Unit) { + SignalExecutors.BOUNDED.execute { + consumer( + DatabaseFactory.getIdentityDatabase(context) + .getIdentity(recipientId) + .orNull() + ) + } + } + + fun getGroupsInCommon(recipientId: RecipientId, consumer: (List) -> Unit) { + SignalExecutors.BOUNDED.execute { + consumer( + DatabaseFactory + .getGroupDatabase(context) + .getPushGroupsContainingMember(recipientId) + .asSequence() + .filter { it.members.contains(Recipient.self().id) } + .map(GroupDatabase.GroupRecord::getRecipientId) + .map(Recipient::resolved) + .sortedBy { gr -> gr.getDisplayName(context) } + .toList() + ) + } + } + + fun getGroupMembership(recipientId: RecipientId, consumer: (List) -> Unit) { + SignalExecutors.BOUNDED.execute { + val groupDatabase = DatabaseFactory.getGroupDatabase(context) + val groupRecords = groupDatabase.getPushGroupsContainingMember(recipientId) + val groupRecipients = ArrayList(groupRecords.size) + for (groupRecord in groupRecords) { + groupRecipients.add(groupRecord.recipientId) + } + consumer(groupRecipients) + } + } + + fun refreshRecipient(recipientId: RecipientId) { + SignalExecutors.UNBOUNDED.execute { + try { + DirectoryHelper.refreshDirectoryFor(context, Recipient.resolved(recipientId), false) + } catch (e: IOException) { + Log.w(TAG, "Failed to refresh user after adding to contacts.") + } + } + } + + fun setMuteUntil(recipientId: RecipientId, until: Long) { + SignalExecutors.BOUNDED.execute { + DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until) + } + } + + fun getGroupCapacity(groupId: GroupId, consumer: (GroupCapacityResult) -> Unit) { + SignalExecutors.BOUNDED.execute { + val groupRecord: GroupDatabase.GroupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId).get() + consumer( + if (groupRecord.isV2Group) { + val decryptedGroup: DecryptedGroup = groupRecord.requireV2GroupProperties().decryptedGroup + val pendingMembers: List = decryptedGroup.pendingMembersList + .map(DecryptedPendingMember::getUuid) + .map(GroupProtoUtil::uuidByteStringToRecipientId) + + val members = mutableListOf() + + members.addAll(groupRecord.members) + members.addAll(pendingMembers) + + GroupCapacityResult(Recipient.self().id, members, FeatureFlags.groupLimits()) + } else { + GroupCapacityResult(Recipient.self().id, groupRecord.members, FeatureFlags.groupLimits()) + } + ) + } + } + + fun addMembers(groupId: GroupId, selected: List, consumer: (GroupAddMembersResult) -> Unit) { + SignalExecutors.BOUNDED.execute { + consumer( + try { + val groupActionResult = GroupManager.addMembers(context, groupId.requirePush(), selected) + GroupAddMembersResult.Success(groupActionResult.addedMemberCount, Recipient.resolvedList(groupActionResult.invitedMembers)) + } catch (e: Exception) { + GroupAddMembersResult.Failure(GroupChangeFailureReason.fromException(e)) + } + ) + } + } + + fun setMuteUntil(groupId: GroupId, until: Long) { + SignalExecutors.BOUNDED.execute { + val recipientId = Recipient.externalGroupExact(context, groupId).id + DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until) + } + } + + fun block(recipientId: RecipientId) { + SignalExecutors.BOUNDED.execute { + val recipient = Recipient.resolved(recipientId) + RecipientUtil.blockNonGroup(context, recipient) + } + } + + fun unblock(recipientId: RecipientId) { + SignalExecutors.BOUNDED.execute { + val recipient = Recipient.resolved(recipientId) + RecipientUtil.unblock(context, recipient) + } + } + + fun block(groupId: GroupId) { + SignalExecutors.BOUNDED.execute { + val recipient = Recipient.externalGroupExact(context, groupId) + RecipientUtil.block(context, recipient) + } + } + + fun unblock(groupId: GroupId) { + SignalExecutors.BOUNDED.execute { + val recipient = Recipient.externalGroupExact(context, groupId) + RecipientUtil.unblock(context, recipient) + } + } + + fun disableProfileSharing(recipientId: RecipientId) { + SignalExecutors.BOUNDED.execute { + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipientId, false) + } + } + + @WorkerThread + fun isMessageRequestAccepted(recipient: Recipient): Boolean { + return RecipientUtil.isMessageRequestAccepted(context, recipient) + } + + fun getMembershipCountDescription(liveGroup: LiveGroup): LiveData { + return liveGroup.getMembershipCountDescription(context.resources) + } + + fun getExternalPossiblyMigratedGroupRecipientId(groupId: GroupId, consumer: (RecipientId) -> Unit) { + SignalExecutors.BOUNDED.execute { + consumer(Recipient.externalPossiblyMigratedGroup(context, groupId).id) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt new file mode 100644 index 000000000..69e1e2ccb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.components.settings.conversation + +import android.database.Cursor +import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference +import org.thoughtcrime.securesms.database.IdentityDatabase +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry +import org.thoughtcrime.securesms.recipients.Recipient + +data class ConversationSettingsState( + val threadId: Long = -1, + val recipient: Recipient = Recipient.UNKNOWN, + val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(), + val disappearingMessagesLifespan: Int = 0, + val canModifyBlockedState: Boolean = false, + val sharedMedia: Cursor? = null, + private val specificSettingsState: SpecificSettingsState, +) { + + val isLoaded: Boolean = recipient != Recipient.UNKNOWN && sharedMedia != null && specificSettingsState.isLoaded + + fun withRecipientSettingsState(consumer: (SpecificSettingsState.RecipientSettingsState) -> Unit) { + if (specificSettingsState is SpecificSettingsState.RecipientSettingsState) { + consumer(specificSettingsState) + } + } + + fun withGroupSettingsState(consumer: (SpecificSettingsState.GroupSettingsState) -> Unit) { + if (specificSettingsState is SpecificSettingsState.GroupSettingsState) { + consumer(specificSettingsState) + } + } + + fun requireRecipientSettingsState(): SpecificSettingsState.RecipientSettingsState = specificSettingsState.requireRecipientSettingsState() + fun requireGroupSettingsState(): SpecificSettingsState.GroupSettingsState = specificSettingsState.requireGroupSettingsState() +} + +sealed class SpecificSettingsState { + + abstract val isLoaded: Boolean + + data class RecipientSettingsState( + val identityRecord: IdentityDatabase.IdentityRecord? = null, + val allGroupsInCommon: List = listOf(), + val groupsInCommon: List = listOf(), + val selfHasGroups: Boolean = false, + val canShowMoreGroupsInCommon: Boolean = false, + val groupsInCommonExpanded: Boolean = false, + val contactLinkState: ContactLinkState = ContactLinkState.NONE, + val displayInternalRecipientDetails: Boolean + ) : SpecificSettingsState() { + + override val isLoaded: Boolean = true + + override fun requireRecipientSettingsState() = this + } + + data class GroupSettingsState( + val groupId: GroupId, + val allMembers: List = listOf(), + val members: List = listOf(), + val isSelfAdmin: Boolean = false, + val canAddToGroup: Boolean = false, + val canEditGroupAttributes: Boolean = false, + val canLeave: Boolean = false, + val canShowMoreGroupMembers: Boolean = false, + val groupMembersExpanded: Boolean = false, + val groupTitle: String = "", + private val groupTitleLoaded: Boolean = false, + val groupDescription: String? = null, + val groupDescriptionShouldLinkify: Boolean = false, + private val groupDescriptionLoaded: Boolean = false, + val groupLinkEnabled: Boolean = false, + val membershipCountDescription: String = "", + val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE + ) : SpecificSettingsState() { + + override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded + + override fun requireGroupSettingsState(): GroupSettingsState = this + } + + open fun requireRecipientSettingsState(): RecipientSettingsState = error("Not a recipient settings state") + open fun requireGroupSettingsState(): GroupSettingsState = error("Not a group settings state") +} + +enum class ContactLinkState { + OPEN, + ADD, + NONE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt new file mode 100644 index 000000000..cfb2f5675 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt @@ -0,0 +1,454 @@ +package org.thoughtcrime.securesms.components.settings.conversation + +import android.database.Cursor +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.signal.core.util.ThreadUtil +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.LiveGroup +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientUtil +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.SingleLiveEvent +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil +import org.thoughtcrime.securesms.util.livedata.Store + +sealed class ConversationSettingsViewModel( + private val repository: ConversationSettingsRepository, + specificSettingsState: SpecificSettingsState, +) : ViewModel() { + + private val openedMediaCursors = HashSet() + + @Volatile + private var cleared = false + + protected val store = Store( + ConversationSettingsState( + specificSettingsState = specificSettingsState + ) + ) + protected val internalEvents = SingleLiveEvent() + + private val sharedMediaUpdateTrigger = MutableLiveData(Unit) + + val state: LiveData = store.stateLiveData + val events: LiveData = internalEvents + + init { + val threadId: LiveData = Transformations.distinctUntilChanged(Transformations.map(state) { it.threadId }) + val updater: LiveData = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId } + + val sharedMedia: LiveData = LiveDataUtil.mapAsync(SignalExecutors.BOUNDED, updater) { tId -> + repository.getThreadMedia(tId) + } + + store.update(sharedMedia) { cursor, state -> + if (!cleared) { + openedMediaCursors.add(cursor) + state.copy(sharedMedia = cursor) + } else { + cursor.ensureClosed() + state.copy(sharedMedia = null) + } + } + } + + fun refreshSharedMedia() { + sharedMediaUpdateTrigger.postValue(Unit) + } + + open fun refreshRecipient(): Unit = error("This ViewModel does not support this interaction") + + abstract fun setMuteUntil(muteUntil: Long) + + abstract fun unmute() + + abstract fun block() + + abstract fun unblock() + + abstract fun onAddToGroup() + + abstract fun onAddToGroupComplete(selected: List, onComplete: () -> Unit) + + abstract fun revealAllMembers() + + override fun onCleared() { + cleared = true + store.update { state -> + openedMediaCursors.forEach { it.ensureClosed() } + state.copy(sharedMedia = null) + } + } + + private fun Cursor?.ensureClosed() { + if (this != null && !this.isClosed) { + this.close() + } + } + + open fun disableProfileSharing(): Unit = error("This ViewModel does not support this interaction") + + open fun initiateGroupUpgrade(): Unit = error("This ViewModel does not support this interaction") + + private class RecipientSettingsViewModel( + private val recipientId: RecipientId, + private val repository: ConversationSettingsRepository + ) : ConversationSettingsViewModel( + repository, + SpecificSettingsState.RecipientSettingsState( + displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled() + ) + ) { + + private val liveRecipient = Recipient.live(recipientId) + + init { + store.update(liveRecipient.liveData) { recipient, state -> + state.copy( + recipient = recipient, + buttonStripState = ButtonStripPreference.State( + isVideoAvailable = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED && !recipient.isSelf, + isAudioAvailable = !recipient.isGroup && !recipient.isSelf, + isAudioSecure = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED, + isMuted = recipient.isMuted, + isMuteAvailable = true, + isSearchAvailable = true + ), + disappearingMessagesLifespan = recipient.expireMessages, + canModifyBlockedState = !recipient.isSelf, + specificSettingsState = state.requireRecipientSettingsState().copy( + contactLinkState = when { + recipient.isSelf -> ContactLinkState.NONE + recipient.isSystemContact -> ContactLinkState.OPEN + else -> ContactLinkState.ADD + } + ) + ) + } + + repository.getThreadId(recipientId) { threadId -> + store.update { state -> + state.copy(threadId = threadId) + } + } + + if (recipientId != Recipient.self().id) { + repository.getGroupsInCommon(recipientId) { groupsInCommon -> + store.update { state -> + val recipientSettings = state.requireRecipientSettingsState() + val expanded = recipientSettings.groupsInCommonExpanded + state.copy( + specificSettingsState = recipientSettings.copy( + allGroupsInCommon = groupsInCommon, + groupsInCommon = if (expanded) groupsInCommon else groupsInCommon.take(5), + canShowMoreGroupsInCommon = !expanded && groupsInCommon.size > 5 + ) + ) + } + } + + repository.hasGroups { hasGroups -> + store.update { state -> + val recipientSettings = state.requireRecipientSettingsState() + state.copy( + specificSettingsState = recipientSettings.copy( + selfHasGroups = hasGroups + ) + ) + } + } + + repository.getIdentity(recipientId) { identityRecord -> + store.update { state -> + state.copy(specificSettingsState = state.requireRecipientSettingsState().copy(identityRecord = identityRecord)) + } + } + } + } + + override fun onAddToGroup() { + repository.getGroupMembership(recipientId) { + internalEvents.postValue(ConversationSettingsEvent.AddToAGroup(recipientId, it)) + } + } + + override fun onAddToGroupComplete(selected: List, onComplete: () -> Unit) { + } + + override fun revealAllMembers() { + store.update { state -> + state.copy( + specificSettingsState = state.requireRecipientSettingsState().copy( + groupsInCommon = state.requireRecipientSettingsState().allGroupsInCommon, + groupsInCommonExpanded = true, + canShowMoreGroupsInCommon = false + ) + ) + } + } + + override fun refreshRecipient() { + repository.refreshRecipient(recipientId) + } + + override fun setMuteUntil(muteUntil: Long) { + repository.setMuteUntil(recipientId, muteUntil) + } + + override fun unmute() { + repository.setMuteUntil(recipientId, 0) + } + + override fun block() { + repository.block(recipientId) + } + + override fun unblock() { + repository.unblock(recipientId) + } + + override fun disableProfileSharing() { + repository.disableProfileSharing(recipientId) + } + } + + private class GroupSettingsViewModel( + private val groupId: GroupId, + private val repository: ConversationSettingsRepository + ) : ConversationSettingsViewModel(repository, SpecificSettingsState.GroupSettingsState(groupId)) { + + private val liveGroup = LiveGroup(groupId) + + init { + store.update(liveGroup.groupRecipient) { recipient, state -> + state.copy( + recipient = recipient, + buttonStripState = ButtonStripPreference.State( + isVideoAvailable = recipient.isPushV2Group, + isAudioAvailable = false, + isAudioSecure = recipient.isPushV2Group, + isMuted = recipient.isMuted, + isMuteAvailable = true, + isSearchAvailable = true + ), + canModifyBlockedState = RecipientUtil.isBlockable(recipient), + specificSettingsState = state.requireGroupSettingsState().copy( + legacyGroupState = getLegacyGroupState(recipient) + ) + ) + } + + repository.getThreadId(groupId) { threadId -> + store.update { state -> + state.copy(threadId = threadId) + } + } + + store.update(liveGroup.selfCanEditGroupAttributes()) { selfCanEditGroupAttributes, state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + canEditGroupAttributes = selfCanEditGroupAttributes + ) + ) + } + + store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + isSelfAdmin = isSelfAdmin + ) + ) + } + + store.update(liveGroup.expireMessages) { expireMessages, state -> + state.copy( + disappearingMessagesLifespan = expireMessages + ) + } + + store.update(liveGroup.selfCanAddMembers()) { canAddMembers, state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + canAddToGroup = canAddMembers + ) + ) + } + + store.update(liveGroup.fullMembers) { fullMembers, state -> + val groupState = state.requireGroupSettingsState() + + state.copy( + specificSettingsState = groupState.copy( + allMembers = fullMembers, + members = if (groupState.groupMembersExpanded) fullMembers else fullMembers.take(5), + canShowMoreGroupMembers = !groupState.groupMembersExpanded && fullMembers.size > 5 + ) + ) + } + + val isMessageRequestAccepted: LiveData = LiveDataUtil.mapAsync(liveGroup.groupRecipient) { r -> repository.isMessageRequestAccepted(r) } + val descriptionState: LiveData = LiveDataUtil.combineLatest(liveGroup.description, isMessageRequestAccepted, ::DescriptionState) + + store.update(descriptionState) { d, state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + groupDescription = d.description, + groupDescriptionShouldLinkify = d.canLinkify, + groupDescriptionLoaded = true + ) + ) + } + + store.update(liveGroup.isActive) { isActive, state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + canLeave = isActive && groupId.isPush + ) + ) + } + + store.update(liveGroup.title) { title, state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + groupTitle = title, + groupTitleLoaded = true + ) + ) + } + + store.update(liveGroup.groupLink) { groupLink, state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + groupLinkEnabled = groupLink.isEnabled + ) + ) + } + + store.update(repository.getMembershipCountDescription(liveGroup)) { description, state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + membershipCountDescription = description + ) + ) + } + } + + private fun getLegacyGroupState(recipient: Recipient): LegacyGroupPreference.State { + val showLegacyInfo = recipient.requireGroupId().isV1 + + return if (showLegacyInfo && recipient.participants.size > FeatureFlags.groupLimits().hardLimit) { + LegacyGroupPreference.State.TOO_LARGE + } else if (showLegacyInfo) { + LegacyGroupPreference.State.UPGRADE + } else if (groupId.isMms) { + LegacyGroupPreference.State.MMS_WARNING + } else { + LegacyGroupPreference.State.NONE + } + } + + override fun onAddToGroup() { + repository.getGroupCapacity(groupId) { capacityResult -> + if (capacityResult.getRemainingCapacity() > 0) { + internalEvents.postValue( + ConversationSettingsEvent.AddMembersToGroup( + groupId, + capacityResult.getSelectionWarning(), + capacityResult.getSelectionLimit(), + capacityResult.getMembersWithoutSelf() + ) + ) + } else { + internalEvents.postValue(ConversationSettingsEvent.ShowGroupHardLimitDialog) + } + } + } + + override fun onAddToGroupComplete(selected: List, onComplete: () -> Unit) { + repository.addMembers(groupId, selected) { + ThreadUtil.runOnMain { onComplete() } + + when (it) { + is GroupAddMembersResult.Success -> { + if (it.newMembersInvited.isNotEmpty()) { + internalEvents.postValue(ConversationSettingsEvent.ShowGroupInvitesSentDialog(it.newMembersInvited)) + } + + if (it.numberOfMembersAdded > 0) { + internalEvents.postValue(ConversationSettingsEvent.ShowMembersAdded(it.numberOfMembersAdded)) + } + } + is GroupAddMembersResult.Failure -> internalEvents.postValue(ConversationSettingsEvent.ShowAddMembersToGroupError(it.reason)) + } + } + } + + override fun revealAllMembers() { + store.update { state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + members = state.requireGroupSettingsState().allMembers, + groupMembersExpanded = true, + canShowMoreGroupMembers = false + ) + ) + } + } + + override fun setMuteUntil(muteUntil: Long) { + repository.setMuteUntil(groupId, muteUntil) + } + + override fun unmute() { + repository.setMuteUntil(groupId, 0) + } + + override fun block() { + repository.block(groupId) + } + + override fun unblock() { + repository.unblock(groupId) + } + + override fun initiateGroupUpgrade() { + repository.getExternalPossiblyMigratedGroupRecipientId(groupId) { + internalEvents.postValue(ConversationSettingsEvent.InitiateGroupMigration(it)) + } + } + } + + class Factory( + private val recipientId: RecipientId? = null, + private val groupId: GroupId? = null, + private val repository: ConversationSettingsRepository, + ) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + return requireNotNull( + modelClass.cast( + when { + recipientId != null -> RecipientSettingsViewModel(recipientId, repository) + groupId != null -> GroupSettingsViewModel(groupId, repository) + else -> error("One of RecipientId or GroupId required.") + } + ) + ) + } + } + + private class DescriptionState( + val description: String?, + val canLinkify: Boolean + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/GroupAddMembersResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/GroupAddMembersResult.kt new file mode 100644 index 000000000..44661b0fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/GroupAddMembersResult.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.components.settings.conversation + +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.recipients.Recipient + +sealed class GroupAddMembersResult { + class Success( + val numberOfMembersAdded: Int, + val newMembersInvited: List + ) : GroupAddMembersResult() + + class Failure( + val reason: GroupChangeFailureReason + ) : GroupAddMembersResult() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/GroupCapacityResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/GroupCapacityResult.kt new file mode 100644 index 000000000..03a4fb048 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/GroupCapacityResult.kt @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.components.settings.conversation + +import org.thoughtcrime.securesms.ContactSelectionListFragment +import org.thoughtcrime.securesms.groups.SelectionLimits +import org.thoughtcrime.securesms.recipients.RecipientId + +class GroupCapacityResult( + private val selfId: RecipientId, + private val members: List, + private val selectionLimits: SelectionLimits +) { + fun getMembers(): List { + return members + } + + fun getSelectionLimit(): Int { + if (!selectionLimits.hasHardLimit()) { + return ContactSelectionListFragment.NO_LIMIT + } + val containsSelf = members.indexOf(selfId) != -1 + return selectionLimits.hardLimit - if (containsSelf) 1 else 0 + } + + fun getSelectionWarning(): Int { + if (!selectionLimits.hasRecommendedLimit()) { + return ContactSelectionListFragment.NO_LIMIT + } + + val containsSelf = members.indexOf(selfId) != -1 + return selectionLimits.recommendedLimit - if (containsSelf) 1 else 0 + } + + fun getRemainingCapacity(): Int { + return selectionLimits.hardLimit - members.size + } + + fun getMembersWithoutSelf(): List { + val recipientIds = ArrayList(members.size) + for (recipientId in members) { + if (recipientId != selfId) { + recipientIds.add(recipientId) + } + } + return recipientIds + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsEvents.kt new file mode 100644 index 000000000..e721632ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsEvents.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.components.settings.conversation.permissions + +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason + +sealed class PermissionsSettingsEvents { + class GroupChangeError(val reason: GroupChangeFailureReason) : PermissionsSettingsEvents() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsFragment.kt new file mode 100644 index 000000000..ae36098d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsFragment.kt @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.components.settings.conversation.permissions + +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.fragment.app.viewModels +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.groups.ParcelableGroupId +import org.thoughtcrime.securesms.groups.ui.GroupErrors + +class PermissionsSettingsFragment : DSLSettingsFragment( + titleId = R.string.ConversationSettingsFragment__permissions +) { + + private val permissionsOptions: Array by lazy { + resources.getStringArray(R.array.PermissionsSettingsFragment__editor_labels) + } + + private val viewModel: PermissionsSettingsViewModel by viewModels( + factoryProducer = { + val args = PermissionsSettingsFragmentArgs.fromBundle(requireArguments()) + val groupId = requireNotNull(ParcelableGroupId.get(args.groupId as ParcelableGroupId)) + val repository = PermissionsSettingsRepository(requireContext()) + + PermissionsSettingsViewModel.Factory(groupId, repository) + } + ) + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + viewModel.state.observe(viewLifecycleOwner) { state -> + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + + viewModel.events.observe(viewLifecycleOwner) { event -> + when (event) { + is PermissionsSettingsEvents.GroupChangeError -> handleGroupChangeError(event) + } + } + } + + private fun handleGroupChangeError(groupChangeError: PermissionsSettingsEvents.GroupChangeError) { + Toast.makeText(context, GroupErrors.getUserDisplayMessage(groupChangeError.reason), Toast.LENGTH_LONG).show() + } + + private fun getConfiguration(state: PermissionsSettingsState): DSLConfiguration { + return configure { + + radioListPref( + title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__add_members), + isEnabled = state.selfCanEditSettings, + listItems = permissionsOptions, + dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_add_new_members), + selected = getSelected(state.nonAdminCanAddMembers), + confirmAction = true, + onSelected = { + viewModel.setNonAdminCanAddMembers(it == 1) + } + ) + + radioListPref( + title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__edit_group_info), + isEnabled = state.selfCanEditSettings, + listItems = permissionsOptions, + dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_edit_this_groups_info), + selected = getSelected(state.nonAdminCanEditGroupInfo), + confirmAction = true, + onSelected = { + viewModel.setNonAdminCanEditGroupInfo(it == 1) + } + ) + } + } + + @StringRes + private fun getSelected(isNonAdminAllowed: Boolean): Int { + return if (isNonAdminAllowed) { + 1 + } else { + 0 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt new file mode 100644 index 000000000..fad256e5d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.components.settings.conversation.permissions + +import android.content.Context +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.groups.GroupAccessControl +import org.thoughtcrime.securesms.groups.GroupChangeException +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupManager +import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import java.io.IOException + +private val TAG = Log.tag(PermissionsSettingsRepository::class.java) + +class PermissionsSettingsRepository(private val context: Context) { + + fun applyMembershipRightsChange(groupId: GroupId, newRights: GroupAccessControl, error: GroupChangeErrorCallback) { + SignalExecutors.UNBOUNDED.execute { + try { + GroupManager.applyMembershipAdditionRightsChange(context, groupId.requireV2(), newRights) + } catch (e: GroupChangeException) { + Log.w(TAG, e) + error.onError(GroupChangeFailureReason.fromException(e)) + } catch (e: IOException) { + Log.w(TAG, e) + error.onError(GroupChangeFailureReason.fromException(e)) + } + } + } + + fun applyAttributesRightsChange(groupId: GroupId, newRights: GroupAccessControl, error: GroupChangeErrorCallback) { + SignalExecutors.UNBOUNDED.execute { + try { + GroupManager.applyAttributesRightsChange(context, groupId.requireV2(), newRights) + } catch (e: GroupChangeException) { + Log.w(TAG, e) + error.onError(GroupChangeFailureReason.fromException(e)) + } catch (e: IOException) { + Log.w(TAG, e) + error.onError(GroupChangeFailureReason.fromException(e)) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsState.kt new file mode 100644 index 000000000..822cc44b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsState.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.components.settings.conversation.permissions + +data class PermissionsSettingsState( + val selfCanEditSettings: Boolean = false, + val nonAdminCanAddMembers: Boolean = false, + val nonAdminCanEditGroupInfo: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt new file mode 100644 index 000000000..025483a91 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.components.settings.conversation.permissions + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.thoughtcrime.securesms.groups.GroupAccessControl +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.LiveGroup +import org.thoughtcrime.securesms.util.SingleLiveEvent +import org.thoughtcrime.securesms.util.livedata.Store + +class PermissionsSettingsViewModel( + private val groupId: GroupId, + private val repository: PermissionsSettingsRepository +) : ViewModel() { + + private val store = Store(PermissionsSettingsState()) + private val liveGroup = LiveGroup(groupId) + private val internalEvents = SingleLiveEvent() + + val state: LiveData = store.stateLiveData + val events: LiveData = internalEvents + + init { + store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state -> + state.copy(selfCanEditSettings = isSelfAdmin) + } + + store.update(liveGroup.membershipAdditionAccessControl) { membershipAdditionAccessControl, state -> + state.copy(nonAdminCanAddMembers = membershipAdditionAccessControl == GroupAccessControl.ALL_MEMBERS) + } + + store.update(liveGroup.attributesAccessControl) { attributesAccessControl, state -> + state.copy(nonAdminCanEditGroupInfo = attributesAccessControl == GroupAccessControl.ALL_MEMBERS) + } + } + + fun setNonAdminCanAddMembers(nonAdminCanAddMembers: Boolean) { + repository.applyMembershipRightsChange(groupId, nonAdminCanAddMembers.asGroupAccessControl()) { reason -> + internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason)) + } + } + + fun setNonAdminCanEditGroupInfo(nonAdminCanEditGroupInfo: Boolean) { + repository.applyAttributesRightsChange(groupId, nonAdminCanEditGroupInfo.asGroupAccessControl()) { reason -> + internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason)) + } + } + + private fun Boolean.asGroupAccessControl(): GroupAccessControl { + return if (this) { + GroupAccessControl.ALL_MEMBERS + } else { + GroupAccessControl.ONLY_ADMINS + } + } + + class Factory( + private val groupId: GroupId, + private val repository: PermissionsSettingsRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(PermissionsSettingsViewModel(groupId, repository))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt new file mode 100644 index 000000000..3d599501b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.view.View +import androidx.core.view.ViewCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingViewHolder + +/** + * Renders a large avatar (80dp) for a given Recipient. + */ +object AvatarPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_avatar_preference_item)) + } + + class Model( + val recipient: Recipient, + val onAvatarClick: (View) -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return recipient == newItem.recipient + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient) + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + private val avatar: AvatarImageView = itemView.findViewById(R.id.bio_preference_avatar).apply { + ViewCompat.setTransitionName(this, "avatar") + } + + override fun bind(model: Model) { + avatar.setAvatar(model.recipient) + avatar.disableQuickContact() + avatar.setOnClickListener { model.onAvatarClick(avatar) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/BioTextPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/BioTextPreference.kt new file mode 100644 index 000000000..61d9d2827 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/BioTextPreference.kt @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.content.Context +import android.view.View +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingViewHolder + +/** + * Renders name, description, about, etc. for a given group or recipient. + */ +object BioTextPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(RecipientModel::class.java, MappingAdapter.LayoutFactory(::RecipientViewHolder, R.layout.conversation_settings_bio_preference_item)) + adapter.registerFactory(GroupModel::class.java, MappingAdapter.LayoutFactory(::GroupViewHolder, R.layout.conversation_settings_bio_preference_item)) + } + + abstract class BioTextPreferenceModel> : PreferenceModel() { + abstract fun getHeadlineText(context: Context): String + abstract fun getSubhead1Text(): String? + abstract fun getSubhead2Text(): String? + } + + class RecipientModel( + private val recipient: Recipient, + ) : BioTextPreferenceModel() { + + override fun getHeadlineText(context: Context): String = recipient.getDisplayNameOrUsername(context) + + override fun getSubhead1Text(): String? = recipient.combinedAboutAndEmoji + + override fun getSubhead2Text(): String? = recipient.e164.orNull() + + override fun areContentsTheSame(newItem: RecipientModel): Boolean { + return super.areContentsTheSame(newItem) && newItem.recipient.hasSameContent(recipient) + } + + override fun areItemsTheSame(newItem: RecipientModel): Boolean { + return newItem.recipient.id == recipient.id + } + } + + class GroupModel( + val groupTitle: String, + val groupMembershipDescription: String? + ) : BioTextPreferenceModel() { + override fun getHeadlineText(context: Context): String = groupTitle + + override fun getSubhead1Text(): String? = groupMembershipDescription + + override fun getSubhead2Text(): String? = null + + override fun areContentsTheSame(newItem: GroupModel): Boolean { + return super.areContentsTheSame(newItem) && + groupTitle == newItem.groupTitle && + groupMembershipDescription == newItem.groupMembershipDescription + } + + override fun areItemsTheSame(newItem: GroupModel): Boolean { + return true + } + } + + private abstract class BioTextViewHolder>(itemView: View) : MappingViewHolder(itemView) { + + private val headline: TextView = itemView.findViewById(R.id.bio_preference_headline) + private val subhead1: TextView = itemView.findViewById(R.id.bio_preference_subhead_1) + private val subhead2: TextView = itemView.findViewById(R.id.bio_preference_subhead_2) + + override fun bind(model: T) { + headline.text = model.getHeadlineText(context) + + model.getSubhead1Text().let { + subhead1.text = it + subhead1.visibility = if (it == null) View.GONE else View.VISIBLE + } + + model.getSubhead2Text().let { + subhead2.text = it + subhead2.visibility = if (it == null) View.GONE else View.VISIBLE + } + } + } + + private class RecipientViewHolder(itemView: View) : BioTextViewHolder(itemView) + private class GroupViewHolder(itemView: View) : BioTextViewHolder(itemView) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/ButtonStripPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/ButtonStripPreference.kt new file mode 100644 index 000000000..7dc6b1413 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/ButtonStripPreference.kt @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingViewHolder +import org.thoughtcrime.securesms.util.visible + +/** + * Renders a configurable strip of buttons + */ +object ButtonStripPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_button_strip)) + } + + class Model( + val state: State, + val background: DSLSettingsIcon? = null, + val onMessageClick: () -> Unit = {}, + val onVideoClick: () -> Unit = {}, + val onAudioClick: () -> Unit = {}, + val onMuteClick: () -> Unit = {}, + val onSearchClick: () -> Unit = {} + ) : PreferenceModel() { + override fun areContentsTheSame(newItem: Model): Boolean { + return super.areContentsTheSame(newItem) && state == newItem.state + } + + override fun areItemsTheSame(newItem: Model): Boolean { + return true + } + } + + class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val message: View = itemView.findViewById(R.id.message) + private val messageLabel: View = itemView.findViewById(R.id.message_label) + private val videoCall: View = itemView.findViewById(R.id.start_video) + private val videoLabel: View = itemView.findViewById(R.id.start_video_label) + private val audioCall: ImageView = itemView.findViewById(R.id.start_audio) + private val audioLabel: TextView = itemView.findViewById(R.id.start_audio_label) + private val mute: ImageView = itemView.findViewById(R.id.mute) + private val muteLabel: TextView = itemView.findViewById(R.id.mute_label) + private val search: View = itemView.findViewById(R.id.search) + private val searchLabel: View = itemView.findViewById(R.id.search_label) + + override fun bind(model: Model) { + message.visible = model.state.isMessageAvailable + messageLabel.visible = model.state.isMessageAvailable + videoCall.visible = model.state.isVideoAvailable + videoLabel.visible = model.state.isVideoAvailable + audioCall.visible = model.state.isAudioAvailable + audioLabel.visible = model.state.isAudioAvailable + mute.visible = model.state.isMuteAvailable + muteLabel.visible = model.state.isMuteAvailable + search.visible = model.state.isSearchAvailable + searchLabel.visible = model.state.isSearchAvailable + + if (model.state.isAudioSecure) { + audioLabel.setText(R.string.ConversationSettingsFragment__audio) + audioCall.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_phone_right_24)) + } else { + audioLabel.setText(R.string.ConversationSettingsFragment__call) + audioCall.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_phone_right_unlock_primary_accent_24)) + } + + if (model.state.isMuted) { + mute.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_bell_disabled_24)) + muteLabel.setText(R.string.ConversationSettingsFragment__muted) + } else { + mute.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_bell_24)) + muteLabel.setText(R.string.ConversationSettingsFragment__mute) + } + + if (model.background != null) { + listOf(message, videoCall, audioCall, mute, search).forEach { + it.background = model.background.resolve(context) + } + } + + message.setOnClickListener { model.onMessageClick() } + videoCall.setOnClickListener { model.onVideoClick() } + audioCall.setOnClickListener { model.onAudioClick() } + mute.setOnClickListener { model.onMuteClick() } + search.setOnClickListener { model.onSearchClick() } + } + } + + data class State( + val isMessageAvailable: Boolean = false, + val isVideoAvailable: Boolean = false, + val isAudioAvailable: Boolean = false, + val isMuteAvailable: Boolean = false, + val isSearchAvailable: Boolean = false, + val isAudioSecure: Boolean = false, + val isMuted: Boolean = false, + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/GroupDescriptionPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/GroupDescriptionPreference.kt new file mode 100644 index 000000000..f34f9068f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/GroupDescriptionPreference.kt @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.view.View +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil +import org.thoughtcrime.securesms.util.LongClickMovementMethod +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingViewHolder + +object GroupDescriptionPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_group_description_preference)) + } + + class Model( + private val groupId: GroupId, + val groupDescription: String?, + val descriptionShouldLinkify: Boolean, + val canEditGroupAttributes: Boolean, + val onEditGroupDescription: () -> Unit, + val onViewGroupDescription: () -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return groupId == newItem.groupId + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return super.areContentsTheSame(newItem) && + groupDescription == newItem.groupDescription && + descriptionShouldLinkify == newItem.descriptionShouldLinkify && + canEditGroupAttributes == newItem.canEditGroupAttributes + } + } + + class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val groupDescriptionTextView: EmojiTextView = findViewById(R.id.manage_group_description) + + override fun bind(model: Model) { + groupDescriptionTextView.movementMethod = LongClickMovementMethod.getInstance(context) + + if (model.groupDescription.isNullOrEmpty()) { + if (model.canEditGroupAttributes) { + groupDescriptionTextView.setOverflowText(null) + groupDescriptionTextView.setText(R.string.ManageGroupActivity_add_group_description) + groupDescriptionTextView.setOnClickListener { model.onEditGroupDescription() } + } + } else { + groupDescriptionTextView.setOnClickListener(null) + GroupDescriptionUtil.setText( + context, + groupDescriptionTextView, + model.groupDescription, + model.descriptionShouldLinkify + ) { + model.onViewGroupDescription() + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/InternalPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/InternalPreference.kt new file mode 100644 index 000000000..1c139dc30 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/InternalPreference.kt @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.view.View +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.Base64 +import org.thoughtcrime.securesms.util.Hex +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingViewHolder +import java.util.UUID + +object InternalPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_internal_preference)) + } + + class Model( + private val recipient: Recipient, + val onDisableProfileSharingClick: () -> Unit + ) : PreferenceModel() { + + val body: String get() { + return String.format( + """ + -- Profile Name -- + [${recipient.profileName.givenName}] [${recipient.profileName.familyName}] + + -- Profile Sharing -- + ${recipient.isProfileSharing} + + -- Profile Key (Base64) -- + ${recipient.profileKey?.let(Base64::encodeBytes) ?: "None"} + + -- Profile Key (Hex) -- + ${recipient.profileKey?.let(Hex::toStringCondensed) ?: "None"} + + -- Sealed Sender Mode -- + ${recipient.unidentifiedAccessMode} + + -- UUID -- + ${recipient.uuid.transform { obj: UUID -> obj.toString() }.or("None")} + + -- RecipientId -- + ${recipient.id.serialize()} + """.trimIndent(), + ) + } + + override fun areItemsTheSame(newItem: Model): Boolean { + return recipient == newItem.recipient + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val body: TextView = itemView.findViewById(R.id.internal_preference_body) + private val disableProfileSharing: View = itemView.findViewById(R.id.internal_disable_profile_sharing) + + override fun bind(model: Model) { + body.text = model.body + disableProfileSharing.setOnClickListener { model.onDisableProfileSharingClick() } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LargeIconClickPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LargeIconClickPreference.kt new file mode 100644 index 000000000..4befc03c1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LargeIconClickPreference.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.view.View +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder +import org.thoughtcrime.securesms.util.MappingAdapter + +/** + * Renders a preference line item with a larger (40dp) icon + */ +object LargeIconClickPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.large_icon_preference_item)) + } + + class Model( + override val title: DSLSettingsText?, + override val icon: DSLSettingsIcon, + val onClick: () -> Unit + ) : PreferenceModel() + + private class ViewHolder(itemView: View) : PreferenceViewHolder(itemView) { + override fun bind(model: Model) { + super.bind(model) + itemView.setOnClickListener { model.onClick() } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LegacyGroupPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LegacyGroupPreference.kt new file mode 100644 index 000000000..b353147dd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LegacyGroupPreference.kt @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.view.View +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingViewHolder +import org.thoughtcrime.securesms.util.views.LearnMoreTextView + +object LegacyGroupPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_legacy_group_preference)) + } + + class Model( + val state: State, + val onLearnMoreClick: () -> Unit, + val onUpgradeClick: () -> Unit, + val onMmsWarningClick: () -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return state == newItem.state + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val groupInfoText: LearnMoreTextView = findViewById(R.id.manage_group_info_text) + + override fun bind(model: Model) { + itemView.visibility = View.VISIBLE + + when (model.state) { + State.LEARN_MORE -> { + groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_learn_more) + groupInfoText.setOnLinkClickListener { model.onLearnMoreClick() } + groupInfoText.setLearnMoreVisible(true) + } + State.UPGRADE -> { + groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_upgrade) + groupInfoText.setOnLinkClickListener { model.onUpgradeClick() } + groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_upgrade_this_group) + } + State.TOO_LARGE -> { + groupInfoText.text = context.getString(R.string.ManageGroupActivity_legacy_group_too_large, FeatureFlags.groupLimits().hardLimit - 1) + groupInfoText.setLearnMoreVisible(false) + } + State.MMS_WARNING -> { + groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group) + groupInfoText.setOnLinkClickListener { model.onMmsWarningClick() } + groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_invite_now) + } + State.NONE -> itemView.visibility = View.GONE + } + } + } + + enum class State { + LEARN_MORE, + UPGRADE, + TOO_LARGE, + MMS_WARNING, + NONE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/RecipientPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/RecipientPreference.kt new file mode 100644 index 000000000..b67bbd426 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/RecipientPreference.kt @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.view.View +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingViewHolder +import org.thoughtcrime.securesms.util.visible + +/** + * Renders a Recipient as a row item with an icon, avatar, status, and admin state + */ +object RecipientPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.group_recipient_list_item)) + } + + class Model( + val recipient: Recipient, + val isAdmin: Boolean = false, + val onClick: () -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return recipient.id == newItem.recipient.id + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient) + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + private val avatar: AvatarImageView = itemView.findViewById(R.id.recipient_avatar) + private val name: TextView = itemView.findViewById(R.id.recipient_name) + private val about: TextView = itemView.findViewById(R.id.recipient_about) + private val admin: View = itemView.findViewById(R.id.admin) + + override fun bind(model: Model) { + itemView.setOnClickListener { model.onClick() } + + avatar.setRecipient(model.recipient) + name.text = if (model.recipient.isSelf) { + context.getString(R.string.Recipient_you) + } else { + model.recipient.getDisplayName(context) + } + + val aboutText = model.recipient.combinedAboutAndEmoji + if (aboutText.isNullOrEmpty()) { + about.visibility = View.GONE + } else { + about.text = model.recipient.combinedAboutAndEmoji + about.visibility = View.VISIBLE + } + + admin.visible = model.isAdmin + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/SharedMediaPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/SharedMediaPreference.kt new file mode 100644 index 000000000..7b7c3fd69 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/SharedMediaPreference.kt @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.database.Cursor +import android.view.View +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ThreadPhotoRailView +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.database.MediaDatabase +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.util.MappingAdapter +import org.thoughtcrime.securesms.util.MappingViewHolder +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * Renders the shared media photo rail. + */ +object SharedMediaPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_shared_media)) + } + + class Model( + val mediaCursor: Cursor, + val onMediaRecordClick: (MediaDatabase.MediaRecord, Boolean) -> Unit + ) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return newItem.mediaCursor == mediaCursor + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val rail: ThreadPhotoRailView = itemView.findViewById(R.id.rail_view) + + override fun bind(model: Model) { + rail.setCursor(GlideApp.with(rail), model.mediaCursor) + rail.setListener { + model.onMediaRecordClick(it, ViewUtil.isLtr(rail)) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/Utils.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/Utils.kt new file mode 100644 index 000000000..527f16e10 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/Utils.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.content.Context +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale + +object Utils { + + fun Long.formatMutedUntil(context: Context): String { + return if (this == Long.MAX_VALUE) { + context.getString(R.string.ConversationSettingsFragment__conversation_muted_forever) + } else { + context.getString( + R.string.ConversationSettingsFragment__conversation_muted_until_s, + DateUtils.getTimeString(context, Locale.getDefault(), this) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment.kt new file mode 100644 index 000000000..5d1f02028 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment.kt @@ -0,0 +1,118 @@ +package org.thoughtcrime.securesms.components.settings.conversation.sounds + +import androidx.fragment.app.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.thoughtcrime.securesms.MuteDialog +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment + +class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment( + titleId = R.string.ConversationSettingsFragment__sounds_and_notifications +) { + + private val mentionLabels: Array by lazy { + resources.getStringArray(R.array.SoundsAndNotificationsSettingsFragment__mention_labels) + } + + private val viewModel: SoundsAndNotificationsSettingsViewModel by viewModels( + factoryProducer = { + val recipientId = SoundsAndNotificationsSettingsFragmentArgs.fromBundle(requireArguments()).recipientId + val repository = SoundsAndNotificationsSettingsRepository(requireContext()) + + SoundsAndNotificationsSettingsViewModel.Factory(recipientId, repository) + } + ) + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + viewModel.state.observe(viewLifecycleOwner) { state -> + if (state.recipientId != Recipient.UNKNOWN.id) { + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + } + } + + private fun getConfiguration(state: SoundsAndNotificationsSettingsState): DSLConfiguration { + return configure { + + val muteSummary = if (state.muteUntil > 0) { + state.muteUntil.formatMutedUntil(requireContext()) + } else { + getString(R.string.SoundsAndNotificationsSettingsFragment__not_muted) + } + + val muteIcon = if (state.muteUntil > 0) { + R.drawable.ic_bell_disabled_24 + } else { + R.drawable.ic_bell_24 + } + + clickPref( + title = DSLSettingsText.from(R.string.SoundsAndNotificationsSettingsFragment__mute_notifications), + icon = DSLSettingsIcon.from(muteIcon), + summary = DSLSettingsText.from(muteSummary), + onClick = { + if (state.muteUntil <= 0) { + MuteDialog.show(requireContext(), viewModel::setMuteUntil) + } else { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(muteSummary) + .setPositiveButton(R.string.ConversationSettingsFragment__unmute) { dialog, _ -> + viewModel.unmute() + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } + .show() + } + } + ) + + if (state.hasMentionsSupport) { + val mentionSelection = if (state.mentionSetting == RecipientDatabase.MentionSetting.ALWAYS_NOTIFY) { + 0 + } else { + 1 + } + + radioListPref( + title = DSLSettingsText.from(R.string.SoundsAndNotificationsSettingsFragment__mentions), + icon = DSLSettingsIcon.from(R.drawable.ic_at_24), + selected = mentionSelection, + listItems = mentionLabels, + onSelected = { + viewModel.setMentionSetting( + if (it == 0) { + RecipientDatabase.MentionSetting.ALWAYS_NOTIFY + } else { + RecipientDatabase.MentionSetting.DO_NOT_NOTIFY + } + ) + } + ) + } + + val customSoundSummary = if (state.hasCustomNotificationSettings) { + R.string.preferences_on + } else { + R.string.preferences_off + } + + clickPref( + title = DSLSettingsText.from(R.string.SoundsAndNotificationsSettingsFragment__custom_notifications), + icon = DSLSettingsIcon.from(R.drawable.ic_speaker_24), + summary = DSLSettingsText.from(customSoundSummary), + onClick = { + CustomNotificationsDialogFragment.create(state.recipientId).show(parentFragmentManager, null) + } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsRepository.kt new file mode 100644 index 000000000..58d133b19 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsRepository.kt @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.components.settings.conversation.sounds + +import android.content.Context +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +class SoundsAndNotificationsSettingsRepository(private val context: Context) { + + fun setMuteUntil(recipientId: RecipientId, muteUntil: Long) { + SignalExecutors.BOUNDED.execute { + DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, muteUntil) + } + } + + fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientDatabase.MentionSetting) { + SignalExecutors.BOUNDED.execute { + DatabaseFactory.getRecipientDatabase(context).setMentionSetting(recipientId, mentionSetting) + } + } + + fun hasCustomNotificationSettings(recipientId: RecipientId, consumer: (Boolean) -> Unit) { + SignalExecutors.BOUNDED.execute { + val recipient = Recipient.resolved(recipientId) + consumer( + if (recipient.notificationChannel != null || !NotificationChannels.supported()) { + true + } else { + NotificationChannels.updateWithShortcutBasedChannel(context, recipient) + } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState.kt new file mode 100644 index 000000000..11ff87414 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.components.settings.conversation.sounds + +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +data class SoundsAndNotificationsSettingsState( + val recipientId: RecipientId = Recipient.UNKNOWN.id, + val muteUntil: Long = 0L, + val mentionSetting: RecipientDatabase.MentionSetting = RecipientDatabase.MentionSetting.DO_NOT_NOTIFY, + val hasCustomNotificationSettings: Boolean = false, + val hasMentionsSupport: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel.kt new file mode 100644 index 000000000..f8806d116 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel.kt @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.components.settings.conversation.sounds + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.livedata.Store + +class SoundsAndNotificationsSettingsViewModel( + private val recipientId: RecipientId, + private val repository: SoundsAndNotificationsSettingsRepository +) : ViewModel() { + + private val store = Store(SoundsAndNotificationsSettingsState()) + + val state: LiveData = store.stateLiveData + + init { + store.update(Recipient.live(recipientId).liveData) { recipient, state -> + state.copy( + recipientId = recipientId, + muteUntil = recipient.muteUntil, + mentionSetting = recipient.mentionSetting, + hasMentionsSupport = recipient.isPushV2Group + ) + } + } + + fun setMuteUntil(muteUntil: Long) { + repository.setMuteUntil(recipientId, muteUntil) + } + + fun unmute() { + repository.setMuteUntil(recipientId, 0L) + } + + fun setMentionSetting(mentionSetting: RecipientDatabase.MentionSetting) { + repository.setMentionSetting(recipientId, mentionSetting) + } + + class Factory( + private val recipientId: RecipientId, + private val repository: SoundsAndNotificationsSettingsRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(SoundsAndNotificationsSettingsViewModel(recipientId, repository))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt index d35f5df3e..04cd62df3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt @@ -1,13 +1,10 @@ package org.thoughtcrime.securesms.components.settings import androidx.annotation.CallSuper -import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.thoughtcrime.securesms.util.MappingModel import org.thoughtcrime.securesms.util.MappingModelList -private const val UNSET = -1 - fun configure(init: DSLConfiguration.() -> Unit): DSLConfiguration { val configuration = DSLConfiguration() configuration.init() @@ -23,13 +20,24 @@ class DSLConfiguration { fun radioListPref( title: DSLSettingsText, - @DrawableRes iconId: Int = UNSET, + icon: DSLSettingsIcon? = null, + dialogTitle: DSLSettingsText = title, isEnabled: Boolean = true, listItems: Array, selected: Int, + confirmAction: Boolean = false, onSelected: (Int) -> Unit ) { - val preference = RadioListPreference(title, iconId, isEnabled, listItems, selected, onSelected) + val preference = RadioListPreference( + title = title, + icon = icon, + isEnabled = isEnabled, + dialogTitle = dialogTitle, + listItems = listItems, + selected = selected, + confirmAction = confirmAction, + onSelected = onSelected + ) children.add(preference) } @@ -47,12 +55,12 @@ class DSLConfiguration { fun switchPref( title: DSLSettingsText, summary: DSLSettingsText? = null, - @DrawableRes iconId: Int = UNSET, + icon: DSLSettingsIcon? = null, isEnabled: Boolean = true, isChecked: Boolean, onClick: () -> Unit ) { - val preference = SwitchPreference(title, summary, iconId, isEnabled, isChecked, onClick) + val preference = SwitchPreference(title, summary, icon, isEnabled, isChecked, onClick) children.add(preference) } @@ -70,20 +78,20 @@ class DSLConfiguration { fun clickPref( title: DSLSettingsText, summary: DSLSettingsText? = null, - @DrawableRes iconId: Int = UNSET, + icon: DSLSettingsIcon? = null, isEnabled: Boolean = true, onClick: () -> Unit ) { - val preference = ClickPreference(title, summary, iconId, isEnabled, onClick) + val preference = ClickPreference(title, summary, icon, isEnabled, onClick) children.add(preference) } fun externalLinkPref( title: DSLSettingsText, - @DrawableRes iconId: Int = UNSET, + icon: DSLSettingsIcon? = null, @StringRes linkId: Int ) { - val preference = ExternalLinkPreference(title, iconId, linkId) + val preference = ExternalLinkPreference(title, icon, linkId) children.add(preference) } @@ -116,8 +124,8 @@ class DSLConfiguration { abstract class PreferenceModel>( open val title: DSLSettingsText? = null, open val summary: DSLSettingsText? = null, - @DrawableRes open val iconId: Int = UNSET, - open val isEnabled: Boolean = true + open val icon: DSLSettingsIcon? = null, + open val isEnabled: Boolean = true, ) : MappingModel { override fun areItemsTheSame(newItem: T): Boolean { return when { @@ -131,7 +139,7 @@ abstract class PreferenceModel>( override fun areContentsTheSame(newItem: T): Boolean { return areItemsTheSame(newItem) && newItem.summary == summary && - newItem.iconId == iconId && + newItem.icon == icon && newItem.isEnabled == isEnabled } } @@ -147,12 +155,14 @@ class DividerPreference : PreferenceModel() { class RadioListPreference( override val title: DSLSettingsText, - @DrawableRes override val iconId: Int = UNSET, + override val icon: DSLSettingsIcon? = null, override val isEnabled: Boolean, + val dialogTitle: DSLSettingsText = title, val listItems: Array, val selected: Int, - val onSelected: (Int) -> Unit -) : PreferenceModel(title = title, iconId = iconId, isEnabled = isEnabled) { + val onSelected: (Int) -> Unit, + val confirmAction: Boolean = false +) : PreferenceModel() { override fun areContentsTheSame(newItem: RadioListPreference): Boolean { return super.areContentsTheSame(newItem) && listItems.contentEquals(newItem.listItems) && selected == newItem.selected @@ -176,11 +186,11 @@ class MultiSelectListPreference( class SwitchPreference( override val title: DSLSettingsText, override val summary: DSLSettingsText? = null, - @DrawableRes override val iconId: Int = UNSET, - isEnabled: Boolean, + override val icon: DSLSettingsIcon? = null, + override val isEnabled: Boolean, val isChecked: Boolean, val onClick: () -> Unit -) : PreferenceModel(title = title, summary = summary, iconId = iconId, isEnabled = isEnabled) { +) : PreferenceModel() { override fun areContentsTheSame(newItem: SwitchPreference): Boolean { return super.areContentsTheSame(newItem) && isChecked == newItem.isChecked } @@ -201,15 +211,15 @@ class RadioPreference( class ClickPreference( override val title: DSLSettingsText, override val summary: DSLSettingsText? = null, - @DrawableRes override val iconId: Int = UNSET, - isEnabled: Boolean = true, + override val icon: DSLSettingsIcon? = null, + override val isEnabled: Boolean = true, val onClick: () -> Unit -) : PreferenceModel(title = title, summary = summary, iconId = iconId, isEnabled = isEnabled) +) : PreferenceModel() class ExternalLinkPreference( override val title: DSLSettingsText, - @DrawableRes override val iconId: Int, + override val icon: DSLSettingsIcon?, @StringRes val linkId: Int -) : PreferenceModel(title = title, iconId = iconId) +) : PreferenceModel() -class SectionHeaderPreference(override val title: DSLSettingsText) : PreferenceModel(title = title) +class SectionHeaderPreference(override val title: DSLSettingsText) : PreferenceModel() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 9ee832d53..20b471414 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -44,7 +44,6 @@ import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.TextWatcher; -import android.view.Display; import android.view.Gravity; import android.view.KeyEvent; import android.view.Menu; @@ -66,12 +65,12 @@ import android.widget.Toast; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; import androidx.annotation.WorkerThread; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.SearchView; import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.core.content.pm.ShortcutInfoCompat; import androidx.core.content.pm.ShortcutManagerCompat; @@ -130,6 +129,7 @@ import org.thoughtcrime.securesms.components.reminder.Reminder; import org.thoughtcrime.securesms.components.reminder.ReminderView; import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder; import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder; +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; import org.thoughtcrime.securesms.contacts.ContactAccessor; import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData; import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; @@ -142,7 +142,6 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationM import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel; import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel; -import org.thoughtcrime.securesms.search.MessageResult; import org.thoughtcrime.securesms.crypto.ReentrantSessionLock; import org.thoughtcrime.securesms.crypto.SecurityEvent; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -174,7 +173,6 @@ import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog; import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity; -import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity; import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment; import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationSuggestionsDialog; import org.thoughtcrime.securesms.insights.InsightsLauncher; @@ -239,8 +237,8 @@ import org.thoughtcrime.securesms.recipients.RecipientFormattingException; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity; -import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity; import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity; +import org.thoughtcrime.securesms.search.MessageResult; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage; @@ -338,6 +336,9 @@ public class ConversationActivity extends PassphraseRequiredActivity private static final String TAG = Log.tag(ConversationActivity.class); private static final String STATE_REACT_WITH_ANY_PAGE = "STATE_REACT_WITH_ANY_PAGE"; + private static final String STATE_HANDLED_INIT_SEARCH = "STATE_HANDLED_INIT_SEARCH"; + + private static final int REQUEST_CODE_SETTINGS = 1000; private static final int PICK_GALLERY = 1; private static final int PICK_DOCUMENT = 2; @@ -496,13 +497,15 @@ public class ConversationActivity extends PassphraseRequiredActivity } }); initializeInsightObserver(); + + handleStartWithSearchOpen(); } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); Log.i(TAG, "onNewIntent()"); - + if (isFinishing()) { Log.w(TAG, "Activity is finishing..."); return; @@ -549,6 +552,8 @@ public class ConversationActivity extends PassphraseRequiredActivity } searchNav.setVisibility(View.GONE); + + handleStartWithSearchOpen(); } @Override @@ -774,6 +779,16 @@ public class ConversationActivity extends PassphraseRequiredActivity } } + private void handleStartWithSearchOpen() { + if (viewModel.getArgs().isWithSearchOpen()) { + toolbar.postDelayed(() -> { + if (searchViewItem.expandActionView()) { + searchViewModel.onSearchOpened(); + } + }, 500); + } + } + @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); @@ -1178,8 +1193,10 @@ public class ConversationActivity extends PassphraseRequiredActivity if (isInMessageRequest()) return; - Intent intent = ManageRecipientActivity.newIntentFromConversation(this, recipient.getId()); - startActivitySceneTransition(intent, titleView.findViewById(R.id.contact_photo_image), "avatar"); + Intent intent = ConversationSettingsActivity.forRecipient(this, recipient.getId()); + Bundle bundle = ConversationSettingsActivity.createTransitionBundle(this, titleView.findViewById(R.id.contact_photo_image), toolbar); + + ActivityCompat.startActivity(this, intent, bundle); } private void handleUnmuteNotifications() { @@ -1338,9 +1355,10 @@ public class ConversationActivity extends PassphraseRequiredActivity } private void handleManageGroup() { - startActivityForResult(ManageGroupActivity.newIntent(ConversationActivity.this, recipient.get().requireGroupId()), - GROUP_EDIT, - ManageGroupActivity.createTransitionBundle(this, titleView.findViewById(R.id.contact_photo_image))); + Intent intent = ConversationSettingsActivity.forGroup(this, recipient.get().requireGroupId()); + Bundle bundle = ConversationSettingsActivity.createTransitionBundle(this, titleView.findViewById(R.id.contact_photo_image), toolbar); + + ActivityCompat.startActivity(this, intent, bundle); } private void handleDistributionBroadcastEnabled(MenuItem item) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java index 05ae0956c..2aa13bb13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java @@ -32,6 +32,7 @@ public class ConversationIntents { private static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type"; private static final String EXTRA_STARTING_POSITION = "starting_position"; private static final String EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP = "first_time_in_group"; + private static final String EXTRA_WITH_SEARCH_OPEN = "with_search_open"; private ConversationIntents() { } @@ -70,6 +71,7 @@ public class ConversationIntents { private final int distributionType; private final int startingPosition; private final boolean firstTimeInSelfCreatedGroup; + private final boolean withSearchOpen; static Args from(@NonNull Intent intent) { if (isBubbleIntent(intent)) { @@ -81,6 +83,7 @@ public class ConversationIntents { false, ThreadDatabase.DistributionTypes.DEFAULT, -1, + false, false); } @@ -92,7 +95,8 @@ public class ConversationIntents { intent.getBooleanExtra(EXTRA_BORDERLESS, false), intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, ThreadDatabase.DistributionTypes.DEFAULT), intent.getIntExtra(EXTRA_STARTING_POSITION, -1), - intent.getBooleanExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false)); + intent.getBooleanExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false), + intent.getBooleanExtra(EXTRA_WITH_SEARCH_OPEN, false)); } private Args(@NonNull RecipientId recipientId, @@ -103,7 +107,8 @@ public class ConversationIntents { boolean isBorderless, int distributionType, int startingPosition, - boolean firstTimeInSelfCreatedGroup) + boolean firstTimeInSelfCreatedGroup, + boolean withSearchOpen) { this.recipientId = recipientId; this.threadId = threadId; @@ -114,6 +119,7 @@ public class ConversationIntents { this.distributionType = distributionType; this.startingPosition = startingPosition; this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup; + this.withSearchOpen = withSearchOpen; } public @NonNull RecipientId getRecipientId() { @@ -160,6 +166,10 @@ public class ConversationIntents { public @NonNull ChatColors getChatColors() { return Recipient.resolved(recipientId).getChatColors(); } + + public boolean isWithSearchOpen() { + return withSearchOpen; + } } public final static class Builder { @@ -177,6 +187,7 @@ public class ConversationIntents { private Uri dataUri; private String dataType; private boolean firstTimeInSelfCreatedGroup; + private boolean withSearchOpen; private Builder(@NonNull Context context, @NonNull RecipientId recipientId, @@ -236,6 +247,11 @@ public class ConversationIntents { return this; } + public @NonNull Builder withSearchOpen(boolean withSearchOpen) { + this.withSearchOpen = withSearchOpen; + return this; + } + public Builder firstTimeInSelfCreatedGroup() { this.firstTimeInSelfCreatedGroup = true; return this; @@ -265,6 +281,7 @@ public class ConversationIntents { intent.putExtra(EXTRA_STARTING_POSITION, startingPosition); intent.putExtra(EXTRA_BORDERLESS, isBorderless); intent.putExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, firstTimeInSelfCreatedGroup); + intent.putExtra(EXTRA_WITH_SEARCH_OPEN, withSearchOpen); if (draftText != null) { intent.putExtra(EXTRA_TEXT, draftText); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java index 25ef7cfa4..da723a694 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java @@ -2,6 +2,10 @@ package org.thoughtcrime.securesms.conversation; import android.content.Context; import android.content.res.ColorStateList; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.Drawable; import android.text.TextUtils; import android.util.AttributeSet; import android.view.View; @@ -25,6 +29,8 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.ViewUtil; +import java.util.Objects; + public class ConversationTitleView extends RelativeLayout { private AvatarImageView avatar; @@ -78,20 +84,21 @@ public class ConversationTitleView extends RelativeLayout { if (recipient == null) setComposeTitle(); else setRecipientTitle(recipient); - int startDrawable = 0; - int endDrawable = 0; + Drawable startDrawable = null; + Drawable endDrawable = null; if (recipient != null && recipient.isBlocked()) { - startDrawable = R.drawable.ic_block_white_18dp; + startDrawable = ContextCompat.getDrawable(getContext(), R.drawable.ic_block_white_18dp); } else if (recipient != null && recipient.isMuted()) { - startDrawable = R.drawable.ic_volume_off_white_18dp; + startDrawable = Objects.requireNonNull(ContextCompat.getDrawable(getContext(), R.drawable.ic_bell_disabled_16)); + startDrawable.setBounds(0, 0, ViewUtil.dpToPx(18), ViewUtil.dpToPx(18)); } if (recipient != null && recipient.isSystemContact() && !recipient.isSelf()) { - endDrawable = R.drawable.ic_profile_circle_outline_16; + endDrawable = ContextCompat.getDrawable(getContext(), R.drawable.ic_profile_circle_outline_16); } - title.setCompoundDrawablesRelativeWithIntrinsicBounds(startDrawable, 0, endDrawable, 0); + title.setCompoundDrawablesRelativeWithIntrinsicBounds(startDrawable, null, endDrawable, null); TextViewCompat.setCompoundDrawableTintList(title, ColorStateList.valueOf(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_80))); if (recipient != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java index d27fe0f97..fdfb082e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java @@ -8,6 +8,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; import com.annimon.stream.Stream; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; @@ -76,7 +77,7 @@ public final class LeaveGroupDialog { } private void showSelectNewAdminDialog() { - new AlertDialog.Builder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.LeaveGroupDialog_choose_new_admin) .setMessage(R.string.LeaveGroupDialog_before_you_leave_you_must_choose_at_least_one_new_admin_for_this_group) .setNegativeButton(android.R.string.cancel, null) @@ -85,7 +86,7 @@ public final class LeaveGroupDialog { } private void showLeaveDialog() { - new AlertDialog.Builder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.LeaveGroupDialog_leave_group) .setCancelable(true) .setMessage(R.string.LeaveGroupDialog_you_will_no_longer_be_able_to_send_or_receive_messages_in_this_group) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java index 82326993d..a2cd4df2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.groups.ui.addmembers; +import android.content.Context; +import android.content.Intent; import android.os.Bundle; import android.view.View; import android.widget.Toast; @@ -9,14 +11,20 @@ import androidx.appcompat.app.AlertDialog; import androidx.lifecycle.ViewModelProviders; import org.thoughtcrime.securesms.ContactSelectionActivity; +import org.thoughtcrime.securesms.ContactSelectionListFragment; import org.thoughtcrime.securesms.PushContactSelectionActivity; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.SelectionLimits; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; +import java.util.ArrayList; +import java.util.List; + public class AddMembersActivity extends PushContactSelectionActivity { public static final String GROUP_ID = "group_id"; @@ -24,6 +32,22 @@ public class AddMembersActivity extends PushContactSelectionActivity { private View done; private AddMembersViewModel viewModel; + public static @NonNull Intent createIntent(@NonNull Context context, + @NonNull GroupId groupId, + int displayModeFlags, + int selectionWarning, + int selectionLimit, + @NonNull List membersWithoutSelf) { + Intent intent = new Intent(context, AddMembersActivity.class); + + intent.putExtra(AddMembersActivity.GROUP_ID, groupId.toString()); + intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayModeFlags); + intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, new SelectionLimits(selectionWarning, selectionLimit)); + intent.putParcelableArrayListExtra(ContactSelectionListFragment.CURRENT_SELECTION, new ArrayList<>(membersWithoutSelf)); + + return intent; + } + @Override protected void onCreate(Bundle icicle, boolean ready) { getIntent().putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.add_members_activity); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupActivity.java deleted file mode 100644 index 52609d71b..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupActivity.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.thoughtcrime.securesms.groups.ui.managegroup; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.ActivityOptionsCompat; - -import org.thoughtcrime.securesms.PassphraseRequiredActivity; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; -import org.thoughtcrime.securesms.util.DynamicTheme; - -public class ManageGroupActivity extends PassphraseRequiredActivity { - - private static final String GROUP_ID = "GROUP_ID"; - - private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); - - public static Intent newIntent(@NonNull Context context, @NonNull GroupId groupId) { - Intent intent = new Intent(context, ManageGroupActivity.class); - intent.putExtra(GROUP_ID, groupId.toString()); - return intent; - } - - public static @Nullable Bundle createTransitionBundle(@NonNull Context activityContext, @NonNull View from) { - if (activityContext instanceof Activity) { - return ActivityOptionsCompat.makeSceneTransitionAnimation((Activity) activityContext, from, "avatar").toBundle(); - } else { - return null; - } - } - - @Override - protected void onPreCreate() { - dynamicTheme.onCreate(this); - } - - @Override - protected void onCreate(Bundle savedInstanceState, boolean ready) { - super.onCreate(savedInstanceState, ready); - getWindow().getDecorView().setSystemUiVisibility(getWindow().getDecorView().getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - setContentView(R.layout.group_manage_activity); - if (savedInstanceState == null) { - getSupportFragmentManager().beginTransaction() - .replace(R.id.container, ManageGroupFragment.newInstance(getIntent().getStringExtra(GROUP_ID))) - .commitNow(); - } - } - - @Override - public void onResume() { - super.onResume(); - dynamicTheme.onResume(this); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java deleted file mode 100644 index 8805884a3..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ /dev/null @@ -1,521 +0,0 @@ -package org.thoughtcrime.securesms.groups.ui.managegroup; - -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.graphics.Color; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CompoundButton; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SwitchCompat; -import androidx.appcompat.widget.Toolbar; -import androidx.core.widget.TextViewCompat; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.ViewModelProviders; - -import com.google.android.material.snackbar.Snackbar; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.AvatarPreviewActivity; -import org.thoughtcrime.securesms.InviteActivity; -import org.thoughtcrime.securesms.LoggingFragment; -import org.thoughtcrime.securesms.MainActivity; -import org.thoughtcrime.securesms.MediaPreviewActivity; -import org.thoughtcrime.securesms.MuteDialog; -import org.thoughtcrime.securesms.PushContactSelectionActivity; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.AvatarImageView; -import org.thoughtcrime.securesms.components.ThreadPhotoRailView; -import org.thoughtcrime.securesms.components.emoji.EmojiTextView; -import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; -import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; -import org.thoughtcrime.securesms.conversation.colors.AvatarColor; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; -import org.thoughtcrime.securesms.groups.ui.GroupErrors; -import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; -import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog; -import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity; -import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog; -import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog; -import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupRightsDialog; -import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment; -import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment; -import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil; -import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; -import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; -import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity; -import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment; -import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.ShareableGroupLinkDialogFragment; -import org.thoughtcrime.securesms.util.AsynchronousCallback; -import org.thoughtcrime.securesms.util.DateUtils; -import org.thoughtcrime.securesms.util.FeatureFlags; -import org.thoughtcrime.securesms.util.LifecycleCursorWrapper; -import org.thoughtcrime.securesms.util.LongClickMovementMethod; -import org.thoughtcrime.securesms.util.ViewUtil; -import org.thoughtcrime.securesms.util.views.LearnMoreTextView; -import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; -import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity; - -import java.util.List; -import java.util.Locale; -import java.util.Objects; - -public class ManageGroupFragment extends LoggingFragment { - private static final String GROUP_ID = "GROUP_ID"; - - private static final String TAG = Log.tag(ManageGroupFragment.class); - - private static final int RETURN_FROM_MEDIA = 33114; - private static final int PICK_CONTACT = 61341; - public static final String DIALOG_TAG = "DIALOG"; - - private ManageGroupViewModel viewModel; - private GroupMemberListView groupMemberList; - private View pendingAndRequestingRow; - private TextView pendingAndRequestingCount; - private Toolbar toolbar; - private TextView groupName; - private EmojiTextView groupDescription; - private LearnMoreTextView groupInfoText; - private TextView memberCountUnderAvatar; - private TextView memberCountAboveList; - private AvatarImageView avatar; - private ThreadPhotoRailView threadPhotoRailView; - private View groupMediaCard; - private View accessControlCard; - private View groupLinkCard; - private ManageGroupViewModel.CursorFactory cursorFactory; - private View sharedMediaRow; - private View editGroupAccessRow; - private TextView editGroupAccessValue; - private View editGroupMembershipRow; - private TextView editGroupMembershipValue; - private View disappearingMessagesCard; - private View disappearingMessagesRow; - private TextView disappearingMessages; - private View blockAndLeaveCard; - private TextView blockGroup; - private TextView unblockGroup; - private TextView leaveGroup; - private TextView addMembers; - private SwitchCompat muteNotificationsSwitch; - private View muteNotificationsRow; - private TextView muteNotificationsUntilLabel; - private TextView customNotificationsButton; - private View customNotificationsRow; - private View mentionsRow; - private TextView mentionsValue; - private View toggleAllMembers; - private View groupLinkRow; - private TextView groupLinkButton; - private TextView wallpaperButton; - - static ManageGroupFragment newInstance(@NonNull String groupId) { - ManageGroupFragment fragment = new ManageGroupFragment(); - Bundle args = new Bundle(); - - args.putString(GROUP_ID, groupId); - fragment.setArguments(args); - - return fragment; - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, - @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) - { - View view = inflater.inflate(R.layout.group_manage_fragment, container, false); - - avatar = view.findViewById(R.id.group_avatar); - toolbar = view.findViewById(R.id.toolbar); - groupName = view.findViewById(R.id.name); - groupDescription = view.findViewById(R.id.manage_group_description); - groupInfoText = view.findViewById(R.id.manage_group_info_text); - memberCountUnderAvatar = view.findViewById(R.id.member_count); - memberCountAboveList = view.findViewById(R.id.member_count_2); - groupMemberList = view.findViewById(R.id.group_members); - pendingAndRequestingRow = view.findViewById(R.id.pending_and_requesting_members_row); - pendingAndRequestingCount = view.findViewById(R.id.pending_and_requesting_members_count); - threadPhotoRailView = view.findViewById(R.id.recent_photos); - groupMediaCard = view.findViewById(R.id.group_media_card); - accessControlCard = view.findViewById(R.id.group_access_control_card); - groupLinkCard = view.findViewById(R.id.group_link_card); - sharedMediaRow = view.findViewById(R.id.shared_media_row); - editGroupAccessRow = view.findViewById(R.id.edit_group_access_row); - editGroupAccessValue = view.findViewById(R.id.edit_group_access_value); - editGroupMembershipRow = view.findViewById(R.id.edit_group_membership_row); - editGroupMembershipValue = view.findViewById(R.id.edit_group_membership_value); - disappearingMessagesCard = view.findViewById(R.id.group_disappearing_messages_card); - disappearingMessagesRow = view.findViewById(R.id.disappearing_messages_row); - disappearingMessages = view.findViewById(R.id.disappearing_messages); - blockAndLeaveCard = view.findViewById(R.id.group_block_and_leave_card); - blockGroup = view.findViewById(R.id.blockGroup); - unblockGroup = view.findViewById(R.id.unblockGroup); - leaveGroup = view.findViewById(R.id.leaveGroup); - addMembers = view.findViewById(R.id.add_members); - muteNotificationsUntilLabel = view.findViewById(R.id.group_mute_notifications_until); - muteNotificationsSwitch = view.findViewById(R.id.group_mute_notifications_switch); - muteNotificationsRow = view.findViewById(R.id.group_mute_notifications_row); - customNotificationsButton = view.findViewById(R.id.group_custom_notifications_button); - customNotificationsRow = view.findViewById(R.id.group_custom_notifications_row); - mentionsRow = view.findViewById(R.id.group_mentions_row); - mentionsValue = view.findViewById(R.id.group_mentions_value); - toggleAllMembers = view.findViewById(R.id.toggle_all_members); - groupLinkRow = view.findViewById(R.id.group_link_row); - groupLinkButton = view.findViewById(R.id.group_link_button); - wallpaperButton = view.findViewById(R.id.chat_wallpaper); - - return view; - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - Context context = requireContext(); - GroupId groupId = getGroupId(); - ManageGroupViewModel.Factory factory = new ManageGroupViewModel.Factory(context, groupId); - - disappearingMessagesCard.setVisibility(groupId.isPush() ? View.VISIBLE : View.GONE); - blockAndLeaveCard.setVisibility(groupId.isPush() ? View.VISIBLE : View.GONE); - - viewModel = ViewModelProviders.of(requireActivity(), factory).get(ManageGroupViewModel.class); - - viewModel.getMembers().observe(getViewLifecycleOwner(), members -> groupMemberList.setMembers(members)); - - viewModel.getCanCollapseMemberList().observe(getViewLifecycleOwner(), canCollapseMemberList -> { - if (canCollapseMemberList) { - toggleAllMembers.setVisibility(View.VISIBLE); - toggleAllMembers.setOnClickListener(v -> viewModel.revealCollapsedMembers()); - } else { - toggleAllMembers.setVisibility(View.GONE); - } - }); - - viewModel.getPendingAndRequestingCount().observe(getViewLifecycleOwner(), pendingAndRequestingCount -> { - pendingAndRequestingRow.setOnClickListener(v -> { - FragmentActivity activity = requireActivity(); - activity.startActivity(ManagePendingAndRequestingMembersActivity.newIntent(activity, groupId.requireV2())); - }); - if (pendingAndRequestingCount == 0) { - this.pendingAndRequestingCount.setVisibility(View.GONE); - } else { - this.pendingAndRequestingCount.setText(String.format(Locale.getDefault(), "%d", pendingAndRequestingCount)); - this.pendingAndRequestingCount.setVisibility(View.VISIBLE); - } - }); - - toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); - toolbar.setOnMenuItemClickListener(this::onMenuItemSelected); - toolbar.inflateMenu(R.menu.manage_group_fragment); - - viewModel.getCanEditGroupAttributes().observe(getViewLifecycleOwner(), canEdit -> { - toolbar.getMenu().findItem(R.id.action_edit).setVisible(canEdit); - disappearingMessages.setEnabled(canEdit); - disappearingMessagesRow.setEnabled(canEdit); - }); - - viewModel.getTitle().observe(getViewLifecycleOwner(), groupName::setText); - viewModel.getDescription().observe(getViewLifecycleOwner(), this::updateGroupDescription); - viewModel.getMemberCountSummary().observe(getViewLifecycleOwner(), memberCountUnderAvatar::setText); - viewModel.getFullMemberCountSummary().observe(getViewLifecycleOwner(), memberCountAboveList::setText); - viewModel.getGroupRecipient().observe(getViewLifecycleOwner(), groupRecipient -> { - avatar.setFallbackPhotoProvider(new FallbackPhotoProvider(groupRecipient.getAvatarColor())); - avatar.setRecipient(groupRecipient); - avatar.setOnClickListener(v -> { - FragmentActivity activity = requireActivity(); - activity.startActivity(AvatarPreviewActivity.intentFromRecipientId(activity, groupRecipient.getId()), - AvatarPreviewActivity.createTransitionBundle(activity, avatar)); - }); - customNotificationsRow.setOnClickListener(v -> CustomNotificationsDialogFragment.create(groupRecipient.getId()) - .show(requireFragmentManager(), DIALOG_TAG)); - wallpaperButton.setOnClickListener(v -> startActivity(ChatWallpaperActivity.createIntent(requireContext(), groupRecipient.getId()))); - - Drawable colorCircle = groupRecipient.getChatColors().asCircle(); - colorCircle.setBounds(0, 0, ViewUtil.dpToPx(16), ViewUtil.dpToPx(16)); - TextViewCompat.setCompoundDrawablesRelative(wallpaperButton, null, null, colorCircle, null); - }); - - if (groupId.isV2()) { - groupLinkRow.setOnClickListener(v -> ShareableGroupLinkDialogFragment.create(groupId.requireV2()) - .show(requireFragmentManager(), DIALOG_TAG)); - viewModel.getGroupLinkOn().observe(getViewLifecycleOwner(), linkEnabled -> groupLinkButton.setText(booleanToOnOff(linkEnabled))); - } - - viewModel.getGroupViewState().observe(getViewLifecycleOwner(), vs -> { - if (vs == null) return; - sharedMediaRow.setOnClickListener(v -> startActivity(MediaOverviewActivity.forThread(context, vs.getThreadId()))); - - setMediaCursorFactory(vs.getMediaCursorFactory()); - - threadPhotoRailView.setListener(mediaRecord -> - startActivityForResult(MediaPreviewActivity.intentFromMediaRecord(context, - mediaRecord, - ViewUtil.isLtr(threadPhotoRailView)), - RETURN_FROM_MEDIA)); - - groupLinkCard.setVisibility(vs.getGroupRecipient().requireGroupId().isV2() ? View.VISIBLE : View.GONE); - }); - - leaveGroup.setVisibility(groupId.isPush() ? View.VISIBLE : View.GONE); - leaveGroup.setOnClickListener(v -> LeaveGroupDialog.handleLeavePushGroup(requireActivity(), groupId.requirePush(), () -> startActivity(MainActivity.clearTop(context)))); - - viewModel.getDisappearingMessageTimer().observe(getViewLifecycleOwner(), string -> disappearingMessages.setText(string)); - - disappearingMessagesRow.setOnClickListener(v -> { - Recipient recipient = viewModel.getGroupRecipient().getValue(); - if (recipient != null) { - startActivity(RecipientDisappearingMessagesActivity.forRecipient(requireContext(), recipient.getId())); - } - }); - blockGroup.setOnClickListener(v -> viewModel.blockAndLeave(requireActivity())); - unblockGroup.setOnClickListener(v -> viewModel.unblock(requireActivity())); - - addMembers.setOnClickListener(v -> viewModel.onAddMembersClick(this, PICK_CONTACT)); - - viewModel.getMembershipRights().observe(getViewLifecycleOwner(), r -> { - if (r != null) { - editGroupMembershipValue.setText(r.getString()); - editGroupMembershipRow.setOnClickListener(v -> new GroupRightsDialog(context, GroupRightsDialog.Type.MEMBERSHIP, r, (from, to) -> viewModel.applyMembershipRightsChange(to)).show()); - } - } - ); - - viewModel.getEditGroupAttributesRights().observe(getViewLifecycleOwner(), r -> { - if (r != null) { - editGroupAccessValue.setText(r.getString()); - editGroupAccessRow.setOnClickListener(v -> new GroupRightsDialog(context, GroupRightsDialog.Type.ATTRIBUTES, r, (from, to) -> viewModel.applyAttributesRightsChange(to)).show()); - } - } - ); - - viewModel.getIsAdmin().observe(getViewLifecycleOwner(), admin -> { - accessControlCard.setVisibility(admin ? View.VISIBLE : View.GONE); - editGroupMembershipRow.setEnabled(admin); - editGroupMembershipValue.setEnabled(admin); - editGroupAccessRow.setEnabled(admin); - editGroupAccessValue.setEnabled(admin); - }); - - viewModel.getCanAddMembers().observe(getViewLifecycleOwner(), canEdit -> addMembers.setVisibility(canEdit ? View.VISIBLE : View.GONE)); - - groupMemberList.setRecipientClickListener(recipient -> RecipientBottomSheetDialogFragment.create(recipient.getId(), groupId).show(requireFragmentManager(), "BOTTOM")); - groupMemberList.setOverScrollMode(View.OVER_SCROLL_NEVER); - - final CompoundButton.OnCheckedChangeListener muteSwitchListener = (buttonView, isChecked) -> { - if (isChecked) { - MuteDialog.show(context, viewModel::setMuteUntil, () -> muteNotificationsSwitch.setChecked(false)); - } else { - viewModel.clearMuteUntil(); - } - }; - - muteNotificationsRow.setOnClickListener(v -> { - if (muteNotificationsSwitch.isEnabled()) { - muteNotificationsSwitch.toggle(); - } - }); - - viewModel.getMuteState().observe(getViewLifecycleOwner(), muteState -> { - if (muteNotificationsSwitch.isChecked() != muteState.isMuted()) { - muteNotificationsSwitch.setOnCheckedChangeListener(null); - muteNotificationsSwitch.setChecked(muteState.isMuted()); - } - - muteNotificationsSwitch.setEnabled(true); - muteNotificationsSwitch.setOnCheckedChangeListener(muteSwitchListener); - muteNotificationsUntilLabel.setVisibility(muteState.isMuted() ? View.VISIBLE : View.GONE); - - if (muteState.isMuted()) { - if (muteState.getMutedUntil() == Long.MAX_VALUE) { - muteNotificationsUntilLabel.setText(R.string.ManageGroupActivity_always); - } else { - muteNotificationsUntilLabel.setText(getString(R.string.ManageGroupActivity_until_s, - DateUtils.getTimeString(requireContext(), - Locale.getDefault(), - muteState.getMutedUntil()))); - } - } - }); - - customNotificationsRow.setVisibility(View.VISIBLE); - - if (NotificationChannels.supported()) { - viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> { - customNotificationsButton.setText(booleanToOnOff(hasCustomNotifications)); - }); - } - - mentionsRow.setVisibility(groupId.isV2() ? View.VISIBLE : View.GONE); - mentionsRow.setOnClickListener(v -> viewModel.handleMentionNotificationSelection()); - viewModel.getMentionSetting().observe(getViewLifecycleOwner(), value -> mentionsValue.setText(value)); - - viewModel.getCanLeaveGroup().observe(getViewLifecycleOwner(), canLeave -> leaveGroup.setVisibility(canLeave ? View.VISIBLE : View.GONE)); - viewModel.getCanBlockGroup().observe(getViewLifecycleOwner(), canBlock -> blockGroup.setVisibility(canBlock ? View.VISIBLE : View.GONE)); - viewModel.getCanUnblockGroup().observe(getViewLifecycleOwner(), canUnblock -> unblockGroup.setVisibility(canUnblock ? View.VISIBLE : View.GONE)); - - viewModel.getGroupInfoMessage().observe(getViewLifecycleOwner(), message -> { - switch (message) { - case LEGACY_GROUP_LEARN_MORE: - groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_learn_more); - groupInfoText.setOnLinkClickListener(v -> GroupsLearnMoreBottomSheetDialogFragment.show(requireFragmentManager())); - groupInfoText.setLearnMoreVisible(true); - groupInfoText.setVisibility(View.VISIBLE); - break; - case LEGACY_GROUP_UPGRADE: - groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_upgrade); - groupInfoText.setOnLinkClickListener(v -> GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(requireFragmentManager(), Recipient.externalPossiblyMigratedGroup(requireContext(), groupId).getId())); - groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_upgrade_this_group); - groupInfoText.setVisibility(View.VISIBLE); - break; - case LEGACY_GROUP_TOO_LARGE: - groupInfoText.setText(context.getString(R.string.ManageGroupActivity_legacy_group_too_large, FeatureFlags.groupLimits().getHardLimit() - 1)); - groupInfoText.setLearnMoreVisible(false); - groupInfoText.setVisibility(View.VISIBLE); - break; - case MMS_WARNING: - groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group); - groupInfoText.setOnLinkClickListener(v -> startActivity(new Intent(requireContext(), InviteActivity.class))); - groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_invite_now); - groupInfoText.setVisibility(View.VISIBLE); - break; - default: - groupInfoText.setVisibility(View.GONE); - break; - } - }); - } - - private static int booleanToOnOff(boolean isOn) { - return isOn ? R.string.ManageGroupActivity_on - : R.string.ManageGroupActivity_off; - } - - public boolean onMenuItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == R.id.action_edit) { - startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), getGroupId())); - return true; - } - - return false; - } - - private GroupId getGroupId() { - return GroupId.parseOrThrow(Objects.requireNonNull(requireArguments().getString(GROUP_ID))); - } - - private void setMediaCursorFactory(@Nullable ManageGroupViewModel.CursorFactory cursorFactory) { - if (this.cursorFactory != cursorFactory) { - this.cursorFactory = cursorFactory; - applyMediaCursorFactory(); - } - } - - private void applyMediaCursorFactory() { - Context context = getContext(); - if (context == null) return; - if (this.cursorFactory != null) { - Cursor cursor = this.cursorFactory.create(); - getViewLifecycleOwner().getLifecycle().addObserver(new LifecycleCursorWrapper(cursor)); - - threadPhotoRailView.setCursor(GlideApp.with(context), cursor); - groupMediaCard.setVisibility(cursor.getCount() > 0 ? View.VISIBLE : View.GONE); - } else { - threadPhotoRailView.setCursor(GlideApp.with(context), null); - groupMediaCard.setVisibility(View.GONE); - } - } - - private void updateGroupDescription(@NonNull ManageGroupViewModel.Description description) { - if (!TextUtils.isEmpty(description.getDescription()) || description.canEditDescription()) { - groupDescription.setVisibility(View.VISIBLE); - groupDescription.setMovementMethod(LongClickMovementMethod.getInstance(requireContext())); - memberCountUnderAvatar.setVisibility(View.GONE); - } else { - groupDescription.setVisibility(View.GONE); - groupDescription.setMovementMethod(null); - memberCountUnderAvatar.setVisibility(View.VISIBLE); - } - - if (TextUtils.isEmpty(description.getDescription())) { - if (description.canEditDescription()) { - groupDescription.setOverflowText(null); - groupDescription.setText(R.string.ManageGroupActivity_add_group_description); - groupDescription.setOnClickListener(v -> startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), getGroupId()))); - } - } else { - groupDescription.setOnClickListener(null); - GroupDescriptionUtil.setText(requireContext(), - groupDescription, - description.getDescription(), - description.shouldLinkifyWebLinks(), - () -> GroupDescriptionDialog.show(getChildFragmentManager(), getGroupId(), null, description.shouldLinkifyWebLinks())); - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - if (requestCode == RETURN_FROM_MEDIA) { - applyMediaCursorFactory(); - } else if (requestCode == PICK_CONTACT && data != null) { - List selected = data.getParcelableArrayListExtra(PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS); - SimpleProgressDialog.DismissibleDialog progress = SimpleProgressDialog.showDelayed(requireContext()); - - viewModel.onAddMembers(selected, new AsynchronousCallback.MainThread() { - @Override - public void onComplete(ManageGroupViewModel.AddMembersResult result) { - progress.dismiss(); - if (!result.getNewInvitedMembers().isEmpty()) { - GroupInviteSentDialog.showInvitesSent(requireContext(), result.getNewInvitedMembers()); - } - - if (result.getNumberOfMembersAdded() > 0) { - String string = getResources().getQuantityString(R.plurals.ManageGroupActivity_added, - result.getNumberOfMembersAdded(), - result.getNumberOfMembersAdded()); - Snackbar.make(requireView(), string, Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show(); - } - } - - @Override - public void onError(@Nullable GroupChangeFailureReason error) { - progress.dismiss(); - Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(error), Toast.LENGTH_LONG).show(); - } - }); - } - } - - private final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { - - private final AvatarColor groupColors; - - private FallbackPhotoProvider(@NonNull AvatarColor groupColors) { - this.groupColors = groupColors; - } - - @Override - public @NonNull FallbackContactPhoto getPhotoForGroup() { - return new FallbackPhoto80dp(R.drawable.ic_group_80, groupColors.colorInt()); - } - }; -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java deleted file mode 100644 index 22c988239..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java +++ /dev/null @@ -1,233 +0,0 @@ -package org.thoughtcrime.securesms.groups.ui.managegroup; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; -import androidx.core.util.Consumer; - -import com.annimon.stream.Stream; - -import org.signal.core.util.concurrent.SignalExecutors; -import org.signal.core.util.logging.Log; -import org.signal.storageservice.protos.groups.local.DecryptedGroup; -import org.thoughtcrime.securesms.ContactSelectionListFragment; -import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.RecipientDatabase; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.groups.GroupAccessControl; -import org.thoughtcrime.securesms.groups.GroupChangeException; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.groups.GroupManager; -import org.thoughtcrime.securesms.groups.GroupProtoUtil; -import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception; -import org.thoughtcrime.securesms.groups.SelectionLimits; -import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback; -import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.recipients.RecipientUtil; -import org.thoughtcrime.securesms.util.AsynchronousCallback; -import org.thoughtcrime.securesms.util.FeatureFlags; -import org.thoughtcrime.securesms.util.concurrent.SimpleTask; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.LinkedList; -import java.util.List; - -final class ManageGroupRepository { - - private static final String TAG = Log.tag(ManageGroupRepository.class); - - private final Context context; - - ManageGroupRepository(@NonNull Context context) { - this.context = context; - } - - void getGroupState(@NonNull GroupId groupId, @NonNull Consumer onGroupStateLoaded) { - SignalExecutors.BOUNDED.execute(() -> onGroupStateLoaded.accept(getGroupState(groupId))); - } - - void getGroupCapacity(@NonNull GroupId groupId, @NonNull Consumer onGroupCapacityLoaded) { - SimpleTask.run(SignalExecutors.BOUNDED, () -> { - GroupDatabase.GroupRecord groupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId).get(); - if (groupRecord.isV2Group()) { - DecryptedGroup decryptedGroup = groupRecord.requireV2GroupProperties().getDecryptedGroup(); - List pendingMembers = Stream.of(decryptedGroup.getPendingMembersList()) - .map(member -> GroupProtoUtil.uuidByteStringToRecipientId(member.getUuid())) - .toList(); - List members = new LinkedList<>(groupRecord.getMembers()); - - members.addAll(pendingMembers); - - return new GroupCapacityResult(members, FeatureFlags.groupLimits()); - } else { - return new GroupCapacityResult(groupRecord.getMembers(), FeatureFlags.groupLimits()); - } - }, onGroupCapacityLoaded::accept); - } - - @WorkerThread - private GroupStateResult getGroupState(@NonNull GroupId groupId) { - ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); - Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); - long threadId = threadDatabase.getThreadIdFor(groupRecipient); - - return new GroupStateResult(threadId, groupRecipient); - } - - void applyMembershipRightsChange(@NonNull GroupId groupId, @NonNull GroupAccessControl newRights, @NonNull GroupChangeErrorCallback error) { - SignalExecutors.UNBOUNDED.execute(() -> { - try { - GroupManager.applyMembershipAdditionRightsChange(context, groupId.requireV2(), newRights); - } catch (GroupChangeException | IOException e) { - Log.w(TAG, e); - error.onError(GroupChangeFailureReason.fromException(e)); - } - }); - } - - void applyAttributesRightsChange(@NonNull GroupId groupId, @NonNull GroupAccessControl newRights, @NonNull GroupChangeErrorCallback error) { - SignalExecutors.UNBOUNDED.execute(() -> { - try { - GroupManager.applyAttributesRightsChange(context, groupId.requireV2(), newRights); - } catch (GroupChangeException | IOException e) { - Log.w(TAG, e); - error.onError(GroupChangeFailureReason.fromException(e)); - } - }); - } - - public void getRecipient(@NonNull GroupId groupId, @NonNull Consumer recipientCallback) { - SimpleTask.run(SignalExecutors.BOUNDED, - () -> Recipient.externalGroupExact(context, groupId), - recipientCallback::accept); - } - - void setMuteUntil(@NonNull GroupId groupId, long until) { - SignalExecutors.BOUNDED.execute(() -> { - RecipientId recipientId = Recipient.externalGroupExact(context, groupId).getId(); - DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until); - }); - } - - void addMembers(@NonNull GroupId groupId, - @NonNull List selected, - @NonNull AsynchronousCallback.WorkerThread callback) - { - SignalExecutors.UNBOUNDED.execute(() -> { - try { - GroupManager.GroupActionResult groupActionResult = GroupManager.addMembers(context, groupId.requirePush(), selected); - callback.onComplete(new ManageGroupViewModel.AddMembersResult(groupActionResult.getAddedMemberCount(), Recipient.resolvedList(groupActionResult.getInvitedMembers()))); - } catch (GroupChangeException | MembershipNotSuitableForV2Exception | IOException e) { - Log.w(TAG, e); - callback.onError(GroupChangeFailureReason.fromException(e)); - } - }); - } - - void blockAndLeaveGroup(@NonNull GroupId groupId, @NonNull GroupChangeErrorCallback error, @NonNull Runnable onSuccess) { - SignalExecutors.UNBOUNDED.execute(() -> { - try { - RecipientUtil.block(context, Recipient.externalGroupExact(context, groupId)); - onSuccess.run(); - } catch (GroupChangeException | IOException e) { - Log.w(TAG, e); - error.onError(GroupChangeFailureReason.fromException(e)); - } - }); - } - - void setMentionSetting(@NonNull GroupId groupId, RecipientDatabase.MentionSetting mentionSetting) { - SignalExecutors.BOUNDED.execute(() -> { - RecipientId recipientId = Recipient.externalGroupExact(context, groupId).getId(); - DatabaseFactory.getRecipientDatabase(context).setMentionSetting(recipientId, mentionSetting); - }); - } - - @WorkerThread - boolean hasCustomNotifications(Recipient recipient) { - if (recipient.getNotificationChannel() != null || !NotificationChannels.supported()) { - return true; - } - - return NotificationChannels.updateWithShortcutBasedChannel(context, recipient); - } - - static final class GroupStateResult { - - private final long threadId; - private final Recipient recipient; - - private GroupStateResult(long threadId, - Recipient recipient) - { - this.threadId = threadId; - this.recipient = recipient; - } - - long getThreadId() { - return threadId; - } - - Recipient getRecipient() { - return recipient; - } - } - - static final class GroupCapacityResult { - private final List members; - private final SelectionLimits selectionLimits; - - GroupCapacityResult(@NonNull List members, @NonNull SelectionLimits selectionLimits) { - this.members = members; - this.selectionLimits = selectionLimits; - } - - public @NonNull List getMembers() { - return members; - } - - public int getSelectionLimit() { - if (!selectionLimits.hasHardLimit()) { - return ContactSelectionListFragment.NO_LIMIT; - } - - boolean containsSelf = members.indexOf(Recipient.self().getId()) != -1; - - return selectionLimits.getHardLimit() - (containsSelf ? 1 : 0); - } - - public int getSelectionWarning() { - if (!selectionLimits.hasRecommendedLimit()) { - return ContactSelectionListFragment.NO_LIMIT; - } - - boolean containsSelf = members.indexOf(Recipient.self().getId()) != -1; - - return selectionLimits.getRecommendedLimit() - (containsSelf ? 1 : 0); - } - - public int getRemainingCapacity() { - return selectionLimits.getHardLimit() - members.size(); - } - - public @NonNull List getMembersWithoutSelf() { - ArrayList recipientIds = new ArrayList<>(members.size()); - RecipientId selfId = Recipient.self().getId(); - - for (RecipientId recipientId : members) { - if (!recipientId.equals(selfId)) { - recipientIds.add(recipientId); - } - } - - return recipientIds; - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java deleted file mode 100644 index ebd737e24..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java +++ /dev/null @@ -1,468 +0,0 @@ -package org.thoughtcrime.securesms.groups.ui.managegroup; - -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.text.TextUtils; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; - -import org.signal.core.util.ThreadUtil; -import org.thoughtcrime.securesms.BlockUnblockDialog; -import org.thoughtcrime.securesms.ContactSelectionListFragment; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; -import org.thoughtcrime.securesms.database.MediaDatabase; -import org.thoughtcrime.securesms.database.MentionUtil; -import org.thoughtcrime.securesms.database.loaders.MediaLoader; -import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; -import org.thoughtcrime.securesms.groups.GroupAccessControl; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.groups.LiveGroup; -import org.thoughtcrime.securesms.groups.SelectionLimits; -import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; -import org.thoughtcrime.securesms.groups.ui.GroupErrors; -import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog; -import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; -import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity; -import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupMentionSettingDialog; -import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.recipients.RecipientUtil; -import org.thoughtcrime.securesms.util.AsynchronousCallback; -import org.thoughtcrime.securesms.util.DefaultValueLiveData; -import org.thoughtcrime.securesms.util.ExpirationUtil; -import org.thoughtcrime.securesms.util.FeatureFlags; -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; -import org.thoughtcrime.securesms.util.livedata.Store; -import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; - -import java.util.ArrayList; -import java.util.List; - -public class ManageGroupViewModel extends ViewModel { - - private static final int MAX_UNCOLLAPSED_MEMBERS = 6; - private static final int SHOW_COLLAPSED_MEMBERS = 5; - - private final Context context; - private final ManageGroupRepository manageGroupRepository; - private final LiveData title; - private final Store descriptionStore; - private final LiveData description; - private final LiveData isAdmin; - private final LiveData canEditGroupAttributes; - private final LiveData canAddMembers; - private final LiveData> members; - private final LiveData pendingMemberCount; - private final LiveData pendingAndRequestingCount; - private final LiveData disappearingMessageTimer; - private final LiveData memberCountSummary; - private final LiveData fullMemberCountSummary; - private final LiveData editMembershipRights; - private final LiveData editGroupAttributesRights; - private final LiveData groupRecipient; - private final MutableLiveData groupViewState = new MutableLiveData<>(null); - private final LiveData muteState; - private final LiveData hasCustomNotifications; - private final LiveData canCollapseMemberList; - private final DefaultValueLiveData memberListCollapseState = new DefaultValueLiveData<>(CollapseState.COLLAPSED); - private final LiveData canLeaveGroup; - private final LiveData canBlockGroup; - private final LiveData canUnblockGroup; - private final LiveData showLegacyIndicator; - private final LiveData mentionSetting; - private final LiveData groupLinkOn; - private final LiveData groupInfoMessage; - - private ManageGroupViewModel(@NonNull Context context, @NonNull GroupId groupId, @NonNull ManageGroupRepository manageGroupRepository) { - this.context = context; - this.manageGroupRepository = manageGroupRepository; - - manageGroupRepository.getGroupState(groupId, this::groupStateLoaded); - - LiveGroup liveGroup = new LiveGroup(groupId); - - this.title = Transformations.map(liveGroup.getTitle(), - title -> TextUtils.isEmpty(title) ? context.getString(R.string.Recipient_unknown) - : title); - this.groupRecipient = liveGroup.getGroupRecipient(); - this.isAdmin = liveGroup.isSelfAdmin(); - this.canCollapseMemberList = LiveDataUtil.combineLatest(memberListCollapseState, - Transformations.map(liveGroup.getFullMembers(), m -> m.size() > MAX_UNCOLLAPSED_MEMBERS), - (state, hasEnoughMembers) -> state != CollapseState.OPEN && hasEnoughMembers); - this.members = LiveDataUtil.combineLatest(liveGroup.getFullMembers(), - memberListCollapseState, - ManageGroupViewModel::filterMemberList); - this.pendingMemberCount = liveGroup.getPendingMemberCount(); - this.pendingAndRequestingCount = liveGroup.getPendingAndRequestingMemberCount(); - this.showLegacyIndicator = Transformations.map(groupRecipient, recipient -> recipient.requireGroupId().isV1()); - this.memberCountSummary = LiveDataUtil.combineLatest(liveGroup.getMembershipCountDescription(context.getResources()), - this.showLegacyIndicator, - (description, legacy) -> legacy ? String.format("%s · %s", description, context.getString(R.string.ManageGroupActivity_legacy_group)) - : description); - this.fullMemberCountSummary = liveGroup.getFullMembershipCountDescription(context.getResources()); - this.editMembershipRights = liveGroup.getMembershipAdditionAccessControl(); - this.editGroupAttributesRights = liveGroup.getAttributesAccessControl(); - this.disappearingMessageTimer = Transformations.map(liveGroup.getExpireMessages(), expiration -> ExpirationUtil.getExpirationDisplayValue(context, expiration)); - this.canEditGroupAttributes = liveGroup.selfCanEditGroupAttributes(); - this.canAddMembers = liveGroup.selfCanAddMembers(); - this.muteState = Transformations.map(this.groupRecipient, - recipient -> new MuteState(recipient.getMuteUntil(), recipient.isMuted())); - this.hasCustomNotifications = LiveDataUtil.mapAsync(this.groupRecipient, manageGroupRepository::hasCustomNotifications); - this.canLeaveGroup = liveGroup.isActive(); - this.canBlockGroup = Transformations.map(this.groupRecipient, recipient -> RecipientUtil.isBlockable(recipient) && !recipient.isBlocked()); - this.canUnblockGroup = Transformations.map(this.groupRecipient, Recipient::isBlocked); - this.mentionSetting = Transformations.distinctUntilChanged(Transformations.map(this.groupRecipient, - recipient -> MentionUtil.getMentionSettingDisplayValue(context, recipient.getMentionSetting()))); - this.groupLinkOn = Transformations.map(liveGroup.getGroupLink(), GroupLinkUrlAndStatus::isEnabled); - this.groupInfoMessage = Transformations.map(this.groupRecipient, - recipient -> { - boolean showLegacyInfo = recipient.requireGroupId().isV1(); - - if (showLegacyInfo && recipient.getParticipants().size() > FeatureFlags.groupLimits().getHardLimit()) { - return GroupInfoMessage.LEGACY_GROUP_TOO_LARGE; - } else if (showLegacyInfo) { - return GroupInfoMessage.LEGACY_GROUP_UPGRADE; - } else if (groupId.isMms()) { - return GroupInfoMessage.MMS_WARNING; - } else { - return GroupInfoMessage.NONE; - } - }); - - this.descriptionStore = new Store<>(Description.NONE); - this.description = groupId.isV2() ? this.descriptionStore.getStateLiveData() : LiveDataUtil.empty(); - - if (groupId.isV2()) { - this.descriptionStore.update(liveGroup.getDescription(), (description, state) -> new Description(description, state.shouldLinkifyWebLinks, state.canEditDescription)); - this.descriptionStore.update(LiveDataUtil.mapAsync(groupRecipient, r -> RecipientUtil.isMessageRequestAccepted(context, r)), (linkify, state) -> new Description(state.description, linkify, state.canEditDescription)); - this.descriptionStore.update(this.canEditGroupAttributes, (canEdit, state) -> new Description(state.description, state.shouldLinkifyWebLinks, canEdit)); - } - } - - @WorkerThread - private void groupStateLoaded(@NonNull ManageGroupRepository.GroupStateResult groupStateResult) { - groupViewState.postValue(new GroupViewState(groupStateResult.getThreadId(), - groupStateResult.getRecipient(), - () -> new ThreadMediaLoader(context, groupStateResult.getThreadId(), MediaLoader.MediaType.GALLERY, MediaDatabase.Sorting.Newest).getCursor())); - } - - LiveData> getMembers() { - return members; - } - - LiveData getPendingMemberCount() { - return pendingMemberCount; - } - - LiveData getPendingAndRequestingCount() { - return pendingAndRequestingCount; - } - - LiveData getMemberCountSummary() { - return memberCountSummary; - } - - LiveData getFullMemberCountSummary() { - return fullMemberCountSummary; - } - - LiveData getGroupRecipient() { - return groupRecipient; - } - - LiveData getGroupViewState() { - return groupViewState; - } - - LiveData getTitle() { - return title; - } - - LiveData getDescription() { - return description; - } - - LiveData getMuteState() { - return muteState; - } - - LiveData getMembershipRights() { - return editMembershipRights; - } - - LiveData getEditGroupAttributesRights() { - return editGroupAttributesRights; - } - - LiveData getIsAdmin() { - return isAdmin; - } - - LiveData getCanEditGroupAttributes() { - return canEditGroupAttributes; - } - - LiveData getCanAddMembers() { - return canAddMembers; - } - - LiveData getDisappearingMessageTimer() { - return disappearingMessageTimer; - } - - LiveData hasCustomNotifications() { - return hasCustomNotifications; - } - - LiveData getCanCollapseMemberList() { - return canCollapseMemberList; - } - - LiveData getCanBlockGroup() { - return canBlockGroup; - } - - LiveData getCanUnblockGroup() { - return canUnblockGroup; - } - - LiveData getCanLeaveGroup() { - return canLeaveGroup; - } - - LiveData getMentionSetting() { - return mentionSetting; - } - - LiveData getGroupLinkOn() { - return groupLinkOn; - } - - LiveData getGroupInfoMessage() { - return groupInfoMessage; - } - - void applyMembershipRightsChange(@NonNull GroupAccessControl newRights) { - manageGroupRepository.applyMembershipRightsChange(getGroupId(), newRights, this::showErrorToast); - } - - void applyAttributesRightsChange(@NonNull GroupAccessControl newRights) { - manageGroupRepository.applyAttributesRightsChange(getGroupId(), newRights, this::showErrorToast); - } - - void blockAndLeave(@NonNull FragmentActivity activity) { - manageGroupRepository.getRecipient(getGroupId(), - recipient -> BlockUnblockDialog.showBlockFor(activity, - activity.getLifecycle(), - recipient, - this::onBlockAndLeaveConfirmed)); - } - - void unblock(@NonNull FragmentActivity activity) { - manageGroupRepository.getRecipient(getGroupId(), - recipient -> BlockUnblockDialog.showUnblockFor(activity, activity.getLifecycle(), recipient, - () -> RecipientUtil.unblock(context, recipient))); - } - - void onAddMembers(@NonNull List selected, - @NonNull AsynchronousCallback.MainThread callback) - { - manageGroupRepository.addMembers(getGroupId(), selected, callback.toWorkerCallback()); - } - - void setMuteUntil(long muteUntil) { - manageGroupRepository.setMuteUntil(getGroupId(), muteUntil); - } - - void clearMuteUntil() { - manageGroupRepository.setMuteUntil(getGroupId(), 0); - } - - void revealCollapsedMembers() { - memberListCollapseState.setValue(CollapseState.OPEN); - } - - void handleMentionNotificationSelection() { - manageGroupRepository.getRecipient(getGroupId(), r -> GroupMentionSettingDialog.show(context, r.getMentionSetting(), setting -> manageGroupRepository.setMentionSetting(getGroupId(), setting))); - } - - private void onBlockAndLeaveConfirmed() { - SimpleProgressDialog.DismissibleDialog dismissibleDialog = SimpleProgressDialog.showDelayed(context); - - manageGroupRepository.blockAndLeaveGroup(getGroupId(), - e -> { - dismissibleDialog.dismiss(); - showErrorToast(e); - }, - dismissibleDialog::dismiss); - } - - private @NonNull GroupId getGroupId() { - return groupRecipient.getValue().requireGroupId(); - } - - private static @NonNull List filterMemberList(@NonNull List members, - @NonNull CollapseState collapseState) - { - if (collapseState == CollapseState.COLLAPSED && members.size() > MAX_UNCOLLAPSED_MEMBERS) { - return members.subList(0, SHOW_COLLAPSED_MEMBERS); - } else { - return members; - } - } - - @WorkerThread - private void showErrorToast(@NonNull GroupChangeFailureReason e) { - ThreadUtil.runOnMain(() -> Toast.makeText(context, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show()); - } - - public void onAddMembersClick(@NonNull Fragment fragment, int resultCode) { - manageGroupRepository.getGroupCapacity(getGroupId(), capacity -> { - int remainingCapacity = capacity.getRemainingCapacity(); - if (remainingCapacity <= 0) { - GroupLimitDialog.showHardLimitMessage(fragment.requireContext()); - } else { - Intent intent = new Intent(fragment.requireActivity(), AddMembersActivity.class); - intent.putExtra(AddMembersActivity.GROUP_ID, getGroupId().toString()); - intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_PUSH); - intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, new SelectionLimits(capacity.getSelectionWarning(), capacity.getSelectionLimit())); - intent.putParcelableArrayListExtra(ContactSelectionListFragment.CURRENT_SELECTION, new ArrayList<>(capacity.getMembersWithoutSelf())); - fragment.startActivityForResult(intent, resultCode); - } - }); - } - - static final class AddMembersResult { - private final int numberOfMembersAdded; - private final List newInvitedMembers; - - AddMembersResult(int numberOfMembersAdded, @NonNull List newInvitedMembers) { - this.numberOfMembersAdded = numberOfMembersAdded; - this.newInvitedMembers = newInvitedMembers; - } - - int getNumberOfMembersAdded() { - return numberOfMembersAdded; - } - - List getNewInvitedMembers() { - return newInvitedMembers; - } - } - - static final class GroupViewState { - private final long threadId; - @NonNull private final Recipient groupRecipient; - @NonNull private final CursorFactory mediaCursorFactory; - - private GroupViewState(long threadId, - @NonNull Recipient groupRecipient, - @NonNull CursorFactory mediaCursorFactory) - { - this.threadId = threadId; - this.groupRecipient = groupRecipient; - this.mediaCursorFactory = mediaCursorFactory; - } - - long getThreadId() { - return threadId; - } - - @NonNull Recipient getGroupRecipient() { - return groupRecipient; - } - - @NonNull CursorFactory getMediaCursorFactory() { - return mediaCursorFactory; - } - } - - static final class MuteState { - private final long mutedUntil; - private final boolean isMuted; - - MuteState(long mutedUntil, boolean isMuted) { - this.mutedUntil = mutedUntil; - this.isMuted = isMuted; - } - - public long getMutedUntil() { - return mutedUntil; - } - - public boolean isMuted() { - return isMuted; - } - } - - enum GroupInfoMessage { - NONE, - LEGACY_GROUP_LEARN_MORE, - LEGACY_GROUP_UPGRADE, - LEGACY_GROUP_TOO_LARGE, - MMS_WARNING - } - - private enum CollapseState { - OPEN, - COLLAPSED - } - - interface CursorFactory { - Cursor create(); - } - - public static class Description { - private static final Description NONE = new Description("", false, false); - - private final String description; - private final boolean shouldLinkifyWebLinks; - private final boolean canEditDescription; - - public Description(String description, boolean shouldLinkifyWebLinks, boolean canEditDescription) { - this.description = description; - this.shouldLinkifyWebLinks = shouldLinkifyWebLinks; - this.canEditDescription = canEditDescription; - } - - public @NonNull String getDescription() { - return description; - } - - public boolean shouldLinkifyWebLinks() { - return shouldLinkifyWebLinks; - } - - public boolean canEditDescription() { - return canEditDescription; - } - } - - public static class Factory implements ViewModelProvider.Factory { - private final Context context; - private final GroupId groupId; - - public Factory(@NonNull Context context, @NonNull GroupId groupId) { - this.context = context; - this.groupId = groupId; - } - - @Override - public @NonNull T create(@NonNull Class modelClass) { - //noinspection unchecked - return (T) new ManageGroupViewModel(context, groupId, new ManageGroupRepository(context.getApplicationContext())); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSettingsCoordinatorLayoutBehavior.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSettingsCoordinatorLayoutBehavior.java deleted file mode 100644 index cf3c6d1e9..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSettingsCoordinatorLayoutBehavior.java +++ /dev/null @@ -1,130 +0,0 @@ -package org.thoughtcrime.securesms.recipients.ui; - -import android.content.Context; -import android.graphics.Rect; -import android.util.AttributeSet; -import android.view.View; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.Interpolator; -import android.widget.TextView; - -import androidx.annotation.IdRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.coordinatorlayout.widget.CoordinatorLayout; - -import com.google.android.material.appbar.AppBarLayout; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.ViewUtil; - -import java.lang.ref.WeakReference; - -public final class RecipientSettingsCoordinatorLayoutBehavior extends CoordinatorLayout.Behavior { - - private static final Interpolator INTERPOLATOR = new DecelerateInterpolator(); - - private final ViewReference avatarTargetRef = new ViewReference(R.id.avatar_target); - private final ViewReference nameRef = new ViewReference(R.id.name); - private final ViewReference nameTargetRef = new ViewReference(R.id.name_target); - private final Rect targetRect = new Rect(); - private final Rect childRect = new Rect(); - - public RecipientSettingsCoordinatorLayoutBehavior(@NonNull Context context, @Nullable AttributeSet attrs) { - } - - @Override - public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) { - return dependency instanceof AppBarLayout; - } - - @Override - public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) { - AppBarLayout appBarLayout = (AppBarLayout) dependency; - int range = appBarLayout.getTotalScrollRange(); - float factor = INTERPOLATOR.getInterpolation(-appBarLayout.getY() / range); - - updateAvatarPositionAndScale(parent, child, factor); - updateNamePosition(parent, factor); - - return true; - } - - private void updateAvatarPositionAndScale(@NonNull CoordinatorLayout parent, @NonNull View child, float factor) { - View target = avatarTargetRef.require(parent); - - targetRect.set(target.getLeft(), target.getTop(), target.getRight(), target.getBottom()); - childRect.set(child.getLeft(), child.getTop(), child.getRight(), child.getBottom()); - - float widthScale = 1f - (1f - (targetRect.width() / (float) childRect.width())) * factor; - float heightScale = 1f - (1f - (targetRect.height() / (float) childRect.height())) * factor; - - float superimposedLeft = childRect.left + (childRect.width() - targetRect.width()) / 2f; - float superimposedTop = childRect.top + (childRect.height() - targetRect.height()) / 2f; - - float xTranslation = (targetRect.left - superimposedLeft) * factor; - float yTranslation = (targetRect.top - superimposedTop) * factor; - - child.setScaleX(widthScale); - child.setScaleY(heightScale); - child.setTranslationX(xTranslation); - child.setTranslationY(yTranslation); - } - - private void updateNamePosition(@NonNull CoordinatorLayout parent, float factor) { - TextView child = (TextView) nameRef.require(parent); - View target = nameTargetRef.require(parent); - - targetRect.set(target.getLeft(), target.getTop(), target.getRight(), target.getBottom()); - childRect.set(child.getLeft(), child.getTop(), child.getRight(), child.getBottom()); - - if (child.getMaxWidth() != targetRect.width()) { - child.setMaxWidth(targetRect.width()); - } - - float deltaTop = targetRect.top - childRect.top; - float deltaStart = getStart(parent, targetRect) - getStart(parent, childRect); - - float yTranslation = deltaTop * factor; - float xTranslation = deltaStart * factor; - - child.setTranslationY(yTranslation); - child.setTranslationX(xTranslation); - } - - private static int getStart(@NonNull CoordinatorLayout parent, @NonNull Rect rect) { - return ViewUtil.isLtr(parent) ? rect.left : rect.right; - } - - private static final class ViewReference { - - private WeakReference ref = new WeakReference<>(null); - - private final @IdRes int idRes; - - private ViewReference(@IdRes int idRes) { - this.idRes = idRes; - } - - private @NonNull View require(@NonNull View parent) { - View view = ref.get(); - - if (view == null) { - view = getChildOrThrow(parent, idRes); - ref = new WeakReference<>(view); - } - - return view; - } - - private static @NonNull View getChildOrThrow(@NonNull View parent, @IdRes int id) { - View child = parent.findViewById(id); - - if (child == null) { - throw new AssertionError("Can't find view with ID " + R.id.avatar_target); - } else { - return child; - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java index 78c52ff3b..816730ff1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java @@ -25,6 +25,8 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon; +import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; import org.thoughtcrime.securesms.groups.GroupId; @@ -34,6 +36,7 @@ import org.thoughtcrime.securesms.recipients.RecipientExporter; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ContextUtil; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.Util; @@ -41,6 +44,8 @@ import org.thoughtcrime.securesms.util.ViewUtil; import java.util.Objects; +import kotlin.Unit; + /** * A bottom sheet that shows some simple recipient details, as well as some actions (like calling, * adding to contacts, etc). @@ -57,10 +62,6 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF private TextView fullName; private TextView about; private TextView usernameNumber; - private Button messageButton; - private Button secureCallButton; - private Button insecureCallButton; - private Button secureVideoCallButton; private Button blockButton; private Button unblockButton; private Button addContactButton; @@ -71,6 +72,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF private Button removeFromGroupButton; private ProgressBar adminActionBusy; private View noteToSelfDescription; + private View buttonStrip; public static BottomSheetDialogFragment create(@NonNull RecipientId recipientId, @Nullable GroupId groupId) @@ -105,10 +107,6 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF fullName = view.findViewById(R.id.rbs_full_name); about = view.findViewById(R.id.rbs_about); usernameNumber = view.findViewById(R.id.rbs_username_number); - messageButton = view.findViewById(R.id.rbs_message_button); - secureCallButton = view.findViewById(R.id.rbs_secure_call_button); - insecureCallButton = view.findViewById(R.id.rbs_insecure_call_button); - secureVideoCallButton = view.findViewById(R.id.rbs_video_call_button); blockButton = view.findViewById(R.id.rbs_block_button); unblockButton = view.findViewById(R.id.rbs_unblock_button); addContactButton = view.findViewById(R.id.rbs_add_contact_button); @@ -119,6 +117,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF removeFromGroupButton = view.findViewById(R.id.rbs_remove_from_group_button); adminActionBusy = view.findViewById(R.id.rbs_admin_action_busy); noteToSelfDescription = view.findViewById(R.id.rbs_note_to_self_description); + buttonStrip = view.findViewById(R.id.button_strip); return view; } @@ -192,10 +191,41 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF unblockButton.setVisibility(View.GONE); } - messageButton.setVisibility(!recipient.isSelf() ? View.VISIBLE : View.GONE); - secureCallButton.setVisibility(recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); - insecureCallButton.setVisibility(!recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); - secureVideoCallButton.setVisibility(recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); + ButtonStripPreference.State buttonStripState = new ButtonStripPreference.State( + /* isMessageAvailable = */ !recipient.isSelf(), + /* isVideoAvailable = */ recipient.isRegistered() && !recipient.isSelf(), + /* isAudioAvailable = */ !recipient.isSelf(), + /* isMuteAvailable = */ false, + /* isSearchAvailable = */ false, + /* isAudioSecure = */ recipient.isRegistered(), + /* isMuted = */ false + ); + + ButtonStripPreference.Model buttonStripModel = new ButtonStripPreference.Model( + buttonStripState, + DSLSettingsIcon.from(ContextUtil.requireDrawable(requireContext(), R.drawable.selectable_recipient_bottom_sheet_icon_button)), + () -> { + dismiss(); + viewModel.onMessageClicked(requireActivity()); + return Unit.INSTANCE; + }, + () -> { + viewModel.onSecureVideoCallClicked(requireActivity()); + return Unit.INSTANCE; + }, + () -> { + if (buttonStripState.isAudioSecure()) { + viewModel.onSecureCallClicked(requireActivity()); + } else { + viewModel.onInsecureCallClicked(requireActivity()); + } + return Unit.INSTANCE; + }, + () -> Unit.INSTANCE, + () -> Unit.INSTANCE + ); + + new ButtonStripPreference.ViewHolder(buttonStrip).bind(buttonStripModel); if (recipient.isSystemContact() || recipient.isGroup() || recipient.isSelf()) { addContactButton.setVisibility(View.GONE); @@ -234,15 +264,6 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF viewModel.onAvatarClicked(requireActivity()); }); - messageButton.setOnClickListener(view -> { - dismiss(); - viewModel.onMessageClicked(requireActivity()); - }); - - secureCallButton.setOnClickListener(view -> viewModel.onSecureCallClicked(requireActivity())); - insecureCallButton.setOnClickListener(view -> viewModel.onInsecureCallClicked(requireActivity())); - secureVideoCallButton.setOnClickListener(view -> viewModel.onSecureVideoCallClicked(requireActivity())); - blockButton.setOnClickListener(view -> viewModel.onBlockClicked(requireActivity())); unblockButton.setOnClickListener(view -> viewModel.onUnblockClicked(requireActivity())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index ccc30154b..e1c38734c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -19,6 +19,7 @@ import org.signal.core.util.ThreadUtil; import org.thoughtcrime.securesms.BlockUnblockDialog; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.VerifyIdentityActivity; +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.groups.GroupId; @@ -29,7 +30,6 @@ import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; -import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; @@ -138,13 +138,13 @@ final class RecipientDialogViewModel extends ViewModel { } void onAvatarClicked(@NonNull Activity activity) { - activity.startActivity(ManageRecipientActivity.newIntent(activity, recipientDialogRepository.getRecipientId())); + activity.startActivity(ConversationSettingsActivity.forRecipient(activity, recipientDialogRepository.getRecipientId())); } void onMakeGroupAdminClicked(@NonNull Activity activity) { new AlertDialog.Builder(activity) .setMessage(context.getString(R.string.RecipientBottomSheet_s_will_be_able_to_edit_group, Objects.requireNonNull(recipient.getValue()).getDisplayName(context))) - .setPositiveButton(R.string.RecipientBottomSheet_make_group_admin, + .setPositiveButton(R.string.RecipientBottomSheet_make_admin, (dialog, which) -> { adminActionBusy.setValue(true); recipientDialogRepository.setMemberAdmin(true, result -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientActivity.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientActivity.java deleted file mode 100644 index 34e7a0187..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientActivity.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.thoughtcrime.securesms.recipients.ui.managerecipient; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.ActivityOptionsCompat; - -import org.thoughtcrime.securesms.PassphraseRequiredActivity; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; -import org.thoughtcrime.securesms.util.DynamicTheme; - -public class ManageRecipientActivity extends PassphraseRequiredActivity { - - private static final String RECIPIENT_ID = "RECIPIENT_ID"; - private static final String FROM_CONVERSATION = "FROM_CONVERSATION"; - - private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); - - public static Intent newIntent(@NonNull Context context, @NonNull RecipientId recipientId) { - Intent intent = new Intent(context, ManageRecipientActivity.class); - intent.putExtra(RECIPIENT_ID, recipientId); - return intent; - } - - /** - * Makes the message button behave like back. - */ - public static Intent newIntentFromConversation(@NonNull Context context, @NonNull RecipientId recipientId) { - Intent intent = new Intent(context, ManageRecipientActivity.class); - intent.putExtra(RECIPIENT_ID, recipientId); - intent.putExtra(FROM_CONVERSATION, true); - return intent; - } - - public static @Nullable Bundle createTransitionBundle(@NonNull Context activityContext, @NonNull View from) { - if (activityContext instanceof Activity) { - return ActivityOptionsCompat.makeSceneTransitionAnimation((Activity) activityContext, from, "avatar").toBundle(); - } else { - return null; - } - } - - @Override - protected void onPreCreate() { - dynamicTheme.onCreate(this); - } - - @Override - protected void onCreate(Bundle savedInstanceState, boolean ready) { - super.onCreate(savedInstanceState, ready); - getWindow().getDecorView().setSystemUiVisibility(getWindow().getDecorView().getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - setContentView(R.layout.recipient_manage_activity); - if (savedInstanceState == null) { - getSupportFragmentManager().beginTransaction() - .replace(R.id.container, ManageRecipientFragment.newInstance(getIntent().getParcelableExtra(RECIPIENT_ID), getIntent().getBooleanExtra(FROM_CONVERSATION, false))) - .commitNow(); - } - } - - @Override - public void onResume() { - super.onResume(); - dynamicTheme.onResume(this); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java deleted file mode 100644 index 23941ff0b..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java +++ /dev/null @@ -1,416 +0,0 @@ -package org.thoughtcrime.securesms.recipients.ui.managerecipient; - -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SwitchCompat; -import androidx.appcompat.widget.Toolbar; -import androidx.core.widget.TextViewCompat; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.ViewModelProviders; - -import org.signal.core.util.concurrent.SignalExecutors; -import org.thoughtcrime.securesms.AvatarPreviewActivity; -import org.thoughtcrime.securesms.LoggingFragment; -import org.thoughtcrime.securesms.MediaPreviewActivity; -import org.thoughtcrime.securesms.MuteDialog; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.AvatarImageView; -import org.thoughtcrime.securesms.components.ThreadPhotoRailView; -import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; -import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; -import org.thoughtcrime.securesms.conversation.colors.AvatarColor; -import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; -import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientExporter; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity; -import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment; -import org.thoughtcrime.securesms.util.DateUtils; -import org.thoughtcrime.securesms.util.LifecycleCursorWrapper; -import org.thoughtcrime.securesms.util.ServiceUtil; -import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.ViewUtil; -import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity; - -import java.util.Locale; -import java.util.Objects; - -public class ManageRecipientFragment extends LoggingFragment { - private static final String RECIPIENT_ID = "RECIPIENT_ID"; - private static final String FROM_CONVERSATION = "FROM_CONVERSATION"; - - private static final int REQUEST_CODE_RETURN_FROM_MEDIA = 405; - private static final int REQUEST_CODE_ADD_CONTACT = 588; - private static final int REQUEST_CODE_VIEW_CONTACT = 610; - - private ManageRecipientViewModel viewModel; - private GroupMemberListView sharedGroupList; - private Toolbar toolbar; - private TextView title; - private TextView about; - private TextView subtitle; - private ViewGroup internalDetails; - private TextView internalDetailsText; - private View disableProfileSharingButton; - private View contactRow; - private TextView contactText; - private ImageView contactIcon; - private AvatarImageView avatar; - private ThreadPhotoRailView threadPhotoRailView; - private View mediaCard; - private ManageRecipientViewModel.CursorFactory cursorFactory; - private View sharedMediaRow; - private View disappearingMessagesCard; - private View disappearingMessagesRow; - private TextView disappearingMessages; - private View blockUnblockCard; - private TextView block; - private TextView unblock; - private View groupMembershipCard; - private TextView addToAGroup; - private SwitchCompat muteNotificationsSwitch; - private View muteNotificationsRow; - private TextView muteNotificationsUntilLabel; - private View notificationsCard; - private TextView customNotificationsButton; - private View customNotificationsRow; - private View toggleAllGroups; - private View viewSafetyNumber; - private TextView groupsInCommonCount; - private View messageButton; - private View secureCallButton; - private View insecureCallButton; - private View secureVideoCallButton; - private TextView chatWallpaperButton; - - static ManageRecipientFragment newInstance(@NonNull RecipientId recipientId, boolean fromConversation) { - ManageRecipientFragment fragment = new ManageRecipientFragment(); - Bundle args = new Bundle(); - - args.putParcelable(RECIPIENT_ID, recipientId); - args.putBoolean(FROM_CONVERSATION, fromConversation); - fragment.setArguments(args); - - return fragment; - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, - @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) - { - View view = inflater.inflate(R.layout.recipient_manage_fragment, container, false); - - avatar = view.findViewById(R.id.recipient_avatar); - toolbar = view.findViewById(R.id.toolbar); - contactRow = view.findViewById(R.id.recipient_contact_row); - contactText = view.findViewById(R.id.recipient_contact_text); - contactIcon = view.findViewById(R.id.recipient_contact_icon); - title = view.findViewById(R.id.name); - about = view.findViewById(R.id.about); - subtitle = view.findViewById(R.id.username_number); - internalDetails = view.findViewById(R.id.recipient_internal_details); - internalDetailsText = view.findViewById(R.id.recipient_internal_details_text); - disableProfileSharingButton = view.findViewById(R.id.recipient_internal_details_disable_profile_sharing_button); - sharedGroupList = view.findViewById(R.id.shared_group_list); - groupsInCommonCount = view.findViewById(R.id.groups_in_common_count); - threadPhotoRailView = view.findViewById(R.id.recent_photos); - mediaCard = view.findViewById(R.id.recipient_media_card); - sharedMediaRow = view.findViewById(R.id.shared_media_row); - disappearingMessagesCard = view.findViewById(R.id.recipient_disappearing_messages_card); - disappearingMessagesRow = view.findViewById(R.id.disappearing_messages_row); - disappearingMessages = view.findViewById(R.id.disappearing_messages); - blockUnblockCard = view.findViewById(R.id.recipient_block_and_leave_card); - block = view.findViewById(R.id.block); - unblock = view.findViewById(R.id.unblock); - viewSafetyNumber = view.findViewById(R.id.view_safety_number); - groupMembershipCard = view.findViewById(R.id.recipient_membership_card); - addToAGroup = view.findViewById(R.id.add_to_a_group); - muteNotificationsUntilLabel = view.findViewById(R.id.recipient_mute_notifications_until); - muteNotificationsSwitch = view.findViewById(R.id.recipient_mute_notifications_switch); - muteNotificationsRow = view.findViewById(R.id.recipient_mute_notifications_row); - notificationsCard = view.findViewById(R.id.recipient_notifications_card); - customNotificationsButton = view.findViewById(R.id.recipient_custom_notifications_button); - customNotificationsRow = view.findViewById(R.id.recipient_custom_notifications_row); - toggleAllGroups = view.findViewById(R.id.toggle_all_groups); - messageButton = view.findViewById(R.id.recipient_message); - secureCallButton = view.findViewById(R.id.recipient_voice_call); - insecureCallButton = view.findViewById(R.id.recipient_insecure_voice_call); - secureVideoCallButton = view.findViewById(R.id.recipient_video_call); - chatWallpaperButton = view.findViewById(R.id.chat_wallpaper); - - return view; - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - RecipientId recipientId = Objects.requireNonNull(requireArguments().getParcelable(RECIPIENT_ID)); - boolean fromConversation = requireArguments().getBoolean(FROM_CONVERSATION, false); - ManageRecipientViewModel.Factory factory = new ManageRecipientViewModel.Factory(recipientId); - - viewModel = ViewModelProviders.of(requireActivity(), factory).get(ManageRecipientViewModel.class); - - viewModel.getCanCollapseMemberList().observe(getViewLifecycleOwner(), canCollapseMemberList -> { - if (canCollapseMemberList) { - toggleAllGroups.setVisibility(View.VISIBLE); - toggleAllGroups.setOnClickListener(v -> viewModel.revealCollapsedMembers()); - } else { - toggleAllGroups.setVisibility(View.GONE); - } - }); - - viewModel.getIdentity().observe(getViewLifecycleOwner(), identityRecord -> { - viewSafetyNumber.setVisibility(identityRecord != null ? View.VISIBLE : View.GONE); - - if (identityRecord != null) { - viewSafetyNumber.setOnClickListener(view -> viewModel.onViewSafetyNumberClicked(requireActivity(), identityRecord)); - } - }); - - toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); - toolbar.setOnMenuItemClickListener(this::onMenuItemSelected); - toolbar.inflateMenu(R.menu.manage_recipient_fragment); - - if (recipientId.equals(Recipient.self().getId())) { - notificationsCard.setVisibility(View.GONE); - groupMembershipCard.setVisibility(View.GONE); - blockUnblockCard.setVisibility(View.GONE); - contactRow.setVisibility(View.GONE); - } else { - viewModel.getVisibleSharedGroups().observe(getViewLifecycleOwner(), members -> sharedGroupList.setMembers(members)); - viewModel.getSharedGroupsCountSummary().observe(getViewLifecycleOwner(), members -> groupsInCommonCount.setText(members)); - addToAGroup.setOnClickListener(v -> viewModel.onAddToGroupButton(requireActivity())); - sharedGroupList.setRecipientClickListener(recipient -> viewModel.onGroupClicked(requireActivity(), recipient)); - sharedGroupList.setOverScrollMode(View.OVER_SCROLL_NEVER); - } - - viewModel.getTitle().observe(getViewLifecycleOwner(), title::setText); - viewModel.getSubtitle().observe(getViewLifecycleOwner(), text -> { - subtitle.setText(text); - subtitle.setVisibility(TextUtils.isEmpty(text) ? View.GONE : View.VISIBLE); - subtitle.setOnLongClickListener(null); - title.setOnLongClickListener(null); - setCopyToClipboardOnLongPress(TextUtils.isEmpty(text) ? title : subtitle); - }); - viewModel.getDisappearingMessageTimer().observe(getViewLifecycleOwner(), string -> disappearingMessages.setText(string)); - viewModel.getRecipient().observe(getViewLifecycleOwner(), this::presentRecipient); - viewModel.getMediaCursor().observe(getViewLifecycleOwner(), this::presentMediaCursor); - viewModel.getMuteState().observe(getViewLifecycleOwner(), this::presentMuteState); - viewModel.getCanAddToAGroup().observe(getViewLifecycleOwner(), canAdd -> addToAGroup.setVisibility(canAdd ? View.VISIBLE : View.GONE)); - - if (SignalStore.internalValues().recipientDetails()) { - viewModel.getInternalDetails().observe(getViewLifecycleOwner(), internalDetailsText::setText); - disableProfileSharingButton.setOnClickListener(v -> { - SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getRecipientDatabase(requireContext()).setProfileSharing(recipientId, false)); - }); - internalDetails.setVisibility(View.VISIBLE); - } else { - internalDetails.setVisibility(View.GONE); - } - - disappearingMessagesRow.setOnClickListener(v -> startActivity(RecipientDisappearingMessagesActivity.forRecipient(requireContext(), recipientId))); - block.setOnClickListener(v -> viewModel.onBlockClicked(requireActivity())); - unblock.setOnClickListener(v -> viewModel.onUnblockClicked(requireActivity())); - - muteNotificationsRow.setOnClickListener(v -> { - if (muteNotificationsSwitch.isEnabled()) { - muteNotificationsSwitch.toggle(); - } - }); - - customNotificationsRow.setVisibility(View.VISIBLE); - customNotificationsRow.setOnClickListener(v -> CustomNotificationsDialogFragment.create(recipientId) - .show(requireFragmentManager(), "CUSTOM_NOTIFICATIONS")); - - //noinspection CodeBlock2Expr - if (NotificationChannels.supported()) { - viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> { - customNotificationsButton.setText(hasCustomNotifications ? R.string.ManageRecipientActivity_on - : R.string.ManageRecipientActivity_off); - }); - } - - viewModel.getCanBlock().observe(getViewLifecycleOwner(), - canBlock -> block.setVisibility(canBlock ? View.VISIBLE : View.GONE)); - - viewModel.getCanUnblock().observe(getViewLifecycleOwner(), - canUnblock -> unblock.setVisibility(canUnblock ? View.VISIBLE : View.GONE)); - - messageButton.setOnClickListener(v -> { - if (fromConversation) { - requireActivity().onBackPressed(); - } else { - viewModel.onMessage(requireActivity()); - } - }); - secureCallButton.setOnClickListener(v -> viewModel.onSecureCall(requireActivity())); - insecureCallButton.setOnClickListener(v -> viewModel.onInsecureCall(requireActivity())); - secureVideoCallButton.setOnClickListener(v -> viewModel.onSecureVideoCall(requireActivity())); - chatWallpaperButton.setOnClickListener(v -> startActivity(ChatWallpaperActivity.createIntent(requireContext(), recipientId))); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == REQUEST_CODE_RETURN_FROM_MEDIA) { - applyMediaCursorFactory(); - } else if (requestCode == REQUEST_CODE_ADD_CONTACT) { - viewModel.onAddedToContacts(); - } else if (requestCode == REQUEST_CODE_VIEW_CONTACT) { - viewModel.onFinishedViewingContact(); - } - } - - private void presentRecipient(@NonNull Recipient recipient) { - Drawable colorCircle = recipient.getChatColors().asCircle(); - colorCircle.setBounds(0, 0, ViewUtil.dpToPx(16), ViewUtil.dpToPx(16)); - TextViewCompat.setCompoundDrawablesRelative(chatWallpaperButton, null, null, colorCircle, null); - - if (recipient.isSystemContact()) { - contactText.setText(R.string.ManageRecipientActivity_this_person_is_in_your_contacts); - contactIcon.setVisibility(View.VISIBLE); - contactRow.setOnClickListener(v -> { - startActivityForResult(new Intent(Intent.ACTION_VIEW, recipient.getContactUri()), REQUEST_CODE_VIEW_CONTACT); - }); - } else { - contactText.setText(R.string.ManageRecipientActivity_add_to_system_contacts); - contactIcon.setVisibility(View.GONE); - contactRow.setOnClickListener(v -> { - startActivityForResult(RecipientExporter.export(recipient).asAddContactIntent(), REQUEST_CODE_ADD_CONTACT); - }); - } - - String aboutText = recipient.getCombinedAboutAndEmoji(); - about.setText(aboutText); - about.setVisibility(Util.isEmpty(aboutText) ? View.GONE : View.VISIBLE); - - disappearingMessagesCard.setVisibility(recipient.isRegistered() ? View.VISIBLE : View.GONE); - addToAGroup.setVisibility(recipient.isRegistered() ? View.VISIBLE : View.GONE); - - AvatarColor recipientColor = recipient.getAvatarColor(); - avatar.setFallbackPhotoProvider(new Recipient.FallbackPhotoProvider() { - @Override - public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { - return new FallbackPhoto80dp(R.drawable.ic_profile_80, recipientColor.colorInt()); - } - - @Override - public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { - return new FallbackPhoto80dp(R.drawable.ic_note_80, recipientColor.colorInt()); - } - }); - avatar.setAvatar(recipient); - avatar.setOnClickListener(v -> { - FragmentActivity activity = requireActivity(); - activity.startActivity(AvatarPreviewActivity.intentFromRecipientId(activity, recipient.getId()), - AvatarPreviewActivity.createTransitionBundle(activity, avatar)); - }); - - secureCallButton.setVisibility(recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); - insecureCallButton.setVisibility(!recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); - secureVideoCallButton.setVisibility(recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); - } - - private void presentMediaCursor(ManageRecipientViewModel.MediaCursor mediaCursor) { - if (mediaCursor == null) return; - sharedMediaRow.setOnClickListener(v -> startActivity(MediaOverviewActivity.forThread(requireContext(), mediaCursor.getThreadId()))); - - setMediaCursorFactory(mediaCursor.getMediaCursorFactory()); - - threadPhotoRailView.setListener(mediaRecord -> - startActivityForResult(MediaPreviewActivity.intentFromMediaRecord(requireContext(), - mediaRecord, - ViewUtil.isLtr(threadPhotoRailView)), - REQUEST_CODE_RETURN_FROM_MEDIA)); - } - - private void presentMuteState(@NonNull ManageRecipientViewModel.MuteState muteState) { - if (muteNotificationsSwitch.isChecked() != muteState.isMuted()) { - muteNotificationsSwitch.setOnCheckedChangeListener(null); - muteNotificationsSwitch.setChecked(muteState.isMuted()); - } - - muteNotificationsSwitch.setEnabled(true); - muteNotificationsSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - if (isChecked) { - MuteDialog.show(requireContext(), viewModel::setMuteUntil, () -> muteNotificationsSwitch.setChecked(false)); - } else { - viewModel.clearMuteUntil(); - } - }); - muteNotificationsUntilLabel.setVisibility(muteState.isMuted() ? View.VISIBLE : View.GONE); - - if (muteState.isMuted()) { - if (muteState.getMutedUntil() == Long.MAX_VALUE) { - muteNotificationsUntilLabel.setText(R.string.ManageRecipientActivity_always); - } else { - muteNotificationsUntilLabel.setText(getString(R.string.ManageRecipientActivity_until_s, - DateUtils.getTimeString(requireContext(), - Locale.getDefault(), - muteState.getMutedUntil()))); - } - } - } - - public boolean onMenuItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == R.id.action_edit) { - startActivity(EditProfileActivity.getIntentForUserProfileEdit(requireActivity())); - return true; - } - - return false; - } - - private void setMediaCursorFactory(@Nullable ManageRecipientViewModel.CursorFactory cursorFactory) { - if (this.cursorFactory != cursorFactory) { - this.cursorFactory = cursorFactory; - applyMediaCursorFactory(); - } - } - - private void applyMediaCursorFactory() { - Context context = getContext(); - if (context == null) return; - if (cursorFactory != null) { - Cursor cursor = cursorFactory.create(); - getViewLifecycleOwner().getLifecycle().addObserver(new LifecycleCursorWrapper(cursor)); - - threadPhotoRailView.setCursor(GlideApp.with(context), cursor); - mediaCard.setVisibility(cursor.getCount() > 0 ? View.VISIBLE : View.GONE); - } else { - threadPhotoRailView.setCursor(GlideApp.with(context), null); - mediaCard.setVisibility(View.GONE); - } - } - - private static void setCopyToClipboardOnLongPress(@NonNull TextView textView) { - textView.setOnLongClickListener(v -> { - Util.copyToClipboard(v.getContext(), textView.getText().toString()); - ServiceUtil.getVibrator(v.getContext()).vibrate(250); - Toast.makeText(v.getContext(), R.string.RecipientBottomSheet_copied_to_clipboard, Toast.LENGTH_SHORT).show(); - return true; - }); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java deleted file mode 100644 index 6d69d4bc8..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java +++ /dev/null @@ -1,115 +0,0 @@ -package org.thoughtcrime.securesms.recipients.ui.managerecipient; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; -import androidx.core.util.Consumer; - -import com.annimon.stream.Stream; - -import org.signal.core.util.concurrent.SignalExecutors; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; -import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.IdentityDatabase; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -final class ManageRecipientRepository { - - private static final String TAG = Log.tag(ManageRecipientRepository.class); - - private final Context context; - private final RecipientId recipientId; - - ManageRecipientRepository(@NonNull Context context, @NonNull RecipientId recipientId) { - this.context = context; - this.recipientId = recipientId; - } - - public RecipientId getRecipientId() { - return recipientId; - } - - void getThreadId(@NonNull Consumer onGetThreadId) { - SignalExecutors.BOUNDED.execute(() -> onGetThreadId.accept(getThreadId())); - } - - @WorkerThread - private long getThreadId() { - ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); - Recipient groupRecipient = Recipient.resolved(recipientId); - - return threadDatabase.getThreadIdFor(groupRecipient); - } - - void getIdentity(@NonNull Consumer callback) { - SignalExecutors.BOUNDED.execute(() -> callback.accept(DatabaseFactory.getIdentityDatabase(context) - .getIdentity(recipientId) - .orNull())); - } - - void getGroupMembership(@NonNull Consumer> onComplete) { - SignalExecutors.BOUNDED.execute(() -> { - GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - List groupRecords = groupDatabase.getPushGroupsContainingMember(recipientId); - ArrayList groupRecipients = new ArrayList<>(groupRecords.size()); - - for (GroupDatabase.GroupRecord groupRecord : groupRecords) { - groupRecipients.add(groupRecord.getRecipientId()); - } - - onComplete.accept(groupRecipients); - }); - } - - public void getRecipient(@NonNull Consumer recipientCallback) { - SignalExecutors.BOUNDED.execute(() -> recipientCallback.accept(Recipient.resolved(recipientId))); - } - - void setMuteUntil(long until) { - SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until)); - } - - void refreshRecipient() { - SignalExecutors.UNBOUNDED.execute(() -> { - try { - DirectoryHelper.refreshDirectoryFor(context, Recipient.resolved(recipientId), false); - } catch (IOException e) { - Log.w(TAG, "Failed to refresh user after adding to contacts."); - } - }); - } - - @WorkerThread - @NonNull List getSharedGroups(@NonNull RecipientId recipientId) { - return Stream.of(DatabaseFactory.getGroupDatabase(context) - .getPushGroupsContainingMember(recipientId)) - .filter(g -> g.getMembers().contains(Recipient.self().getId())) - .map(GroupDatabase.GroupRecord::getRecipientId) - .map(Recipient::resolved) - .sortBy(gr -> gr.getDisplayName(context)) - .toList(); - } - - void getActiveGroupCount(@NonNull Consumer onComplete) { - SignalExecutors.BOUNDED.execute(() -> onComplete.accept(DatabaseFactory.getGroupDatabase(context).getActiveGroupCount())); - } - - @WorkerThread - boolean hasCustomNotifications(Recipient recipient) { - if (recipient.getNotificationChannel() != null || !NotificationChannels.supported()) { - return true; - } - - return NotificationChannels.updateWithShortcutBasedChannel(context, recipient); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java deleted file mode 100644 index 97a83ab35..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java +++ /dev/null @@ -1,361 +0,0 @@ -package org.thoughtcrime.securesms.recipients.ui.managerecipient; - -import android.app.Activity; -import android.app.NotificationChannel; -import android.content.Context; -import android.database.Cursor; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; -import androidx.core.util.Consumer; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; - -import com.annimon.stream.Stream; - -import org.signal.core.util.ThreadUtil; -import org.thoughtcrime.securesms.BlockUnblockDialog; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.VerifyIdentityActivity; -import org.thoughtcrime.securesms.database.IdentityDatabase; -import org.thoughtcrime.securesms.database.MediaDatabase; -import org.thoughtcrime.securesms.database.loaders.MediaLoader; -import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; -import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.recipients.RecipientUtil; -import org.thoughtcrime.securesms.util.Base64; -import org.thoughtcrime.securesms.util.CommunicationActions; -import org.thoughtcrime.securesms.util.DefaultValueLiveData; -import org.thoughtcrime.securesms.util.ExpirationUtil; -import org.thoughtcrime.securesms.util.Hex; -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; - -import java.util.List; -import java.util.UUID; - -public final class ManageRecipientViewModel extends ViewModel { - - private static final int MAX_UNCOLLAPSED_GROUPS = 6; - private static final int SHOW_COLLAPSED_GROUPS = 5; - - private final Context context; - private final ManageRecipientRepository manageRecipientRepository; - private final LiveData title; - private final LiveData subtitle; - private final LiveData internalDetails; - private final LiveData disappearingMessageTimer; - private final MutableLiveData identity; - private final LiveData recipient; - private final MutableLiveData mediaCursor; - private final LiveData muteState; - private final LiveData hasCustomNotifications; - private final LiveData canCollapseMemberList; - private final DefaultValueLiveData groupListCollapseState; - private final LiveData canBlock; - private final LiveData canUnblock; - private final LiveData> visibleSharedGroups; - private final LiveData sharedGroupsCountSummary; - private final LiveData canAddToAGroup; - - private ManageRecipientViewModel(@NonNull Context context, @NonNull ManageRecipientRepository manageRecipientRepository) { - this.context = context; - this.manageRecipientRepository = manageRecipientRepository; - this.recipient = Recipient.live(manageRecipientRepository.getRecipientId()).getLiveData(); - this.title = Transformations.map(recipient, r -> getDisplayTitle(r, context) ); - this.subtitle = Transformations.map(recipient, r -> getDisplaySubtitle(r, context)); - this.identity = new MutableLiveData<>(); - this.mediaCursor = new MutableLiveData<>(null); - this.groupListCollapseState = new DefaultValueLiveData<>(CollapseState.COLLAPSED); - this.disappearingMessageTimer = Transformations.map(this.recipient, r -> ExpirationUtil.getExpirationDisplayValue(context, r.getExpireMessages())); - this.muteState = Transformations.map(this.recipient, r -> new MuteState(r.getMuteUntil(), r.isMuted())); - this.hasCustomNotifications = LiveDataUtil.mapAsync(this.recipient, manageRecipientRepository::hasCustomNotifications); - this.canBlock = Transformations.map(this.recipient, r -> RecipientUtil.isBlockable(r) && !r.isBlocked()); - this.canUnblock = Transformations.map(this.recipient, Recipient::isBlocked); - this.internalDetails = Transformations.map(this.recipient, this::populateInternalDetails); - - manageRecipientRepository.getThreadId(this::onThreadIdLoaded); - - LiveData> allSharedGroups = LiveDataUtil.mapAsync(this.recipient, r -> manageRecipientRepository.getSharedGroups(r.getId())); - - this.sharedGroupsCountSummary = Transformations.map(allSharedGroups, list -> { - int size = list.size(); - return size == 0 ? context.getString(R.string.ManageRecipientActivity_no_groups_in_common) - : context.getResources().getQuantityString(R.plurals.ManageRecipientActivity_d_groups_in_common, size, size); - }); - - this.canCollapseMemberList = LiveDataUtil.combineLatest(this.groupListCollapseState, - Transformations.map(allSharedGroups, m -> m.size() > MAX_UNCOLLAPSED_GROUPS), - (state, hasEnoughMembers) -> state != CollapseState.OPEN && hasEnoughMembers); - this.visibleSharedGroups = Transformations.map(LiveDataUtil.combineLatest(allSharedGroups, - this.groupListCollapseState, - ManageRecipientViewModel::filterSharedGroupList), - recipients -> Stream.of(recipients).map(r -> new GroupMemberEntry.FullMember(r, false)).toList()); - - - boolean isSelf = manageRecipientRepository.getRecipientId().equals(Recipient.self().getId()); - if (!isSelf) { - manageRecipientRepository.getIdentity(identity::postValue); - } - - MutableLiveData localGroupCount = new MutableLiveData<>(0); - - this.canAddToAGroup = LiveDataUtil.combineLatest(recipient, - localGroupCount, - (r, count) -> count > 0 && r.isRegistered() && !r.isGroup() && !r.isSelf()); - - manageRecipientRepository.getActiveGroupCount(localGroupCount::postValue); - } - - private static @NonNull String getDisplayTitle(@NonNull Recipient recipient, @NonNull Context context) { - if (recipient.isSelf()) { - return context.getString(R.string.note_to_self); - } else { - return recipient.getDisplayName(context); - } - } - - private static @NonNull String getDisplaySubtitle(@NonNull Recipient recipient, @NonNull Context context) { - if (!recipient.isSelf() && recipient.hasAUserSetDisplayName(context)) { - return recipient.getSmsAddress().transform(PhoneNumberFormatter::prettyPrint).or("").trim(); - } else { - return ""; - } - } - - @WorkerThread - private void onThreadIdLoaded(long threadId) { - mediaCursor.postValue(new MediaCursor(threadId, - () -> new ThreadMediaLoader(context, threadId, MediaLoader.MediaType.GALLERY, MediaDatabase.Sorting.Newest).getCursor())); - } - - LiveData getTitle() { - return title; - } - - LiveData getSubtitle() { - return subtitle; - } - - LiveData getInternalDetails() { - return internalDetails; - } - - LiveData getRecipient() { - return recipient; - } - - LiveData getCanAddToAGroup() { - return canAddToAGroup; - } - - LiveData getMediaCursor() { - return mediaCursor; - } - - LiveData getMuteState() { - return muteState; - } - - LiveData getDisappearingMessageTimer() { - return disappearingMessageTimer; - } - - LiveData hasCustomNotifications() { - return hasCustomNotifications; - } - - LiveData getCanCollapseMemberList() { - return canCollapseMemberList; - } - - LiveData getCanBlock() { - return canBlock; - } - - LiveData getCanUnblock() { - return canUnblock; - } - - void setMuteUntil(long muteUntil) { - manageRecipientRepository.setMuteUntil(muteUntil); - } - - void clearMuteUntil() { - manageRecipientRepository.setMuteUntil(0); - } - - void revealCollapsedMembers() { - groupListCollapseState.setValue(CollapseState.OPEN); - } - - void onAddToGroupButton(@NonNull Activity activity) { - manageRecipientRepository.getGroupMembership(existingGroups -> ThreadUtil.runOnMain(() -> activity.startActivity(AddToGroupsActivity.newIntent(activity, manageRecipientRepository.getRecipientId(), existingGroups)))); - } - - private void withRecipient(@NonNull Consumer mainThreadRecipientCallback) { - manageRecipientRepository.getRecipient(recipient -> ThreadUtil.runOnMain(() -> mainThreadRecipientCallback.accept(recipient))); - } - - private static @NonNull List filterSharedGroupList(@NonNull List groups, - @NonNull CollapseState collapseState) - { - if (collapseState == CollapseState.COLLAPSED && groups.size() > MAX_UNCOLLAPSED_GROUPS) { - return groups.subList(0, SHOW_COLLAPSED_GROUPS); - } else { - return groups; - } - } - - LiveData getIdentity() { - return identity; - } - - void onBlockClicked(@NonNull FragmentActivity activity) { - withRecipient(recipient -> BlockUnblockDialog.showBlockFor(activity, activity.getLifecycle(), recipient, () -> RecipientUtil.blockNonGroup(context, recipient))); - } - - void onUnblockClicked(@NonNull FragmentActivity activity) { - withRecipient(recipient -> BlockUnblockDialog.showUnblockFor(activity, activity.getLifecycle(), recipient, () -> RecipientUtil.unblock(context, recipient))); - } - - void onViewSafetyNumberClicked(@NonNull Activity activity, @NonNull IdentityDatabase.IdentityRecord identityRecord) { - activity.startActivity(VerifyIdentityActivity.newIntent(activity, identityRecord)); - } - - LiveData> getVisibleSharedGroups() { - return visibleSharedGroups; - } - - LiveData getSharedGroupsCountSummary() { - return sharedGroupsCountSummary; - } - - void onGroupClicked(@NonNull Activity activity, @NonNull Recipient recipient) { - CommunicationActions.startConversation(activity, recipient, null); - activity.finish(); - } - - void onMessage(@NonNull FragmentActivity activity) { - withRecipient(r -> { - CommunicationActions.startConversation(activity, r, null); - activity.finish(); - }); - } - - void onSecureCall(@NonNull FragmentActivity activity) { - withRecipient(r -> CommunicationActions.startVoiceCall(activity, r)); - } - - void onInsecureCall(@NonNull FragmentActivity activity) { - withRecipient(r -> CommunicationActions.startInsecureCall(activity, r)); - } - - void onSecureVideoCall(@NonNull FragmentActivity activity) { - withRecipient(r -> CommunicationActions.startVideoCall(activity, r)); - } - - void onAddedToContacts() { - manageRecipientRepository.refreshRecipient(); - } - - void onFinishedViewingContact() { - manageRecipientRepository.refreshRecipient(); - } - - private @NonNull String populateInternalDetails(@NonNull Recipient recipient) { - if (!SignalStore.internalValues().recipientDetails()) { - return ""; - } - - String profileKeyBase64 = recipient.getProfileKey() != null ? Base64.encodeBytes(recipient.getProfileKey()) : "None"; - String profileKeyHex = recipient.getProfileKey() != null ? Hex.toStringCondensed(recipient.getProfileKey()) : "None"; - return String.format("-- Profile Name --\n[%s] [%s]\n\n" + - "-- Profile Sharing --\n%s\n\n" + - "-- Profile Key (Base64) --\n%s\n\n" + - "-- Profile Key (Hex) --\n%s\n\n" + - "-- Sealed Sender Mode --\n%s\n\n" + - "-- UUID --\n%s\n\n" + - "-- RecipientId --\n%s", - recipient.getProfileName().getGivenName(), recipient.getProfileName().getFamilyName(), - recipient.isProfileSharing(), - profileKeyBase64, - profileKeyHex, - recipient.getUnidentifiedAccessMode(), - recipient.getUuid().transform(UUID::toString).or("None"), - recipient.getId().serialize()); - } - - static final class MediaCursor { - private final long threadId; - @NonNull private final CursorFactory mediaCursorFactory; - - private MediaCursor(long threadId, - @NonNull CursorFactory mediaCursorFactory) - { - this.threadId = threadId; - this.mediaCursorFactory = mediaCursorFactory; - } - - long getThreadId() { - return threadId; - } - - @NonNull CursorFactory getMediaCursorFactory() { - return mediaCursorFactory; - } - } - - static final class MuteState { - private final long mutedUntil; - private final boolean isMuted; - - MuteState(long mutedUntil, boolean isMuted) { - this.mutedUntil = mutedUntil; - this.isMuted = isMuted; - } - - long getMutedUntil() { - return mutedUntil; - } - - public boolean isMuted() { - return isMuted; - } - } - - private enum CollapseState { - OPEN, - COLLAPSED - } - - interface CursorFactory { - Cursor create(); - } - - public static class Factory implements ViewModelProvider.Factory { - private final Context context; - private final RecipientId recipientId; - - public Factory(@NonNull RecipientId recipientId) { - this.context = ApplicationDependencies.getApplication(); - this.recipientId = recipientId; - } - - @Override - public @NonNull T create(@NonNull Class modelClass) { - //noinspection unchecked - return (T) new ManageRecipientViewModel(context, new ManageRecipientRepository(context, recipientId)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DynamicConversationSettingsTheme.java b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicConversationSettingsTheme.java new file mode 100644 index 000000000..75411f8df --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DynamicConversationSettingsTheme.java @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.StyleRes; + +import org.thoughtcrime.securesms.R; + +public class DynamicConversationSettingsTheme extends DynamicTheme { + + protected @StyleRes int getTheme() { + return R.style.Signal_DayNight_ConversationSettings; + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.webp b/app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.webp deleted file mode 100644 index 1df83242af8c0a38ee9ff77e9f595d5a73be1756..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 314 zcmV-A0mc4ONk&F80RRA3MM6+kP&iB_0RR9m8h{26Cqj^IQ|bF>r?yQ15}O#1ZKqkpP+vxY zdp;X&vsax@hU#H+ph}h`8;S@E-?swr&YlyVvZ$Shz<~=V!n&S(%e5O}95{06W~D+A z`C!jB>`gF93$teyVar4SY2yyCD=>_~r2yG4F~F%N(t0BOoS;E+t$Y-}>#>DQaOuD! M$$U62tLt9|0Or<%cmMzZ diff --git a/app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.webp b/app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.webp deleted file mode 100644 index 2bc08c0dce8bef344aee88d9e35df9ad9a112c6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224 zcmV<603ZKSNk&H400012MM6+kP&iD>0000l5kLeGuR$PbW9j#2yu{Q^o#M8U)YvWa z!S~d*ZNnf_5AVR*2nGOQXdoO2f}lWv2NVv#O8|rbk3f~da$o~+5CB*{0Dg{k!B9Ai zY+GqA?hp^|7T4Q|&)OJb#@G_#?tat8kTMZk0OtKX zVtHQAR6xAOp3A|B!xZ5|k)F*QWqc^iTD3J45Fqi|lHW29ZehC2D)MNty diff --git a/app/src/main/res/drawable-night/ic_bell_disabled_16.xml b/app/src/main/res/drawable-night/ic_bell_disabled_16.xml new file mode 100644 index 000000000..c1a2b36d9 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_bell_disabled_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_bell_disabled_24.xml b/app/src/main/res/drawable-night/ic_bell_disabled_24.xml new file mode 100644 index 000000000..d01a0353a --- /dev/null +++ b/app/src/main/res/drawable-night/ic_bell_disabled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_safety_number_24.xml b/app/src/main/res/drawable-night/ic_safety_number_24.xml new file mode 100644 index 000000000..16cf56ac9 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_safety_number_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_speaker_24.xml b/app/src/main/res/drawable-night/ic_speaker_24.xml new file mode 100644 index 000000000..fe4c351f0 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_speaker_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_wallpaper_24.xml b/app/src/main/res/drawable-night/ic_wallpaper_24.xml new file mode 100644 index 000000000..e8974b6c8 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_wallpaper_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-v21/selectable_icon_button.xml b/app/src/main/res/drawable-v21/selectable_icon_button.xml new file mode 100644 index 000000000..178226daf --- /dev/null +++ b/app/src/main/res/drawable-v21/selectable_icon_button.xml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/selectable_recipient_bottom_sheet_icon_button.xml b/app/src/main/res/drawable-v21/selectable_recipient_bottom_sheet_icon_button.xml new file mode 100644 index 000000000..ec3cc562c --- /dev/null +++ b/app/src/main/res/drawable-v21/selectable_recipient_bottom_sheet_icon_button.xml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.webp b/app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.webp deleted file mode 100644 index abf5aa27f5e33b47b0dd66aacb03ba63cec3037e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 364 zcmV-y0h9hxNk&Fw0RRA3MM6+kP&iCi0RR9mBftm{H)0@do0z_Tx|^7~sZ-oWQk%O? zWcZ=5ZQCa4p1cEf0|IJ601sF~Foc1D76KhY@ZW(6&xA|>V)qgZgAkM?3b+}93B~7Tku0F{;?4?`AUdW&fJ-%x_a_Oi=$4ul7z;(bwLX>Gu zW&k|vXA;Gr0{uDz0AsQw%(60XXh<_v&6?sPgWiNJODj0Ml4)tM-I4pab+CTBiJe8i z&H~=GtekR?f=d*Tf)k|7?%#M8-EDtQo<*mxIZV&G(d_ZT_yZVRX+eU8c zyKX^vg>Bn5NxEBAci_DNG*KV`Lfr>&BLQ25gdCIfaP~3qk!fNvhjiUa!mnKwBMd;X zs1pQzLMYrJR1>CYRBGC{wI&C(hgMQDa}#{4N;RoW6c~y0Uk7i+hnQ}I zJ|Ff1&8jb@A7)ecmhf|P%X$e*wt`W~G+A7Xbv7#10#Dm1umWc<6$)EK|GIj6DsC*MgM!g5%NLTvg z(>=I~+yTG){epeBeO>AJ$BaHd6VdZNk&F|0ssJ4MM6+kP&iC)0ssInN5Byf|AU}y+nDtEtL=0QQ#W-8114!u z252d4+qUW6W!}KN1)U572wVX`5D4xEXx8%*i~_j#>Og+JD53%31%VWS9W^Z|6E`^= zNK7z3E>Hx%k(AMM3+3eC3Yz37AnV#DMQT3W;)cc*cZp{q?(XjHx(n2Q&-tpWy84c* zi2g%xBT11I8Os!Vd|$vyvF9*JShn}rXMA|S|2PQ z6ae-<)?P!b>%Lf^{_r2=sbIUmSO9%*l-W-?w_~V^DiSS(oEbCc+l=17@k}dwm?rMA$R+6Q+Yd;M<}w{6rZ;s^T71 z%tey0lf0uc7pUn + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bell_disabled_16.xml b/app/src/main/res/drawable/ic_bell_disabled_16.xml new file mode 100644 index 000000000..cbea253f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_bell_disabled_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bell_disabled_24.xml b/app/src/main/res/drawable/ic_bell_disabled_24.xml new file mode 100644 index 000000000..9a1de4f6d --- /dev/null +++ b/app/src/main/res/drawable/ic_bell_disabled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_link_16.xml b/app/src/main/res/drawable/ic_link_16.xml new file mode 100644 index 000000000..07d166879 --- /dev/null +++ b/app/src/main/res/drawable/ic_link_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_safety_number_24.xml b/app/src/main/res/drawable/ic_safety_number_24.xml new file mode 100644 index 000000000..6b26bf4d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_safety_number_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker_24.xml b/app/src/main/res/drawable/ic_speaker_24.xml new file mode 100644 index 000000000..7fa3ed8e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_wallpaper_24.xml b/app/src/main/res/drawable/ic_wallpaper_24.xml new file mode 100644 index 000000000..ad23ef800 --- /dev/null +++ b/app/src/main/res/drawable/ic_wallpaper_24.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/drawable/icon_button_squircle.xml b/app/src/main/res/drawable/icon_button_squircle.xml new file mode 100644 index 000000000..b06cecbd1 --- /dev/null +++ b/app/src/main/res/drawable/icon_button_squircle.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/recipient_bottom_sheet_icon_button_squircle.xml b/app/src/main/res/drawable/recipient_bottom_sheet_icon_button_squircle.xml new file mode 100644 index 000000000..d5454e5fe --- /dev/null +++ b/app/src/main/res/drawable/recipient_bottom_sheet_icon_button_squircle.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selectable_icon_button.xml b/app/src/main/res/drawable/selectable_icon_button.xml new file mode 100644 index 000000000..12de62fc7 --- /dev/null +++ b/app/src/main/res/drawable/selectable_icon_button.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selectable_recipient_bottom_sheet_icon_button.xml b/app/src/main/res/drawable/selectable_recipient_bottom_sheet_icon_button.xml new file mode 100644 index 000000000..1d4cfed7b --- /dev/null +++ b/app/src/main/res/drawable/selectable_recipient_bottom_sheet_icon_button.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/show_more.xml b/app/src/main/res/drawable/show_more.xml new file mode 100644 index 000000000..1c5bb8c11 --- /dev/null +++ b/app/src/main/res/drawable/show_more.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_avatar_preference_item.xml b/app/src/main/res/layout/conversation_settings_avatar_preference_item.xml new file mode 100644 index 000000000..7b53fee6e --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_avatar_preference_item.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_bio_preference_item.xml b/app/src/main/res/layout/conversation_settings_bio_preference_item.xml new file mode 100644 index 000000000..fa0de6a67 --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_bio_preference_item.xml @@ -0,0 +1,54 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_button_strip.xml b/app/src/main/res/layout/conversation_settings_button_strip.xml new file mode 100644 index 000000000..4c0863976 --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_button_strip.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_fragment.xml b/app/src/main/res/layout/conversation_settings_fragment.xml new file mode 100644 index 000000000..2c5e62179 --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_fragment.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_group_description_preference.xml b/app/src/main/res/layout/conversation_settings_group_description_preference.xml new file mode 100644 index 000000000..d953fce83 --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_group_description_preference.xml @@ -0,0 +1,15 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_internal_preference.xml b/app/src/main/res/layout/conversation_settings_internal_preference.xml new file mode 100644 index 000000000..10fe8c61a --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_internal_preference.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_legacy_group_preference.xml b/app/src/main/res/layout/conversation_settings_legacy_group_preference.xml new file mode 100644 index 000000000..e088cb274 --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_legacy_group_preference.xml @@ -0,0 +1,15 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_shared_media.xml b/app/src/main/res/layout/conversation_settings_shared_media.xml new file mode 100644 index 000000000..714340679 --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_shared_media.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_toolbar.xml b/app/src/main/res/layout/conversation_settings_toolbar.xml new file mode 100644 index 000000000..2570964ba --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_toolbar.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_settings_activity.xml b/app/src/main/res/layout/dsl_settings_activity.xml index c2fe2cfdd..a2bf31576 100644 --- a/app/src/main/res/layout/dsl_settings_activity.xml +++ b/app/src/main/res/layout/dsl_settings_activity.xml @@ -2,4 +2,6 @@ \ No newline at end of file + android:layout_height="match_parent" + android:background="@color/signal_background_primary" + android:transitionName="window_content" /> \ No newline at end of file diff --git a/app/src/main/res/layout/group_manage_activity.xml b/app/src/main/res/layout/group_manage_activity.xml deleted file mode 100644 index e98b23ce2..000000000 --- a/app/src/main/res/layout/group_manage_activity.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/group_manage_fragment.xml b/app/src/main/res/layout/group_manage_fragment.xml deleted file mode 100644 index 5c43aafe5..000000000 --- a/app/src/main/res/layout/group_manage_fragment.xml +++ /dev/null @@ -1,733 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/group_recipient_list_item.xml b/app/src/main/res/layout/group_recipient_list_item.xml index 4edef244a..4c3780cfa 100644 --- a/app/src/main/res/layout/group_recipient_list_item.xml +++ b/app/src/main/res/layout/group_recipient_list_item.xml @@ -3,16 +3,17 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="64dp" + android:layout_height="wrap_content" android:background="?selectableItemBackground" android:clickable="true" - android:focusable="true"> + android:focusable="true" + android:minHeight="64dp" + android:paddingHorizontal="@dimen/dsl_settings_gutter"> + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/recipient_bottom_sheet.xml b/app/src/main/res/layout/recipient_bottom_sheet.xml index 94e63d325..f3d6e7d29 100644 --- a/app/src/main/res/layout/recipient_bottom_sheet.xml +++ b/app/src/main/res/layout/recipient_bottom_sheet.xml @@ -8,10 +8,10 @@ + app:layout_constraintTop_toBottomOf="@id/rbs_full_name" + tools:text="🕷🕷🕷Hangin' on the web🕷🕷" /> - + - - - - - - - - - +