1 /** 2 * Copyright (c) 2011, Google Inc. 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.compose; 17 18 import android.annotation.TargetApi; 19 import android.content.ContentResolver; 20 import android.content.Context; 21 import android.database.Cursor; 22 import android.database.sqlite.SQLiteException; 23 import android.net.Uri; 24 import android.os.ParcelFileDescriptor; 25 import android.provider.DocumentsContract; 26 import android.provider.OpenableColumns; 27 import android.text.TextUtils; 28 import android.util.AttributeSet; 29 import android.view.View; 30 import android.view.ViewGroup; 31 import android.view.inputmethod.InputMethodManager; 32 import android.webkit.MimeTypeMap; 33 import android.widget.LinearLayout; 34 35 import com.android.mail.R; 36 import com.android.mail.providers.Account; 37 import com.android.mail.providers.Attachment; 38 import com.android.mail.ui.AttachmentTile; 39 import com.android.mail.ui.AttachmentTile.AttachmentPreview; 40 import com.android.mail.ui.AttachmentTileGrid; 41 import com.android.mail.utils.LogTag; 42 import com.android.mail.utils.LogUtils; 43 import com.android.mail.utils.Utils; 44 import com.google.common.annotations.VisibleForTesting; 45 import com.google.common.collect.Lists; 46 47 import java.io.FileNotFoundException; 48 import java.io.IOException; 49 import java.util.ArrayList; 50 51 /* 52 * View for displaying attachments in the compose screen. 53 */ 54 class AttachmentsView extends LinearLayout { 55 private static final String LOG_TAG = LogTag.getLogTag(); 56 57 private final ArrayList<Attachment> mAttachments; 58 private AttachmentAddedOrDeletedListener mChangeListener; 59 private AttachmentTileGrid mTileGrid; 60 private LinearLayout mAttachmentLayout; 61 AttachmentsView(Context context)62 public AttachmentsView(Context context) { 63 this(context, null); 64 } 65 AttachmentsView(Context context, AttributeSet attrs)66 public AttachmentsView(Context context, AttributeSet attrs) { 67 super(context, attrs); 68 mAttachments = Lists.newArrayList(); 69 } 70 71 @Override onFinishInflate()72 protected void onFinishInflate() { 73 super.onFinishInflate(); 74 75 mTileGrid = (AttachmentTileGrid) findViewById(R.id.attachment_tile_grid); 76 mAttachmentLayout = (LinearLayout) findViewById(R.id.attachment_bar_list); 77 } 78 expandView()79 public void expandView() { 80 mTileGrid.setVisibility(VISIBLE); 81 mAttachmentLayout.setVisibility(VISIBLE); 82 83 InputMethodManager imm = (InputMethodManager) getContext().getSystemService( 84 Context.INPUT_METHOD_SERVICE); 85 if (imm != null) { 86 imm.hideSoftInputFromWindow(getWindowToken(), 0); 87 } 88 } 89 90 /** 91 * Set a listener for changes to the attachments. 92 */ setAttachmentChangesListener(AttachmentAddedOrDeletedListener listener)93 public void setAttachmentChangesListener(AttachmentAddedOrDeletedListener listener) { 94 mChangeListener = listener; 95 } 96 97 /** 98 * Adds an attachment and updates the ui accordingly. 99 */ addAttachment(final Attachment attachment)100 private void addAttachment(final Attachment attachment) { 101 mAttachments.add(attachment); 102 103 // If the attachment is inline do not display this attachment. 104 if (attachment.isInlineAttachment()) { 105 return; 106 } 107 108 if (!isShown()) { 109 setVisibility(View.VISIBLE); 110 } 111 112 expandView(); 113 114 // If we have an attachment that should be shown in a tiled look, 115 // set up the tile and add it to the tile grid. 116 if (AttachmentTile.isTiledAttachment(attachment)) { 117 final ComposeAttachmentTile attachmentTile = 118 mTileGrid.addComposeTileFromAttachment(attachment); 119 attachmentTile.addDeleteListener(new OnClickListener() { 120 @Override 121 public void onClick(View v) { 122 deleteAttachment(attachmentTile, attachment); 123 } 124 }); 125 // Otherwise, use the old bar look and add it to the new 126 // inner LinearLayout. 127 } else { 128 final AttachmentComposeView attachmentView = 129 new AttachmentComposeView(getContext(), attachment); 130 131 attachmentView.addDeleteListener(new OnClickListener() { 132 @Override 133 public void onClick(View v) { 134 deleteAttachment(attachmentView, attachment); 135 } 136 }); 137 138 139 mAttachmentLayout.addView(attachmentView, new LinearLayout.LayoutParams( 140 LinearLayout.LayoutParams.MATCH_PARENT, 141 LinearLayout.LayoutParams.MATCH_PARENT)); 142 } 143 if (mChangeListener != null) { 144 mChangeListener.onAttachmentAdded(); 145 } 146 } 147 148 @VisibleForTesting deleteAttachment(final View attachmentView, final Attachment attachment)149 protected void deleteAttachment(final View attachmentView, 150 final Attachment attachment) { 151 mAttachments.remove(attachment); 152 ((ViewGroup) attachmentView.getParent()).removeView(attachmentView); 153 if (mChangeListener != null) { 154 mChangeListener.onAttachmentDeleted(); 155 } 156 } 157 158 /** 159 * Get all attachments being managed by this view. 160 * @return attachments. 161 */ getAttachments()162 public ArrayList<Attachment> getAttachments() { 163 return mAttachments; 164 } 165 166 /** 167 * Get all attachments previews that have been loaded 168 * @return attachments previews. 169 */ getAttachmentPreviews()170 public ArrayList<AttachmentPreview> getAttachmentPreviews() { 171 return mTileGrid.getAttachmentPreviews(); 172 } 173 174 /** 175 * Call this on restore instance state so previews persist across configuration changes 176 */ setAttachmentPreviews(ArrayList<AttachmentPreview> previews)177 public void setAttachmentPreviews(ArrayList<AttachmentPreview> previews) { 178 mTileGrid.setAttachmentPreviews(previews); 179 } 180 181 /** 182 * Delete all attachments being managed by this view. 183 */ deleteAllAttachments()184 public void deleteAllAttachments() { 185 mAttachments.clear(); 186 mTileGrid.removeAllViews(); 187 mAttachmentLayout.removeAllViews(); 188 setVisibility(GONE); 189 } 190 191 /** 192 * Get the total size of all attachments currently in this view. 193 */ getTotalAttachmentsSize()194 private long getTotalAttachmentsSize() { 195 long totalSize = 0; 196 for (Attachment attachment : mAttachments) { 197 totalSize += attachment.size; 198 } 199 return totalSize; 200 } 201 202 /** 203 * Interface to implement to be notified about changes to the attachments 204 * explicitly made by the user. 205 */ 206 public interface AttachmentAddedOrDeletedListener { onAttachmentDeleted()207 public void onAttachmentDeleted(); 208 onAttachmentAdded()209 public void onAttachmentAdded(); 210 } 211 212 /** 213 * Checks if the passed Uri is a virtual document. 214 * 215 * @param contentUri 216 * @return true if virtual, false if regular. 217 */ 218 @TargetApi(24) isVirtualDocument(Uri contentUri)219 private boolean isVirtualDocument(Uri contentUri) { 220 // For SAF files, check if it's a virtual document. 221 if (!DocumentsContract.isDocumentUri(getContext(), contentUri)) { 222 return false; 223 } 224 225 final ContentResolver contentResolver = getContext().getContentResolver(); 226 final Cursor metadataCursor = contentResolver.query(contentUri, 227 new String[] { DocumentsContract.Document.COLUMN_FLAGS }, null, null, null); 228 if (metadataCursor != null) { 229 try { 230 int flags = 0; 231 if (metadataCursor.moveToNext()) { 232 flags = metadataCursor.getInt(0); 233 } 234 if ((flags & DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0) { 235 return true; 236 } 237 } finally { 238 metadataCursor.close(); 239 } 240 } 241 242 return false; 243 } 244 245 /** 246 * Generate an {@link Attachment} object for a given local content URI. Attempts to populate 247 * the {@link Attachment#name}, {@link Attachment#size}, and {@link Attachment#contentType} 248 * fields using a {@link ContentResolver}. 249 * 250 * @param contentUri 251 * @return an Attachment object 252 * @throws AttachmentFailureException 253 */ generateLocalAttachment(Uri contentUri)254 public Attachment generateLocalAttachment(Uri contentUri) throws AttachmentFailureException { 255 if (contentUri == null || TextUtils.isEmpty(contentUri.getPath())) { 256 throw new AttachmentFailureException("Failed to create local attachment"); 257 } 258 259 // FIXME: do not query resolver for type on the UI thread 260 final ContentResolver contentResolver = getContext().getContentResolver(); 261 String contentType = contentResolver.getType(contentUri); 262 263 if (contentType == null) contentType = ""; 264 265 final Attachment attachment = new Attachment(); 266 attachment.uri = null; // URI will be assigned by the provider upon send/save 267 attachment.contentUri = contentUri; 268 attachment.thumbnailUri = contentUri; 269 270 Cursor metadataCursor = null; 271 String name = null; 272 int size = -1; // Unknown, will be determined either now or in the service 273 final boolean isVirtual = Utils.isRunningNOrLater() 274 ? isVirtualDocument(contentUri) : false; 275 276 if (isVirtual) { 277 final String[] mimeTypes = contentResolver.getStreamTypes(contentUri, "*/*"); 278 if (mimeTypes != null && mimeTypes.length > 0) { 279 attachment.virtualMimeType = mimeTypes[0]; 280 } else{ 281 throw new AttachmentFailureException( 282 "Cannot attach a virtual document without any streamable format."); 283 } 284 } 285 286 try { 287 metadataCursor = contentResolver.query( 288 contentUri, new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}, 289 null, null, null); 290 if (metadataCursor != null) { 291 try { 292 if (metadataCursor.moveToNext()) { 293 name = metadataCursor.getString(0); 294 // For virtual document this size is not the one which will be attached, 295 // so ignore it. 296 if (!isVirtual) { 297 size = metadataCursor.getInt(1); 298 } 299 } 300 } finally { 301 metadataCursor.close(); 302 } 303 } 304 } catch (SQLiteException ex) { 305 // One of the two columns is probably missing, let's make one more attempt to get at 306 // least one. 307 // Note that the documentations in Intent#ACTION_OPENABLE and 308 // OpenableColumns seem to contradict each other about whether these columns are 309 // required, but it doesn't hurt to fail properly. 310 311 // Let's try to get DISPLAY_NAME 312 try { 313 metadataCursor = getOptionalColumn(contentResolver, contentUri, 314 OpenableColumns.DISPLAY_NAME); 315 if (metadataCursor != null && metadataCursor.moveToNext()) { 316 name = metadataCursor.getString(0); 317 } 318 } finally { 319 if (metadataCursor != null) metadataCursor.close(); 320 } 321 322 // Let's try to get SIZE 323 if (!isVirtual) { 324 try { 325 metadataCursor = 326 getOptionalColumn(contentResolver, contentUri, OpenableColumns.SIZE); 327 if (metadataCursor != null && metadataCursor.moveToNext()) { 328 size = metadataCursor.getInt(0); 329 } else { 330 // Unable to get the size from the metadata cursor. Open the file and seek. 331 size = getSizeFromFile(contentUri, contentResolver); 332 } 333 } finally { 334 if (metadataCursor != null) metadataCursor.close(); 335 } 336 } 337 } catch (SecurityException e) { 338 throw new AttachmentFailureException("Security Exception from attachment uri", e); 339 } 340 341 if (name == null) { 342 name = contentUri.getLastPathSegment(); 343 } 344 345 // For virtual files append the inferred extension name. 346 if (isVirtual) { 347 String extension = MimeTypeMap.getSingleton() 348 .getExtensionFromMimeType(attachment.virtualMimeType); 349 if (extension != null) { 350 name += "." + extension; 351 } 352 } 353 354 // TODO: This can't work with pipes. Fix it. 355 if (size == -1 && !isVirtual) { 356 // if the attachment is not a content:// for example, a file:// URI 357 size = getSizeFromFile(contentUri, contentResolver); 358 } 359 360 // Save the computed values into the attachment. 361 attachment.size = size; 362 attachment.setName(name); 363 attachment.setContentType(contentType); 364 365 return attachment; 366 } 367 368 /** 369 * Adds an attachment of either local or remote origin, checking to see if the attachment 370 * exceeds file size limits. 371 * @param account 372 * @param attachment the attachment to be added. 373 * 374 * @throws AttachmentFailureException if an error occurs adding the attachment. 375 */ addAttachment(Account account, Attachment attachment)376 public void addAttachment(Account account, Attachment attachment) 377 throws AttachmentFailureException { 378 final int maxSize = account.settings.getMaxAttachmentSize(); 379 380 // The attachment size is known and it's too big. 381 if (attachment.size > maxSize) { 382 throw new AttachmentFailureException( 383 "Attachment too large to attach", R.string.too_large_to_attach_single); 384 } else if (attachment.size != -1 && (getTotalAttachmentsSize() 385 + attachment.size) > maxSize) { 386 throw new AttachmentFailureException( 387 "Attachment too large to attach", R.string.too_large_to_attach_additional); 388 } else { 389 addAttachment(attachment); 390 } 391 } 392 393 /** 394 * @return size of the file or -1 if unknown. 395 */ getSizeFromFile(Uri uri, ContentResolver contentResolver)396 private static int getSizeFromFile(Uri uri, ContentResolver contentResolver) { 397 int size = -1; 398 ParcelFileDescriptor file = null; 399 try { 400 file = contentResolver.openFileDescriptor(uri, "r"); 401 size = (int) file.getStatSize(); 402 } catch (FileNotFoundException e) { 403 LogUtils.w(LOG_TAG, e, "Error opening file to obtain size."); 404 } finally { 405 try { 406 if (file != null) { 407 file.close(); 408 } 409 } catch (IOException e) { 410 LogUtils.w(LOG_TAG, "Error closing file opened to obtain size."); 411 } 412 } 413 return size; 414 } 415 416 /** 417 * @return a cursor to the requested column or null if an exception occurs while trying 418 * to query it. 419 */ getOptionalColumn(ContentResolver contentResolver, Uri uri, String columnName)420 private static Cursor getOptionalColumn(ContentResolver contentResolver, Uri uri, 421 String columnName) { 422 Cursor result = null; 423 try { 424 result = contentResolver.query(uri, new String[]{columnName}, null, null, null); 425 } catch (SQLiteException ex) { 426 // ignore, leave result null 427 } 428 return result; 429 } 430 focusLastAttachment()431 public void focusLastAttachment() { 432 Attachment lastAttachment = mAttachments.get(mAttachments.size() - 1); 433 View lastView = null; 434 int last = 0; 435 if (AttachmentTile.isTiledAttachment(lastAttachment)) { 436 last = mTileGrid.getChildCount() - 1; 437 if (last > 0) { 438 lastView = mTileGrid.getChildAt(last); 439 } 440 } else { 441 last = mAttachmentLayout.getChildCount() - 1; 442 if (last > 0) { 443 lastView = mAttachmentLayout.getChildAt(last); 444 } 445 } 446 if (lastView != null) { 447 lastView.requestFocus(); 448 } 449 } 450 451 /** 452 * Class containing information about failures when adding attachments. 453 */ 454 static class AttachmentFailureException extends Exception { 455 private static final long serialVersionUID = 1L; 456 private final int errorRes; 457 AttachmentFailureException(String detailMessage)458 public AttachmentFailureException(String detailMessage) { 459 super(detailMessage); 460 this.errorRes = R.string.generic_attachment_problem; 461 } 462 AttachmentFailureException(String error, int errorRes)463 public AttachmentFailureException(String error, int errorRes) { 464 super(error); 465 this.errorRes = errorRes; 466 } 467 AttachmentFailureException(String detailMessage, Throwable throwable)468 public AttachmentFailureException(String detailMessage, Throwable throwable) { 469 super(detailMessage, throwable); 470 this.errorRes = R.string.generic_attachment_problem; 471 } 472 473 /** 474 * Get the error string resource that corresponds to this attachment failure. Always a valid 475 * string resource. 476 */ getErrorRes()477 public int getErrorRes() { 478 return errorRes; 479 } 480 } 481 } 482