From dc64a186d55a84444b294b0d9981cf2fe7be63c9 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 19 Oct 2020 18:16:29 -0300 Subject: [PATCH] Fix mediastore access for Android Q. --- app/src/main/AndroidManifest.xml | 3 +- .../securesms/MediaPreviewActivity.java | 18 +++- .../components/RecentPhotoViewRail.java | 5 +- .../conversation/AttachmentKeyboard.java | 6 +- .../conversation/ConversationActivity.java | 1 + .../conversation/ConversationFragment.java | 49 ++++++--- .../database/loaders/RecentPhotosLoader.java | 6 +- .../securesms/mediaoverview/MediaActions.java | 75 +++++++------ .../securesms/mediasend/MediaRepository.java | 47 ++++---- .../securesms/mms/AttachmentManager.java | 44 +------- .../scribbles/ImageEditorFragment.java | 46 ++++---- .../securesms/util/BackupUtil.java | 6 +- .../securesms/util/SaveAttachmentTask.java | 101 +++++++++++------- .../securesms/util/StorageUtil.java | 40 +++++-- 14 files changed, 247 insertions(+), 200 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2ef7d3e906..62ba85dab3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,7 +36,8 @@ - + diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 3e19317a39..294ce5437d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -70,6 +70,7 @@ import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.FullscreenHelper; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment; +import org.thoughtcrime.securesms.util.StorageUtil; import java.util.HashMap; import java.util.Locale; @@ -384,21 +385,30 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity if (mediaItem != null) { SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> { + if (StorageUtil.canWriteToMediaStore()) { + performSavetoDisk(mediaItem); + return; + } + Permissions.with(this) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .ifNecessary() .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) .onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) .onAllGranted(() -> { - SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); - long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis(); - saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, null)); + performSavetoDisk(mediaItem); }) .execute(); }); } } + private void performSavetoDisk(@NonNull MediaItem mediaItem) { + SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); + long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis(); + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, null)); + } + @SuppressLint("StaticFieldLeak") private void deleteMedia() { MediaItem mediaItem = getCurrentMediaItem(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java b/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java index f9d67bac84..b4eff84f51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components; import android.annotation.TargetApi; +import android.content.ContentUris; import android.content.Context; import android.database.Cursor; import android.net.Uri; @@ -106,7 +107,7 @@ public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.Lo public void onBindItemViewHolder(RecentPhotoViewHolder viewHolder, @NonNull Cursor cursor) { viewHolder.imageView.setImageDrawable(null); - String path = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATA)); + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID)); long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN)); long dateModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_MODIFIED)); String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.MIME_TYPE)); @@ -116,7 +117,7 @@ public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.Lo int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); - final Uri uri = Uri.fromFile(new File(path)); + final Uri uri = ContentUris.withAppendedId(RecentPhotosLoader.BASE_URL, rowId); Key signature = new MediaStoreSignature(mimeType, dateModified, orientation); 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 71c506d417..836c04bec3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/AttachmentKeyboard.java @@ -1,8 +1,6 @@ package org.thoughtcrime.securesms.conversation; -import android.Manifest; import android.content.Context; -import android.content.pm.PackageManager; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; @@ -10,7 +8,6 @@ import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -19,6 +16,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.InputAwareLayout; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.StorageUtil; import java.util.Arrays; import java.util.List; @@ -84,7 +82,7 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout. } public void onMediaChanged(@NonNull List media) { - if (ContextCompat.checkSelfPermission(getContext(), Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { + if (StorageUtil.canReadFromMediaStore()) { mediaAdapter.setMedia(media); permissionButton.setVisibility(GONE); permissionText.setVisibility(GONE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 65f6396c51..0d5a24169c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -1034,6 +1034,7 @@ public class ConversationActivity extends PassphraseRequiredActivity Permissions.with(this) .request(Manifest.permission.READ_EXTERNAL_STORAGE) .onAllGranted(() -> viewModel.onAttachmentKeyboardOpen()) + .withPermanentDenialDialog(getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) .execute(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 4939f3a2f3..5ac355938e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -16,6 +16,7 @@ */ package org.thoughtcrime.securesms.conversation; +import android.Manifest; import android.annotation.SuppressLint; import android.app.Activity; import android.content.ClipData; @@ -102,6 +103,7 @@ import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.profiles.UnknownSenderView; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment; @@ -123,6 +125,7 @@ import org.thoughtcrime.securesms.util.RemoteDeleteUtil; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.SnapToTopDataObserver; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.thoughtcrime.securesms.util.StorageUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; @@ -854,26 +857,40 @@ public class ConversationFragment extends LoggingFragment { throw new AssertionError("Cannot save a view-once message."); } - SaveAttachmentTask.showWarningDialog(getActivity(), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - List attachments = Stream.of(message.getSlideDeck().getSlides()) - .filter(s -> s.getUri() != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument())) - .map(s -> new SaveAttachmentTask.Attachment(s.getUri(), s.getContentType(), message.getDateReceived(), s.getFileName().orNull())) - .toList(); - if (!Util.isEmpty(attachments)) { - SaveAttachmentTask saveTask = new SaveAttachmentTask(getActivity()); - saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, attachments.toArray(new SaveAttachmentTask.Attachment[0])); - return; - } - - Log.w(TAG, "No slide with attachable media found, failing nicely."); - Toast.makeText(getActivity(), - getResources().getQuantityString(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, 1), - Toast.LENGTH_LONG).show(); + SaveAttachmentTask.showWarningDialog(getActivity(), (dialog, which) -> { + if (StorageUtil.canWriteToMediaStore()) { + performSave(message); + return; } + + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) + .onAllGranted(() -> performSave(message)) + .execute(); }); } + private void performSave(final MediaMmsMessageRecord message) { + List attachments = Stream.of(message.getSlideDeck().getSlides()) + .filter(s -> s.getUri() != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument())) + .map(s -> new SaveAttachmentTask.Attachment(s.getUri(), s.getContentType(), message.getDateReceived(), s.getFileName().orNull())) + .toList(); + + if (!Util.isEmpty(attachments)) { + SaveAttachmentTask saveTask = new SaveAttachmentTask(getActivity()); + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, attachments.toArray(new SaveAttachmentTask.Attachment[0])); + return; + } + + Log.w(TAG, "No slide with attachable media found, failing nicely."); + Toast.makeText(getActivity(), + getResources().getQuantityString(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, 1), + Toast.LENGTH_LONG).show(); + } + private void clearHeaderIfNotTyping(ConversationAdapter adapter) { if (adapter.getHeaderView() != typingView) { adapter.setHeaderView(null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java index 3c24a0ae11..0dd5c48f1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java @@ -5,6 +5,7 @@ import android.Manifest; import android.content.Context; import android.database.Cursor; import android.net.Uri; +import android.os.Build; import android.provider.MediaStore; import androidx.loader.content.CursorLoader; @@ -15,7 +16,7 @@ public class RecentPhotosLoader extends CursorLoader { public static Uri BASE_URL = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; private static final String[] PROJECTION = new String[] { - MediaStore.Images.ImageColumns.DATA, + MediaStore.Images.ImageColumns._ID, MediaStore.Images.ImageColumns.DATE_TAKEN, MediaStore.Images.ImageColumns.DATE_MODIFIED, MediaStore.Images.ImageColumns.ORIENTATION, @@ -26,7 +27,8 @@ public class RecentPhotosLoader extends CursorLoader { MediaStore.Images.ImageColumns.HEIGHT }; - private static final String SELECTION = MediaStore.Images.Media.DATA + " NOT NULL"; + private static final String SELECTION = Build.VERSION.SDK_INT > 28 ? MediaStore.Images.Media.IS_PENDING + " != 1" + : MediaStore.Images.Media.DATA + " IS NULL"; private final Context context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java index c32cada086..1cb4460c01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java @@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.database.MediaDatabase; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.AttachmentUtil; import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.StorageUtil; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; import java.util.Collection; @@ -32,43 +33,18 @@ final class MediaActions { { Context context = fragment.requireContext(); + if (StorageUtil.canWriteToMediaStore()) { + performSaveToDisk(context, mediaRecords, postExecute); + return; + } + SaveAttachmentTask.showWarningDialog(context, (dialogInterface, which) -> Permissions.with(fragment) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .ifNecessary() .withPermanentDenialDialog(fragment.getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) .onAnyDenied(() -> Toast.makeText(context, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) - .onAllGranted(() -> - new ProgressDialogAsyncTask>(context, - R.string.MediaOverviewActivity_collecting_attachments, - R.string.please_wait) - { - @Override - protected List doInBackground(Void... params) { - List attachments = new LinkedList<>(); - - for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) { - if (mediaRecord.getAttachment().getUri() != null) { - attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getUri(), - mediaRecord.getContentType(), - mediaRecord.getDate(), - mediaRecord.getAttachment().getFileName())); - } - } - - return attachments; - } - - @Override - protected void onPostExecute(List attachments) { - super.onPostExecute(attachments); - SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size()); - saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR, - attachments.toArray(new SaveAttachmentTask.Attachment[0])); - - if (postExecute != null) postExecute.run(); - } - }.execute() - ).execute(), mediaRecords.size()); + .onAllGranted(() -> performSaveToDisk(context, mediaRecords, postExecute)) + .execute(), mediaRecords.size()); } static void handleDeleteMedia(@NonNull Context context, @@ -111,4 +87,37 @@ final class MediaActions { builder.setNegativeButton(android.R.string.cancel, null); builder.show(); } + + private static void performSaveToDisk(@NonNull Context context, @NonNull Collection mediaRecords, @Nullable Runnable postExecute) { + new ProgressDialogAsyncTask>(context, + R.string.MediaOverviewActivity_collecting_attachments, + R.string.please_wait) + { + @Override + protected List doInBackground(Void... params) { + List attachments = new LinkedList<>(); + + for (MediaDatabase.MediaRecord mediaRecord : mediaRecords) { + if (mediaRecord.getAttachment().getUri() != null) { + attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getUri(), + mediaRecord.getContentType(), + mediaRecord.getDate(), + mediaRecord.getAttachment().getFileName())); + } + } + + return attachments; + } + + @Override + protected void onPostExecute(List attachments) { + super.onPostExecute(attachments); + SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size()); + saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR, + attachments.toArray(new SaveAttachmentTask.Attachment[0])); + + if (postExecute != null) postExecute.run(); + } + }.execute(); + } } 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 5c05d5e406..867fb0434c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -1,11 +1,12 @@ package org.thoughtcrime.securesms.mediasend; -import android.Manifest; import android.annotation.TargetApi; +import android.content.ContentUris; import android.content.Context; import android.database.Cursor; import android.net.Uri; -import android.os.Environment; +import android.os.Build; +import android.provider.MediaStore; import android.provider.MediaStore.Images; import android.provider.MediaStore.Video; import android.provider.OpenableColumns; @@ -20,13 +21,12 @@ import com.annimon.stream.Stream; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.PartAuthority; -import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.StorageUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.whispersystems.libsignal.util.guava.Optional; -import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -84,7 +84,7 @@ public class MediaRepository { @WorkerThread private @NonNull List getFolders(@NonNull Context context) { - if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) { + if (!StorageUtil.canReadFromMediaStore()) { return Collections.emptyList(); } @@ -132,20 +132,19 @@ public class MediaRepository { @WorkerThread private @NonNull FolderResult getFolders(@NonNull Context context, @NonNull Uri contentUri) { - String cameraPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() + File.separator + "Camera"; String cameraBucketId = null; Uri globalThumbnail = null; long thumbnailTimestamp = 0; Map folders = new HashMap<>(); - String[] projection = new String[] { Images.Media.DATA, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED }; - String selection = Images.Media.DATA + " NOT NULL"; + String[] projection = new String[] { Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED }; + String selection = isNotPending(); String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + Images.Media.DATE_MODIFIED + " DESC"; try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, null, sortBy)) { while (cursor != null && cursor.moveToNext()) { - String path = cursor.getString(cursor.getColumnIndexOrThrow(projection[0])); - Uri thumbnail = Uri.fromFile(new File(path)); + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])); + Uri thumbnail = ContentUris.withAppendedId(contentUri, rowId); String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])); String title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])); long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3])); @@ -154,7 +153,7 @@ public class MediaRepository { folder.incrementCount(); folders.put(bucketId, folder); - if (cameraBucketId == null && path.startsWith(cameraPath)) { + if (cameraBucketId == null && "Camera".equals(title)) { cameraBucketId = bucketId; } @@ -170,7 +169,7 @@ public class MediaRepository { @WorkerThread private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId) { - if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) { + if (!StorageUtil.canReadFromMediaStore()) { return Collections.emptyList(); } @@ -188,27 +187,27 @@ public class MediaRepository { @WorkerThread private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean isImage) { List media = new LinkedList<>(); - String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL"; + String selection = Images.Media.BUCKET_ID + " = ? AND " + isNotPending(); String[] selectionArgs = new String[] { bucketId }; String sortBy = Images.Media.DATE_MODIFIED + " DESC"; String[] projection; if (isImage) { - projection = new String[]{Images.Media.DATA, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE}; + projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE}; } else { - projection = new String[]{Images.Media.DATA, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE, Video.Media.DURATION}; + projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE, Video.Media.DURATION}; } if (Media.ALL_MEDIA_BUCKET_ID.equals(bucketId)) { - selection = Images.Media.DATA + " NOT NULL"; + selection = isNotPending(); selectionArgs = null; } try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, selectionArgs, sortBy)) { while (cursor != null && cursor.moveToNext()) { - String path = cursor.getString(cursor.getColumnIndexOrThrow(projection[0])); - Uri uri = Uri.fromFile(new File(path)); + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])); + Uri uri = ContentUris.withAppendedId(contentUri, rowId); String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE)); long date = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED)); int orientation = isImage ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0; @@ -224,12 +223,12 @@ public class MediaRepository { return media; } + private @NonNull String isNotPending() { + return Build.VERSION.SDK_INT <= 28 ? Images.Media.DATA + " NOT NULL" : MediaStore.MediaColumns.IS_PENDING + " != 1"; + } + @WorkerThread private List getPopulatedMedia(@NonNull Context context, @NonNull List media) { - if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) { - return media; - } - return Stream.of(media).map(m -> { try { if (isPopulated(m)) { @@ -265,10 +264,6 @@ public class MediaRepository { @WorkerThread private Optional getMostRecentItem(@NonNull Context context) { - if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) { - return Optional.absent(); - } - List media = getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, Images.Media.EXTERNAL_CONTENT_URI, true); return media.size() > 0 ? Optional.of(media.get(0)) : Optional.absent(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index 5e7eb8e101..10989189b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -367,36 +367,21 @@ public class AttachmentManager { } public static void selectDocument(Activity activity, int requestCode) { - Permissions.with(activity) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .ifNecessary() - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) - .onAllGranted(() -> selectMediaType(activity, "*/*", null, requestCode)) - .execute(); + selectMediaType(activity, "*/*", null, requestCode); } public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull CharSequence body, @NonNull TransportOption transport) { Permissions.with(activity) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .request(Manifest.permission.READ_EXTERNAL_STORAGE) .ifNecessary() .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) - .onAllGranted(() -> selectMediaType(activity, "image/*", new String[] {"image/*", "video/*"}, requestCode)) .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body, transport), requestCode)) .execute(); } - public static void selectAudio(Activity activity, int requestCode) { - Permissions.with(activity) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .ifNecessary() - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) - .onAllGranted(() -> selectMediaType(activity, "audio/*", null, requestCode)) - .execute(); - } - public static void selectContactInfo(Activity activity, int requestCode) { Permissions.with(activity) - .request(Manifest.permission.WRITE_CONTACTS) + .request(Manifest.permission.READ_CONTACTS) .ifNecessary() .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_contacts_permission_in_order_to_attach_contact_information)) .onAllGranted(() -> { @@ -430,29 +415,6 @@ public class AttachmentManager { return captureUri; } - public void capturePhoto(Activity activity, int requestCode) { - Permissions.with(activity) - .request(Manifest.permission.CAMERA) - .ifNecessary() - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied)) - .onAllGranted(() -> { - try { - Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { - if (captureUri == null) { - captureUri = DeprecatedPersistentBlobProvider.getInstance(context).createForExternal(context, MediaUtil.IMAGE_JPEG); - } - Log.d(TAG, "captureUri path is " + captureUri.getPath()); - captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, captureUri); - activity.startActivityForResult(captureIntent, requestCode); - } - } catch (IOException ioe) { - Log.w(TAG, ioe); - } - }) - .execute(); - } - private static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { final Intent intent = new Intent(); intent.setType(type); diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 259955a1d1..e6bcc3d0da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -40,6 +40,7 @@ import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.ParcelUtil; import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.StorageUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; @@ -412,29 +413,17 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu @Override public void onSave() { SaveAttachmentTask.showWarningDialog(requireContext(), (dialogInterface, i) -> { + if (StorageUtil.canWriteToMediaStore()) { + performSaveToDisk(); + return; + } + Permissions.with(this) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .ifNecessary() .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) - .onAllGranted(() -> { - SimpleTask.run(() -> { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - Bitmap image = imageEditorView.getModel().render(requireContext()); - - image.compress(Bitmap.CompressFormat.JPEG, 80, outputStream); - - return BlobProvider.getInstance() - .forData(outputStream.toByteArray()) - .withMimeType(MediaUtil.IMAGE_JPEG) - .createForSingleUseInMemory(); - - }, uri -> { - SaveAttachmentTask saveTask = new SaveAttachmentTask(requireContext()); - SaveAttachmentTask.Attachment attachment = new SaveAttachmentTask.Attachment(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), null); - saveTask.executeOnExecutor(SignalExecutors.BOUNDED, attachment); - }); - }) + .onAllGranted(this::performSaveToDisk) .execute(); }); } @@ -469,6 +458,25 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu controller.onDoneEditing(); } + private void performSaveToDisk() { + SimpleTask.run(() -> { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + Bitmap image = imageEditorView.getModel().render(requireContext()); + + image.compress(Bitmap.CompressFormat.JPEG, 80, outputStream); + + return BlobProvider.getInstance() + .forData(outputStream.toByteArray()) + .withMimeType(MediaUtil.IMAGE_JPEG) + .createForSingleUseInMemory(); + + }, uri -> { + SaveAttachmentTask saveTask = new SaveAttachmentTask(requireContext()); + SaveAttachmentTask.Attachment attachment = new SaveAttachmentTask.Attachment(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), null); + saveTask.executeOnExecutor(SignalExecutors.BOUNDED, attachment); + }); + } + private void refreshUniqueColors() { imageEditorHud.setColorPalette(imageEditorView.getModel().getUniqueColorsIgnoringAlpha()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java index 5eed8f7eb9..5863ad6b33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java @@ -244,12 +244,12 @@ public class BackupUtil { private final long timestamp; private final long size; - private final Uri uri; + private final Uri uri; BackupInfo(long timestamp, long size, Uri uri) { this.timestamp = timestamp; - this.size = size; - this.uri = uri; + this.size = size; + this.uri = uri; } public long getTimestamp() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java index e44280e700..101b4dd39d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.util; +import android.content.ContentValues; import android.content.Context; import android.content.DialogInterface.OnClickListener; import android.media.MediaScannerConnection; @@ -7,6 +8,10 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.documentfile.provider.DocumentFile; + +import android.os.Build; +import android.provider.MediaStore; import android.text.TextUtils; import android.webkit.MimeTypeMap; import android.widget.Toast; @@ -59,7 +64,7 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask(WRITE_ACCESS_FAILURE, null); } @@ -76,14 +81,13 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask 1) return new Pair<>(SUCCESS, null); else return new Pair<>(SUCCESS, directory); - } catch (NoExternalStorageException|IOException ioe) { + } catch (IOException ioe) { Log.w(TAG, ioe); return new Pair<>(FAILURE, null); } } - private @Nullable String saveAttachment(Context context, Attachment attachment) - throws NoExternalStorageException, IOException + private @Nullable String saveAttachment(Context context, Attachment attachment) throws IOException { String contentType = MediaUtil.getCorrectedMimeType(attachment.contentType); String fileName = attachment.fileName; @@ -91,40 +95,46 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask 28) { + ContentValues updatePendingValues = new ContentValues(); + updatePendingValues.put(MediaStore.MediaColumns.IS_PENDING, 0); + getContext().getContentResolver().update(mediaUri, updatePendingValues, null, null); + } - MediaScannerConnection.scanFile(context, new String[]{mediaFile.getAbsolutePath()}, - new String[]{contentType}, null); - - return outputDirectory.getName(); + return outputUri.getLastPathSegment(); } - private File createOutputDirectoryFromContentType(@NonNull String contentType) - throws NoExternalStorageException - { - File outputDirectory; - + private @NonNull Uri getMediaStoreContentUriForType(@NonNull String contentType) { if (contentType.startsWith("video/")) { - outputDirectory = StorageUtil.getVideoDir(); + return StorageUtil.getVideoUri(); } else if (contentType.startsWith("audio/")) { - outputDirectory = StorageUtil.getAudioDir(); + return StorageUtil.getAudioUri(); } else if (contentType.startsWith("image/")) { - outputDirectory = StorageUtil.getImageDir(); + return StorageUtil.getImageUri(); } else { - outputDirectory = StorageUtil.getDownloadDir(); + return StorageUtil.getDownloadUri(); } - - if (!outputDirectory.mkdirs()) Log.w(TAG, "mkdirs() returned false, attempting to continue"); - return outputDirectory; } private String generateOutputFileName(@NonNull String contentType, long timestamp) { @@ -142,25 +152,34 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask 28) { + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1); } - if (outputFile.isHidden()) { - throw new IOException("Specified name would not be visible"); + if (Build.VERSION.SDK_INT <= 28 && outputUri.equals(StorageUtil.getLegacyDownloadUri())) { + String[] fileParts = getFileNameParts(fileName); + String base = fileParts[0]; + String extension = fileParts[1]; + File outputDirectory = new File(outputUri.getPath()); + File outputFile = new File(outputDirectory, base + "." + extension); + + int i = 0; + while (outputFile.exists()) { + outputFile = new File(outputDirectory, base + "-" + (++i) + "." + extension); + } + + if (outputFile.isHidden()) { + throw new IOException("Specified name would not be visible"); + } + + return Uri.fromFile(outputFile); } - return outputFile; + return getContext().getContentResolver().insert(outputUri, contentValues); } private String[] getFileNameParts(String fileName) { 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 77c7c2859b..a84457401f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java @@ -1,12 +1,19 @@ package org.thoughtcrime.securesms.util; +import android.Manifest; import android.content.Context; +import android.net.Uri; +import android.os.Build; import android.os.Environment; +import android.provider.MediaStore; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.permissions.Permissions; import java.io.File; @@ -68,20 +75,37 @@ public class StorageUtil { return getSignalStorageDir(); } - public static File getVideoDir() throws NoExternalStorageException { - return new File(getSignalStorageDir(), Environment.DIRECTORY_MOVIES); + public static boolean canWriteToMediaStore() { + return Build.VERSION.SDK_INT > 28 || + Permissions.hasAll(ApplicationDependencies.getApplication(), Manifest.permission.WRITE_EXTERNAL_STORAGE); } - public static File getAudioDir() throws NoExternalStorageException { - return new File(getSignalStorageDir(), Environment.DIRECTORY_MUSIC); + public static boolean canReadFromMediaStore() { + return Permissions.hasAll(ApplicationDependencies.getApplication(), Manifest.permission.READ_EXTERNAL_STORAGE); } - public static File getImageDir() throws NoExternalStorageException { - return new File(getSignalStorageDir(), Environment.DIRECTORY_PICTURES); + public static @NonNull Uri getVideoUri() { + return MediaStore.Video.Media.EXTERNAL_CONTENT_URI; } - public static File getDownloadDir() throws NoExternalStorageException { - return new File(getSignalStorageDir(), Environment.DIRECTORY_DOWNLOADS); + public static @NonNull Uri getAudioUri() { + return MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + + public static @NonNull Uri getImageUri() { + return MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } + + public static @NonNull Uri getDownloadUri() { + if (Build.VERSION.SDK_INT > 28) { + return MediaStore.Downloads.EXTERNAL_CONTENT_URI; + } else { + return getLegacyDownloadUri(); + } + } + + public static @NonNull Uri getLegacyDownloadUri() { + return Uri.fromFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)); } public static @Nullable String getCleanFileName(@Nullable String fileName) {