1/*
2 * Copyright (C) 2013 Google Inc.
3 * Licensed to The Android Open Source Project.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package com.android.mail.providers;
19
20import android.app.DownloadManager;
21import android.content.ContentProvider;
22import android.content.ContentResolver;
23import android.content.ContentValues;
24import android.content.Context;
25import android.content.Intent;
26import android.content.UriMatcher;
27import android.database.Cursor;
28import android.database.MatrixCursor;
29import android.net.Uri;
30import android.os.Environment;
31import android.os.ParcelFileDescriptor;
32import android.os.SystemClock;
33import android.text.TextUtils;
34
35import com.android.ex.photo.provider.PhotoContract;
36import com.android.mail.R;
37import com.android.mail.utils.LogTag;
38import com.android.mail.utils.LogUtils;
39import com.android.mail.utils.MimeType;
40import com.google.common.collect.Lists;
41import com.google.common.collect.Maps;
42
43import java.io.File;
44import java.io.FileInputStream;
45import java.io.FileNotFoundException;
46import java.io.FileOutputStream;
47import java.io.IOException;
48import java.io.InputStream;
49import java.io.OutputStream;
50import java.util.List;
51import java.util.Map;
52
53/**
54 * A {@link ContentProvider} for attachments created from eml files.
55 * Supports all of the semantics (query/insert/update/delete/openFile)
56 * of the regular attachment provider.
57 *
58 * One major difference is that all attachment info is stored in memory (with the
59 * exception of the attachment raw data which is stored in the cache). When
60 * the process is killed, all of the attachments disappear if they still
61 * exist.
62 */
63public class EmlAttachmentProvider extends ContentProvider {
64    private static final String LOG_TAG = LogTag.getLogTag();
65
66    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
67    private static boolean sUrisAddedToMatcher = false;
68
69    private static final int ATTACHMENT_LIST = 0;
70    private static final int ATTACHMENT = 1;
71    private static final int ATTACHMENT_BY_CID = 2;
72
73    /**
74     * The buffer size used to copy data from cache to sd card.
75     */
76    private static final int BUFFER_SIZE = 4096;
77
78    /** Any IO reads should be limited to this timeout */
79    private static final long READ_TIMEOUT = 3600 * 1000;
80
81    private static Uri BASE_URI;
82
83    private DownloadManager mDownloadManager;
84
85    /**
86     * Map that contains a mapping from an attachment list uri to a list of uris.
87     */
88    private Map<Uri, List<Uri>> mUriListMap;
89
90    /**
91     * Map that contains a mapping from an attachment uri to an {@link Attachment} object.
92     */
93    private Map<Uri, Attachment> mUriAttachmentMap;
94
95
96    @Override
97    public boolean onCreate() {
98        final String authority =
99                getContext().getResources().getString(R.string.eml_attachment_provider);
100        BASE_URI = new Uri.Builder().scheme("content").authority(authority).build();
101
102        if (!sUrisAddedToMatcher) {
103            sUrisAddedToMatcher = true;
104            sUriMatcher.addURI(authority, "attachments/*/*", ATTACHMENT_LIST);
105            sUriMatcher.addURI(authority, "attachment/*/*/#", ATTACHMENT);
106            sUriMatcher.addURI(authority, "attachmentByCid/*/*/*", ATTACHMENT_BY_CID);
107        }
108
109        mDownloadManager =
110                (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE);
111
112        mUriListMap = Maps.newHashMap();
113        mUriAttachmentMap = Maps.newHashMap();
114        return true;
115    }
116
117    @Override
118    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
119            String sortOrder) {
120        final int match = sUriMatcher.match(uri);
121        // ignore other projections
122        final MatrixCursor cursor = new MatrixCursor(UIProvider.ATTACHMENT_PROJECTION);
123        final ContentResolver cr = getContext().getContentResolver();
124
125        switch (match) {
126            case ATTACHMENT_LIST: {
127                final List<String> contentTypeQueryParameters =
128                        uri.getQueryParameters(PhotoContract.ContentTypeParameters.CONTENT_TYPE);
129                uri = uri.buildUpon().clearQuery().build();
130                final List<Uri> attachmentUris = mUriListMap.get(uri);
131                for (final Uri attachmentUri : attachmentUris) {
132                    addRow(cursor, attachmentUri, contentTypeQueryParameters);
133                }
134                cursor.setNotificationUri(cr, uri);
135                break;
136            }
137            case ATTACHMENT: {
138                addRow(cursor, mUriAttachmentMap.get(uri));
139                cursor.setNotificationUri(cr, getListUriFromAttachmentUri(uri));
140                break;
141            }
142            case ATTACHMENT_BY_CID: {
143                // form the attachment lists uri by clipping off the cid from the given uri
144                final Uri attachmentsListUri = getListUriFromAttachmentUri(uri);
145                final String cid = uri.getPathSegments().get(3);
146
147                // find all uris for the parent message
148                final List<Uri> attachmentUris = mUriListMap.get(attachmentsListUri);
149
150                if (attachmentUris != null) {
151                    // find the attachment that contains the given cid
152                    for (Uri attachmentsUri : attachmentUris) {
153                        final Attachment attachment = mUriAttachmentMap.get(attachmentsUri);
154                        if (TextUtils.equals(cid, attachment.partId)) {
155                            addRow(cursor, attachment);
156                            cursor.setNotificationUri(cr, attachmentsListUri);
157                            break;
158                        }
159                    }
160                }
161                break;
162            }
163            default:
164                break;
165        }
166
167        return cursor;
168    }
169
170    @Override
171    public String getType(Uri uri) {
172        final int match = sUriMatcher.match(uri);
173        switch (match) {
174            case ATTACHMENT:
175                return mUriAttachmentMap.get(uri).getContentType();
176            default:
177                return null;
178        }
179    }
180
181    @Override
182    public Uri insert(Uri uri, ContentValues values) {
183        final Uri listUri = getListUriFromAttachmentUri(uri);
184
185        // add mapping from uri to attachment
186        if (mUriAttachmentMap.put(uri, new Attachment(values)) == null) {
187            // only add uri to list if the list
188            // get list of attachment uris, creating if necessary
189            List<Uri> list = mUriListMap.get(listUri);
190            if (list == null) {
191                list = Lists.newArrayList();
192                mUriListMap.put(listUri, list);
193            }
194
195            list.add(uri);
196        }
197
198        return uri;
199    }
200
201    @Override
202    public int delete(Uri uri, String selection, String[] selectionArgs) {
203        final int match = sUriMatcher.match(uri);
204        switch (match) {
205            case ATTACHMENT_LIST:
206                // remove from list mapping
207                final List<Uri> attachmentUris = mUriListMap.remove(uri);
208
209                // delete each file and remove each element from the mapping
210                for (final Uri attachmentUri : attachmentUris) {
211                    mUriAttachmentMap.remove(attachmentUri);
212                }
213
214                deleteDirectory(getCacheFileDirectory(uri));
215                // return rows affected
216                return attachmentUris.size();
217            default:
218                return 0;
219        }
220    }
221
222    @Override
223    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
224        final int match = sUriMatcher.match(uri);
225        switch (match) {
226            case ATTACHMENT:
227                return copyAttachment(uri, values);
228            default:
229                return 0;
230        }
231    }
232
233    /**
234     * Adds a row to the cursor for the attachment at the specific attachment {@link Uri}
235     * if the attachment's mime type matches one of the query parameters.
236     *
237     * Matching is defined to be starting with one of the query parameters. If no
238     * parameters exist, all rows are added.
239     */
240    private void addRow(MatrixCursor cursor, Uri uri,
241            List<String> contentTypeQueryParameters) {
242        final Attachment attachment = mUriAttachmentMap.get(uri);
243
244        if (contentTypeQueryParameters != null && !contentTypeQueryParameters.isEmpty()) {
245            for (final String type : contentTypeQueryParameters) {
246                if (attachment.getContentType().startsWith(type)) {
247                    addRow(cursor, attachment);
248                    return;
249                }
250            }
251        } else {
252            addRow(cursor, attachment);
253        }
254    }
255
256    /**
257     * Adds a new row to the cursor for the specific attachment.
258     */
259    private static void addRow(MatrixCursor cursor, Attachment attachment) {
260        cursor.newRow()
261                .add(attachment.getName())                          // displayName
262                .add(attachment.size)                               // size
263                .add(attachment.uri)                                // uri
264                .add(attachment.getContentType())                   // contentType
265                .add(attachment.state)                              // state
266                .add(attachment.destination)                        // destination
267                .add(attachment.downloadedSize)                     // downloadedSize
268                .add(attachment.contentUri)                         // contentUri
269                .add(attachment.thumbnailUri)                       // thumbnailUri
270                .add(attachment.previewIntentUri)                   // previewIntentUri
271                .add(attachment.providerData)                       // providerData
272                .add(attachment.supportsDownloadAgain() ? 1 : 0)    // supportsDownloadAgain
273                .add(attachment.type)                               // type
274                .add(attachment.flags)                              // flags
275                .add(attachment.partId);                            // partId (same as RFC822 cid)
276    }
277
278    /**
279     * Copies an attachment at the specified {@link Uri}
280     * from cache to the external downloads directory (usually the sd card).
281     * @return the number of attachments affected. Should be 1 or 0.
282     */
283    private int copyAttachment(Uri uri, ContentValues values) {
284        final Integer newState = values.getAsInteger(UIProvider.AttachmentColumns.STATE);
285        final Integer newDestination =
286                values.getAsInteger(UIProvider.AttachmentColumns.DESTINATION);
287        if (newState == null && newDestination == null) {
288            return 0;
289        }
290
291        final int destination = newDestination != null ?
292                newDestination.intValue() : UIProvider.AttachmentDestination.CACHE;
293        final boolean saveToSd =
294                destination == UIProvider.AttachmentDestination.EXTERNAL;
295
296        final Attachment attachment = mUriAttachmentMap.get(uri);
297
298        // 1. check if already saved to sd (via uri save to sd)
299        // and return if so (we shouldn't ever be here)
300
301        // if the call was not to save to sd or already saved to sd, just bail out
302        if (!saveToSd || attachment.isSavedToExternal()) {
303            return 0;
304        }
305
306
307        // 2. copy file
308        final String oldFilePath = getFilePath(uri);
309
310        // update the destination before getting the new file path
311        // otherwise it will just point to the old location.
312        attachment.destination = UIProvider.AttachmentDestination.EXTERNAL;
313        final String newFilePath = getFilePath(uri);
314
315        InputStream inputStream = null;
316        OutputStream outputStream = null;
317
318        try {
319            try {
320                inputStream = new FileInputStream(oldFilePath);
321            } catch (FileNotFoundException e) {
322                LogUtils.e(LOG_TAG, "File not found for file %s", oldFilePath);
323                return 0;
324            }
325            try {
326                outputStream = new FileOutputStream(newFilePath);
327            } catch (FileNotFoundException e) {
328                LogUtils.e(LOG_TAG, "File not found for file %s", newFilePath);
329                return 0;
330            }
331            try {
332                final long now = SystemClock.elapsedRealtime();
333                final byte data[] = new byte[BUFFER_SIZE];
334                int size = 0;
335                while (true) {
336                    final int len = inputStream.read(data);
337                    if (len != -1) {
338                        outputStream.write(data, 0, len);
339
340                        size += len;
341                    } else {
342                        break;
343                    }
344                    if (SystemClock.elapsedRealtime() - now > READ_TIMEOUT) {
345                        throw new IOException("Timed out copying attachment.");
346                    }
347                }
348
349                // if the attachment is an APK, change contentUri to be a direct file uri
350                if (MimeType.isInstallable(attachment.getContentType())) {
351                    attachment.contentUri = Uri.parse("file://" + newFilePath);
352                }
353
354                // 3. add file to download manager
355
356                try {
357                    // TODO - make a better description
358                    final String description = attachment.getName();
359                    mDownloadManager.addCompletedDownload(attachment.getName(),
360                            description, true, attachment.getContentType(),
361                            newFilePath, size, false);
362                }
363                catch (IllegalArgumentException e) {
364                    // Even if we cannot save the download to the downloads app,
365                    // (likely due to a bad mimeType), we still want to save it.
366                    LogUtils.e(LOG_TAG, e, "Failed to save download to Downloads app.");
367                }
368                final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
369                intent.setData(Uri.parse("file://" + newFilePath));
370                getContext().sendBroadcast(intent);
371
372                // 4. delete old file
373                new File(oldFilePath).delete();
374            } catch (IOException e) {
375                // Error writing file, delete partial file
376                LogUtils.e(LOG_TAG, e, "Cannot write to file %s", newFilePath);
377                new File(newFilePath).delete();
378            }
379        } finally {
380            try {
381                if (inputStream != null) {
382                    inputStream.close();
383                }
384            } catch (IOException e) {
385            }
386            try {
387                if (outputStream != null) {
388                    outputStream.close();
389                }
390            } catch (IOException e) {
391            }
392        }
393
394        // 5. notify that the list of attachments has changed so the UI will update
395        getContext().getContentResolver().notifyChange(
396                getListUriFromAttachmentUri(uri), null, false);
397        return 1;
398    }
399
400    @Override
401    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
402        final String filePath = getFilePath(uri);
403
404        final int fileMode;
405
406        if ("rwt".equals(mode)) {
407            fileMode = ParcelFileDescriptor.MODE_READ_WRITE |
408                    ParcelFileDescriptor.MODE_TRUNCATE |
409                    ParcelFileDescriptor.MODE_CREATE;
410        } else if ("rw".equals(mode)) {
411            fileMode = ParcelFileDescriptor.MODE_READ_WRITE | ParcelFileDescriptor.MODE_CREATE;
412        } else {
413            fileMode = ParcelFileDescriptor.MODE_READ_ONLY;
414        }
415
416        return ParcelFileDescriptor.open(new File(filePath), fileMode);
417    }
418
419    /**
420     * Returns an attachment list uri for the specific attachment uri passed.
421     */
422    private static Uri getListUriFromAttachmentUri(Uri uri) {
423        final List<String> segments = uri.getPathSegments();
424        return BASE_URI.buildUpon()
425                .appendPath("attachments")
426                .appendPath(segments.get(1))
427                .appendPath(segments.get(2))
428                .build();
429    }
430
431    /**
432     * Returns an attachment list uri for an eml file at the given uri with the given message id.
433     */
434    public static Uri getAttachmentsListUri(Uri emlFileUri, String messageId) {
435        return BASE_URI.buildUpon()
436                .appendPath("attachments")
437                .appendPath(Integer.toString(emlFileUri.hashCode()))
438                .appendPath(messageId)
439                .build();
440    }
441
442    /**
443     * Returns an attachment uri for an eml file at the given uri with the given message id.
444     * The consumer of this uri must append a specific CID to it to complete the uri.
445     */
446    public static Uri getAttachmentByCidUri(Uri emlFileUri, String messageId) {
447        return BASE_URI.buildUpon()
448                .appendPath("attachmentByCid")
449                .appendPath(Integer.toString(emlFileUri.hashCode()))
450                .appendPath(messageId)
451                .build();
452    }
453
454    /**
455     * Returns an attachment uri for an attachment from the given eml file uri with
456     * the given message id and part id.
457     */
458    public static Uri getAttachmentUri(Uri emlFileUri, String messageId, String partId) {
459        return BASE_URI.buildUpon()
460                .appendPath("attachment")
461                .appendPath(Integer.toString(emlFileUri.hashCode()))
462                .appendPath(messageId)
463                .appendPath(partId)
464                .build();
465    }
466
467    /**
468     * Returns the absolute file path for the attachment at the given uri.
469     */
470    private String getFilePath(Uri uri) {
471        final Attachment attachment = mUriAttachmentMap.get(uri);
472        final boolean saveToSd =
473                attachment.destination == UIProvider.AttachmentDestination.EXTERNAL;
474        final String pathStart = (saveToSd) ?
475                Environment.getExternalStoragePublicDirectory(
476                Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() : getCacheDir();
477
478        // we want the root of the downloads directory if the attachment is
479        // saved to external (or we're saving to external)
480        final String directoryPath = (saveToSd) ? pathStart : pathStart + uri.getEncodedPath();
481
482        final File directory = new File(directoryPath);
483        if (!directory.exists()) {
484            directory.mkdirs();
485        }
486        return directoryPath + "/" + attachment.getName();
487    }
488
489    /**
490     * Returns the root directory for the attachments for the specific uri.
491     */
492    private String getCacheFileDirectory(Uri uri) {
493        return getCacheDir() + "/" + Uri.encode(uri.getPathSegments().get(1));
494    }
495
496    /**
497     * Returns the cache directory for eml attachment files.
498     */
499    private String getCacheDir() {
500        return getContext().getCacheDir().getAbsolutePath().concat("/eml");
501    }
502
503    /**
504     * Recursively delete the directory at the passed file path.
505     */
506    private void deleteDirectory(String cacheFileDirectory) {
507        recursiveDelete(new File(cacheFileDirectory));
508    }
509
510    /**
511     * Recursively deletes a file or directory.
512     */
513    private void recursiveDelete(File file) {
514        if (file.isDirectory()) {
515            final File[] children = file.listFiles();
516            for (final File child : children) {
517                recursiveDelete(child);
518            }
519        }
520
521        file.delete();
522    }
523}
524