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.database.Cursor;
21import android.database.sqlite.SQLiteException;
22import android.net.Uri;
23import android.os.ParcelFileDescriptor;
24import android.provider.OpenableColumns;
25import android.text.TextUtils;
26import android.util.AttributeSet;
27import android.view.View;
28import android.view.ViewGroup;
29import android.view.inputmethod.InputMethodManager;
30import android.widget.LinearLayout;
31
32import com.android.mail.R;
33import com.android.mail.providers.Account;
34import com.android.mail.providers.Attachment;
35import com.android.mail.ui.AttachmentTile;
36import com.android.mail.ui.AttachmentTile.AttachmentPreview;
37import com.android.mail.ui.AttachmentTileGrid;
38import com.android.mail.utils.LogTag;
39import com.android.mail.utils.LogUtils;
40import com.google.common.annotations.VisibleForTesting;
41import com.google.common.collect.Lists;
42
43import java.io.FileNotFoundException;
44import java.io.IOException;
45import java.util.ArrayList;
46
47/*
48 * View for displaying attachments in the compose screen.
49 */
50class 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
58    public AttachmentsView(Context context) {
59        this(context, null);
60    }
61
62    public AttachmentsView(Context context, AttributeSet attrs) {
63        super(context, attrs);
64        mAttachments = Lists.newArrayList();
65    }
66
67    @Override
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
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     */
89    public void setAttachmentChangesListener(AttachmentAddedOrDeletedListener listener) {
90        mChangeListener = listener;
91    }
92
93    /**
94     * Adds an attachment and updates the ui accordingly.
95     */
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
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     */
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     */
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     */
173    public void setAttachmentPreviews(ArrayList<AttachmentPreview> previews) {
174        mTileGrid.setAttachmentPreviews(previews);
175    }
176
177    /**
178     * Delete all attachments being managed by this view.
179     */
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     */
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 {
203        public void onAttachmentDeleted();
204
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     */
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     */
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
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     */
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
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
389        public AttachmentFailureException(String detailMessage) {
390            super(detailMessage);
391            this.errorRes = R.string.generic_attachment_problem;
392        }
393
394        public AttachmentFailureException(String error, int errorRes) {
395            super(error);
396            this.errorRes = errorRes;
397        }
398
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         */
408        public int getErrorRes() {
409            return errorRes;
410        }
411    }
412}
413