Add selected photos access.

This commit is contained in:
Michelle Tang 2024-07-30 11:35:48 -04:00 committed by mtang-signal
parent 4f001a0c95
commit 57adab858c
19 changed files with 505 additions and 65 deletions

View file

@ -83,6 +83,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<application android:name=".ApplicationContext"
android:icon="@mipmap/ic_launcher"

View file

@ -1032,7 +1032,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
findViewById(R.id.missing_permissions_container).setVisibility(View.GONE);
})
.onAnyDenied(() -> 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)

View file

@ -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<AttachmentKeyboardButton> DEFAULT_BUTTONS = Arrays.asList(
private static final int ANIMATION_DURATION = 150;
private static final List<AttachmentKeyboardButton> 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> 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);
}
}

View file

@ -20,12 +20,15 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter<AttachmentKeyboardMediaAdapter.MediaViewHolder> {
class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter<AttachmentKeyboardMediaAdapter.ViewHolder> {
private final List<Media> media;
private final RequestManager requestManager;
private final Listener listener;
private final StableIdGenerator<Media> idGenerator;
private static final int VIEW_TYPE_MEDIA = 0;
private static final int VIEW_TYPE_PLACEHOLDER = 1;
private final List<MediaContent> media;
private final RequestManager requestManager;
private final Listener listener;
private final StableIdGenerator<MediaContent> idGenerator;
AttachmentKeyboardMediaAdapter(@NonNull RequestManager requestManager, @NonNull Listener listener) {
this.requestManager = requestManager;
@ -42,17 +45,21 @@ class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter<AttachmentKeyb
}
@Override
public @NonNull MediaViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new MediaViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.attachment_keyboad_media_item, parent, false));
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return switch (viewType) {
case VIEW_TYPE_MEDIA -> 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<AttachmentKeyb
return media.size();
}
public void setMedia(@NonNull List<Media> media) {
@Override
public int getItemViewType(int position) {
return media.get(position).isPlaceholder ? VIEW_TYPE_PLACEHOLDER : VIEW_TYPE_MEDIA;
}
public void setMedia(@NonNull List<Media> 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<AttachmentKeyb
void onMediaClicked(@NonNull Media media);
}
static class MediaViewHolder extends RecyclerView.ViewHolder {
private class MediaContent {
private Media media;
private boolean isPlaceholder;
public MediaContent(Media media) {
this.media = media;
}
public MediaContent(boolean isPlaceholder) {
this.isPlaceholder = isPlaceholder;
}
}
static abstract class ViewHolder extends RecyclerView.ViewHolder {
public ViewHolder(@NonNull View itemView) {
super(itemView);
}
void bind(@NonNull MediaContent media, @NonNull RequestManager requestManager, @NonNull Listener listener) {}
void recycle() {}
}
static class PlaceholderViewHolder extends ViewHolder {
public PlaceholderViewHolder(@NonNull View itemView) {
super(itemView);
}
}
static class MediaViewHolder extends ViewHolder {
private final ThumbnailView image;
private final TextView duration;
@ -84,7 +128,9 @@ class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter<AttachmentKeyb
videoIcon = itemView.findViewById(R.id.attachment_keyboard_item_video_icon);
}
void bind(@NonNull Media media, @NonNull RequestManager requestManager, @NonNull Listener listener) {
@Override
void bind(@NonNull MediaContent mediaContent, @NonNull RequestManager requestManager, @NonNull Listener listener) {
Media media = mediaContent.media;
image.setImageResource(requestManager, media.getUri(), 400, 400);
image.setOnClickListener(v -> listener.onMediaClicked(media));
@ -99,6 +145,7 @@ class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter<AttachmentKeyb
}
}
@Override
void recycle() {
image.setOnClickListener(null);
}

View file

@ -0,0 +1,75 @@
package org.thoughtcrime.securesms.conversation
import android.content.Context
import android.view.View
import android.view.ViewGroup
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
/**
* A context menu shown when handling selected media only permissions.
* Will give users the ability to go to settings or to choose more media to give permission to
*/
object ManageContextMenu {
fun show(
context: Context,
anchorView: View,
rootView: ViewGroup = anchorView.rootView as ViewGroup,
showAbove: Boolean = false,
showAtStart: Boolean = false,
onSelectMore: () -> 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<ActionItem>().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()
}
}

View file

@ -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()
}

View file

@ -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 -> {

View file

@ -55,7 +55,7 @@ public class MediaRepository {
* Retrieves a list of folders that contain media.
*/
public void getFolders(@NonNull Context context, @NonNull Callback<List<MediaFolder>> 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<List<Media>> getRecentMedia() {
return Single.<List<Media>>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<List<Media>> 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<Optional<Media>> callback) {
if (!StorageUtil.canReadFromMediaStore()) {
if (!StorageUtil.canReadAnyFromMediaStore()) {
Log.w(TAG, "No storage permissions!", new Throwable());
callback.onComplete(Optional.empty());
return;

View file

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

View file

@ -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()
}

View file

@ -15,7 +15,9 @@ import android.os.Build
object PermissionCompat {
@JvmStatic
fun forImages(): Array<String> {
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<String> {
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<String> {
return setOf(*(forImages() + forVideos())).toTypedArray()
}
fun getRequiredPermissionsForDenial(): Array<String> {
return if (Build.VERSION.SDK_INT >= 34) {
arrayOf(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
} else {
forImagesAndVideos()
}
}
}

View file

@ -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)
)

View file

@ -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)

View file

@ -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;
}

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.SquareFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="12dp"
app:square_height="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Signal.Text.BodyMedium"
android:layout_gravity="center"
android:textAlignment="center"
android:paddingBottom="8dp"
app:autoSizeMaxTextSize="14sp"
app:autoSizeMinTextSize="4sp"
app:autoSizeTextType="uniform"
android:text="@string/AttachmentKeyboard_signal_has_limited_access" />
</org.thoughtcrime.securesms.components.SquareFrameLayout>

View file

@ -27,6 +27,24 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/attachment_keyboard_manage_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="104dp"
android:minHeight="36dp"
app:layout_constraintBottom_toBottomOf="@id/attachment_keyboard_media_list"
app:layout_constraintEnd_toEndOf="@id/attachment_keyboard_media_list"
android:layout_marginEnd="40dp"
android:layout_marginBottom="16dp"
android:padding="8dp"
app:iconPadding="0dp"
style="@style/Signal.Widget.Button.Large.Tonal"
android:text="@string/AttachmentKeyboard_manage"
android:maxLines="1"
android:visibility="gone"
app:icon="@drawable/symbol_settings_android_24" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/attachment_keyboard_button_list"
android:layout_width="match_parent"

View file

@ -23,13 +23,42 @@
app:title="@string/AttachmentKeyboard_gallery"
app:titleTextAppearance="@style/Signal.Text.TitleLarge" />
<LinearLayout
android:id="@+id/media_gallery_manage_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="72dp"
android:gravity="center"
android:padding="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/media_gallery_toolbar">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
style="@style/Signal.Text.BodyMedium"
android:layout_marginEnd="16dp"
android:text="@string/AttachmentKeyboard_signal_has_limited_access" />
<com.google.android.material.button.MaterialButton
android:id="@+id/media_gallery_manage_button"
style="@style/Signal.Widget.Button.Base.Tonal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="32dp"
android:padding="0dp"
android:text="@string/AttachmentKeyboard_manage" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/media_gallery_grid"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toTopOf="@id/media_gallery_bottom_bar_barrier"
app:layout_constraintTop_toBottomOf="@id/media_gallery_toolbar"
app:layout_constraintTop_toBottomOf="@id/media_gallery_manage_container"
app:spanCount="4"
tools:itemCount="36"
tools:listitem="@layout/v2_media_gallery_item" />
@ -52,6 +81,7 @@
android:src="@drawable/permission_gallery" />
<TextView
android:id="@+id/media_gallery_permission_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/AttachmentKeyboard_Signal_needs_permission_to_show_your_photos_and_videos"

View file

@ -8,7 +8,7 @@
<dimen name="signal_m3_toolbar_height">64dp</dimen>
<dimen name="min_keyboard_size">60dp</dimen>
<dimen name="default_custom_keyboard_size">240dp</dimen>
<dimen name="default_custom_keyboard_size">260dp</dimen>
<dimen name="min_custom_keyboard_size">110dp</dimen>
<dimen name="min_custom_keyboard_top_margin_portrait">170dp</dimen>
<dimen name="min_custom_keyboard_top_margin_landscape_bubble">56dp</dimen>

View file

@ -89,6 +89,20 @@
<!-- Text for a button prompting users to allow Signal access to their gallery storage -->
<string name="AttachmentKeyboard_allow_access">Allow Access</string>
<string name="AttachmentKeyboard_payment">Payment</string>
<!-- Text in a button that allows users to manage which media Signal has access to -->
<string name="AttachmentKeyboard_manage">Manage</string>
<!-- Option in menu to select more photos that Signal will have access to -->
<string name="AttachmentKeyboard_select_more_photos">Select more photos</string>
<!-- Option in menu to go to settings -->
<string name="AttachmentKeyboard_go_to_settings">Go to Settings</string>
<!-- Text explaining that Signal has limited access to photos -->
<string name="AttachmentKeyboard_signal_has_limited_access">Signal has limited access to photos or videos</string>
<!-- Text shown when user has given no photos for Signal to access and an explanation on how to change those permissions -->
<string name="AttachmentKeyboard_no_photos_found">No photos found, select photos and videos to appear here or change permissions</string>
<!-- MediaGalleryFragment -->
<!-- Text describing that no photos or videos are currently found in the gallery and that Signal can only access media that the user allows -->
<string name="MediaGalleryFragment__no_photos_found">No photos or videos found. Signal only has access to photos and videos you selected.</string>
<!-- AttachmentManager -->
<string name="AttachmentManager_cant_open_media_selection">Can\'t find an app to select media.</string>
@ -109,7 +123,7 @@
<!-- Dialog description that will explain the steps needed to give gallery storage permission -->
<string name="AttachmentManager_signal_to_show_photos">To show photos and videos:</string>
<!-- Toast text explaining Signal\'s need for storage access -->
<string name="AttachmentManager_signal_needs_storage_access">Signal needs storage access to show your photos and videos.</string>
<string name="AttachmentManager_signal_needs_storage_access">Signal needs access to show your photos and videos.</string>
<!-- Alert dialog title to show the recipient has not activated payments -->
<string name="AttachmentManager__not_activated_payments">%1$s hasn\'t activated Payments </string>
@ -3734,6 +3748,10 @@
<string name="PermissionDeniedBottomSheet__1_tap_settings_below">1. Tap “Settings” below</string>
<!-- Sheet describing step 2 on how to give permissions by checking the permissions button in settings where %s will be replaced with an image of a checked button -->
<string name="PermissionDeniedBottomSheet__2_allow_permission">2. %s Allow the permission</string>
<!-- Sheet describing step 2 on how to give permissions by tapping permissions in their settings -->
<string name="PermissionDeniedBottomSheet__2_tap_permissions">2. Tap “Permissions”</string>
<!-- Sheet describing step 3 on how to give permissions for photos and videos in settings where %s will be replaced with an image of a checked button -->
<string name="PermissionDeniedBottomSheet__3_allow_permission">3. %1$s Allow the “Photos and videos” permission</string>
<!-- Label for button at the bottom of the sheet which opens the system permission settings -->
<string name="PermissionDeniedBottomSheet__settings">Settings</string>