Home | History | Annotate | Download | only in compose
      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 
     62     public AttachmentsView(Context context) {
     63         this(context, null);
     64     }
     65 
     66     public AttachmentsView(Context context, AttributeSet attrs) {
     67         super(context, attrs);
     68         mAttachments = Lists.newArrayList();
     69     }
     70 
     71     @Override
     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 
     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      */
     93     public void setAttachmentChangesListener(AttachmentAddedOrDeletedListener listener) {
     94         mChangeListener = listener;
     95     }
     96 
     97     /**
     98      * Adds an attachment and updates the ui accordingly.
     99      */
    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
    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      */
    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      */
    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      */
    177     public void setAttachmentPreviews(ArrayList<AttachmentPreview> previews) {
    178         mTileGrid.setAttachmentPreviews(previews);
    179     }
    180 
    181     /**
    182      * Delete all attachments being managed by this view.
    183      */
    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      */
    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 {
    207         public void onAttachmentDeleted();
    208 
    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)
    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      */
    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      */
    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      */
    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      */
    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 
    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 
    458         public AttachmentFailureException(String detailMessage) {
    459             super(detailMessage);
    460             this.errorRes = R.string.generic_attachment_problem;
    461         }
    462 
    463         public AttachmentFailureException(String error, int errorRes) {
    464             super(error);
    465             this.errorRes = errorRes;
    466         }
    467 
    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          */
    477         public int getErrorRes() {
    478             return errorRes;
    479         }
    480     }
    481 }
    482