deltachat/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java
2024-06-12 02:17:10 +02:00

376 lines
13 KiB
Java

package org.thoughtcrime.securesms.util;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.DialogInterface.OnClickListener;
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.util.Log;
import android.webkit.MimeTypeMap;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.loader.content.CursorLoader;
import org.thoughtcrime.securesms.R;
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.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTask.Attachment, Void, Pair<Integer, Uri>> {
private static final String TAG = SaveAttachmentTask.class.getSimpleName();
static final int SUCCESS = 0;
private static final int FAILURE = 1;
private static final int WRITE_ACCESS_FAILURE = 2;
private final WeakReference<Context> contextReference;
public SaveAttachmentTask(Context context) {
super(context,
context.getResources().getString(R.string.one_moment),
context.getResources().getString(R.string.one_moment));
this.contextReference = new WeakReference<>(context);
}
@Override
protected Pair<Integer, Uri> 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();
Uri uri = null;
if (!StorageUtil.canWriteToMediaStore(context)) {
return new Pair<>(WRITE_ACCESS_FAILURE, null);
}
if (context == null) {
return new Pair<>(FAILURE, null);
}
for (Attachment attachment : attachments) {
if (attachment != null) {
uri = saveAttachment(context, attachment);
if (uri == null) return new Pair<>(FAILURE, null);
}
}
if (attachments.length > 1) return new Pair<>(SUCCESS, null);
else return new Pair<>(SUCCESS, uri);
} catch (IOException ioe) {
Log.w(TAG, ioe);
return new Pair<>(FAILURE, null);
}
}
private @Nullable Uri 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);
Uri outputUri = getMediaStoreContentUriForType(contentType);
Uri mediaUri = createOutputUri(outputUri, contentType, fileName);
ContentValues updateValues = new ContentValues();
if (mediaUri == null) {
Log.w(TAG, "Failed to create mediaUri for " + contentType);
return null;
}
try (InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.uri)) {
if (inputStream == null) {
return null;
}
if (Util.equals(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 mediaUri;
}
private @Nullable String getRealPathFromURI(Uri contentUri) {
String[] proj = {MediaStore.MediaColumns.DATA};
CursorLoader loader = new CursorLoader(getContext(), contentUri, proj, null, null, null);
Cursor cursor = loader.loadInBackground();
int column_index = 0;
String result = null;
if (cursor != null) {
column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
cursor.moveToFirst();
result = cursor.getString(column_index);
cursor.close();
}
return result;
}
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_DOWNLOADS);
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.
*
* 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) {
String extension = MediaUtil.getExtensionFromMimeType(contentType);
SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd-HHmmss");
String base = "deltachat-" + dateFormatter.format(timestamp);
if (extension == null) extension = "attach";
return base + "." + extension;
}
private String sanitizeOutputFileName(@NonNull String fileName) {
return new File(fileName).getName();
}
private @Nullable Uri createOutputUri(@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;
}
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) {
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1);
} else if (Util.equals(outputUri.getScheme(), ContentResolver.SCHEME_FILE)) {
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);
} 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 (pathTaken(outputUri, dataPath)) {
Log.d(TAG, "The content exists. Rename and check again.");
outputFileName = base + "-" + (++i) + "." + extension;
dataPath = String.format("%s/%s", dir, outputFileName);
}
contentValues.put(MediaStore.MediaColumns.DATA, dataPath);
}
return getContext().getContentResolver().insert(outputUri, contentValues);
}
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 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, Uri> result) {
super.onPostExecute(result);
final Context context = contextReference.get();
if (context == null) return;
switch (result.first()) {
case FAILURE:
Toast.makeText(context,
context.getResources().getString(R.string.error),
Toast.LENGTH_LONG).show();
break;
case SUCCESS:
Uri uri = result.second();
String dir;
if (uri == null) {
dir = null;
} else {
String path = getRealPathFromURI(uri);
if (path != null) uri = Uri.parse(path);
List<String> segments = uri.getPathSegments();
if (segments.size() >= 2) {
dir = segments.get(segments.size() - 2);
} else {
dir = uri.getPath();
}
}
Toast.makeText(context,
dir==null? context.getString(R.string.done) : context.getString(R.string.file_saved_to, dir),
Toast.LENGTH_LONG).show();
break;
case WRITE_ACCESS_FAILURE:
Toast.makeText(context, R.string.error,
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, @NonNull 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;
}
}
public static void showWarningDialog(Context context, OnClickListener onAcceptListener) {
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setCancelable(true);
builder.setMessage(R.string.ask_export_attachment);
builder.setPositiveButton(R.string.yes, onAcceptListener);
builder.setNegativeButton(R.string.no, null);
builder.show();
}
}