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 
AttachmentsView(Context context)58     public AttachmentsView(Context context) {
59         this(context, null);
60     }
61 
AttachmentsView(Context context, AttributeSet attrs)62     public AttachmentsView(Context context, AttributeSet attrs) {
63         super(context, attrs);
64         mAttachments = Lists.newArrayList();
65     }
66 
67     @Override
onFinishInflate()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 
expandView()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      */
setAttachmentChangesListener(AttachmentAddedOrDeletedListener listener)89     public void setAttachmentChangesListener(AttachmentAddedOrDeletedListener listener) {
90         mChangeListener = listener;
91     }
92 
93     /**
94      * Adds an attachment and updates the ui accordingly.
95      */
addAttachment(final Attachment attachment)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
deleteAttachment(final View attachmentView, final Attachment attachment)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      */
getAttachments()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      */
getAttachmentPreviews()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      */
setAttachmentPreviews(ArrayList<AttachmentPreview> previews)173     public void setAttachmentPreviews(ArrayList<AttachmentPreview> previews) {
174         mTileGrid.setAttachmentPreviews(previews);
175     }
176 
177     /**
178      * Delete all attachments being managed by this view.
179      */
deleteAllAttachments()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      */
getTotalAttachmentsSize()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 {
onAttachmentDeleted()203         public void onAttachmentDeleted();
204 
onAttachmentAdded()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      */
generateLocalAttachment(Uri contentUri)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      */
addAttachment(Account account, Attachment attachment)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 
getSizeFromFile(Uri uri, ContentResolver contentResolver)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      */
getOptionalColumn(ContentResolver contentResolver, Uri uri, String columnName)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 
focusLastAttachment()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 
AttachmentFailureException(String detailMessage)389         public AttachmentFailureException(String detailMessage) {
390             super(detailMessage);
391             this.errorRes = R.string.generic_attachment_problem;
392         }
393 
AttachmentFailureException(String error, int errorRes)394         public AttachmentFailureException(String error, int errorRes) {
395             super(error);
396             this.errorRes = errorRes;
397         }
398 
AttachmentFailureException(String detailMessage, Throwable throwable)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          */
getErrorRes()408         public int getErrorRes() {
409             return errorRes;
410         }
411     }
412 }
413