OSDN Git Service

ee2ce96727fc8a1f0d86c1bffd12cc3470351f57
[android-x86/packages-apps-Gallery2.git] / src / com / android / gallery3d / filtershow / tools / SaveCopyTask.java
1 /*
2  * Copyright (C) 2010 The Android Open Source Project
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  */
16
17 package com.android.gallery3d.filtershow.tools;
18
19 import android.content.ContentResolver;
20 import android.content.ContentValues;
21 import android.content.Context;
22 import android.database.Cursor;
23 import android.graphics.Bitmap;
24 import android.graphics.BitmapFactory;
25 import android.net.Uri;
26 import android.os.AsyncTask;
27 import android.os.Environment;
28 import android.provider.MediaStore;
29 import android.provider.MediaStore.Images;
30 import android.provider.MediaStore.Images.ImageColumns;
31 import android.util.Log;
32
33 import com.android.gallery3d.common.Utils;
34 import com.android.gallery3d.exif.ExifInterface;
35 import com.android.gallery3d.filtershow.cache.CachingPipeline;
36 import com.android.gallery3d.filtershow.cache.ImageLoader;
37 import com.android.gallery3d.filtershow.filters.FiltersManager;
38 import com.android.gallery3d.filtershow.presets.ImagePreset;
39 import com.android.gallery3d.util.UsageStatistics;
40 import com.android.gallery3d.util.XmpUtilHelper;
41
42 import java.io.File;
43 import java.io.FileNotFoundException;
44 import java.io.FilenameFilter;
45 import java.io.IOException;
46 import java.io.InputStream;
47 import java.sql.Date;
48 import java.text.SimpleDateFormat;
49 import java.util.TimeZone;
50
51 /**
52  * Asynchronous task for saving edited photo as a new copy.
53  */
54 public class SaveCopyTask extends AsyncTask<ImagePreset, Void, Uri> {
55
56     private static final String LOGTAG = "SaveCopyTask";
57
58     /**
59      * Callback for the completed asynchronous task.
60      */
61     public interface Callback {
62
63         void onComplete(Uri result);
64     }
65
66     public interface ContentResolverQueryCallback {
67
68         void onCursorResult(Cursor cursor);
69     }
70
71     private static final String TIME_STAMP_NAME = "_yyyyMMdd_HHmmss";
72     private static final String PREFIX_PANO = "PANO";
73     private static final String PREFIX_IMG = "IMG";
74     private static final String POSTFIX_JPG = ".jpg";
75     private static final String AUX_DIR_NAME = ".aux";
76
77     // When this is true, the source file will be saved into auxiliary directory
78     // and hidden from MediaStore. Otherwise, the source will be kept as the
79     // same.
80     private static final boolean USE_AUX_DIR = true;
81
82     private final Context mContext;
83     private final Uri mSourceUri;
84     private final Callback mCallback;
85     private final File mDestinationFile;
86     private final Uri mSelectedImageUri;
87
88     // In order to support the new edit-save behavior such that user won't see
89     // the edited image together with the original image, we are adding a new
90     // auxiliary directory for the edited image. Basically, the original image
91     // will be hidden in that directory after edit and user will see the edited
92     // image only.
93     // Note that deletion on the edited image will also cause the deletion of
94     // the original image under auxiliary directory.
95     //
96     // There are several situations we need to consider:
97     // 1. User edit local image local01.jpg. A local02.jpg will be created in the
98     // same directory, and original image will be moved to auxiliary directory as
99     // ./.aux/local02.jpg.
100     // If user edit the local02.jpg, local03.jpg will be created in the local
101     // directory and ./.aux/local02.jpg will be renamed to ./.aux/local03.jpg
102     //
103     // 2. User edit remote image remote01.jpg from picassa or other server.
104     // remoteSavedLocal01.jpg will be saved under proper local directory.
105     // In remoteSavedLocal01.jpg, there will be a reference pointing to the
106     // remote01.jpg. There will be no local copy of remote01.jpg.
107     // If user edit remoteSavedLocal01.jpg, then a new remoteSavedLocal02.jpg
108     // will be generated and still pointing to the remote01.jpg
109     //
110     // 3. User delete any local image local.jpg.
111     // Since the filenames are kept consistent in auxiliary directory, every
112     // time a local.jpg get deleted, the files in auxiliary directory whose
113     // names starting with "local." will be deleted.
114     // This pattern will facilitate the multiple images deletion in the auxiliary
115     // directory.
116     //
117     // TODO: Move the saving into a background service.
118
119     /**
120      * @param context
121      * @param sourceUri The Uri for the original image, which can be the hidden
122      *  image under the auxiliary directory or the same as selectedImageUri.
123      * @param selectedImageUri The Uri for the image selected by the user.
124      *  In most cases, it is a content Uri for local image or remote image.
125      * @param destination Destinaton File, if this is null, a new file will be
126      *  created under the same directory as selectedImageUri.
127      * @param callback Let the caller know the saving has completed.
128      * @return the newSourceUri
129      */
130     public SaveCopyTask(Context context, Uri sourceUri, Uri selectedImageUri,
131             File destination, Callback callback)  {
132         mContext = context;
133         mSourceUri = sourceUri;
134         mCallback = callback;
135         if (destination == null) {
136             mDestinationFile = getNewFile(context, selectedImageUri);
137         } else {
138             mDestinationFile = destination;
139         }
140
141         mSelectedImageUri = selectedImageUri;
142     }
143
144     public static File getFinalSaveDirectory(Context context, Uri sourceUri) {
145         File saveDirectory = getSaveDirectory(context, sourceUri);
146         if ((saveDirectory == null) || !saveDirectory.canWrite()) {
147             saveDirectory = new File(Environment.getExternalStorageDirectory(),
148                     ImageLoader.DEFAULT_SAVE_DIRECTORY);
149         }
150         // Create the directory if it doesn't exist
151         if (!saveDirectory.exists())
152             saveDirectory.mkdirs();
153         return saveDirectory;
154     }
155
156     public static File getNewFile(Context context, Uri sourceUri) {
157         File saveDirectory = getFinalSaveDirectory(context, sourceUri);
158         String filename = new SimpleDateFormat(TIME_STAMP_NAME).format(new Date(
159                 System.currentTimeMillis()));
160         if (hasPanoPrefix(context, sourceUri)) {
161             return new File(saveDirectory, PREFIX_PANO + filename + POSTFIX_JPG);
162         }
163         return new File(saveDirectory, PREFIX_IMG + filename + POSTFIX_JPG);
164     }
165
166     /**
167      * Remove the files in the auxiliary directory whose names are the same as
168      * the source image.
169      * @param contentResolver The application's contentResolver
170      * @param srcContentUri The content Uri for the source image.
171      */
172     public static void deleteAuxFiles(ContentResolver contentResolver,
173             Uri srcContentUri) {
174         final String[] fullPath = new String[1];
175         String[] queryProjection = new String[] { ImageColumns.DATA };
176         querySourceFromContentResolver(contentResolver,
177                 srcContentUri, queryProjection,
178                 new ContentResolverQueryCallback() {
179                     @Override
180                     public void onCursorResult(Cursor cursor) {
181                         fullPath[0] = cursor.getString(0);
182                     }
183                 }
184         );
185         if (fullPath[0] != null) {
186             // Construct the auxiliary directory given the source file's path.
187             // Then select and delete all the files starting with the same name
188             // under the auxiliary directory.
189             File currentFile = new File(fullPath[0]);
190
191             String filename = currentFile.getName();
192             int firstDotPos = filename.indexOf(".");
193             final String filenameNoExt = (firstDotPos == -1) ? filename :
194                 filename.substring(0, firstDotPos);
195             File auxDir = getLocalAuxDirectory(currentFile);
196             if (auxDir.exists()) {
197                 FilenameFilter filter = new FilenameFilter() {
198                     @Override
199                     public boolean accept(File dir, String name) {
200                         if (name.startsWith(filenameNoExt + ".")) {
201                             return true;
202                         } else {
203                             return false;
204                         }
205                     }
206                 };
207
208                 // Delete all auxiliary files whose name is matching the
209                 // current local image.
210                 File[] auxFiles = auxDir.listFiles(filter);
211                 for (File file : auxFiles) {
212                     file.delete();
213                 }
214             }
215         }
216     }
217
218     public Object getPanoramaXMPData(Uri source, ImagePreset preset) {
219         Object xmp = null;
220         if (preset.isPanoramaSafe()) {
221             InputStream is = null;
222             try {
223                 is = mContext.getContentResolver().openInputStream(source);
224                 xmp = XmpUtilHelper.extractXMPMeta(is);
225             } catch (FileNotFoundException e) {
226                 Log.w(LOGTAG, "Failed to get XMP data from image: ", e);
227             } finally {
228                 Utils.closeSilently(is);
229             }
230         }
231         return xmp;
232     }
233
234     public boolean putPanoramaXMPData(File file, Object xmp) {
235         if (xmp != null) {
236             return XmpUtilHelper.writeXMPMeta(file.getAbsolutePath(), xmp);
237         }
238         return false;
239     }
240
241     public ExifInterface getExifData(Uri source) {
242         ExifInterface exif = new ExifInterface();
243         String mimeType = mContext.getContentResolver().getType(mSelectedImageUri);
244         if (mimeType.equals(ImageLoader.JPEG_MIME_TYPE)) {
245             InputStream inStream = null;
246             try {
247                 inStream = mContext.getContentResolver().openInputStream(source);
248                 exif.readExif(inStream);
249             } catch (FileNotFoundException e) {
250                 Log.w(LOGTAG, "Cannot find file: " + source, e);
251             } catch (IOException e) {
252                 Log.w(LOGTAG, "Cannot read exif for: " + source, e);
253             } finally {
254                 Utils.closeSilently(inStream);
255             }
256         }
257         return exif;
258     }
259
260     public boolean putExifData(File file, ExifInterface exif, Bitmap image) {
261         boolean ret = false;
262         try {
263             exif.writeExif(image, file.getAbsolutePath());
264             ret = true;
265         } catch (FileNotFoundException e) {
266             Log.w(LOGTAG, "File not found: " + file.getAbsolutePath(), e);
267         } catch (IOException e) {
268             Log.w(LOGTAG, "Could not write exif: ", e);
269         }
270         return ret;
271     }
272
273     /**
274      * The task should be executed with one given bitmap to be saved.
275      */
276     @Override
277     protected Uri doInBackground(ImagePreset... params) {
278         // TODO: Support larger dimensions for photo saving.
279         if (params[0] == null || mSourceUri == null || mSelectedImageUri == null) {
280             return null;
281         }
282         ImagePreset preset = params[0];
283         BitmapFactory.Options options = new BitmapFactory.Options();
284         Uri uri = null;
285         boolean noBitmap = true;
286         int num_tries = 0;
287
288         // If necessary, move the source file into the auxiliary directory,
289         // newSourceUri is then pointing to the new location.
290         // If no file is moved, newSourceUri will be the same as mSourceUri.
291         Uri newSourceUri;
292         if (USE_AUX_DIR) {
293             newSourceUri = moveSrcToAuxIfNeeded(mSourceUri, mDestinationFile);
294         } else {
295             newSourceUri = mSourceUri;
296         }
297         // Stopgap fix for low-memory devices.
298         while (noBitmap) {
299             try {
300                 // Try to do bitmap operations, downsample if low-memory
301                 Bitmap bitmap = ImageLoader.loadMutableBitmap(mContext, newSourceUri, options);
302                 if (bitmap == null) {
303                     return null;
304                 }
305                 CachingPipeline pipeline = new CachingPipeline(FiltersManager.getManager(), "Saving");
306                 bitmap = pipeline.renderFinalImage(bitmap, preset);
307
308                 Object xmp = getPanoramaXMPData(mSelectedImageUri, preset);
309                 ExifInterface exif = getExifData(mSelectedImageUri);
310
311                 // Set tags
312                 long time = System.currentTimeMillis();
313                 exif.addDateTimeStampTag(ExifInterface.TAG_DATE_TIME, time,
314                         TimeZone.getDefault());
315                 exif.setTag(exif.buildTag(ExifInterface.TAG_ORIENTATION,
316                         ExifInterface.Orientation.TOP_LEFT));
317
318                 // Remove old thumbnail
319                 exif.removeCompressedThumbnail();
320
321                 // If we succeed in writing the bitmap as a jpeg, return a uri.
322                 if (putExifData(mDestinationFile, exif, bitmap)) {
323                     putPanoramaXMPData(mDestinationFile, xmp);
324                     uri = insertContent(mContext, mSelectedImageUri, mDestinationFile,
325                             time);
326                 }
327
328                 // mDestinationFile will save the newSourceUri info in the XMP.
329                 XmpPresets.writeFilterXMP(mContext, newSourceUri, mDestinationFile, preset);
330
331                 // Since we have a new image inserted to media store, we can
332                 // safely remove the old one which is selected by the user.
333                 if (USE_AUX_DIR) {
334                     String scheme = mSelectedImageUri.getScheme();
335                     if (scheme != null && scheme.equals(ContentResolver.SCHEME_CONTENT)) {
336                         if (mSelectedImageUri.getAuthority().equals(MediaStore.AUTHORITY)) {
337                             mContext.getContentResolver().delete(mSelectedImageUri, null, null);
338                         }
339                     }
340                 }
341                 noBitmap = false;
342                 UsageStatistics.onEvent(UsageStatistics.COMPONENT_EDITOR,
343                         "SaveComplete", null);
344             } catch (java.lang.OutOfMemoryError e) {
345                 // Try 5 times before failing for good.
346                 if (++num_tries >= 5) {
347                     throw e;
348                 }
349                 System.gc();
350                 options.inSampleSize *= 2;
351             }
352         }
353         return uri;
354     }
355
356     /**
357      *  Move the source file to auxiliary directory if needed and return the Uri
358      *  pointing to this new source file.
359      * @param srcUri Uri to the source image.
360      * @param dstFile Providing the destination file info to help to build the
361      *  auxiliary directory and new source file's name.
362      * @return the newSourceUri pointing to the new source image.
363      */
364     private Uri moveSrcToAuxIfNeeded(Uri srcUri, File dstFile) {
365         File srcFile = getFileFromUri(mContext, srcUri);
366         if (srcFile == null) {
367             Log.d(LOGTAG, "Source file is not a local file, no update.");
368             return srcUri;
369         }
370
371         // Get the destination directory and create the auxilliary directory
372         // if necessary.
373         File auxDiretory = getLocalAuxDirectory(dstFile);
374         if (!auxDiretory.exists()) {
375             auxDiretory.mkdirs();
376         }
377
378         // Make sure there is a .nomedia file in the auxiliary directory, such
379         // that MediaScanner will not report those files under this directory.
380         File noMedia = new File(auxDiretory, ".nomedia");
381         if (!noMedia.exists()) {
382             try {
383                 noMedia.createNewFile();
384             } catch (IOException e) {
385                 Log.e(LOGTAG, "Can't create the nomedia");
386                 return srcUri;
387             }
388         }
389         // We are using the destination file name such that photos sitting in
390         // the auxiliary directory are matching the parent directory.
391         File newSrcFile = new File(auxDiretory, dstFile.getName());
392
393         if (!newSrcFile.exists()) {
394             srcFile.renameTo(newSrcFile);
395         }
396
397         return Uri.fromFile(newSrcFile);
398
399     }
400
401     private static File getLocalAuxDirectory(File dstFile) {
402         File dstDirectory = dstFile.getParentFile();
403         File auxDiretory = new File(dstDirectory + "/" + AUX_DIR_NAME);
404         return auxDiretory;
405     }
406
407     @Override
408     protected void onPostExecute(Uri result) {
409         if (mCallback != null) {
410             mCallback.onComplete(result);
411         }
412     }
413
414     private static void querySource(Context context, Uri sourceUri, String[] projection,
415             ContentResolverQueryCallback callback) {
416         ContentResolver contentResolver = context.getContentResolver();
417         querySourceFromContentResolver(contentResolver, sourceUri, projection, callback);
418     }
419
420     private static void querySourceFromContentResolver(
421             ContentResolver contentResolver, Uri sourceUri, String[] projection,
422             ContentResolverQueryCallback callback) {
423         Cursor cursor = null;
424         try {
425             cursor = contentResolver.query(sourceUri, projection, null, null,
426                     null);
427             if ((cursor != null) && cursor.moveToNext()) {
428                 callback.onCursorResult(cursor);
429             }
430         } catch (Exception e) {
431             // Ignore error for lacking the data column from the source.
432         } finally {
433             if (cursor != null) {
434                 cursor.close();
435             }
436         }
437     }
438
439     private static File getSaveDirectory(Context context, Uri sourceUri) {
440         File file = getFileFromUri(context, sourceUri);
441         if (file != null) {
442             return file.getParentFile();
443         } else {
444             return null;
445         }
446     }
447
448     /**
449      * Construct a File object based on the srcUri.
450      * @return The file object. Return null if srcUri is invalid or not a local
451      * file.
452      */
453     private static File getFileFromUri(Context context, Uri srcUri) {
454         if (srcUri == null) {
455             Log.e(LOGTAG, "srcUri is null.");
456             return null;
457         }
458
459         String scheme = srcUri.getScheme();
460         if (scheme == null) {
461             Log.e(LOGTAG, "scheme is null.");
462             return null;
463         }
464
465         final File[] file = new File[1];
466         // sourceUri can be a file path or a content Uri, it need to be handled
467         // differently.
468         if (scheme.equals(ContentResolver.SCHEME_CONTENT)) {
469             if (srcUri.getAuthority().equals(MediaStore.AUTHORITY)) {
470                 querySource(context, srcUri, new String[] {
471                         ImageColumns.DATA
472                 },
473                         new ContentResolverQueryCallback() {
474
475                             @Override
476                             public void onCursorResult(Cursor cursor) {
477                                 file[0] = new File(cursor.getString(0));
478                             }
479                         });
480             }
481         } else if (scheme.equals(ContentResolver.SCHEME_FILE)) {
482             file[0] = new File(srcUri.getPath());
483         }
484         return file[0];
485     }
486
487     /**
488      * Gets the actual filename for a Uri from Gallery's ContentProvider.
489      */
490     private static String getTrueFilename(Context context, Uri src) {
491         if (context == null || src == null) {
492             return null;
493         }
494         final String[] trueName = new String[1];
495         querySource(context, src, new String[] {
496                 ImageColumns.DATA
497         }, new ContentResolverQueryCallback() {
498             @Override
499             public void onCursorResult(Cursor cursor) {
500                 trueName[0] = new File(cursor.getString(0)).getName();
501             }
502         });
503         return trueName[0];
504     }
505
506     /**
507      * Checks whether the true filename has the panorama image prefix.
508      */
509     private static boolean hasPanoPrefix(Context context, Uri src) {
510         String name = getTrueFilename(context, src);
511         return name != null && name.startsWith(PREFIX_PANO);
512     }
513
514     /**
515      * Insert the content (saved file) with proper source photo properties.
516      */
517     private static Uri insertContent(Context context, Uri sourceUri, File file,
518             long time) {
519         time /= 1000;
520
521         final ContentValues values = new ContentValues();
522         values.put(Images.Media.TITLE, file.getName());
523         values.put(Images.Media.DISPLAY_NAME, file.getName());
524         values.put(Images.Media.MIME_TYPE, "image/jpeg");
525         values.put(Images.Media.DATE_TAKEN, time);
526         values.put(Images.Media.DATE_MODIFIED, System.currentTimeMillis());
527         values.put(Images.Media.DATE_ADDED, time);
528         values.put(Images.Media.ORIENTATION, 0);
529         values.put(Images.Media.DATA, file.getAbsolutePath());
530         values.put(Images.Media.SIZE, file.length());
531
532         final String[] projection = new String[] {
533                 ImageColumns.DATE_TAKEN,
534                 ImageColumns.LATITUDE, ImageColumns.LONGITUDE,
535         };
536         querySource(context, sourceUri, projection,
537                 new ContentResolverQueryCallback() {
538
539                     @Override
540                     public void onCursorResult(Cursor cursor) {
541                         values.put(Images.Media.DATE_TAKEN, cursor.getLong(0));
542
543                         double latitude = cursor.getDouble(1);
544                         double longitude = cursor.getDouble(2);
545                         // TODO: Change || to && after the default location
546                         // issue is fixed.
547                         if ((latitude != 0f) || (longitude != 0f)) {
548                             values.put(Images.Media.LATITUDE, latitude);
549                             values.put(Images.Media.LONGITUDE, longitude);
550                         }
551                     }
552                 });
553
554         return context.getContentResolver().insert(
555                 Images.Media.EXTERNAL_CONTENT_URI, values);
556     }
557
558 }