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> { 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 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 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.ENGLISH, "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 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 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(); } }