AttachmentsView.java revision b334c9035e9b7a38766bb66c29da2208525d1e11
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 */
16package com.android.mail.compose;
17
18import android.content.ContentResolver;
19import android.content.Context;
20import android.content.res.Resources;
21import android.database.Cursor;
22import android.database.sqlite.SQLiteException;
23import android.net.Uri;
24import android.os.ParcelFileDescriptor;
25import android.provider.OpenableColumns;
26import android.text.TextUtils;
27import android.util.AttributeSet;
28import android.view.Gravity;
29import android.view.View;
30import android.view.View.OnClickListener;
31import android.view.ViewGroup;
32import android.widget.GridLayout;
33import android.widget.ImageView;
34import android.widget.LinearLayout;
35import android.widget.TextView;
36import android.widget.Toast;
37
38import com.android.mail.R;
39import com.android.mail.providers.Account;
40import com.android.mail.providers.Attachment;
41import com.android.mail.providers.Message;
42import com.android.mail.providers.UIProvider;
43import com.android.mail.ui.AttachmentTile;
44import com.android.mail.ui.AttachmentTileGrid;
45import com.android.mail.utils.LogTag;
46import com.android.mail.utils.LogUtils;
47import com.google.common.annotations.VisibleForTesting;
48import com.google.common.collect.Lists;
49
50import java.io.FileNotFoundException;
51import java.io.IOException;
52import java.util.ArrayList;
53
54/*
55 * View for displaying attachments in the compose screen.
56 */
57class AttachmentsView extends LinearLayout implements OnClickListener {
58    private static final String LOG_TAG = LogTag.getLogTag();
59
60    private final Resources mResources;
61
62    private ArrayList<Attachment> mAttachments;
63    private AttachmentDeletedListener mChangeListener;
64    private AttachmentTileGrid mTileGrid;
65    private LinearLayout mAttachmentLayout;
66    private GridLayout mCollapseLayout;
67    private TextView mCollapseText;
68    private ImageView mCollapseCaret;
69
70    private boolean mIsExpanded;
71
72    public AttachmentsView(Context context) {
73        this(context, null);
74    }
75
76    public AttachmentsView(Context context, AttributeSet attrs) {
77        super(context, attrs);
78        mAttachments = Lists.newArrayList();
79        mResources = context.getResources();
80    }
81
82    @Override
83    protected void onFinishInflate() {
84        super.onFinishInflate();
85
86        mTileGrid = (AttachmentTileGrid) findViewById(R.id.attachment_tile_grid);
87        mAttachmentLayout = (LinearLayout) findViewById(R.id.attachment_bar_list);
88        mCollapseLayout = (GridLayout) findViewById(R.id.attachment_collapse_view);
89        mCollapseText = (TextView) findViewById(R.id.attachment_collapse_text);
90        mCollapseCaret = (ImageView) findViewById(R.id.attachment_collapse_caret);
91
92        mCollapseLayout.setOnClickListener(this);
93    }
94
95    @Override
96    public void onClick(View view) {
97        switch (view.getId()) {
98            case R.id.attachment_collapse_view:
99                if (mIsExpanded) {
100                    collapseView();
101                } else {
102                    expandView();
103                }
104                break;
105        }
106    }
107
108    public void expandView() {
109        mTileGrid.setVisibility(VISIBLE);
110        mAttachmentLayout.setVisibility(VISIBLE);
111        setupCollapsibleView(false);
112        mIsExpanded = true;
113    }
114
115    public void collapseView() {
116        mTileGrid.setVisibility(GONE);
117        mAttachmentLayout.setVisibility(GONE);
118
119        // If there are some attachments, show the preview
120        if (!mAttachments.isEmpty()) {
121            setupCollapsibleView(true);
122            mCollapseLayout.setVisibility(VISIBLE);
123        }
124
125        mIsExpanded = false;
126    }
127
128    private void setupCollapsibleView(boolean isCollapsed) {
129        // setup text
130        final int numAttachments = mAttachments.size();
131        final String attachmentText = mResources.getQuantityString(
132                R.plurals.number_of_attachments, numAttachments, numAttachments);
133        mCollapseText.setText(attachmentText);
134
135        if (isCollapsed) {
136            mCollapseCaret.setImageResource(R.drawable.ic_menu_expander_minimized_holo_light);
137        } else {
138            mCollapseCaret.setImageResource(R.drawable.ic_menu_expander_maximized_holo_light);
139        }
140    }
141
142    /**
143     * Set a listener for changes to the attachments.
144     * @param listener
145     */
146    public void setAttachmentChangesListener(AttachmentDeletedListener listener) {
147        mChangeListener = listener;
148    }
149
150    /**
151     * Add an attachment and update the ui accordingly.
152     * @param attachment
153     */
154    public void addAttachment(final Attachment attachment) {
155        if (!isShown()) {
156            setVisibility(View.VISIBLE);
157        }
158
159        mAttachments.add(attachment);
160        expandView();
161
162        // If we have an attachment that should be shown in a tiled look,
163        // set up the tile and add it to the tile grid.
164        if (AttachmentTile.isTiledAttachment(attachment)) {
165            final ComposeAttachmentTile attachmentTile =
166                    mTileGrid.addComposeTileFromAttachment(attachment);
167            attachmentTile.addDeleteListener(new OnClickListener() {
168                @Override
169                public void onClick(View v) {
170                    deleteAttachment(attachmentTile, attachment);
171                }
172            });
173        // Otherwise, use the old bar look and add it to the new
174        // inner LinearLayout.
175        } else {
176            final AttachmentComposeView attachmentView =
177                new AttachmentComposeView(getContext(), attachment);
178
179            attachmentView.addDeleteListener(new OnClickListener() {
180                @Override
181                public void onClick(View v) {
182                    deleteAttachment(attachmentView, attachment);
183                }
184            });
185
186
187            mAttachmentLayout.addView(attachmentView, new LinearLayout.LayoutParams(
188                    LinearLayout.LayoutParams.MATCH_PARENT,
189                    LinearLayout.LayoutParams.MATCH_PARENT));
190        }
191    }
192
193    @VisibleForTesting
194    protected void deleteAttachment(final View attachmentView,
195            final Attachment attachment) {
196        mAttachments.remove(attachment);
197        ((ViewGroup) attachmentView.getParent()).removeView(attachmentView);
198        if (mChangeListener != null) {
199            mChangeListener.onAttachmentDeleted();
200        }
201        if (mAttachments.size() == 0) {
202            setVisibility(View.GONE);
203            collapseView();
204        } else {
205            setupCollapsibleView(true);
206        }
207    }
208
209    /**
210     * Get all attachments being managed by this view.
211     * @return attachments.
212     */
213    public ArrayList<Attachment> getAttachments() {
214        return mAttachments;
215    }
216
217    /**
218     * Delete all attachments being managed by this view.
219     */
220    public void deleteAllAttachments() {
221        mAttachments.clear();
222        mTileGrid.removeAllViews();
223        mAttachmentLayout.removeAllViews();
224    }
225
226    /**
227     * Get the total size of all attachments currently in this view.
228     */
229    public long getTotalAttachmentsSize() {
230        long totalSize = 0;
231        for (Attachment attachment : mAttachments) {
232            totalSize += attachment.size;
233        }
234        return totalSize;
235    }
236
237    /**
238     * Interface to implement to be notified about changes to the attachments.
239     *
240     */
241    public interface AttachmentDeletedListener {
242        public void onAttachmentDeleted();
243    }
244
245    /**
246     * When an attachment is too large to be added to a message, show a toast.
247     * This method also updates the position of the toast so that it is shown
248     * clearly above they keyboard if it happens to be open.
249     */
250    private void showAttachmentTooBigToast() {
251        showErrorToast(R.string.too_large_to_attach);
252    }
253
254    private void showGenericAttachmentError() {
255        showErrorToast(R.string.generic_attachment_problem);
256    }
257
258    private void showErrorToast(int resId) {
259        Toast t = Toast.makeText(getContext(), resId,
260                Toast.LENGTH_LONG);
261        t.setText(resId);
262        t.setGravity(Gravity.CENTER_HORIZONTAL, 0,
263                getResources().getDimensionPixelSize(R.dimen.attachment_toast_yoffset));
264        t.show();
265    }
266
267    /**
268     * Generate an {@link Attachment} object for a given local content URI. Attempts to populate
269     * the {@link Attachment#name}, {@link Attachment#size}, and {@link Attachment#contentType}
270     * fields using a {@link ContentResolver}.
271     *
272     * @param contentUri
273     * @return an Attachment object
274     * @throws AttachmentFailureException
275     */
276    public Attachment generateLocalAttachment(Uri contentUri) throws AttachmentFailureException {
277        // FIXME: do not query resolver for type on the UI thread
278        final ContentResolver contentResolver = getContext().getContentResolver();
279        String contentType = contentResolver.getType(contentUri);
280        if (contentUri == null || TextUtils.isEmpty(contentUri.getPath())) {
281            showGenericAttachmentError();
282            throw new AttachmentFailureException("Attachment too large to attach");
283        }
284
285        if (contentType == null) contentType = "";
286
287        final Attachment attachment = new Attachment();
288        attachment.uri = null; // URI will be assigned by the provider upon send/save
289        attachment.name = null;
290        attachment.contentType = contentType;
291        attachment.size = 0;
292        attachment.contentUri = contentUri;
293
294        Cursor metadataCursor = null;
295        try {
296            metadataCursor = contentResolver.query(
297                    contentUri, new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE},
298                    null, null, null);
299            if (metadataCursor != null) {
300                try {
301                    if (metadataCursor.moveToNext()) {
302                        attachment.name = metadataCursor.getString(0);
303                        attachment.size = metadataCursor.getInt(1);
304                    }
305                } finally {
306                    metadataCursor.close();
307                }
308            }
309        } catch (SQLiteException ex) {
310            // One of the two columns is probably missing, let's make one more attempt to get at
311            // least one.
312            // Note that the documentations in Intent#ACTION_OPENABLE and
313            // OpenableColumns seem to contradict each other about whether these columns are
314            // required, but it doesn't hurt to fail properly.
315
316            // Let's try to get DISPLAY_NAME
317            try {
318                metadataCursor = getOptionalColumn(contentResolver, contentUri,
319                        OpenableColumns.DISPLAY_NAME);
320                if (metadataCursor != null && metadataCursor.moveToNext()) {
321                    attachment.name = metadataCursor.getString(0);
322                }
323            } finally {
324                if (metadataCursor != null) metadataCursor.close();
325            }
326
327            // Let's try to get SIZE
328            try {
329                metadataCursor =
330                        getOptionalColumn(contentResolver, contentUri, OpenableColumns.SIZE);
331                if (metadataCursor != null && metadataCursor.moveToNext()) {
332                    attachment.size = metadataCursor.getInt(0);
333                } else {
334                    // Unable to get the size from the metadata cursor. Open the file and seek.
335                    attachment.size = getSizeFromFile(contentUri, contentResolver);
336                }
337            } finally {
338                if (metadataCursor != null) metadataCursor.close();
339            }
340        } catch (SecurityException e) {
341            // We received a security exception when attempting to add an
342            // attachment.  Warn the user.
343            // TODO(pwestbro): determine if we need more specific text in the toast.
344            Toast.makeText(getContext(),
345                    R.string.generic_attachment_problem, Toast.LENGTH_LONG).show();
346            throw new AttachmentFailureException("Security Exception from attachment uri", e);
347        }
348
349        if (attachment.name == null) {
350            attachment.name = contentUri.getLastPathSegment();
351        }
352
353        return attachment;
354    }
355
356    /**
357     * Adds a local attachment by file path.
358     * @param account
359     * @param contentUri the uri of the local file path
360     *
361     * @return size of the attachment added.
362     * @throws AttachmentFailureException if an error occurs adding the attachment.
363     */
364    public long addAttachment(Account account, Uri contentUri)
365            throws AttachmentFailureException {
366        return addAttachment(account, generateLocalAttachment(contentUri));
367    }
368
369    /**
370     * Adds an attachment of either local or remote origin, checking to see if the attachment
371     * exceeds file size limits.
372     * @param account
373     * @param attachment the attachment to be added.
374     *
375     * @return size of the attachment added.
376     * @throws AttachmentFailureException if an error occurs adding the attachment.
377     */
378    public long addAttachment(Account account, Attachment attachment)
379            throws AttachmentFailureException {
380        int maxSize = UIProvider.getMailMaxAttachmentSize(account.name);
381
382        // Error getting the size or the size was too big.
383        // FIXME: exceptions should not be used to direct control flow
384        if (attachment.size == -1 || attachment.size > maxSize) {
385            showAttachmentTooBigToast();
386            throw new AttachmentFailureException("Attachment too large to attach");
387        } else if ((getTotalAttachmentsSize()
388                + attachment.size) > maxSize) {
389            showAttachmentTooBigToast();
390            throw new AttachmentFailureException("Attachment too large to attach");
391        } else {
392            addAttachment(attachment);
393        }
394
395        return attachment.size;
396    }
397
398
399    public void addAttachments(Account account, Message refMessage) {
400        if (refMessage.hasAttachments) {
401            try {
402                for (Attachment a : refMessage.getAttachments()) {
403                    addAttachment(account, a);
404                }
405            } catch (AttachmentFailureException e) {
406                // A toast has already been shown to the user, no need to do
407                // anything.
408                LogUtils.e(LOG_TAG, e, "Error adding attachment");
409            }
410        }
411    }
412
413    @VisibleForTesting
414    protected int getSizeFromFile(Uri uri, ContentResolver contentResolver) {
415        int size = -1;
416        ParcelFileDescriptor file = null;
417        try {
418            file = contentResolver.openFileDescriptor(uri, "r");
419            size = (int) file.getStatSize();
420        } catch (FileNotFoundException e) {
421            LogUtils.w(LOG_TAG, "Error opening file to obtain size.");
422        } finally {
423            try {
424                if (file != null) {
425                    file.close();
426                }
427            } catch (IOException e) {
428                LogUtils.w(LOG_TAG, "Error closing file opened to obtain size.");
429            }
430        }
431        return size;
432    }
433
434    /**
435     * @return a cursor to the requested column or null if an exception occurs while trying
436     * to query it.
437     */
438    private Cursor getOptionalColumn(ContentResolver contentResolver, Uri uri, String columnName) {
439        Cursor result = null;
440        try {
441            result = contentResolver.query(uri, new String[]{columnName}, null, null, null);
442        } catch (SQLiteException ex) {
443            // ignore, leave result null
444        }
445        return result;
446    }
447
448    /**
449     * Class containing information about failures when adding attachments.
450     */
451    static class AttachmentFailureException extends Exception {
452        private static final long serialVersionUID = 1L;
453
454        public AttachmentFailureException(String error) {
455            super(error);
456        }
457        public AttachmentFailureException(String detailMessage, Throwable throwable) {
458            super(detailMessage, throwable);
459        }
460    }
461}
462