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