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