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