Use Storage Access Framework to save attachments

This commit is contained in:
cketti 2018-12-14 01:44:13 +01:00
parent ffc69c9f34
commit c2e80122f7
7 changed files with 48 additions and 381 deletions

View file

@ -1,62 +0,0 @@
package com.fsck.k9.cache;
import java.io.File;
import java.io.IOException;
import android.content.Context;
import timber.log.Timber;
import com.fsck.k9.helper.FileHelper;
public class TemporaryAttachmentStore {
private static final String TEMPORARY_ATTACHMENT_DIRECTORY = "attachments";
private static final long MAX_FILE_AGE = 12 * 60 * 60 * 1000; // 12h
public static File getFile(Context context, String attachmentName) {
File directory = getTemporaryAttachmentDirectory(context);
String filename = FileHelper.sanitizeFilename(attachmentName);
return new File(directory, filename);
}
public static File getFileForWriting(Context context, String attachmentName) throws IOException {
File directory = createOrCleanAttachmentDirectory(context);
String filename = FileHelper.sanitizeFilename(attachmentName);
return new File(directory, filename);
}
private static File createOrCleanAttachmentDirectory(Context context) throws IOException {
File directory = getTemporaryAttachmentDirectory(context);
if (directory.exists()) {
cleanOldFiles(directory);
} else {
if (!directory.mkdir()) {
throw new IOException("Couldn't create temporary attachment store: " + directory.getAbsolutePath());
}
}
return directory;
}
private static File getTemporaryAttachmentDirectory(Context context) {
return new File(context.getExternalCacheDir(), TEMPORARY_ATTACHMENT_DIRECTORY);
}
private static void cleanOldFiles(File directory) {
File[] files = directory.listFiles();
if (files == null) {
return;
}
long cutOffTime = System.currentTimeMillis() - MAX_FILE_AGE;
for (File file : files) {
if (file.lastModified() < cutOffTime) {
if (file.delete()) {
Timber.d("Deleted from temporary attachment store: %s", file.getName());
} else {
Timber.w("Couldn't delete from temporary attachment store: %s", file.getName());
}
}
}
}
}

View file

@ -1,167 +0,0 @@
package com.fsck.k9.ui.helper;
import java.io.File;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.os.Environment;
import android.support.v4.app.Fragment;
import android.text.InputType;
import android.widget.EditText;
import com.fsck.k9.ui.R;
public class FileBrowserHelper {
/**
* A string array that specifies the name of the intent to use, and the scheme to use with it
* when setting the data for the intent.
*/
private static final String[][] PICK_DIRECTORY_INTENTS = {
{ "org.openintents.action.PICK_DIRECTORY", "file://" }, // OI File Manager (maybe others)
{ "com.estrongs.action.PICK_DIRECTORY", "file://" }, // ES File Explorer
{ Intent.ACTION_PICK, "folder://" }, // Blackmoon File Browser (maybe others)
{ "com.androidworkz.action.PICK_DIRECTORY", "file://" }
}; // SystemExplorer
private static FileBrowserHelper sInstance;
/**
* callback class to provide the result of the fallback textedit path dialog
*/
public interface FileBrowserFailOverCallback {
/**
* the user has entered a path
* @param path the path as String
*/
void onPathEntered(String path);
/**
* the user has cancel the inputtext dialog
*/
void onCancel();
}
/**
* factory method
*
*/
private FileBrowserHelper() {
}
public synchronized static FileBrowserHelper getInstance() {
if (sInstance == null) {
sInstance = new FileBrowserHelper();
}
return sInstance;
}
/**
* tries to open known filebrowsers.
* If no filebrowser is found and fallback textdialog is shown
* @param c the context as activity
* @param startPath: the default value, where the filebrowser will start.
* if startPath = null => the default path is used
* @param requestcode: the int you will get as requestcode in onActivityResult
* (only used if there is a filebrowser installed)
* @param callback: the callback (only used when no filebrowser is installed.
* if a filebrowser is installed => override the onActivtyResult Method
*
* @return true: if a filebrowser has been found (the result will be in the onActivityResult
* false: a fallback textinput has been shown. The Result will be sent with the callback method
*
*
*/
public boolean showFileBrowserActivity(Activity c, File startPath, int requestcode, FileBrowserFailOverCallback callback) {
boolean success = false;
if (startPath == null) {
startPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
}
int listIndex = 0;
do {
String intentAction = PICK_DIRECTORY_INTENTS[listIndex][0];
String uriPrefix = PICK_DIRECTORY_INTENTS[listIndex][1];
Intent intent = new Intent(intentAction);
intent.setData(Uri.parse(uriPrefix + startPath.getPath()));
try {
c.startActivityForResult(intent, requestcode);
success = true;
} catch (ActivityNotFoundException e) {
// Try the next intent in the list
listIndex++;
}
} while (!success && (listIndex < PICK_DIRECTORY_INTENTS.length));
if (listIndex == PICK_DIRECTORY_INTENTS.length) {
//No Filebrowser is installed => show a fallback textdialog
showPathTextInput(c, startPath, callback);
success = false;
}
return success;
}
public boolean showFileBrowserActivity(Fragment c, File startPath, int requestcode, FileBrowserFailOverCallback callback) {
boolean success = false;
if (startPath == null) {
startPath = startPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
}
int listIndex = 0;
do {
String intentAction = PICK_DIRECTORY_INTENTS[listIndex][0];
String uriPrefix = PICK_DIRECTORY_INTENTS[listIndex][1];
Intent intent = new Intent(intentAction);
intent.setData(Uri.parse(uriPrefix + startPath.getPath()));
try {
c.startActivityForResult(intent, requestcode);
success = true;
} catch (ActivityNotFoundException e) {
// Try the next intent in the list
listIndex++;
}
} while (!success && (listIndex < PICK_DIRECTORY_INTENTS.length));
if (listIndex == PICK_DIRECTORY_INTENTS.length) {
//No Filebrowser is installed => show a fallback textdialog
showPathTextInput(c.getActivity(), startPath, callback);
success = false;
}
return success;
}
private void showPathTextInput(final Activity c, final File startPath, final FileBrowserFailOverCallback callback) {
AlertDialog.Builder alert = new AlertDialog.Builder(c);
alert.setTitle(c.getString(R.string.attachment_save_title));
alert.setMessage(c.getString(R.string.attachment_save_desc));
final EditText input = new EditText(c);
input.setInputType(InputType.TYPE_CLASS_TEXT);
if (startPath != null)
input.setText(startPath.toString());
alert.setView(input);
alert.setPositiveButton(c.getString(R.string.okay_action), new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
String path = input.getText().toString();
callback.onPathEntered(path);
}
});
alert.setNegativeButton(c.getString(R.string.cancel_action),
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
callback.onCancel();
}
});
alert.show();
}
}

View file

@ -1,33 +1,26 @@
package com.fsck.k9.ui.messageview;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import android.app.DownloadManager;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Environment;
import android.support.annotation.WorkerThread;
import timber.log.Timber;
import android.widget.Toast;
import com.fsck.k9.Account;
import com.fsck.k9.Preferences;
import com.fsck.k9.ui.R;
import com.fsck.k9.cache.TemporaryAttachmentStore;
import com.fsck.k9.controller.MessagingController;
import com.fsck.k9.controller.SimpleMessagingListener;
import com.fsck.k9.helper.FileHelper;
import com.fsck.k9.mail.Message;
import com.fsck.k9.mail.Part;
import com.fsck.k9.mail.internet.MimeUtility;
@ -35,7 +28,9 @@ import com.fsck.k9.mailstore.AttachmentViewInfo;
import com.fsck.k9.mailstore.LocalMessage;
import com.fsck.k9.mailstore.LocalPart;
import com.fsck.k9.provider.AttachmentTempFileProvider;
import com.fsck.k9.ui.R;
import org.apache.commons.io.IOUtils;
import timber.log.Timber;
public class AttachmentController {
@ -43,14 +38,12 @@ public class AttachmentController {
private final MessagingController controller;
private final MessageViewFragment messageViewFragment;
private final AttachmentViewInfo attachment;
private final DownloadManager downloadManager;
AttachmentController(MessagingController controller, DownloadManager downloadManager,
MessageViewFragment messageViewFragment, AttachmentViewInfo attachment) {
AttachmentController(MessagingController controller, MessageViewFragment messageViewFragment,
AttachmentViewInfo attachment) {
this.context = messageViewFragment.getApplicationContext();
this.controller = controller;
this.downloadManager = downloadManager;
this.messageViewFragment = messageViewFragment;
this.attachment = attachment;
}
@ -63,8 +56,12 @@ public class AttachmentController {
}
}
public void saveAttachmentTo(String directory) {
saveAttachmentTo(new File(directory));
public void saveAttachmentTo(Uri documentUri) {
if (!attachment.isContentAvailable()) {
downloadAndSaveAttachmentTo((LocalPart) attachment.part, documentUri);
} else {
saveLocalAttachmentTo(documentUri);
}
}
private void downloadAndViewAttachment(LocalPart localPart) {
@ -76,12 +73,12 @@ public class AttachmentController {
});
}
private void downloadAndSaveAttachmentTo(LocalPart localPart, final File directory) {
private void downloadAndSaveAttachmentTo(LocalPart localPart, final Uri documentUri) {
downloadAttachment(localPart, new Runnable() {
@Override
public void run() {
messageViewFragment.refreshAttachmentThumbnail(attachment);
saveLocalAttachmentTo(directory);
saveLocalAttachmentTo(documentUri);
}
});
}
@ -111,46 +108,15 @@ public class AttachmentController {
new ViewAttachmentAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
private void saveAttachmentTo(File directory) {
boolean isExternalStorageMounted = Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED);
if (!isExternalStorageMounted) {
String message = context.getString(R.string.message_view_status_attachment_not_saved);
displayMessageToUser(message);
return;
private void saveLocalAttachmentTo(Uri documentUri) {
new SaveAttachmentAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, documentUri);
}
if (attachment.size > directory.getFreeSpace()) {
String message = context.getString(R.string.message_view_status_no_space);
displayMessageToUser(message);
return;
}
if (!attachment.isContentAvailable()) {
downloadAndSaveAttachmentTo((LocalPart) attachment.part, directory);
} else {
saveLocalAttachmentTo(directory);
}
}
private void saveLocalAttachmentTo(File directory) {
new SaveAttachmentAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, directory);
}
private File saveAttachmentWithUniqueFileName(File directory) throws IOException {
String filename = FileHelper.sanitizeFilename(attachment.displayName);
File file = FileHelper.createUniqueFile(directory, filename);
writeAttachmentToStorage(file);
addSavedAttachmentToDownloadsDatabase(file);
return file;
}
private void writeAttachmentToStorage(File file) throws IOException {
InputStream in = context.getContentResolver().openInputStream(attachment.internalUri);
private void writeAttachment(Uri documentUri) throws IOException {
ContentResolver contentResolver = context.getContentResolver();
InputStream in = contentResolver.openInputStream(attachment.internalUri);
try {
OutputStream out = new FileOutputStream(file);
OutputStream out = contentResolver.openOutputStream(documentUri);
try {
IOUtils.copy(in, out);
out.flush();
@ -162,17 +128,8 @@ public class AttachmentController {
}
}
private void addSavedAttachmentToDownloadsDatabase(File file) {
String fileName = file.getName();
String path = file.getAbsolutePath();
long fileLength = file.length();
String mimeType = attachment.mimeType;
downloadManager.addCompletedDownload(fileName, fileName, true, mimeType, path, fileLength, true);
}
@WorkerThread
private Intent getBestViewIntentAndSaveFile() {
private Intent getBestViewIntent() {
Uri intentDataUri;
try {
intentDataUri = AttachmentTempFileProvider.createTempUriForContentUri(context, attachment.internalUri);
@ -187,53 +144,25 @@ public class AttachmentController {
IntentAndResolvedActivitiesCount resolvedIntentInfo;
String mimeType = attachment.mimeType;
if (MimeUtility.isDefaultMimeType(mimeType)) {
resolvedIntentInfo = getBestViewIntentForMimeType(intentDataUri, inferredMimeType);
resolvedIntentInfo = getViewIntentForMimeType(intentDataUri, inferredMimeType);
} else {
resolvedIntentInfo = getBestViewIntentForMimeType(intentDataUri, mimeType);
resolvedIntentInfo = getViewIntentForMimeType(intentDataUri, mimeType);
if (!resolvedIntentInfo.hasResolvedActivities() && !inferredMimeType.equals(mimeType)) {
resolvedIntentInfo = getBestViewIntentForMimeType(intentDataUri, inferredMimeType);
resolvedIntentInfo = getViewIntentForMimeType(intentDataUri, inferredMimeType);
}
}
if (!resolvedIntentInfo.hasResolvedActivities()) {
resolvedIntentInfo = getBestViewIntentForMimeType(
intentDataUri, MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE);
resolvedIntentInfo = getViewIntentForMimeType(intentDataUri, MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE);
}
Intent viewIntent;
if (resolvedIntentInfo.hasResolvedActivities() && resolvedIntentInfo.containsFileUri()) {
try {
File tempFile = TemporaryAttachmentStore.getFileForWriting(context, displayName);
writeAttachmentToStorage(tempFile);
viewIntent = createViewIntentForFileUri(resolvedIntentInfo.getMimeType(), Uri.fromFile(tempFile));
} catch (IOException e) {
Timber.e(e, "Error while saving attachment to use file:// URI with ACTION_VIEW Intent");
viewIntent = createViewIntentForAttachmentProviderUri(intentDataUri, MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE);
}
} else {
viewIntent = resolvedIntentInfo.getIntent();
return resolvedIntentInfo.getIntent();
}
return viewIntent;
}
private IntentAndResolvedActivitiesCount getBestViewIntentForMimeType(Uri contentUri, String mimeType) {
private IntentAndResolvedActivitiesCount getViewIntentForMimeType(Uri contentUri, String mimeType) {
Intent contentUriIntent = createViewIntentForAttachmentProviderUri(contentUri, mimeType);
int contentUriActivitiesCount = getResolvedIntentActivitiesCount(contentUriIntent);
if (contentUriActivitiesCount > 0) {
return new IntentAndResolvedActivitiesCount(contentUriIntent, contentUriActivitiesCount);
}
File tempFile = TemporaryAttachmentStore.getFile(context, attachment.displayName);
Uri tempFileUri = Uri.fromFile(tempFile);
Intent fileUriIntent = createViewIntentForFileUri(mimeType, tempFileUri);
int fileUriActivitiesCount = getResolvedIntentActivitiesCount(fileUriIntent);
if (fileUriActivitiesCount > 0) {
return new IntentAndResolvedActivitiesCount(fileUriIntent, fileUriActivitiesCount);
}
return new IntentAndResolvedActivitiesCount(contentUriIntent, contentUriActivitiesCount);
}
@ -248,14 +177,6 @@ public class AttachmentController {
return intent;
}
private Intent createViewIntentForFileUri(String mimeType, Uri uri) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(uri, mimeType);
addUiIntentFlags(intent);
return intent;
}
private void addUiIntentFlags(Intent intent) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
}
@ -294,14 +215,6 @@ public class AttachmentController {
public boolean hasResolvedActivities() {
return activitiesCount > 0;
}
public String getMimeType() {
return intent.getType();
}
public boolean containsFileUri() {
return "file".equals(intent.getData().getScheme());
}
}
private class ViewAttachmentAsyncTask extends AsyncTask<Void, Void, Intent> {
@ -313,7 +226,7 @@ public class AttachmentController {
@Override
protected Intent doInBackground(Void... params) {
return getBestViewIntentAndSaveFile();
return getBestViewIntent();
}
@Override
@ -334,7 +247,7 @@ public class AttachmentController {
}
}
private class SaveAttachmentAsyncTask extends AsyncTask<File, Void, File> {
private class SaveAttachmentAsyncTask extends AsyncTask<Uri, Void, Boolean> {
@Override
protected void onPreExecute() {
@ -342,20 +255,21 @@ public class AttachmentController {
}
@Override
protected File doInBackground(File... params) {
protected Boolean doInBackground(Uri... params) {
try {
File directory = params[0];
return saveAttachmentWithUniqueFileName(directory);
Uri documentUri = params[0];
writeAttachment(documentUri);
return true;
} catch (IOException e) {
Timber.e(e, "Error saving attachment");
return null;
return false;
}
}
@Override
protected void onPostExecute(File file) {
protected void onPostExecute(Boolean success) {
messageViewFragment.enableAttachmentButtons(attachment);
if (file == null) {
if (!success) {
displayAttachmentNotSavedMessage();
}
}

View file

@ -10,7 +10,6 @@ import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.content.IntentSender.SendIntentException;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Parcelable;
@ -47,8 +46,6 @@ import com.fsck.k9.mailstore.AttachmentViewInfo;
import com.fsck.k9.mailstore.LocalMessage;
import com.fsck.k9.mailstore.MessageViewInfo;
import com.fsck.k9.ui.R;
import com.fsck.k9.ui.helper.FileBrowserHelper;
import com.fsck.k9.ui.helper.FileBrowserHelper.FileBrowserFailOverCallback;
import com.fsck.k9.ui.messageview.CryptoInfoDialog.OnClickShowCryptoKeyListener;
import com.fsck.k9.ui.messageview.MessageCryptoPresenter.MessageCryptoMvpView;
import com.fsck.k9.ui.settings.account.AccountSettingsActivity;
@ -63,7 +60,7 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF
private static final int ACTIVITY_CHOOSE_FOLDER_MOVE = 1;
private static final int ACTIVITY_CHOOSE_FOLDER_COPY = 2;
private static final int ACTIVITY_CHOOSE_DIRECTORY = 3;
private static final int REQUEST_CODE_CREATE_DOCUMENT = 3;
public static final int REQUEST_MASK_LOADER_HELPER = (1 << 8);
public static final int REQUEST_MASK_CRYPTO_PRESENTER = (1 << 9);
@ -455,16 +452,9 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF
// launched through the MessageList activity, and delivered back via onPendingIntentResult()
switch (requestCode) {
case ACTIVITY_CHOOSE_DIRECTORY: {
if (data != null) {
// obtain the filename
Uri fileUri = data.getData();
if (fileUri != null) {
String filePath = fileUri.getPath();
if (filePath != null) {
getAttachmentController(currentAttachmentViewInfo).saveAttachmentTo(filePath);
}
}
case REQUEST_CODE_CREATE_DOCUMENT: {
if (data != null && data.getData() != null) {
getAttachmentController(currentAttachmentViewInfo).saveAttachmentTo(data.getData());
}
break;
}
@ -819,21 +809,16 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF
@Override
public void onSaveAttachment(final AttachmentViewInfo attachment) {
currentAttachmentViewInfo = attachment;
FileBrowserHelper.getInstance().showFileBrowserActivity(MessageViewFragment.this, null,
ACTIVITY_CHOOSE_DIRECTORY, new FileBrowserFailOverCallback() {
@Override
public void onPathEntered(String path) {
getAttachmentController(attachment).saveAttachmentTo(path);
}
@Override
public void onCancel() {
// Do nothing
}
});
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.setType(attachment.mimeType);
intent.putExtra(Intent.EXTRA_TITLE, attachment.displayName);
intent.addCategory(Intent.CATEGORY_OPENABLE);
startActivityForResult(intent, REQUEST_CODE_CREATE_DOCUMENT);
}
private AttachmentController getAttachmentController(AttachmentViewInfo attachment) {
return new AttachmentController(mController, downloadManager, this, attachment);
return new AttachmentController(mController, this, attachment);
}
}

View file

@ -1,6 +1,5 @@
package com.fsck.k9.ui.settings
import com.fsck.k9.ui.helper.FileBrowserHelper
import com.fsck.k9.helper.NamedThreadFactory
import com.fsck.k9.ui.account.AccountsLiveData
import com.fsck.k9.ui.settings.account.AccountSettingsDataStoreFactory
@ -14,7 +13,6 @@ val settingsUiModule = applicationContext {
bean { AccountsLiveData(get()) }
viewModel { SettingsViewModel(get()) }
bean { FileBrowserHelper.getInstance() }
bean { GeneralSettingsDataStore(get(), get(), get("SaveSettingsExecutorService")) }
bean("SaveSettingsExecutorService") {
Executors.newSingleThreadExecutor(NamedThreadFactory("SaveSettings"))

View file

@ -292,8 +292,7 @@ Please submit bug reports, contribute new features and ask questions at
<string name="message_view_bcc_label">Bcc:</string>
<string name="message_view_attachment_view_action">Open</string>
<string name="message_view_attachment_download_action">Save</string>
<string name="message_view_status_attachment_not_saved">Unable to save attachment to SD card.</string>
<string name="message_view_status_no_space">The attachment could not be saved as there is not enough space.</string>
<string name="message_view_status_attachment_not_saved">Unable to save attachment.</string>
<string name="message_view_show_pictures_action">Show pictures</string>
<string name="message_view_no_viewer">Unable to find viewer for <xliff:g id="mimetype">%s</xliff:g>.</string>
<string name="message_view_download_remainder">Download complete message</string>

View file

@ -2,7 +2,7 @@ buildscript {
ext {
buildConfig = [
'compileSdk': 27,
'minSdk': 15,
'minSdk': 19,
'buildTools': '27.0.3'
]