From 57adab858cddb2cc0df288b0f712023335bcfe3d Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Tue, 30 Jul 2024 11:35:48 -0400 Subject: [PATCH] Add selected photos access. --- app/src/main/AndroidManifest.xml | 1 + .../securesms/WebRtcCallActivity.java | 10 +- .../conversation/AttachmentKeyboard.java | 131 ++++++++++++++++-- .../AttachmentKeyboardMediaAdapter.java | 73 ++++++++-- .../conversation/ManageContextMenu.kt | 75 ++++++++++ .../v2/keyboard/AttachmentKeyboardFragment.kt | 30 +++- .../securesms/mediasend/CameraXFragment.java | 4 +- .../securesms/mediasend/MediaRepository.java | 10 +- .../v2/capture/MediaCaptureRepository.kt | 2 +- .../v2/gallery/MediaGalleryFragment.kt | 71 ++++++++-- .../securesms/permissions/PermissionCompat.kt | 16 ++- .../PermissionDeniedBottomSheet.kt | 29 +++- .../securesms/permissions/Permissions.java | 12 +- .../securesms/util/StorageUtil.java | 10 +- ...achment_keyboad_media_placeholder_item.xml | 24 ++++ .../main/res/layout/attachment_keyboard.xml | 18 +++ .../res/layout/v2_media_gallery_fragment.xml | 32 ++++- app/src/main/res/values/dimens.xml | 2 +- app/src/main/res/values/strings.xml | 20 ++- 19 files changed, 505 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ManageContextMenu.kt create mode 100644 app/src/main/res/layout/attachment_keyboad_media_placeholder_item.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 19d2ecd9ef..dbd91920ad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -83,6 +83,7 @@ + Toast.makeText(this, R.string.WebRtcCallActivity__signal_needs_camera_access_enable_video, Toast.LENGTH_LONG).show()) - .onAnyPermanentlyDenied(() -> showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera, R.string.WebRtcCallActivity__to_enable_video).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)) + .onAnyPermanentlyDenied(() -> showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera, R.string.WebRtcCallActivity__to_enable_video, false).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)) .execute(); } } @@ -1050,7 +1050,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan Toast.makeText(this, R.string.WebRtcCallActivity__signal_needs_microphone_start_call, Toast.LENGTH_LONG).show(); handleDenyCall(); }) - .onAnyPermanentlyDenied(() -> showPermissionFragment(R.string.WebRtcCallActivity__allow_access_microphone, R.string.WebRtcCallActivity__to_start_call).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)) + .onAnyPermanentlyDenied(() -> showPermissionFragment(R.string.WebRtcCallActivity__allow_access_microphone, R.string.WebRtcCallActivity__to_start_call, false).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)) .execute(); } } @@ -1065,11 +1065,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan .onAnyResult(() -> isAskingForPermission = false) .onSomePermanentlyDenied(deniedPermissions -> { if (deniedPermissions.containsAll(List.of(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))) { - showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera_microphone, R.string.WebRtcCallActivity__to_start_call).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera_microphone, R.string.WebRtcCallActivity__to_start_call, false).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); } else if (deniedPermissions.contains(Manifest.permission.CAMERA)) { - showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera, R.string.WebRtcCallActivity__to_enable_video).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera, R.string.WebRtcCallActivity__to_enable_video, false).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); } else { - showPermissionFragment(R.string.WebRtcCallActivity__allow_access_microphone, R.string.WebRtcCallActivity__to_start_call).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + showPermissionFragment(R.string.WebRtcCallActivity__allow_access_microphone, R.string.WebRtcCallActivity__to_start_call, false).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); } }) .onAllGranted(onGranted) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java index 1a8ec94185..ace4edd330 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java @@ -1,10 +1,13 @@ package org.thoughtcrime.securesms.conversation; +import android.animation.Animator; +import android.animation.ValueAnimator; import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -13,8 +16,10 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; +import com.google.android.material.button.MaterialButton; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; import org.thoughtcrime.securesms.components.InputAwareLayout; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.util.StorageUtil; @@ -26,7 +31,8 @@ import java.util.stream.Collectors; public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.InputView { - private static final List DEFAULT_BUTTONS = Arrays.asList( + private static final int ANIMATION_DURATION = 150; + private static final List DEFAULT_BUTTONS = Arrays.asList( AttachmentKeyboardButton.GALLERY, AttachmentKeyboardButton.FILE, AttachmentKeyboardButton.CONTACT, @@ -39,9 +45,10 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout. private AttachmentKeyboardButtonAdapter buttonAdapter; private Callback callback; - private RecyclerView mediaList; - private View permissionText; - private View permissionButton; + private RecyclerView mediaList; + private TextView permissionText; + private MaterialButton permissionButton; + private MaterialButton manageButton; public AttachmentKeyboard(@NonNull Context context) { super(context); @@ -60,6 +67,7 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout. this.mediaList = findViewById(R.id.attachment_keyboard_media_list); this.permissionText = findViewById(R.id.attachment_keyboard_permission_text); this.permissionButton = findViewById(R.id.attachment_keyboard_permission_button); + this.manageButton = findViewById(R.id.attachment_keyboard_manage_button); RecyclerView buttonList = findViewById(R.id.attachment_keyboard_button_list); buttonList.setItemAnimator(null); @@ -76,7 +84,17 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout. } }); + manageButton.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + + manageButton.setOnClickListener(v -> { + if (callback != null) { + callback.onDisplayMoreContextMenu(v, true, false); + } + }); + mediaList.setAdapter(mediaAdapter); + mediaList.addOnScrollListener(new ScrollListener(manageButton.getMeasuredWidth())); buttonList.setAdapter(buttonAdapter); buttonAdapter.registerAdapterDataObserver(new AttachmentButtonCenterHelper(buttonList)); @@ -100,14 +118,37 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout. } public void onMediaChanged(@NonNull List media) { - if (StorageUtil.canReadFromMediaStore()) { - mediaAdapter.setMedia(media); + if (StorageUtil.canReadAllFromMediaStore()) { + mediaList.setVisibility(VISIBLE); + mediaAdapter.setMedia(media, false); permissionButton.setVisibility(GONE); permissionText.setVisibility(GONE); - } else { - permissionButton.setVisibility(VISIBLE); + manageButton.setVisibility(GONE); + } else if (StorageUtil.canOnlyReadSelectedMediaStore() && media.isEmpty()) { + mediaList.setVisibility(GONE); + manageButton.setVisibility(GONE); permissionText.setVisibility(VISIBLE); - + permissionText.setText(getContext().getString(R.string.AttachmentKeyboard_no_photos_found)); + permissionButton.setVisibility(VISIBLE); + permissionButton.setText(getContext().getString(R.string.AttachmentKeyboard_manage)); + permissionButton.setOnClickListener(v -> { + if (callback != null) { + callback.onDisplayMoreContextMenu(v, true, true); + } + }); + } else if (StorageUtil.canOnlyReadSelectedMediaStore()) { + mediaList.setVisibility(VISIBLE); + mediaAdapter.setMedia(media, true); + manageButton.setVisibility(VISIBLE); + permissionText.setVisibility(GONE); + permissionButton.setVisibility(GONE); + } else { + mediaList.setVisibility(GONE); + manageButton.setVisibility(GONE); + permissionButton.setVisibility(VISIBLE); + permissionButton.setText(getContext().getString(R.string.AttachmentKeyboard_allow_access)); + permissionText.setVisibility(VISIBLE); + permissionText.setText(getContext().getString(R.string.AttachmentKeyboard_Signal_needs_permission_to_show_your_photos_and_videos)); permissionButton.setOnClickListener(v -> { if (callback != null) { callback.onAttachmentPermissionsRequested(); @@ -144,9 +185,81 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout. return getVisibility() == VISIBLE; } + private class ScrollListener extends RecyclerView.OnScrollListener { + + private final int originalWidth; + private final int iconWidth; + + private ValueAnimator animator; + private boolean isCollapsed; + + public ScrollListener(int originalWidth) { + this.originalWidth = originalWidth; + this.iconWidth = manageButton.getIconSize() + manageButton.getPaddingLeft() + manageButton.getPaddingRight(); + } + + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + if (manageButton == null || recyclerView.getLayoutManager() == null || recyclerView.getAdapter() == null) { + return; + } + + GridLayoutManager layoutManager = (GridLayoutManager) recyclerView.getLayoutManager(); + View childView = layoutManager.getChildAt(0); + int position = layoutManager.findLastVisibleItemPosition(); + + boolean visibleFirstChild = childView != null && childView.getTop() == 0 && layoutManager.getPosition(childView) == 0; + boolean visibleLastChild = position == recyclerView.getAdapter().getItemCount() - 1; + boolean shouldCollapse = !visibleFirstChild && !visibleLastChild; + + if (shouldCollapse && !isCollapsed) { + isCollapsed = true; + if (animator != null) { + animator.cancel(); + } + animator = createWidthAnimator(manageButton, originalWidth, iconWidth, new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + manageButton.setText(""); + } + }); + animator.start(); + } else if (!shouldCollapse && isCollapsed) { + isCollapsed = false; + if (animator != null) { + animator.cancel(); + } + manageButton.setText(getContext().getString(R.string.AttachmentKeyboard_manage)); + animator = createWidthAnimator(manageButton, iconWidth, originalWidth, null); + animator.start(); + } + } + } + + private static ValueAnimator createWidthAnimator(@NonNull View view, + int originalWidth, + int finalWidth, + @Nullable AnimationCompleteListener onAnimationComplete) + { + ValueAnimator animator = ValueAnimator.ofInt(originalWidth, finalWidth).setDuration(ANIMATION_DURATION); + + animator.addUpdateListener(animation -> { + ViewGroup.LayoutParams params = view.getLayoutParams(); + params.width = (int) animation.getAnimatedValue(); + view.setLayoutParams(params); + }); + + if (onAnimationComplete != null) { + animator.addListener(onAnimationComplete); + } + + return animator; + } + public interface Callback { void onAttachmentMediaClicked(@NonNull Media media); void onAttachmentSelectorClicked(@NonNull AttachmentKeyboardButton button); void onAttachmentPermissionsRequested(); + void onDisplayMoreContextMenu(View v, boolean showAbove, boolean showAtStart); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardMediaAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardMediaAdapter.java index 8aa6692a61..ef3a425af5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardMediaAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboardMediaAdapter.java @@ -20,12 +20,15 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; -class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter { +class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter { - private final List media; - private final RequestManager requestManager; - private final Listener listener; - private final StableIdGenerator idGenerator; + private static final int VIEW_TYPE_MEDIA = 0; + private static final int VIEW_TYPE_PLACEHOLDER = 1; + + private final List media; + private final RequestManager requestManager; + private final Listener listener; + private final StableIdGenerator idGenerator; AttachmentKeyboardMediaAdapter(@NonNull RequestManager requestManager, @NonNull Listener listener) { this.requestManager = requestManager; @@ -42,17 +45,21 @@ class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter new MediaViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.attachment_keyboad_media_item, parent, false)); + case VIEW_TYPE_PLACEHOLDER -> new PlaceholderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.attachment_keyboad_media_placeholder_item, parent, false)); + default -> throw new IllegalArgumentException("Unsupported viewType: " + viewType); + }; } @Override - public void onBindViewHolder(@NonNull MediaViewHolder holder, int position) { + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { holder.bind(media.get(position), requestManager, listener); } @Override - public void onViewRecycled(@NonNull MediaViewHolder holder) { + public void onViewRecycled(@NonNull ViewHolder holder) { holder.recycle(); } @@ -61,9 +68,17 @@ class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter media) { + @Override + public int getItemViewType(int position) { + return media.get(position).isPlaceholder ? VIEW_TYPE_PLACEHOLDER : VIEW_TYPE_MEDIA; + } + + public void setMedia(@NonNull List media, boolean addFooter) { this.media.clear(); - this.media.addAll(media); + this.media.addAll(media.stream().map(MediaContent::new).collect(java.util.stream.Collectors.toList())); + if (addFooter) { + this.media.add(new MediaContent(true)); + } notifyDataSetChanged(); } @@ -71,7 +86,36 @@ class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter listener.onMediaClicked(media)); @@ -99,6 +145,7 @@ class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter Unit, + onSettings: () -> Unit + ) { + show( + context = context, + anchorView = anchorView, + rootView = rootView, + showAbove = showAbove, + showAtStart = showAtStart, + callbacks = object : Callbacks { + override fun onSelectMore() = onSelectMore() + override fun onSettings() = onSettings() + } + ) + } + + private fun show( + context: Context, + anchorView: View, + rootView: ViewGroup = anchorView.rootView as ViewGroup, + showAbove: Boolean = false, + showAtStart: Boolean = false, + callbacks: Callbacks + ) { + val actions = mutableListOf().apply { + add( + ActionItem(R.drawable.symbol_settings_android_24, context.getString(R.string.AttachmentKeyboard_go_to_settings)) { + callbacks.onSettings() + } + ) + add( + ActionItem(R.drawable.symbol_album_tilt_24, context.getString(R.string.AttachmentKeyboard_select_more_photos)) { + callbacks.onSelectMore() + } + ) + } + + if (!showAbove) { + actions.reverse() + } + + SignalContextMenu.Builder(anchorView, rootView) + .preferredHorizontalPosition(if (showAtStart) SignalContextMenu.HorizontalPosition.START else SignalContextMenu.HorizontalPosition.END) + .preferredVerticalPosition(if (showAbove) SignalContextMenu.VerticalPosition.ABOVE else SignalContextMenu.VerticalPosition.BELOW) + .offsetY(DimensionUnit.DP.toPixels(8f).toInt()) + .show(actions) + } + + private interface Callbacks { + fun onSelectMore() + fun onSettings() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt index 08a309faa6..1215ceac75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/keyboard/AttachmentKeyboardFragment.kt @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.LoggingFragment import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.conversation.AttachmentKeyboard import org.thoughtcrime.securesms.conversation.AttachmentKeyboardButton +import org.thoughtcrime.securesms.conversation.ManageContextMenu import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.Media @@ -96,9 +97,32 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_ Permissions.with(requireParentFragment()) .request(*PermissionCompat.forImagesAndVideos()) .ifNecessary() - .onAllGranted { viewModel.refreshRecentMedia() } - .withPermanentDenialDialog(getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio), null, R.string.AttachmentManager_signal_allow_storage, R.string.AttachmentManager_signal_to_show_photos, parentFragmentManager) - .onAnyDenied { Toast.makeText(requireContext(), R.string.AttachmentManager_signal_needs_storage_access, Toast.LENGTH_LONG).show() } + .onAnyResult { viewModel.refreshRecentMedia() } + .withPermanentDenialDialog(getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio), null, R.string.AttachmentManager_signal_allow_storage, R.string.AttachmentManager_signal_to_show_photos, true, parentFragmentManager) + .onSomeDenied { + val deniedPermissions = PermissionCompat.getRequiredPermissionsForDenial() + if (it.containsAll(deniedPermissions.toList())) { + Toast.makeText(requireContext(), R.string.AttachmentManager_signal_needs_storage_access, Toast.LENGTH_LONG).show() + } + } + .execute() + } + + override fun onDisplayMoreContextMenu(v: View, showAbove: Boolean, showAtStart: Boolean) { + ManageContextMenu.show( + context = requireContext(), + anchorView = v, + showAbove = showAbove, + showAtStart = showAtStart, + onSelectMore = { selectMorePhotos() }, + onSettings = { requireContext().startActivity(Permissions.getApplicationSettingsIntent(requireContext())) } + ) + } + + private fun selectMorePhotos() { + Permissions.with(requireParentFragment()) + .request(*PermissionCompat.forImagesAndVideos()) + .onAnyResult { viewModel.refreshRecentMedia() } .execute() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java index 5cf3a08aa5..6368efbac3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java @@ -308,9 +308,9 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { }) .onSomePermanentlyDenied(deniedPermissions -> { if (deniedPermissions.containsAll(List.of(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))) { - showPermissionFragment(R.string.CameraXFragment_allow_access_camera_microphone, R.string.CameraXFragment_to_capture_photos_videos).show(getParentFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + showPermissionFragment(R.string.CameraXFragment_allow_access_camera_microphone, R.string.CameraXFragment_to_capture_photos_videos, false).show(getParentFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); } else if (deniedPermissions.contains(Manifest.permission.CAMERA)) { - showPermissionFragment(R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_capture_photos_videos).show(getParentFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + showPermissionFragment(R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_capture_photos_videos, false).show(getParentFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); } }) .onSomeDenied(deniedPermissions -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java index 0a750a8003..a764fa6146 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -55,7 +55,7 @@ public class MediaRepository { * Retrieves a list of folders that contain media. */ public void getFolders(@NonNull Context context, @NonNull Callback> callback) { - if (!StorageUtil.canReadFromMediaStore()) { + if (!StorageUtil.canReadAnyFromMediaStore()) { Log.w(TAG, "No storage permissions!", new Throwable()); callback.onComplete(Collections.emptyList()); return; @@ -69,7 +69,7 @@ public class MediaRepository { */ public Single> getRecentMedia() { return Single.>fromCallable(() -> { - if (!StorageUtil.canReadFromMediaStore()) { + if (!StorageUtil.canReadAnyFromMediaStore()) { Log.w(TAG, "No storage permissions!", new Throwable()); return Collections.emptyList(); } @@ -87,7 +87,7 @@ public class MediaRepository { * Retrieves a list of media items (images and videos) that are present int he specified bucket. */ public void getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Callback> callback) { - if (!StorageUtil.canReadFromMediaStore()) { + if (!StorageUtil.canReadAnyFromMediaStore()) { Log.w(TAG, "No storage permissions!", new Throwable()); callback.onComplete(Collections.emptyList()); return; @@ -106,7 +106,7 @@ public class MediaRepository { return; } - if (!StorageUtil.canReadFromMediaStore()) { + if (!StorageUtil.canReadAnyFromMediaStore()) { Log.w(TAG, "No storage permissions!", new Throwable()); callback.onComplete(media); return; @@ -117,7 +117,7 @@ public class MediaRepository { } void getMostRecentItem(@NonNull Context context, @NonNull Callback> callback) { - if (!StorageUtil.canReadFromMediaStore()) { + if (!StorageUtil.canReadAnyFromMediaStore()) { Log.w(TAG, "No storage permissions!", new Throwable()); callback.onComplete(Optional.empty()); return; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt index 2ff22ea411..e251465083 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt @@ -29,7 +29,7 @@ class MediaCaptureRepository(context: Context) { private val context: Context = context.applicationContext fun getMostRecentItem(callback: (Media?) -> Unit) { - if (!StorageUtil.canReadFromMediaStore()) { + if (!StorageUtil.canReadAnyFromMediaStore()) { Log.w(TAG, "Cannot read from storage.") callback(null) return diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt index b1353dcf2a..5623c0df7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend.v2.gallery import android.Manifest import android.os.Bundle import android.view.View +import android.view.ViewGroup import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.constraintlayout.widget.ConstraintLayout @@ -17,6 +18,7 @@ import androidx.recyclerview.widget.ItemTouchHelper import org.signal.core.util.Stopwatch import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration +import org.thoughtcrime.securesms.conversation.ManageContextMenu import org.thoughtcrime.securesms.databinding.V2MediaGalleryFragmentBinding import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaRepository @@ -24,6 +26,7 @@ import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil import org.thoughtcrime.securesms.permissions.PermissionCompat import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.util.Material3OnScrollHelper +import org.thoughtcrime.securesms.util.StorageUtil import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter @@ -164,8 +167,6 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { binding.mediaGalleryToolbar.title = state.bucketTitle ?: requireContext().getString(R.string.AttachmentKeyboard_gallery) } - binding.mediaGalleryAllowAccess.setOnClickListener { requestRequiredPermissions() } - val galleryItemsWithSelection = LiveDataUtil.combineLatest( viewModel.state.map { it.items }, viewStateLiveData.map { it.selectedMedia } @@ -180,20 +181,44 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { } galleryItemsWithSelection.observe(viewLifecycleOwner) { - if (!Permissions.hasAll(requireContext(), *PermissionCompat.forImagesAndVideos())) { - binding.mediaGalleryMissingPermissions.visibility = View.VISIBLE - shouldEnableScrolling = false - galleryAdapter.submitList((1..100).map { MediaGallerySelectableItem.PlaceholderModel() }) - } else { - binding.mediaGalleryMissingPermissions.visibility = View.GONE + if (StorageUtil.canReadAllFromMediaStore()) { + binding.mediaGalleryMissingPermissions.visible = false + binding.mediaGalleryManageContainer.visible = false shouldEnableScrolling = true galleryAdapter.submitList(it) + } else if (StorageUtil.canOnlyReadSelectedMediaStore() && it.isEmpty()) { + binding.mediaGalleryMissingPermissions.visible = true + binding.mediaGalleryManageContainer.visible = false + binding.mediaGalleryPermissionText.text = getString(R.string.MediaGalleryFragment__no_photos_found) + binding.mediaGalleryAllowAccess.text = getString(R.string.AttachmentKeyboard_manage) + binding.mediaGalleryAllowAccess.setOnClickListener { v -> showManageContextMenu(v, v.parent as ViewGroup, false, true) } + shouldEnableScrolling = false + galleryAdapter.submitList((1..100).map { MediaGallerySelectableItem.PlaceholderModel() }) + } else if (StorageUtil.canOnlyReadSelectedMediaStore()) { + binding.mediaGalleryMissingPermissions.visible = false + binding.mediaGalleryManageContainer.visible = true + binding.mediaGalleryManageButton.setOnClickListener { v -> showManageContextMenu(v, v.rootView as ViewGroup, false, false) } + shouldEnableScrolling = true + galleryAdapter.submitList(it) + } else { + binding.mediaGalleryMissingPermissions.visible = true + binding.mediaGalleryManageContainer.visible = false + binding.mediaGalleryPermissionText.text = getString(R.string.AttachmentKeyboard_Signal_needs_permission_to_show_your_photos_and_videos) + binding.mediaGalleryAllowAccess.text = getString(R.string.AttachmentKeyboard_allow_access) + binding.mediaGalleryAllowAccess.setOnClickListener { requestRequiredPermissions() } + shouldEnableScrolling = false + galleryAdapter.submitList((1..100).map { MediaGallerySelectableItem.PlaceholderModel() }) } } requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) } + override fun onResume() { + super.onResume() + refreshMediaGallery() + } + private fun refreshMediaGallery() { viewModel.refreshMediaGallery() } @@ -203,13 +228,37 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) } + private fun showManageContextMenu(view: View, rootView: ViewGroup, showAbove: Boolean, showAtStart: Boolean) { + ManageContextMenu.show( + context = requireContext(), + anchorView = view, + rootView = rootView, + showAbove = showAbove, + showAtStart = showAtStart, + onSelectMore = { selectMorePhotos() }, + onSettings = { requireContext().startActivity(Permissions.getApplicationSettingsIntent(requireContext())) } + ) + } + + private fun selectMorePhotos() { + Permissions.with(requireParentFragment()) + .request(*PermissionCompat.forImagesAndVideos()) + .onAnyResult { refreshMediaGallery() } + .execute() + } + private fun requestRequiredPermissions() { Permissions.with(this) .request(*PermissionCompat.forImagesAndVideos()) .ifNecessary() - .onAllGranted { refreshMediaGallery() } - .withPermanentDenialDialog(getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio), null, R.string.AttachmentManager_signal_allow_storage, R.string.AttachmentManager_signal_to_show_photos, parentFragmentManager) - .onAnyDenied { Toast.makeText(requireContext(), R.string.AttachmentManager_signal_needs_storage_access, Toast.LENGTH_LONG).show() } + .onAnyResult { refreshMediaGallery() } + .withPermanentDenialDialog(getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio), null, R.string.AttachmentManager_signal_allow_storage, R.string.AttachmentManager_signal_to_show_photos, true, parentFragmentManager) + .onSomeDenied { + val deniedPermission = PermissionCompat.getRequiredPermissionsForDenial() + if (it.containsAll(deniedPermission.toList())) { + Toast.makeText(requireContext(), R.string.AttachmentManager_signal_needs_storage_access, Toast.LENGTH_LONG).show() + } + } .execute() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionCompat.kt b/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionCompat.kt index 34ab560d2a..66b9b266e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionCompat.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionCompat.kt @@ -15,7 +15,9 @@ import android.os.Build object PermissionCompat { @JvmStatic fun forImages(): Array { - return if (Build.VERSION.SDK_INT >= 33) { + return if (Build.VERSION.SDK_INT >= 34) { + arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + } else if (Build.VERSION.SDK_INT == 33) { arrayOf(Manifest.permission.READ_MEDIA_IMAGES) } else { arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) @@ -23,7 +25,9 @@ object PermissionCompat { } private fun forVideos(): Array { - return if (Build.VERSION.SDK_INT >= 33) { + return if (Build.VERSION.SDK_INT >= 34) { + arrayOf(Manifest.permission.READ_MEDIA_VIDEO, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + } else if (Build.VERSION.SDK_INT == 33) { arrayOf(Manifest.permission.READ_MEDIA_VIDEO) } else { arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) @@ -34,4 +38,12 @@ object PermissionCompat { fun forImagesAndVideos(): Array { return setOf(*(forImages() + forVideos())).toTypedArray() } + + fun getRequiredPermissionsForDenial(): Array { + return if (Build.VERSION.SDK_INT >= 34) { + arrayOf(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + } else { + forImagesAndVideos() + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionDeniedBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionDeniedBottomSheet.kt index 1850351b10..083bfc6e9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionDeniedBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/PermissionDeniedBottomSheet.kt @@ -39,16 +39,20 @@ private const val PLACEHOLDER = "__RADIO_BUTTON_PLACEHOLDER__" */ class PermissionDeniedBottomSheet private constructor() : ComposeBottomSheetDialogFragment() { + override val peekHeightPercentage: Float = 0.66f + companion object { private const val ARG_TITLE = "argument.title_res" private const val ARG_SUBTITLE = "argument.subtitle_res" + private const val ARG_USE_EXTENDED = "argument.use.extended" @JvmStatic - fun showPermissionFragment(titleRes: Int, subtitleRes: Int): ComposeBottomSheetDialogFragment { + fun showPermissionFragment(titleRes: Int, subtitleRes: Int, useExtended: Boolean = false): ComposeBottomSheetDialogFragment { return PermissionDeniedBottomSheet().apply { arguments = bundleOf( ARG_TITLE to titleRes, - ARG_SUBTITLE to subtitleRes + ARG_SUBTITLE to subtitleRes, + ARG_USE_EXTENDED to useExtended ) } } @@ -59,6 +63,7 @@ class PermissionDeniedBottomSheet private constructor() : ComposeBottomSheetDial PermissionDeniedSheetContent( titleRes = remember { requireArguments().getInt(ARG_TITLE) }, subtitleRes = remember { requireArguments().getInt(ARG_SUBTITLE) }, + useExtended = remember { requireArguments().getBoolean(ARG_USE_EXTENDED) }, onSettingsClicked = this::goToSettings ) } @@ -85,6 +90,7 @@ private fun PermissionDeniedSheetContentPreview() { private fun PermissionDeniedSheetContent( titleRes: Int, subtitleRes: Int, + useExtended: Boolean = false, onSettingsClicked: () -> Unit ) { Column( @@ -119,9 +125,18 @@ private fun PermissionDeniedSheetContent( modifier = Modifier.padding(bottom = 24.dp) ) - val step2String = stringResource(id = R.string.PermissionDeniedBottomSheet__2_allow_permission, PLACEHOLDER) - val (step2Text, step2InlineContent) = remember(step2String) { - val parts = step2String.split(PLACEHOLDER) + if (useExtended) { + Text( + text = stringResource(R.string.PermissionDeniedBottomSheet__2_tap_permissions), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 24.dp) + ) + } + + val stringId = if (useExtended) R.string.PermissionDeniedBottomSheet__3_allow_permission else R.string.PermissionDeniedBottomSheet__2_allow_permission + val stepString = stringResource(id = stringId, PLACEHOLDER) + val (stepText, stepInlineContent) = remember(stepString) { + val parts = stepString.split(PLACEHOLDER) val annotatedString = buildAnnotatedString { append(parts[0]) appendInlineContent("radio") @@ -142,8 +157,8 @@ private fun PermissionDeniedSheetContent( } Text( - text = step2Text, - inlineContent = step2InlineContent, + text = stepText, + inlineContent = stepInlineContent, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 32.dp) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java index 58e6e329cd..9ee6062df3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java @@ -135,7 +135,11 @@ public class Permissions { } public PermissionsBuilder withPermanentDenialDialog(@NonNull String message, @Nullable Runnable onDialogDismissed, int titleRes, int detailsRes, @Nullable FragmentManager fragmentManager) { - return onAnyPermanentlyDenied(new SettingsDialogListener(permissionObject.getContext(), message, onDialogDismissed, titleRes, detailsRes, fragmentManager)); + return withPermanentDenialDialog(message, onDialogDismissed, titleRes, detailsRes, false, fragmentManager); + } + + public PermissionsBuilder withPermanentDenialDialog(@NonNull String message, @Nullable Runnable onDialogDismissed, int titleRes, int detailsRes, boolean useExtended, @Nullable FragmentManager fragmentManager) { + return onAnyPermanentlyDenied(new SettingsDialogListener(permissionObject.getContext(), message, onDialogDismissed, titleRes, detailsRes, useExtended, fragmentManager)); } public PermissionsBuilder onAllGranted(Runnable allGrantedListener) { @@ -402,14 +406,16 @@ public class Permissions { private final int titleRes; private final int detailsRes; private final boolean useBottomSheet; + private final boolean useExtended; - SettingsDialogListener(Context context, String message, @Nullable Runnable onDialogDismissed, int titleRes, int detailsRes, @Nullable FragmentManager fragmentManager) { + SettingsDialogListener(Context context, String message, @Nullable Runnable onDialogDismissed, int titleRes, int detailsRes, boolean useExtended, @Nullable FragmentManager fragmentManager) { this.message = message; this.context = new WeakReference<>(context); this.onDialogDismissed = onDialogDismissed; this.fragmentManager = new WeakReference<>(fragmentManager); this.titleRes = titleRes; this.detailsRes = detailsRes; + this.useExtended = useExtended; this.useBottomSheet = fragmentManager != null; } @@ -420,7 +426,7 @@ public class Permissions { if (context != null) { if (useBottomSheet && fragmentManager != null) { - PermissionDeniedBottomSheet.showPermissionFragment(titleRes, detailsRes).show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + PermissionDeniedBottomSheet.showPermissionFragment(titleRes, detailsRes, useExtended).show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); } else if (!useBottomSheet){ new MaterialAlertDialogBuilder(context) .setTitle(R.string.Permissions_permission_required) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java index 96f3c4670c..1413c6eb5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java @@ -110,10 +110,18 @@ public class StorageUtil { Permissions.hasAll(AppDependencies.getApplication(), Manifest.permission.WRITE_EXTERNAL_STORAGE); } - public static boolean canReadFromMediaStore() { + public static boolean canReadAnyFromMediaStore() { return Permissions.hasAny(AppDependencies.getApplication(), PermissionCompat.forImagesAndVideos()); } + public static boolean canOnlyReadSelectedMediaStore() { + return Build.VERSION.SDK_INT >= 34 && Permissions.hasAll(AppDependencies.getApplication(), Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED); + } + + public static boolean canReadAllFromMediaStore() { + return Permissions.hasAll(AppDependencies.getApplication(), PermissionCompat.forImagesAndVideos()); + } + public static @NonNull Uri getVideoUri() { return MediaStore.Video.Media.EXTERNAL_CONTENT_URI; } diff --git a/app/src/main/res/layout/attachment_keyboad_media_placeholder_item.xml b/app/src/main/res/layout/attachment_keyboad_media_placeholder_item.xml new file mode 100644 index 0000000000..9e90152f99 --- /dev/null +++ b/app/src/main/res/layout/attachment_keyboad_media_placeholder_item.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/app/src/main/res/layout/attachment_keyboard.xml b/app/src/main/res/layout/attachment_keyboard.xml index d3c6b0c951..8fd1d230bf 100644 --- a/app/src/main/res/layout/attachment_keyboard.xml +++ b/app/src/main/res/layout/attachment_keyboard.xml @@ -27,6 +27,24 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + + @@ -52,6 +81,7 @@ android:src="@drawable/permission_gallery" /> 64dp 60dp - 240dp + 260dp 110dp 170dp 56dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1453abdf22..e66053ea37 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,6 +89,20 @@ Allow Access Payment + + Manage + + Select more photos + + Go to Settings + + Signal has limited access to photos or videos + + No photos found, select photos and videos to appear here or change permissions + + + + No photos or videos found. Signal only has access to photos and videos you selected. Can\'t find an app to select media. @@ -109,7 +123,7 @@ To show photos and videos: - Signal needs storage access to show your photos and videos. + Signal needs access to show your photos and videos. %1$s hasn\'t activated Payments @@ -3734,6 +3748,10 @@ 1. Tap “Settings” below 2. %s Allow the permission + + 2. Tap “Permissions” + + 3. %1$s Allow the “Photos and videos” permission Settings