Use AttachmentSaver to save media overview files to device storage.

This commit is contained in:
Jeffrey Starke
2025-03-25 15:36:57 -04:00
committed by Cody Henthorne
parent 18328079c8
commit 0ef627b864
6 changed files with 36 additions and 532 deletions

View File

@@ -21,6 +21,7 @@ import org.signal.core.util.orNull
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.ProgressCardDialogFragmentArgs
import org.thoughtcrime.securesms.database.MediaTable
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.permissions.Permissions
@@ -56,6 +57,20 @@ class AttachmentSaver(private val host: Host) {
fun saveAttachmentsRx(attachments: Set<SaveAttachment>): Completable = rxCompletable { saveAttachments(attachments) }
suspend fun saveAttachments(records: Collection<MediaTable.MediaRecord>) {
val attachments = records.mapNotNull { record ->
val uri = record.attachment?.uri
val contentType = record.contentType
if (uri != null && contentType != null) {
SaveAttachment(uri, contentType, record.date, record.attachment.fileName)
} else {
null
}
}.toSet()
saveAttachments(attachments)
}
fun saveAttachmentsRx(records: Collection<MediaTable.MediaRecord>): Completable = rxCompletable { saveAttachments(records) }
suspend fun saveAttachments(attachments: Set<SaveAttachment>) {
if (checkIsSaveWarningAccepted(attachmentCount = attachments.size) == SaveToStorageWarningResult.ACCEPTED) {
if (checkCanWriteToMediaStore() == RequestPermissionResult.GRANTED) {

View File

@@ -1,56 +1,37 @@
package org.thoughtcrime.securesms.mediaoverview;
import android.Manifest;
import android.content.Context;
import android.content.res.Resources;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.AttachmentSaver;
import org.thoughtcrime.securesms.database.MediaTable;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSyncJob;
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.Util;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import io.reactivex.rxjava3.core.Completable;
final class MediaActions {
private MediaActions() {
}
static void handleSaveMedia(@NonNull Fragment fragment,
@NonNull Collection<MediaTable.MediaRecord> mediaRecords,
@Nullable Runnable postExecute)
static Completable handleSaveMedia(@NonNull Fragment fragment,
@NonNull Collection<MediaTable.MediaRecord> mediaRecords)
{
Context context = fragment.requireContext();
if (StorageUtil.canWriteToMediaStore()) {
performSaveToDisk(context, mediaRecords, postExecute);
return;
}
SaveAttachmentTask.showWarningDialogIfNecessary(context, mediaRecords.size(), () -> Permissions.with(fragment)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.withPermanentDenialDialog(fragment.getString(R.string.AttachmentSaver__signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
.onAnyDenied(() -> Toast.makeText(context, R.string.AttachmentSaver__unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
.onAllGranted(() -> performSaveToDisk(context, mediaRecords, postExecute))
.execute());
return new AttachmentSaver(fragment).saveAttachmentsRx(mediaRecords);
}
static void handleDeleteMedia(@NonNull Context context,
@@ -96,37 +77,4 @@ final class MediaActions {
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
private static void performSaveToDisk(@NonNull Context context, @NonNull Collection<MediaTable.MediaRecord> mediaRecords, @Nullable Runnable postExecute) {
new ProgressDialogAsyncTask<Void, Void, List<SaveAttachmentTask.Attachment>>(context,
R.string.MediaOverviewActivity_collecting_attachments,
R.string.please_wait)
{
@Override
protected List<SaveAttachmentTask.Attachment> doInBackground(Void... params) {
List<SaveAttachmentTask.Attachment> attachments = new LinkedList<>();
for (MediaTable.MediaRecord mediaRecord : mediaRecords) {
if (mediaRecord.getAttachment().getUri() != null) {
attachments.add(new SaveAttachmentTask.Attachment(mediaRecord.getAttachment().getUri(),
mediaRecord.getContentType(),
mediaRecord.getDate(),
mediaRecord.getAttachment().fileName));
}
}
return attachments;
}
@Override
protected void onPostExecute(List<SaveAttachmentTask.Attachment> 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();
}
}

View File

@@ -46,14 +46,16 @@ import org.thoughtcrime.securesms.database.loaders.MediaLoader;
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
import org.thoughtcrime.securesms.mediapreview.MediaPreviewV2Activity;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.BottomOffsetDecoration;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Arrays;
import java.util.Objects;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
public final class MediaOverviewPageFragment extends Fragment
implements MediaGalleryAllAdapter.ItemClickListener,
MediaGalleryAllAdapter.AudioItemListener,
@@ -359,9 +361,12 @@ public final class MediaOverviewPageFragment extends Fragment
bottomActionBar.setItems(Arrays.asList(
new ActionItem(R.drawable.symbol_save_android_24, getResources().getQuantityString(R.plurals.MediaOverviewActivity_save_plural, selectionCount), () -> {
MediaActions.handleSaveMedia(MediaOverviewPageFragment.this,
getListAdapter().getSelectedMedia(),
this::exitMultiSelect);
lifecycleDisposable.add(
MediaActions
.handleSaveMedia(MediaOverviewPageFragment.this, getListAdapter().getSelectedMedia())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::exitMultiSelect)
);
}),
new ActionItem(R.drawable.symbol_check_circle_24, getString(R.string.MediaOverviewActivity_select_all), this::handleSelectAllMedia),
new ActionItem(R.drawable.symbol_trash_24, getResources().getQuantityString(R.plurals.MediaOverviewActivity_delete_plural, selectionCount), this::handleDeleteSelectedMedia)
@@ -400,6 +405,12 @@ public final class MediaOverviewPageFragment extends Fragment
voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(observer);
}
@SuppressWarnings("deprecation")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
private class ActionModeCallback implements ActionMode.Callback {
@Override

View File

@@ -1,465 +0,0 @@
package org.thoughtcrime.securesms.util;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore;
import android.webkit.MimeTypeMap;
import android.widget.CheckBox;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.MapUtil;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.util.Pair;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.ref.WeakReference;
import java.text.SimpleDateFormat;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* @deprecated Use {@link SaveAttachmentUtil} instead.
*/
@Deprecated
public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTask.Attachment, Void, Pair<Integer, String>> {
private static final String TAG = Log.tag(SaveAttachmentTask.class);
static final int SUCCESS = 0;
private static final int FAILURE = 1;
private static final int WRITE_ACCESS_FAILURE = 2;
private final WeakReference<Context> contextReference;
private final int attachmentCount;
private final Map<Uri, Set<String>> batchOperationNameCache = new HashMap<>();
public SaveAttachmentTask(Context context) {
this(context, 1);
}
public SaveAttachmentTask(Context context, int count) {
super(context,
context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count),
context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count));
this.contextReference = new WeakReference<>(context);
this.attachmentCount = count;
}
@Override
protected Pair<Integer, String> doInBackground(SaveAttachmentTask.Attachment... attachments) {
if (attachments == null || attachments.length == 0) {
throw new AssertionError("must pass in at least one attachment");
}
try {
Context context = contextReference.get();
String directory = null;
if (!StorageUtil.canWriteToMediaStore()) {
return new Pair<>(WRITE_ACCESS_FAILURE, null);
}
if (context == null) {
return new Pair<>(FAILURE, null);
}
for (Attachment attachment : attachments) {
if (attachment != null) {
directory = saveAttachment(context, attachment);
if (directory == null) {
return new Pair<>(FAILURE, null);
}
}
}
if (attachments.length > 1) {
return new Pair<>(SUCCESS, null);
} else {
return new Pair<>(SUCCESS, directory);
}
} catch (IOException ioe) {
Log.w(TAG, ioe);
return new Pair<>(FAILURE, null);
}
}
private @Nullable String saveAttachment(Context context, Attachment attachment) throws IOException
{
String contentType = Objects.requireNonNull(MediaUtil.getCorrectedMimeType(attachment.contentType));
String fileName = attachment.fileName;
if (fileName == null) {
fileName = generateOutputFileName(contentType, attachment.date);
}
fileName = sanitizeOutputFileName(fileName);
CreateMediaUriResult result = createMediaUri(getMediaStoreContentUriForType(contentType), contentType, fileName);
ContentValues updateValues = new ContentValues();
final Uri mediaUri = result.mediaUri;
if (mediaUri == null) {
return null;
}
try (InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.uri)) {
if (inputStream == null) {
return null;
}
if (Objects.equals(result.outputUri.getScheme(), ContentResolver.SCHEME_FILE)) {
try (OutputStream outputStream = new FileOutputStream(mediaUri.getPath())) {
StreamUtil.copy(inputStream, outputStream);
MediaScannerConnection.scanFile(context, new String[] { mediaUri.getPath() }, new String[] { contentType }, null);
}
} else {
try (OutputStream outputStream = context.getContentResolver().openOutputStream(mediaUri, "w")) {
long total = StreamUtil.copy(inputStream, outputStream);
if (total > 0) {
updateValues.put(MediaStore.MediaColumns.SIZE, total);
}
}
}
}
if (Build.VERSION.SDK_INT > 28) {
updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0);
}
if (updateValues.size() > 0) {
getContext().getContentResolver().update(mediaUri, updateValues, null, null);
}
return result.outputUri.getLastPathSegment();
}
private @NonNull Uri getMediaStoreContentUriForType(@NonNull String contentType) {
if (contentType.startsWith("video/")) {
return StorageUtil.getVideoUri();
} else if (contentType.startsWith("audio/")) {
return StorageUtil.getAudioUri();
} else if (contentType.startsWith("image/")) {
return StorageUtil.getImageUri();
} else {
return StorageUtil.getDownloadUri();
}
}
private @Nullable File ensureExternalPath(@Nullable File path) {
if (path != null && path.exists()) {
return path;
}
if (path == null) {
File documents = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
if (documents.exists() || documents.mkdirs()) {
return documents;
} else {
return null;
}
}
if (path.mkdirs()) {
return path;
} else {
return null;
}
}
/**
* Returns a path to a shared media (or documents) directory for the type of the file.
* <p>
* Note that this method attempts to create a directory if the path returned from
* Environment object does not exist yet. The attempt may fail in which case it attempts
* to return the default "Document" path. It finally returns null if it also fails.
* Otherwise it returns the absolute path to the directory.
*
* @param contentType a MIME type of a file
* @return an absolute path to a directory or null
*/
private @Nullable String getExternalPathForType(String contentType) {
File storage = null;
if (contentType.startsWith("video/")) {
storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
} else if (contentType.startsWith("audio/")) {
storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
} else if (contentType.startsWith("image/")) {
storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
}
storage = ensureExternalPath(storage);
if (storage == null) {
return null;
}
return storage.getAbsolutePath();
}
private String generateOutputFileName(@NonNull String contentType, long timestamp) {
MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
String extension = mimeTypeMap.getExtensionFromMimeType(contentType);
SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS");
String base = "signal-" + dateFormatter.format(timestamp);
if (extension == null) extension = "attach";
return base + "." + extension;
}
private String sanitizeOutputFileName(@NonNull String fileName) {
return new File(fileName).getName();
}
private @NonNull CreateMediaUriResult createMediaUri(@NonNull Uri outputUri, @NonNull String contentType, @NonNull String fileName)
throws IOException
{
String[] fileParts = getFileNameParts(fileName);
String base = fileParts[0];
String extension = fileParts[1];
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (MediaUtil.isOctetStream(mimeType) && MediaUtil.isImageVideoOrAudioType(contentType)) {
Log.d(TAG, "MimeTypeMap returned octet stream for media, changing to provided content type [" + contentType + "] instead.");
mimeType = contentType;
}
if (MediaUtil.isOctetStream(mimeType)) {
if (outputUri.equals(StorageUtil.getAudioUri())) {
mimeType = "audio/*";
} else if (outputUri.equals(StorageUtil.getVideoUri())) {
mimeType = "video/*";
} else if (outputUri.equals(StorageUtil.getImageUri())) {
mimeType = "image/*";
}
}
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()));
contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()));
if (Build.VERSION.SDK_INT > 28) {
int i = 0;
String displayName = fileName;
while (pathInCache(outputUri, displayName) || displayNameTaken(outputUri, displayName)) {
displayName = base + "-" + (++i) + "." + extension;
}
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1);
putInCache(outputUri, displayName);
} else if (Objects.equals(outputUri.getScheme(), ContentResolver.SCHEME_FILE)) {
File outputDirectory = new File(outputUri.getPath());
File outputFile = new File(outputDirectory, base + "." + extension);
int i = 0;
while (pathInCache(outputUri, outputFile.getPath()) || outputFile.exists()) {
outputFile = new File(outputDirectory, base + "-" + (++i) + "." + extension);
}
if (outputFile.isHidden()) {
throw new IOException("Specified name would not be visible");
}
putInCache(outputUri, outputFile.getPath());
return new CreateMediaUriResult(outputUri, Uri.fromFile(outputFile));
} else {
String dir = getExternalPathForType(contentType);
if (dir == null) {
throw new IOException(String.format(Locale.US, "Path for type: %s was not available", contentType));
}
String outputFileName = fileName;
String dataPath = String.format("%s/%s", dir, outputFileName);
int i = 0;
while (pathInCache(outputUri, dataPath) || pathTaken(outputUri, dataPath)) {
Log.d(TAG, "The content exists. Rename and check again.");
outputFileName = base + "-" + (++i) + "." + extension;
dataPath = String.format("%s/%s", dir, outputFileName);
}
putInCache(outputUri, outputFileName);
contentValues.put(MediaStore.MediaColumns.DATA, dataPath);
}
try {
return new CreateMediaUriResult(outputUri, getContext().getContentResolver().insert(outputUri, contentValues));
} catch (RuntimeException e) {
if (e instanceof IllegalArgumentException || e.getCause() instanceof IllegalArgumentException) {
Log.w(TAG, "Unable to create uri in " + outputUri + " with mimeType [" + mimeType + "]");
return new CreateMediaUriResult(StorageUtil.getDownloadUri(), getContext().getContentResolver().insert(StorageUtil.getDownloadUri(), contentValues));
} else {
throw e;
}
}
}
private void putInCache(@NonNull Uri outputUri, @NonNull String dataPath) {
Set<String> pathSet = MapUtil.getOrDefault(batchOperationNameCache, outputUri, new HashSet<>());
if (!pathSet.add(dataPath)) {
throw new IllegalStateException("Path already used in data set.");
}
batchOperationNameCache.put(outputUri, pathSet);
}
private boolean pathInCache(@NonNull Uri outputUri, @NonNull String dataPath) {
Set<String> pathSet = batchOperationNameCache.get(outputUri);
if (pathSet == null) {
return false;
}
return pathSet.contains(dataPath);
}
private boolean pathTaken(@NonNull Uri outputUri, @NonNull String dataPath) throws IOException {
try (Cursor cursor = getContext().getContentResolver().query(outputUri,
new String[] { MediaStore.MediaColumns.DATA },
MediaStore.MediaColumns.DATA + " = ?",
new String[] { dataPath },
null))
{
if (cursor == null) {
throw new IOException("Something is wrong with the filename to save");
}
return cursor.moveToFirst();
}
}
private boolean displayNameTaken(@NonNull Uri outputUri, @NonNull String displayName) throws IOException {
try (Cursor cursor = getContext().getContentResolver().query(outputUri,
new String[] { MediaStore.MediaColumns.DISPLAY_NAME },
MediaStore.MediaColumns.DISPLAY_NAME + " = ?",
new String[] { displayName },
null))
{
if (cursor == null) {
throw new IOException("Something is wrong with the displayName to save");
}
return cursor.moveToFirst();
}
}
private String[] getFileNameParts(String fileName) {
String[] result = new String[2];
String[] tokens = fileName.split("\\.(?=[^\\.]+$)");
result[0] = tokens[0];
if (tokens.length > 1) {
result[1] = tokens[1];
} else {
result[1] = "";
}
return result;
}
@Override
protected void onPostExecute(final Pair<Integer, String> result) {
super.onPostExecute(result);
final Context context = contextReference.get();
if (context == null) return;
switch (result.first()) {
case FAILURE:
Toast.makeText(context,
context.getResources().getQuantityText(R.plurals.SaveAttachment_error_while_saving_attachments_to_sd_card, attachmentCount),
Toast.LENGTH_LONG).show();
break;
case SUCCESS:
Toast.makeText(context,
context.getResources().getQuantityText(R.plurals.SaveAttachment_saved_success, attachmentCount),
Toast.LENGTH_LONG).show();
break;
case WRITE_ACCESS_FAILURE:
Toast.makeText(context, R.string.SaveAttachment_unable_to_write_to_sd_card_exclamation, Toast.LENGTH_LONG).show();
break;
}
}
public static class Attachment {
public Uri uri;
public String fileName;
public String contentType;
public long date;
public Attachment(
@NonNull Uri uri,
@Nullable String contentType,
long date,
@Nullable String fileName
) {
if (uri == null || contentType == null || date < 0) {
throw new AssertionError("uri, content type, and date must all be specified");
}
this.uri = uri;
this.fileName = fileName;
this.contentType = contentType;
this.date = date;
}
}
private static class CreateMediaUriResult {
final Uri outputUri;
final Uri mediaUri;
private CreateMediaUriResult(Uri outputUri, Uri mediaUri) {
this.outputUri = outputUri;
this.mediaUri = mediaUri;
}
}
public static void showWarningDialogIfNecessary(Context context, int count, Runnable onSave) {
if (SignalStore.uiHints().hasDismissedSaveStorageWarning()) {
onSave.run();
} else {
new MaterialAlertDialogBuilder(context)
.setView(R.layout.dialog_save_attachment)
.setTitle(R.string.AttachmentSaver__save_to_phone)
.setCancelable(true)
.setMessage(context.getResources().getQuantityString(R.plurals.AttachmentSaver__this_media_will_be_saved, count, count))
.setPositiveButton(R.string.save, ((dialog, i) -> {
CheckBox checkbox = ((AlertDialog) dialog).findViewById(R.id.checkbox);
if (checkbox.isChecked()) {
SignalStore.uiHints().markDismissedSaveStorageWarning();
}
onSave.run();
}))
.setNegativeButton(android.R.string.cancel, null)
.show();
}
}
}

View File

@@ -40,10 +40,6 @@ import java.util.concurrent.TimeUnit
*/
private typealias BatchOperationNameCache = HashMap<Uri, HashSet<String>>
/**
* This is a rewrite of [SaveAttachmentTask] that does not handle displaying
* a progress dialog and is not backed by an async task.
*/
object SaveAttachmentUtil {
private val TAG = Log.tag(SaveAttachmentUtil::class.java)