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