1 /* 2 * Copyright (C) 2012 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.mail.utils; 17 18 import android.app.DownloadManager; 19 import android.content.Context; 20 import android.net.ConnectivityManager; 21 import android.net.NetworkInfo; 22 import android.os.Bundle; 23 import android.os.ParcelFileDescriptor; 24 import android.os.SystemClock; 25 import android.text.TextUtils; 26 27 import com.android.mail.R; 28 import com.android.mail.providers.Attachment; 29 import com.google.common.collect.ImmutableMap; 30 31 import java.io.File; 32 import java.io.FileInputStream; 33 import java.io.FileNotFoundException; 34 import java.io.FileOutputStream; 35 import java.io.IOException; 36 import java.io.InputStream; 37 import java.text.DecimalFormat; 38 import java.text.SimpleDateFormat; 39 import java.util.Date; 40 import java.util.Map; 41 42 public class AttachmentUtils { 43 private static final String LOG_TAG = LogTag.getLogTag(); 44 45 private static final int KILO = 1024; 46 private static final int MEGA = KILO * KILO; 47 48 /** Any IO reads should be limited to this timeout */ 49 private static final long READ_TIMEOUT = 3600 * 1000; 50 51 private static final float MIN_CACHE_THRESHOLD = 0.25f; 52 private static final int MIN_CACHE_AVAILABLE_SPACE_BYTES = 100 * 1024 * 1024; 53 54 /** 55 * Singleton map of MIME->friendly description 56 * @see #getMimeTypeDisplayName(Context, String) 57 */ 58 private static Map<String, String> sDisplayNameMap; 59 60 /** 61 * @return A string suitable for display in bytes, kilobytes or megabytes 62 * depending on its size. 63 */ convertToHumanReadableSize(Context context, long size)64 public static String convertToHumanReadableSize(Context context, long size) { 65 final String count; 66 if (size == 0) { 67 return ""; 68 } else if (size < KILO) { 69 count = String.valueOf(size); 70 return context.getString(R.string.bytes, count); 71 } else if (size < MEGA) { 72 count = String.valueOf(size / KILO); 73 return context.getString(R.string.kilobytes, count); 74 } else { 75 DecimalFormat onePlace = new DecimalFormat("0.#"); 76 count = onePlace.format((float) size / (float) MEGA); 77 return context.getString(R.string.megabytes, count); 78 } 79 } 80 81 /** 82 * Return a friendly localized file type for this attachment, or the empty string if 83 * unknown. 84 * @param context a Context to do resource lookup against 85 * @return friendly file type or empty string 86 */ getDisplayType(final Context context, final Attachment attachment)87 public static String getDisplayType(final Context context, final Attachment attachment) { 88 if ((attachment.flags & Attachment.FLAG_DUMMY_ATTACHMENT) != 0) { 89 // This is a dummy attachment, display blank for type. 90 return ""; 91 } 92 93 // try to get a friendly name for the exact mime type 94 // then try to show a friendly name for the mime family 95 // finally, give up and just show the file extension 96 final String contentType = attachment.getContentType(); 97 String displayType = getMimeTypeDisplayName(context, contentType); 98 int index = !TextUtils.isEmpty(contentType) ? contentType.indexOf('/') : -1; 99 if (displayType == null && index > 0) { 100 displayType = getMimeTypeDisplayName(context, contentType.substring(0, index)); 101 } 102 if (displayType == null) { 103 String extension = Utils.getFileExtension(attachment.getName()); 104 // show '$EXTENSION File' for unknown file types 105 if (extension != null && extension.length() > 1 && extension.indexOf('.') == 0) { 106 displayType = context.getString(R.string.attachment_unknown, 107 extension.substring(1).toUpperCase()); 108 } 109 } 110 if (displayType == null) { 111 // no extension to display, but the map doesn't accept null entries 112 displayType = ""; 113 } 114 return displayType; 115 } 116 117 /** 118 * Returns a user-friendly localized description of either a complete a MIME type or a 119 * MIME family. 120 * @param context used to look up localized strings 121 * @param type complete MIME type or just MIME family 122 * @return localized description text, or null if not recognized 123 */ getMimeTypeDisplayName(final Context context, String type)124 public static synchronized String getMimeTypeDisplayName(final Context context, 125 String type) { 126 if (sDisplayNameMap == null) { 127 String docName = context.getString(R.string.attachment_application_msword); 128 String presoName = context.getString(R.string.attachment_application_vnd_ms_powerpoint); 129 String sheetName = context.getString(R.string.attachment_application_vnd_ms_excel); 130 131 sDisplayNameMap = new ImmutableMap.Builder<String, String>() 132 .put("image", context.getString(R.string.attachment_image)) 133 .put("audio", context.getString(R.string.attachment_audio)) 134 .put("video", context.getString(R.string.attachment_video)) 135 .put("text", context.getString(R.string.attachment_text)) 136 .put("application/pdf", context.getString(R.string.attachment_application_pdf)) 137 138 // Documents 139 .put("application/msword", docName) 140 .put("application/vnd.openxmlformats-officedocument.wordprocessingml.document", 141 docName) 142 143 // Presentations 144 .put("application/vnd.ms-powerpoint", 145 presoName) 146 .put("application/vnd.openxmlformats-officedocument.presentationml.presentation", 147 presoName) 148 149 // Spreadsheets 150 .put("application/vnd.ms-excel", sheetName) 151 .put("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 152 sheetName) 153 154 .build(); 155 } 156 return sDisplayNameMap.get(type); 157 } 158 159 /** 160 * Cache the file specified by the given attachment. This will attempt to use any 161 * {@link ParcelFileDescriptor} in the Bundle parameter 162 * @param context 163 * @param attachment Attachment to be cached 164 * @param attachmentFds optional {@link Bundle} containing {@link ParcelFileDescriptor} if the 165 * caller has opened the files 166 * @return String file path for the cached attachment 167 */ 168 // TODO(pwestbro): Once the attachment has a field for the cached path, this method should be 169 // changed to update the attachment, and return a boolean indicating that the attachment has 170 // been cached. cacheAttachmentUri(Context context, Attachment attachment, Bundle attachmentFds)171 public static String cacheAttachmentUri(Context context, Attachment attachment, 172 Bundle attachmentFds) { 173 final File cacheDir = context.getCacheDir(); 174 175 final long totalSpace = cacheDir.getTotalSpace(); 176 if (attachment.size > 0) { 177 final long usableSpace = cacheDir.getUsableSpace() - attachment.size; 178 if (isLowSpace(totalSpace, usableSpace)) { 179 LogUtils.w(LOG_TAG, "Low memory (%d/%d). Can't cache attachment %s", 180 usableSpace, totalSpace, attachment); 181 return null; 182 } 183 } 184 InputStream inputStream = null; 185 FileOutputStream outputStream = null; 186 File file = null; 187 try { 188 final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd-kk:mm:ss"); 189 file = File.createTempFile(dateFormat.format(new Date()), ".attachment", cacheDir); 190 final ParcelFileDescriptor fileDescriptor = attachmentFds != null 191 && attachment.contentUri != null ? (ParcelFileDescriptor) attachmentFds 192 .getParcelable(attachment.contentUri.toString()) 193 : null; 194 if (fileDescriptor != null) { 195 // Get the input stream from the file descriptor 196 inputStream = new FileInputStream(fileDescriptor.getFileDescriptor()); 197 } else { 198 if (attachment.contentUri == null) { 199 // The contentUri of the attachment is null. This can happen when sending a 200 // message that has been previously saved, and the attachments had been 201 // uploaded. 202 LogUtils.d(LOG_TAG, "contentUri is null in attachment: %s", attachment); 203 throw new FileNotFoundException("Missing contentUri in attachment"); 204 } 205 // Attempt to open the file 206 inputStream = context.getContentResolver().openInputStream(attachment.contentUri); 207 } 208 outputStream = new FileOutputStream(file); 209 final long now = SystemClock.elapsedRealtime(); 210 final byte[] bytes = new byte[1024]; 211 while (true) { 212 int len = inputStream.read(bytes); 213 if (len <= 0) { 214 break; 215 } 216 outputStream.write(bytes, 0, len); 217 if (SystemClock.elapsedRealtime() - now > READ_TIMEOUT) { 218 throw new IOException("Timed out reading attachment data"); 219 } 220 } 221 outputStream.flush(); 222 String cachedFileUri = file.getAbsolutePath(); 223 LogUtils.d(LOG_TAG, "Cached %s to %s", attachment.contentUri, cachedFileUri); 224 225 final long usableSpace = cacheDir.getUsableSpace(); 226 if (isLowSpace(totalSpace, usableSpace)) { 227 file.delete(); 228 LogUtils.w(LOG_TAG, "Low memory (%d/%d). Can't cache attachment %s", 229 usableSpace, totalSpace, attachment); 230 cachedFileUri = null; 231 } 232 233 return cachedFileUri; 234 } catch (IOException | SecurityException e) { 235 // Catch any exception here to allow for unexpected failures during caching se we don't 236 // leave app in inconsistent state as we call this method outside of a transaction for 237 // performance reasons. 238 LogUtils.e(LOG_TAG, e, "Failed to cache attachment %s", attachment); 239 if (file != null) { 240 file.delete(); 241 } 242 return null; 243 } finally { 244 try { 245 if (inputStream != null) { 246 inputStream.close(); 247 } 248 if (outputStream != null) { 249 outputStream.close(); 250 } 251 } catch (IOException e) { 252 LogUtils.w(LOG_TAG, e, "Failed to close stream"); 253 } 254 } 255 } 256 isLowSpace(long totalSpace, long usableSpace)257 private static boolean isLowSpace(long totalSpace, long usableSpace) { 258 // For caching attachments we want to enable caching if there is 259 // more than 100MB available, or if 25% of total space is free on devices 260 // where the cache partition is < 400MB. 261 return usableSpace < 262 Math.min(totalSpace * MIN_CACHE_THRESHOLD, MIN_CACHE_AVAILABLE_SPACE_BYTES); 263 } 264 265 /** 266 * Checks if the attachment can be downloaded with the current network 267 * connection. 268 * 269 * @param attachment the attachment to be checked 270 * @return true if the attachment can be downloaded. 271 */ canDownloadAttachment(Context context, Attachment attachment)272 public static boolean canDownloadAttachment(Context context, Attachment attachment) { 273 ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService( 274 Context.CONNECTIVITY_SERVICE); 275 NetworkInfo info = connectivityManager.getActiveNetworkInfo(); 276 if (info == null) { 277 return false; 278 } else if (info.isConnected()) { 279 if (info.getType() != ConnectivityManager.TYPE_MOBILE) { 280 // not mobile network 281 return true; 282 } else { 283 // mobile network 284 Long maxBytes = DownloadManager.getMaxBytesOverMobile(context); 285 return maxBytes == null || attachment == null || attachment.size <= maxBytes; 286 } 287 } else { 288 return false; 289 } 290 } 291 } 292