diff --git a/res/values-ru/strings.xml b/res/values-ru/strings.xml index 1d0dba7f1..aa573b580 100644 --- a/res/values-ru/strings.xml +++ b/res/values-ru/strings.xml @@ -226,7 +226,7 @@ K-9 Mail — почтовый клиент для Android. Вложения Показать вложения Извлечение вложений - Отсутствует просмотрщик %s %s + Отсутствует просмотрщик %s Загрузить полностью Загрузка… diff --git a/src/com/fsck/k9/K9.java b/src/com/fsck/k9/K9.java index 6b5d83d56..d46dc09d7 100644 --- a/src/com/fsck/k9/K9.java +++ b/src/com/fsck/k9/K9.java @@ -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 */ diff --git a/src/com/fsck/k9/cache/TemporaryAttachmentStore.java b/src/com/fsck/k9/cache/TemporaryAttachmentStore.java new file mode 100644 index 000000000..098e919e9 --- /dev/null +++ b/src/com/fsck/k9/cache/TemporaryAttachmentStore.java @@ -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()); + } + } + } + } +} diff --git a/src/com/fsck/k9/fragment/MessageViewFragment.java b/src/com/fsck/k9/fragment/MessageViewFragment.java index ebecda26a..3a8bb4ddd 100644 --- a/src/com/fsck/k9/fragment/MessageViewFragment.java +++ b/src/com/fsck/k9/fragment/MessageViewFragment.java @@ -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(); } } diff --git a/src/com/fsck/k9/helper/FileHelper.java b/src/com/fsck/k9/helper/FileHelper.java new file mode 100644 index 000000000..e4f98c322 --- /dev/null +++ b/src/com/fsck/k9/helper/FileHelper.java @@ -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. + * + *

+ * Allowed are: + *

+ * + * @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); + } +} diff --git a/src/com/fsck/k9/helper/MediaScannerNotifier.java b/src/com/fsck/k9/helper/MediaScannerNotifier.java index 8229b8297..9dbed11ce 100644 --- a/src/com/fsck/k9/helper/MediaScannerNotifier.java +++ b/src/com/fsck/k9/helper/MediaScannerNotifier.java @@ -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); } } diff --git a/src/com/fsck/k9/helper/Utility.java b/src/com/fsck/k9/helper/Utility.java index 370920d59..50ca99e3a 100644 --- a/src/com/fsck/k9/helper/Utility.java +++ b/src/com/fsck/k9/helper/Utility.java @@ -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. - * - *

- * Allowed are: - *

- * - * @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 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); - } - } - - /** - * 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:]+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.) diff --git a/src/com/fsck/k9/mail/internet/MimeUtility.java b/src/com/fsck/k9/mail/internet/MimeUtility.java index 21dad28c7..c6308a313 100644 --- a/src/com/fsck/k9/mail/internet/MimeUtility.java +++ b/src/com/fsck/k9/mail/internet/MimeUtility.java @@ -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); } /** diff --git a/src/com/fsck/k9/mail/store/LockableDatabase.java b/src/com/fsck/k9/mail/store/LockableDatabase.java index f08076087..b9aaa8b84 100644 --- a/src/com/fsck/k9/mail/store/LockableDatabase.java +++ b/src/com/fsck/k9/mail/store/LockableDatabase.java @@ -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(); diff --git a/src/com/fsck/k9/preferences/SettingsExporter.java b/src/com/fsck/k9/preferences/SettingsExporter.java index d7d77f6dc..6085f3014 100644 --- a/src/com/fsck/k9/preferences/SettingsExporter.java +++ b/src/com/fsck/k9/preferences/SettingsExporter.java @@ -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); diff --git a/src/com/fsck/k9/provider/AttachmentProvider.java b/src/com/fsck/k9/provider/AttachmentProvider.java index c0bd8b0f7..6f5bf32ac 100644 --- a/src/com/fsck/k9/provider/AttachmentProvider.java +++ b/src/com/fsck/k9/provider/AttachmentProvider.java @@ -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) { diff --git a/src/com/fsck/k9/view/AttachmentView.java b/src/com/fsck/k9/view/AttachmentView.java index cffb37c2a..ea917f334 100644 --- a/src/com/fsck/k9/view/AttachmentView.java +++ b/src/com/fsck/k9/view/AttachmentView.java @@ -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. - * *

* 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. *

* - * @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() { - 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 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. + *

+ * 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 { + 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 { + + @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); + } + } + } } diff --git a/src/com/fsck/k9/view/SingleMessageView.java b/src/com/fsck/k9/view/SingleMessageView.java index 2ed03fda4..1506bd4dd 100644 --- a/src/com/fsck/k9/view/SingleMessageView.java +++ b/src/com/fsck/k9/view/SingleMessageView.java @@ -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); diff --git a/tests/src/com/fsck/k9/helper/FileHelperTest.java b/tests/src/com/fsck/k9/helper/FileHelperTest.java new file mode 100644 index 000000000..1e8260256 --- /dev/null +++ b/tests/src/com/fsck/k9/helper/FileHelperTest.java @@ -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)); + } +}