Merge branch 'open_attachment_improvements'

Conflicts:
	src/com/fsck/k9/helper/Utility.java
This commit is contained in:
cketti 2014-11-27 21:03:15 +01:00
commit 98b5d63909
14 changed files with 615 additions and 477 deletions

View File

@ -226,7 +226,7 @@ K-9 Mail — почтовый клиент для Android.
<string name="message_view_show_attachments_action">Вложения</string>
<string name="message_view_show_more_attachments_action">Показать вложения</string>
<string name="message_view_fetching_attachment_toast">Извлечение вложений</string>
<string name="message_view_no_viewer">Отсутствует просмотрщик %s <xliff:g id="mimetype">%s</xliff:g></string>
<string name="message_view_no_viewer">Отсутствует просмотрщик <xliff:g id="mimetype">%s</xliff:g></string>
<string name="message_view_download_remainder">Загрузить полностью</string>
<string name="message_view_downloading">Загрузка…</string>
<!--NOTE: The following message refers to strings with id account_setup_incoming_save_all_headers_label and account_setup_incoming_title-->

View File

@ -283,33 +283,6 @@ public class K9 extends Application {
*/
private static boolean sDatabasesUpToDate = false;
/**
* The MIME type(s) of attachments we're willing to view.
*/
public static final String[] ACCEPTABLE_ATTACHMENT_VIEW_TYPES = new String[] {
"*/*",
};
/**
* The MIME type(s) of attachments we're not willing to view.
*/
public static final String[] UNACCEPTABLE_ATTACHMENT_VIEW_TYPES = new String[] {
};
/**
* The MIME type(s) of attachments we're willing to download to SD.
*/
public static final String[] ACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES = new String[] {
"*/*",
};
/**
* The MIME type(s) of attachments we're not willing to download to SD.
*/
public static final String[] UNACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES = new String[] {
};
/**
* For use when displaying that no folder is selected
*/

View File

@ -0,0 +1,65 @@
package com.fsck.k9.cache;
import java.io.File;
import java.io.IOException;
import android.content.Context;
import android.util.Log;
import com.fsck.k9.K9;
import com.fsck.k9.helper.FileHelper;
public class TemporaryAttachmentStore {
private static String TEMPORARY_ATTACHMENT_DIRECTORY = "attachments";
private static 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()) {
if (K9.DEBUG) {
Log.d(K9.LOG_TAG, "Deleted from temporary attachment store: " + file.getName());
}
} else {
Log.w(K9.LOG_TAG, "Couldn't delete from temporary attachment store: " + file.getName());
}
}
}
}
}

View File

@ -206,7 +206,7 @@ public class MessageViewFragment extends Fragment implements OnClickListener,
mMessageView.setAttachmentCallback(new AttachmentFileDownloadCallback() {
@Override
public void showFileBrowser(final AttachmentView caller) {
public void pickDirectoryToSaveAttachmentTo(final AttachmentView caller) {
FileBrowserHelper.getInstance()
.showFileBrowserActivity(MessageViewFragment.this,
null,
@ -498,15 +498,8 @@ public class MessageViewFragment extends Fragment implements OnClickListener,
@Override
public void onClick(View view) {
switch (view.getId()) {
case R.id.download: {
((AttachmentView)view).saveFile();
break;
}
case R.id.download_remainder: {
onDownloadRemainder();
break;
}
if (view.getId() == R.id.download_remainder) {
onDownloadRemainder();
}
}

View File

@ -0,0 +1,166 @@
package com.fsck.k9.helper;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.Locale;
import android.util.Log;
import com.fsck.k9.K9;
public class FileHelper {
/**
* Regular expression that represents characters we won't allow in file names.
*
* <p>
* Allowed are:
* <ul>
* <li>word characters (letters, digits, and underscores): {@code \w}</li>
* <li>spaces: {@code " "}</li>
* <li>special characters: {@code !}, {@code #}, {@code $}, {@code %}, {@code &}, {@code '},
* {@code (}, {@code )}, {@code -}, {@code @}, {@code ^}, {@code `}, <code>&#123;</code>,
* <code>&#125;</code>, {@code ~}, {@code .}, {@code ,}</li>
* </ul></p>
*
* @see #sanitizeFilename(String)
*/
private static final String INVALID_CHARACTERS = "[^\\w !#$%&'()\\-@\\^`{}~.,]";
/**
* Invalid characters in a file name are replaced by this character.
*
* @see #sanitizeFilename(String)
*/
private static final String REPLACEMENT_CHARACTER = "_";
/**
* Creates a unique file in the given directory by appending a hyphen
* and a number to the given filename.
*/
public static File createUniqueFile(File directory, String filename) {
File file = new File(directory, filename);
if (!file.exists()) {
return file;
}
// Get the extension of the file, if any.
int index = filename.lastIndexOf('.');
String format;
if (index != -1) {
String name = filename.substring(0, index);
String extension = filename.substring(index);
format = name + "-%d" + extension;
} else {
format = filename + "-%d";
}
for (int i = 2; i < Integer.MAX_VALUE; i++) {
file = new File(directory, String.format(Locale.US, format, i));
if (!file.exists()) {
return file;
}
}
return null;
}
public static void touchFile(final File parentDir, final String name) {
final File file = new File(parentDir, name);
try {
if (!file.exists()) {
file.createNewFile();
} else {
file.setLastModified(System.currentTimeMillis());
}
} catch (Exception e) {
Log.d(K9.LOG_TAG, "Unable to touch file: " + file.getAbsolutePath(), e);
}
}
public static boolean move(final File from, final File to) {
if (to.exists()) {
to.delete();
}
to.getParentFile().mkdirs();
try {
FileInputStream in = new FileInputStream(from);
try {
FileOutputStream out = new FileOutputStream(to);
try {
byte[] buffer = new byte[1024];
int count = -1;
while ((count = in.read(buffer)) > 0) {
out.write(buffer, 0, count);
}
} finally {
out.close();
}
} finally {
try { in.close(); } catch (Throwable ignore) {}
}
from.delete();
return true;
} catch (Exception e) {
Log.w(K9.LOG_TAG, "cannot move " + from.getAbsolutePath() + " to " + to.getAbsolutePath(), e);
return false;
}
}
public static void moveRecursive(final File fromDir, final File toDir) {
if (!fromDir.exists()) {
return;
}
if (!fromDir.isDirectory()) {
if (toDir.exists()) {
if (!toDir.delete()) {
Log.w(K9.LOG_TAG, "cannot delete already existing file/directory " + toDir.getAbsolutePath());
}
}
if (!fromDir.renameTo(toDir)) {
Log.w(K9.LOG_TAG, "cannot rename " + fromDir.getAbsolutePath() + " to " + toDir.getAbsolutePath() + " - moving instead");
move(fromDir, toDir);
}
return;
}
if (!toDir.exists() || !toDir.isDirectory()) {
if (toDir.exists()) {
toDir.delete();
}
if (!toDir.mkdirs()) {
Log.w(K9.LOG_TAG, "cannot create directory " + toDir.getAbsolutePath());
}
}
File[] files = fromDir.listFiles();
for (File file : files) {
if (file.isDirectory()) {
moveRecursive(file, new File(toDir, file.getName()));
file.delete();
} else {
File target = new File(toDir, file.getName());
if (!file.renameTo(target)) {
Log.w(K9.LOG_TAG, "cannot rename " + file.getAbsolutePath() + " to " + target.getAbsolutePath() + " - moving instead");
move(file, target);
}
}
}
if (!fromDir.delete()) {
Log.w(K9.LOG_TAG, "cannot delete " + fromDir.getAbsolutePath());
}
}
/**
* Replace characters we don't allow in file names with a replacement character.
*
* @param filename
* The original file name.
*
* @return The sanitized file name containing only allowed characters.
*/
public static String sanitizeFilename(String filename) {
return filename.replaceAll(INVALID_CHARACTERS, REPLACEMENT_CHARACTER);
}
}

View File

@ -1,40 +1,15 @@
package com.fsck.k9.helper;
import android.content.Context;
import android.content.Intent;
import android.media.MediaScannerConnection;
import android.media.MediaScannerConnection.MediaScannerConnectionClient;
import android.net.Uri;
import java.io.File;
import android.content.Context;
import android.media.MediaScannerConnection;
public class MediaScannerNotifier implements MediaScannerConnectionClient {
private MediaScannerConnection mConnection;
private File mFile;
private Context mContext;
public MediaScannerNotifier(Context context, File file) {
mFile = file;
mConnection = new MediaScannerConnection(context, this);
mConnection.connect();
mContext = context;
}
public void onMediaScannerConnected() {
mConnection.scanFile(mFile.getAbsolutePath(), null);
}
public void onScanCompleted(String path, Uri uri) {
try {
if (uri != null) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(uri);
mContext.startActivity(intent);
}
} finally {
mConnection.disconnect();
}
public class MediaScannerNotifier {
public static void notify(Context context, File file) {
String[] paths = { file.getAbsolutePath() };
MediaScannerConnection.scanFile(context, paths, null, null);
}
}

View File

@ -17,43 +17,13 @@ import android.widget.TextView;
import com.fsck.k9.K9;
import com.fsck.k9.mail.filter.Base64;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.Serializable;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Utility {
/**
* Regular expression that represents characters we won't allow in file names.
*
* <p>
* Allowed are:
* <ul>
* <li>word characters (letters, digits, and underscores): {@code \w}</li>
* <li>spaces: {@code " "}</li>
* <li>special characters: {@code !}, {@code #}, {@code $}, {@code %}, {@code &}, {@code '},
* {@code (}, {@code )}, {@code -}, {@code @}, {@code ^}, {@code `}, <code>&#123;</code>,
* <code>&#125;</code>, {@code ~}, {@code .}, {@code ,}</li>
* </ul></p>
*
* @see #sanitizeFilename(String)
*/
private static final String INVALID_CHARACTERS = "[^\\w !#$%&'()\\-@\\^`{}~.,]+";
/**
* Invalid characters in a file name are replaced by this character.
*
* @see #sanitizeFilename(String)
*/
private static final String REPLACEMENT_CHARACTER = "_";
// \u00A0 (non-breaking space) happens to be used by French MUA
@ -471,139 +441,6 @@ public class Utility {
}
}
/**
* @param parentDir
* @param name
* Never <code>null</code>.
*/
public static void touchFile(final File parentDir, final String name) {
final File file = new File(parentDir, name);
try {
if (!file.exists()) {
file.createNewFile();
} else {
file.setLastModified(System.currentTimeMillis());
}
} catch (Exception e) {
Log.d(K9.LOG_TAG, "Unable to touch file: " + file.getAbsolutePath(), e);
}
}
/**
* Creates a unique file in the given directory by appending a hyphen
* and a number to the given filename.
*
* @param directory
* @param filename
* @return
*/
public static File createUniqueFile(File directory, String filename) {
File file = new File(directory, filename);
if (!file.exists()) {
return file;
}
// Get the extension of the file, if any.
int index = filename.lastIndexOf('.');
String format;
if (index != -1) {
String name = filename.substring(0, index);
String extension = filename.substring(index);
format = name + "-%d" + extension;
} else {
format = filename + "-%d";
}
for (int i = 2; i < Integer.MAX_VALUE; i++) {
file = new File(directory, String.format(Locale.US, format, i));
if (!file.exists()) {
return file;
}
}
return null;
}
/**
* @param from
* @param to
* @return
*/
public static boolean move(final File from, final File to) {
if (to.exists()) {
to.delete();
}
to.getParentFile().mkdirs();
try {
FileInputStream in = new FileInputStream(from);
try {
FileOutputStream out = new FileOutputStream(to);
try {
byte[] buffer = new byte[1024];
int count = -1;
while ((count = in.read(buffer)) > 0) {
out.write(buffer, 0, count);
}
} finally {
out.close();
}
} finally {
try { in.close(); } catch (Throwable ignore) {}
}
from.delete();
return true;
} catch (Exception e) {
Log.w(K9.LOG_TAG, "cannot move " + from.getAbsolutePath() + " to " + to.getAbsolutePath(), e);
return false;
}
}
/**
* @param fromDir
* @param toDir
*/
public static void moveRecursive(final File fromDir, final File toDir) {
if (!fromDir.exists()) {
return;
}
if (!fromDir.isDirectory()) {
if (toDir.exists()) {
if (!toDir.delete()) {
Log.w(K9.LOG_TAG, "cannot delete already existing file/directory " + toDir.getAbsolutePath());
}
}
if (!fromDir.renameTo(toDir)) {
Log.w(K9.LOG_TAG, "cannot rename " + fromDir.getAbsolutePath() + " to " + toDir.getAbsolutePath() + " - moving instead");
move(fromDir, toDir);
}
return;
}
if (!toDir.exists() || !toDir.isDirectory()) {
if (toDir.exists()) {
toDir.delete();
}
if (!toDir.mkdirs()) {
Log.w(K9.LOG_TAG, "cannot create directory " + toDir.getAbsolutePath());
}
}
File[] files = fromDir.listFiles();
for (File file : files) {
if (file.isDirectory()) {
moveRecursive(file, new File(toDir, file.getName()));
file.delete();
} else {
File target = new File(toDir, file.getName());
if (!file.renameTo(target)) {
Log.w(K9.LOG_TAG, "cannot rename " + file.getAbsolutePath() + " to " + target.getAbsolutePath() + " - moving instead");
move(file, target);
}
}
}
if (!fromDir.delete()) {
Log.w(K9.LOG_TAG, "cannot delete " + fromDir.getAbsolutePath());
}
}
private static final String IMG_SRC_REGEX = "(?is:<img[^>]+src\\s*=\\s*['\"]?([a-z]+)\\:)";
private static final Pattern IMG_PATTERN = Pattern.compile(IMG_SRC_REGEX);
@ -641,18 +478,6 @@ public class Utility {
}
}
/**
* Replace characters we don't allow in file names with a replacement character.
*
* @param filename
* The original file name.
*
* @return The sanitized file name containing only allowed characters.
*/
public static String sanitizeFilename(String filename) {
return filename.replaceAll(INVALID_CHARACTERS, REPLACEMENT_CHARACTER);
}
/**
* Check to see if we have network connectivity.
* @param app Current application (Hint: see if your base class has a getApplication() method.)

View File

@ -1111,20 +1111,8 @@ public class MimeUtility {
return p.matcher(mimeType).matches();
}
/**
* Returns true if the given mimeType matches any of the matchAgainst specifications.
* @param mimeType A MIME type to check.
* @param matchAgainst An array of MIME types to check against. May include wildcards such
* as image/* or * /*.
* @return
*/
public static boolean mimeTypeMatches(String mimeType, String[] matchAgainst) {
for (String matchType : matchAgainst) {
if (mimeTypeMatches(mimeType, matchType)) {
return true;
}
}
return false;
public static boolean isDefaultMimeType(String mimeType) {
return DEFAULT_ATTACHMENT_MIME_TYPE.equalsIgnoreCase(mimeType);
}
/**

View File

@ -14,7 +14,7 @@ import android.os.Build;
import android.util.Log;
import com.fsck.k9.K9;
import com.fsck.k9.helper.Utility;
import com.fsck.k9.helper.FileHelper;
import com.fsck.k9.mail.MessagingException;
public class LockableDatabase {
@ -337,9 +337,10 @@ public class LockableDatabase {
prepareStorage(newProviderId);
// move all database files
Utility.moveRecursive(oldDatabase, storageManager.getDatabase(uUid, newProviderId));
FileHelper.moveRecursive(oldDatabase, storageManager.getDatabase(uUid, newProviderId));
// move all attachment files
Utility.moveRecursive(storageManager.getAttachmentDirectory(uUid, oldProviderId), storageManager.getAttachmentDirectory(uUid, newProviderId));
FileHelper.moveRecursive(storageManager.getAttachmentDirectory(uUid, oldProviderId),
storageManager.getAttachmentDirectory(uUid, newProviderId));
// remove any remaining old journal files
deleteDatabase(oldDatabase);
@ -421,14 +422,14 @@ public class LockableDatabase {
// Android seems to be unmounting the storage...
throw new UnavailableStorageException("Unable to access: " + databaseParentDir);
}
Utility.touchFile(databaseParentDir, ".nomedia");
FileHelper.touchFile(databaseParentDir, ".nomedia");
}
final File attachmentDir = storageManager.getAttachmentDirectory(uUid, providerId);
final File attachmentParentDir = attachmentDir.getParentFile();
if (!attachmentParentDir.exists()) {
attachmentParentDir.mkdirs();
Utility.touchFile(attachmentParentDir, ".nomedia");
FileHelper.touchFile(attachmentParentDir, ".nomedia");
}
if (!attachmentDir.exists()) {
attachmentDir.mkdirs();

View File

@ -12,6 +12,8 @@ import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.Map.Entry;
import com.fsck.k9.helper.FileHelper;
import org.xmlpull.v1.XmlSerializer;
import android.content.Context;
@ -23,7 +25,6 @@ import android.util.Xml;
import com.fsck.k9.Account;
import com.fsck.k9.K9;
import com.fsck.k9.Preferences;
import com.fsck.k9.helper.Utility;
import com.fsck.k9.mail.Store;
import com.fsck.k9.mail.ServerSettings;
import com.fsck.k9.mail.Transport;
@ -87,7 +88,7 @@ public class SettingsExporter {
File dir = new File(Environment.getExternalStorageDirectory() + File.separator
+ context.getPackageName());
dir.mkdirs();
File file = Utility.createUniqueFile(dir, EXPORT_FILENAME);
File file = FileHelper.createUniqueFile(dir, EXPORT_FILENAME);
filename = file.getAbsolutePath();
os = new FileOutputStream(filename);

View File

@ -51,11 +51,21 @@ public class AttachmentProvider extends ContentProvider {
public static Uri getAttachmentUri(Account account, long id) {
return getAttachmentUri(account.getUuid(), id, true);
return CONTENT_URI.buildUpon()
.appendPath(account.getUuid())
.appendPath(Long.toString(id))
.appendPath(FORMAT_RAW)
.build();
}
public static Uri getAttachmentUriForViewing(Account account, long id) {
return getAttachmentUri(account.getUuid(), id, false);
public static Uri getAttachmentUriForViewing(Account account, long id, String mimeType, String filename) {
return CONTENT_URI.buildUpon()
.appendPath(account.getUuid())
.appendPath(Long.toString(id))
.appendPath(FORMAT_VIEW)
.appendPath(mimeType)
.appendPath(filename)
.build();
}
public static Uri getAttachmentThumbnailUri(Account account, long id, int width, int height) {
@ -68,14 +78,6 @@ public class AttachmentProvider extends ContentProvider {
.build();
}
private static Uri getAttachmentUri(String db, long id, boolean raw) {
return CONTENT_URI.buildUpon()
.appendPath(db)
.appendPath(Long.toString(id))
.appendPath(raw ? FORMAT_RAW : FORMAT_VIEW)
.build();
}
public static void clear(Context context) {
/*
* We use the cache dir as a temporary directory (since Android doesn't give us one) so
@ -146,8 +148,9 @@ public class AttachmentProvider extends ContentProvider {
String dbName = segments.get(0);
String id = segments.get(1);
String format = segments.get(2);
String mimeType = (segments.size() < 4) ? null : segments.get(3);
return getType(dbName, id, format);
return getType(dbName, id, format, mimeType);
}
@Override
@ -165,7 +168,7 @@ public class AttachmentProvider extends ContentProvider {
file = getThumbnailFile(getContext(), accountUuid, attachmentId);
if (!file.exists()) {
String type = getType(accountUuid, attachmentId, FORMAT_VIEW);
String type = getType(accountUuid, attachmentId, FORMAT_VIEW, null);
try {
FileInputStream in = new FileInputStream(getFile(accountUuid, attachmentId));
try {
@ -258,7 +261,7 @@ public class AttachmentProvider extends ContentProvider {
return null;
}
private String getType(String dbName, String id, String format) {
private String getType(String dbName, String id, String format, String mimeType) {
String type;
if (FORMAT_THUMBNAIL.equals(format)) {
type = "image/png";
@ -269,10 +272,9 @@ public class AttachmentProvider extends ContentProvider {
final LocalStore localStore = LocalStore.getLocalInstance(account, K9.app);
AttachmentInfo attachmentInfo = localStore.getAttachmentInfo(id);
if (FORMAT_VIEW.equals(format)) {
type = MimeUtility.getMimeTypeForViewing(attachmentInfo.type, attachmentInfo.name);
if (FORMAT_VIEW.equals(format) && mimeType != null) {
type = mimeType;
} else {
// When accessing the "raw" message we deliver the original MIME type.
type = attachmentInfo.type;
}
} catch (MessagingException e) {

View File

@ -1,16 +1,18 @@
package com.fsck.k9.view;
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 org.apache.commons.io.IOUtils;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
@ -30,11 +32,12 @@ import android.widget.Toast;
import com.fsck.k9.Account;
import com.fsck.k9.K9;
import com.fsck.k9.R;
import com.fsck.k9.cache.TemporaryAttachmentStore;
import com.fsck.k9.controller.MessagingController;
import com.fsck.k9.controller.MessagingListener;
import com.fsck.k9.helper.FileHelper;
import com.fsck.k9.helper.MediaScannerNotifier;
import com.fsck.k9.helper.SizeFormatter;
import com.fsck.k9.helper.Utility;
import com.fsck.k9.mail.Message;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.Part;
@ -42,53 +45,48 @@ import com.fsck.k9.mail.internet.MimeHeader;
import com.fsck.k9.mail.internet.MimeUtility;
import com.fsck.k9.mail.store.local.LocalAttachmentBodyPart;
import com.fsck.k9.provider.AttachmentProvider;
import org.apache.commons.io.IOUtils;
public class AttachmentView extends FrameLayout implements OnClickListener, OnLongClickListener {
private Context mContext;
public Button viewButton;
public Button downloadButton;
public LocalAttachmentBodyPart part;
private Message mMessage;
private Account mAccount;
private MessagingController mController;
private MessagingListener mListener;
public String name;
public String contentType;
public long size;
public ImageView iconView;
private Context context;
private Message message;
private LocalAttachmentBodyPart part;
private Account account;
private MessagingController controller;
private MessagingListener listener;
private AttachmentFileDownloadCallback callback;
private Button viewButton;
private Button downloadButton;
private String name;
private String contentType;
private long size;
public AttachmentView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mContext = context;
this.context = context;
}
public AttachmentView(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
this.context = context;
}
public AttachmentView(Context context) {
super(context);
mContext = context;
this.context = context;
}
public interface AttachmentFileDownloadCallback {
/**
* this method i called by the attachmentview when
* he wants to show a filebrowser
* the provider should show the filebrowser activity
* and save the reference to the attachment view for later.
* in his onActivityResult he can get the saved reference and
* call the saveFile method of AttachmentView
* @param view
*/
public void showFileBrowser(AttachmentView caller);
public void setButtonsEnabled(boolean enabled) {
viewButton.setEnabled(enabled);
downloadButton.setEnabled(enabled);
}
/**
* Populates this view with information about the attachment.
*
* <p>
* This method also decides which attachments are displayed when the "show attachments" button
* is pressed, and which attachments are only displayed after the "show more attachments"
@ -96,26 +94,33 @@ public class AttachmentView extends FrameLayout implements OnClickListener, OnLo
* Inline attachments with content ID and unnamed attachments fall into the second category.
* </p>
*
* @param inputPart
* @param message
* @param account
* @param controller
* @param listener
*
* @return {@code true} for a regular attachment. {@code false}, otherwise.
*
* @throws MessagingException
* In case of an error
* @return {@code true} for a regular attachment. {@code false} for attachments that should be initially hidden.
*/
public boolean populateFromPart(Part inputPart, Message message, Account account,
MessagingController controller, MessagingListener listener) throws MessagingException {
boolean firstClassAttachment = true;
part = (LocalAttachmentBodyPart) inputPart;
contentType = MimeUtility.unfoldAndDecode(part.getContentType());
part = (LocalAttachmentBodyPart) inputPart;
this.message = message;
this.account = account;
this.controller = controller;
this.listener = listener;
boolean firstClassAttachment = extractAttachmentInformation(part);
displayAttachmentInformation();
return firstClassAttachment;
}
//TODO: extract this code to a helper class
private boolean extractAttachmentInformation(Part part) throws MessagingException {
boolean firstClassAttachment = true;
contentType = part.getMimeType();
String contentTypeHeader = MimeUtility.unfoldAndDecode(part.getContentType());
String contentDisposition = MimeUtility.unfoldAndDecode(part.getDisposition());
name = MimeUtility.getHeaderParameter(contentType, "name");
name = MimeUtility.getHeaderParameter(contentTypeHeader, "name");
if (name == null) {
name = MimeUtility.getHeaderParameter(contentDisposition, "filename");
}
@ -135,11 +140,6 @@ public class AttachmentView extends FrameLayout implements OnClickListener, OnLo
firstClassAttachment = false;
}
mAccount = account;
mMessage = message;
mController = controller;
mListener = listener;
String sizeParam = MimeUtility.getHeaderParameter(contentDisposition, "size");
if (sizeParam != null) {
try {
@ -147,20 +147,15 @@ public class AttachmentView extends FrameLayout implements OnClickListener, OnLo
} catch (NumberFormatException e) { /* ignore */ }
}
contentType = MimeUtility.getMimeTypeForViewing(part.getMimeType(), name);
return firstClassAttachment;
}
private void displayAttachmentInformation() {
TextView attachmentName = (TextView) findViewById(R.id.attachment_name);
TextView attachmentInfo = (TextView) findViewById(R.id.attachment_info);
final ImageView attachmentIcon = (ImageView) findViewById(R.id.attachment_icon);
viewButton = (Button) findViewById(R.id.view);
downloadButton = (Button) findViewById(R.id.download);
if ((!MimeUtility.mimeTypeMatches(contentType, K9.ACCEPTABLE_ATTACHMENT_VIEW_TYPES))
|| (MimeUtility.mimeTypeMatches(contentType, K9.UNACCEPTABLE_ATTACHMENT_VIEW_TYPES))) {
viewButton.setVisibility(View.GONE);
}
if ((!MimeUtility.mimeTypeMatches(contentType, K9.ACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES))
|| (MimeUtility.mimeTypeMatches(contentType, K9.UNACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES))) {
downloadButton.setVisibility(View.GONE);
}
if (size > K9.MAX_ATTACHMENT_DOWNLOAD_SIZE) {
viewButton.setVisibility(View.GONE);
downloadButton.setVisibility(View.GONE);
@ -171,23 +166,10 @@ public class AttachmentView extends FrameLayout implements OnClickListener, OnLo
downloadButton.setOnLongClickListener(this);
attachmentName.setText(name);
attachmentInfo.setText(SizeFormatter.formatSize(mContext, size));
new AsyncTask<Void, Void, Bitmap>() {
protected Bitmap doInBackground(Void... asyncTaskArgs) {
Bitmap previewIcon = getPreviewIcon();
return previewIcon;
}
attachmentInfo.setText(SizeFormatter.formatSize(context, size));
protected void onPostExecute(Bitmap previewIcon) {
if (previewIcon != null) {
attachmentIcon.setImageBitmap(previewIcon);
} else {
attachmentIcon.setImageResource(R.drawable.attached_image_placeholder);
}
}
}.execute();
return firstClassAttachment;
ImageView thumbnail = (ImageView) findViewById(R.id.attachment_icon);
new LoadAndDisplayThumbnailAsyncTask(thumbnail).execute();
}
@Override
@ -207,156 +189,291 @@ public class AttachmentView extends FrameLayout implements OnClickListener, OnLo
@Override
public boolean onLongClick(View view) {
if (view.getId() == R.id.download) {
callback.showFileBrowser(this);
callback.pickDirectoryToSaveAttachmentTo(this);
return true;
}
return false;
}
private Bitmap getPreviewIcon() {
Bitmap icon = null;
try {
InputStream input = mContext.getContentResolver().openInputStream(
AttachmentProvider.getAttachmentThumbnailUri(mAccount,
part.getAttachmentId(),
62,
62));
icon = BitmapFactory.decodeStream(input);
input.close();
} catch (Exception e) {
/*
* We don't care what happened, we just return null for the preview icon.
*/
}
return icon;
}
private void onViewButtonClicked() {
if (mMessage != null) {
mController.loadAttachment(mAccount, mMessage, part, new Object[] { false, this }, mListener);
if (message != null) {
controller.loadAttachment(account, message, part, new Object[] {false, this}, listener);
}
}
private void onSaveButtonClicked() {
saveFile();
}
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;
}
/**
* Writes the attachment onto the given path
* @param directory: the base dir where the file should be saved.
*/
public void writeFile(File directory) {
try {
String filename = Utility.sanitizeFilename(name);
File file = Utility.createUniqueFile(directory, filename);
Uri uri = AttachmentProvider.getAttachmentUri(mAccount, part.getAttachmentId());
InputStream in = mContext.getContentResolver().openInputStream(uri);
OutputStream out = new FileOutputStream(file);
IOUtils.copy(in, out);
out.flush();
out.close();
in.close();
attachmentSaved(file.toString());
new MediaScannerNotifier(mContext, file);
} catch (IOException ioe) {
if (K9.DEBUG) {
Log.e(K9.LOG_TAG, "Error saving attachment", ioe);
}
attachmentNotSaved();
if (message != null) {
controller.loadAttachment(account, message, part, new Object[] {true, this}, listener);
}
}
/**
* saves the file to the defaultpath setting in the config, or if the config
* is not set => to the Environment
*/
public void writeFile() {
writeFile(new File(K9.getAttachmentDefaultPath()));
}
public void saveFile() {
//TODO: Can the user save attachments on the internal filesystem or sd card only?
if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
/*
* Abort early if there's no place to save the attachment. We don't want to spend
* the time downloading it and then abort.
*/
Toast.makeText(mContext,
mContext.getString(R.string.message_view_status_attachment_not_saved),
Toast.LENGTH_SHORT).show();
return;
}
if (mMessage != null) {
mController.loadAttachment(mAccount, mMessage, part, new Object[] {true, this}, mListener);
/**
* Saves the attachment as file in the given directory
*/
public void writeFile(File directory) {
try {
File file = saveAttachmentWithUniqueFileName(directory);
displayAttachmentSavedMessage(file.toString());
MediaScannerNotifier.notify(context, file);
} catch (IOException ioe) {
if (K9.DEBUG) {
Log.e(K9.LOG_TAG, "Error saving attachment", ioe);
}
displayAttachmentNotSavedMessage();
}
}
private File saveAttachmentWithUniqueFileName(File directory) throws IOException {
String filename = FileHelper.sanitizeFilename(name);
File file = FileHelper.createUniqueFile(directory, filename);
writeAttachmentToStorage(file);
return file;
}
private void writeAttachmentToStorage(File file) throws IOException {
Uri uri = AttachmentProvider.getAttachmentUri(account, part.getAttachmentId());
InputStream in = context.getContentResolver().openInputStream(uri);
try {
OutputStream out = new FileOutputStream(file);
try {
IOUtils.copy(in, out);
out.flush();
} finally {
out.close();
}
} finally {
in.close();
}
}
public void showFile() {
Uri uri = AttachmentProvider.getAttachmentUriForViewing(mAccount, part.getAttachmentId());
Intent intent = new Intent(Intent.ACTION_VIEW);
// We explicitly set the ContentType in addition to the URI because some attachment viewers (such as Polaris office 3.0.x) choke on documents without a mime type
intent.setDataAndType(uri, contentType);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
try {
mContext.startActivity(intent);
} catch (Exception e) {
Log.e(K9.LOG_TAG, "Could not display attachment of type " + contentType, e);
Toast toast = Toast.makeText(mContext, mContext.getString(R.string.message_view_no_viewer, contentType), Toast.LENGTH_LONG);
toast.show();
}
new ViewAttachmentAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* Check the {@link PackageManager} if the phone has an application
* installed to view this type of attachment.
* If not, {@link #viewButton} is disabled.
* This should be done in any place where
* attachment.viewButton.setEnabled(enabled); is called.
* This method is safe to be called from the UI-thread.
*/
public void checkViewable() {
if (viewButton.getVisibility() == View.GONE) {
// nothing to do
return;
}
if (!viewButton.isEnabled()) {
// nothing to do
return;
}
try {
Uri uri = AttachmentProvider.getAttachmentUriForViewing(mAccount, part.getAttachmentId());
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(uri);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
if (intent.resolveActivity(mContext.getPackageManager()) == null) {
viewButton.setEnabled(false);
private Intent getBestViewIntentAndSaveFileIfNecessary() {
String inferredMimeType = MimeUtility.getMimeTypeByExtension(name);
IntentAndResolvedActivitiesCount resolvedIntentInfo;
if (MimeUtility.isDefaultMimeType(contentType)) {
resolvedIntentInfo = getBestViewIntentForMimeType(inferredMimeType);
} else {
resolvedIntentInfo = getBestViewIntentForMimeType(contentType);
if (!resolvedIntentInfo.hasResolvedActivities() && !inferredMimeType.equals(contentType)) {
resolvedIntentInfo = getBestViewIntentForMimeType(inferredMimeType);
}
// currently we do not cache re result.
} catch (Exception e) {
Log.e(K9.LOG_TAG, "Cannot resolve activity to determine if we shall show the 'view'-button for an attachment", e);
}
if (!resolvedIntentInfo.hasResolvedActivities()) {
resolvedIntentInfo = getBestViewIntentForMimeType(MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE);
}
Intent viewIntent;
if (resolvedIntentInfo.hasResolvedActivities() && resolvedIntentInfo.containsFileUri()) {
try {
File tempFile = TemporaryAttachmentStore.getFileForWriting(context, name);
writeAttachmentToStorage(tempFile);
viewIntent = createViewIntentForFileUri(resolvedIntentInfo.getMimeType(), Uri.fromFile(tempFile));
} catch (IOException e) {
if (K9.DEBUG) {
Log.e(K9.LOG_TAG, "Error while saving attachment to use file:// URI with ACTION_VIEW Intent", e);
}
viewIntent = createViewIntentForAttachmentProviderUri(MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE);
}
} else {
viewIntent = resolvedIntentInfo.getIntent();
}
return viewIntent;
}
public void attachmentSaved(final String filename) {
Toast.makeText(mContext, String.format(
mContext.getString(R.string.message_view_status_attachment_saved), filename),
Toast.LENGTH_LONG).show();
private IntentAndResolvedActivitiesCount getBestViewIntentForMimeType(String mimeType) {
Intent contentUriIntent = createViewIntentForAttachmentProviderUri(mimeType);
int contentUriActivitiesCount = getResolvedIntentActivitiesCount(contentUriIntent);
if (contentUriActivitiesCount > 0) {
return new IntentAndResolvedActivitiesCount(contentUriIntent, contentUriActivitiesCount);
}
File tempFile = TemporaryAttachmentStore.getFile(context, name);
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);
}
public void attachmentNotSaved() {
Toast.makeText(mContext,
mContext.getString(R.string.message_view_status_attachment_not_saved),
Toast.LENGTH_LONG).show();
private Intent createViewIntentForAttachmentProviderUri(String mimeType) {
Uri uri = AttachmentProvider.getAttachmentUriForViewing(account, part.getAttachmentId(), mimeType, name);
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(uri, mimeType);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
addUiIntentFlags(intent);
return intent;
}
public AttachmentFileDownloadCallback getCallback() {
return callback;
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_CLEAR_WHEN_TASK_RESET);
}
private int getResolvedIntentActivitiesCount(Intent intent) {
PackageManager packageManager = context.getPackageManager();
List<ResolveInfo> resolveInfos =
packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
return resolveInfos.size();
}
private void displayAttachmentSavedMessage(final String filename) {
String message = context.getString(R.string.message_view_status_attachment_saved, filename);
displayMessageToUser(message);
}
private void displayAttachmentNotSavedMessage() {
String message = context.getString(R.string.message_view_status_attachment_not_saved);
displayMessageToUser(message);
}
private void displayMessageToUser(String message) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show();
}
public void setCallback(AttachmentFileDownloadCallback callback) {
this.callback = callback;
}
public interface AttachmentFileDownloadCallback {
/**
* This method is called to ask the user to pick a directory to save the attachment to.
* <p/>
* After the user has selected a directory, the implementation of this interface has to call
* {@link #writeFile(File)} on the object supplied as argument in order for the attachment to be saved.
*/
public void pickDirectoryToSaveAttachmentTo(AttachmentView caller);
}
private static class IntentAndResolvedActivitiesCount {
private Intent intent;
private int activitiesCount;
IntentAndResolvedActivitiesCount(Intent intent, int activitiesCount) {
this.intent = intent;
this.activitiesCount = activitiesCount;
}
public Intent getIntent() {
return intent;
}
public boolean hasResolvedActivities() {
return activitiesCount > 0;
}
public String getMimeType() {
return intent.getType();
}
public boolean containsFileUri() {
return "file".equals(intent.getData().getScheme());
}
}
private class LoadAndDisplayThumbnailAsyncTask extends AsyncTask<Void, Void, Bitmap> {
private final ImageView thumbnail;
public LoadAndDisplayThumbnailAsyncTask(ImageView thumbnail) {
this.thumbnail = thumbnail;
}
protected Bitmap doInBackground(Void... asyncTaskArgs) {
return getPreviewIcon();
}
private Bitmap getPreviewIcon() {
Bitmap icon = null;
try {
InputStream input = context.getContentResolver().openInputStream(
AttachmentProvider.getAttachmentThumbnailUri(account,
part.getAttachmentId(),
62,
62));
icon = BitmapFactory.decodeStream(input);
input.close();
} catch (Exception e) {
// We don't care what happened, we just return null for the preview icon.
}
return icon;
}
protected void onPostExecute(Bitmap previewIcon) {
if (previewIcon != null) {
thumbnail.setImageBitmap(previewIcon);
} else {
thumbnail.setImageResource(R.drawable.attached_image_placeholder);
}
}
}
private class ViewAttachmentAsyncTask extends AsyncTask<Void, Void, Intent> {
@Override
protected void onPreExecute() {
viewButton.setEnabled(false);
}
@Override
protected Intent doInBackground(Void... params) {
return getBestViewIntentAndSaveFileIfNecessary();
}
@Override
protected void onPostExecute(Intent intent) {
viewAttachment(intent);
viewButton.setEnabled(true);
}
private void viewAttachment(Intent intent) {
try {
context.startActivity(intent);
} catch (ActivityNotFoundException e) {
Log.e(K9.LOG_TAG, "Could not display attachment of type " + contentType, e);
String message = context.getString(R.string.message_view_no_viewer, contentType);
displayMessageToUser(message);
}
}
}
}

View File

@ -46,6 +46,7 @@ import com.fsck.k9.crypto.PgpData;
import com.fsck.k9.fragment.MessageViewFragment;
import com.fsck.k9.helper.ClipboardManager;
import com.fsck.k9.helper.Contacts;
import com.fsck.k9.helper.FileHelper;
import com.fsck.k9.helper.HtmlConverter;
import com.fsck.k9.helper.UrlEncodingHelper;
import com.fsck.k9.helper.Utility;
@ -598,8 +599,7 @@ public class SingleMessageView extends LinearLayout implements OnClickListener,
public void setAttachmentsEnabled(boolean enabled) {
for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
AttachmentView attachment = (AttachmentView) mAttachments.getChildAt(i);
attachment.viewButton.setEnabled(enabled);
attachment.downloadButton.setEnabled(enabled);
attachment.setButtonsEnabled(enabled);
}
}
@ -834,10 +834,10 @@ public class SingleMessageView extends LinearLayout implements OnClickListener,
filename += "." + extension;
}
String sanitized = Utility.sanitizeFilename(filename);
String sanitized = FileHelper.sanitizeFilename(filename);
File directory = new File(K9.getAttachmentDefaultPath());
File file = Utility.createUniqueFile(directory, sanitized);
File file = FileHelper.createUniqueFile(directory, sanitized);
FileOutputStream out = new FileOutputStream(file);
try {
IOUtils.copy(in, out);

View File

@ -0,0 +1,32 @@
package com.fsck.k9.helper;
import junit.framework.TestCase;
import java.lang.String;
public class FileHelperTest extends TestCase {
public void testSanitize1() {
checkSanitization(".._bla_", "../bla_");
}
public void testSanitize2() {
checkSanitization("_etc_bla", "/etc/bla");
}
public void testSanitize3() {
checkSanitization("_пPп", "+пPп");
}
public void testSanitize4() {
checkSanitization(".東京_!", ".東京?!");
}
public void testSanitize5() {
checkSanitization("Plan 9", "Plan 9");
}
private void checkSanitization(String expected, String actual) {
assertEquals(expected, FileHelper.sanitizeFilename(actual));
}
}