OSDN Git Service

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