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