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