2 * Copyright (C) 2010 The Android Open Source Project
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
8 * http://www.apache.org/licenses/LICENSE-2.0
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.
17 package com.android.gallery3d.filtershow.tools;
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 import android.widget.Toast;
33 import com.android.gallery3d.R;
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.cache.ImageLoader;
38 import com.android.gallery3d.filtershow.filters.FilterRepresentation;
39 import com.android.gallery3d.filtershow.filters.FiltersManager;
40 import com.android.gallery3d.filtershow.imageshow.MasterImage;
41 import com.android.gallery3d.filtershow.pipeline.CachingPipeline;
42 import com.android.gallery3d.filtershow.pipeline.ImagePreset;
43 import com.android.gallery3d.filtershow.pipeline.ProcessingService;
44 import com.android.gallery3d.util.XmpUtilHelper;
47 import java.io.FileNotFoundException;
48 import java.io.FilenameFilter;
49 import java.io.IOException;
50 import java.io.InputStream;
51 import java.io.OutputStream;
52 import java.text.SimpleDateFormat;
53 import java.util.Date;
54 import java.util.TimeZone;
57 * Handles saving edited photo
59 public class SaveImage {
60 private static final String LOGTAG = "SaveImage";
63 * Callback for updates
65 public interface Callback {
66 void onPreviewSaved(Uri uri);
67 void onProgress(int max, int current);
70 public interface ContentResolverQueryCallback {
71 void onCursorResult(Cursor cursor);
74 private static final String TIME_STAMP_NAME = "_yyyyMMdd_HHmmss";
75 private static final String PREFIX_PANO = "PANO";
76 private static final String PREFIX_IMG = "IMG";
77 private static final String POSTFIX_JPG = ".jpg";
78 private static final String AUX_DIR_NAME = ".aux";
80 private final Context mContext;
81 private final Uri mSourceUri;
82 private final Callback mCallback;
83 private final File mDestinationFile;
84 private final Uri mSelectedImageUri;
85 private final Bitmap mPreviewImage;
87 private int mCurrentProcessingStep = 1;
89 public static final int MAX_PROCESSING_STEPS = 6;
90 public static final String DEFAULT_SAVE_DIRECTORY = "EditedOnlinePhotos";
92 // In order to support the new edit-save behavior such that user won't see
93 // the edited image together with the original image, we are adding a new
94 // auxiliary directory for the edited image. Basically, the original image
95 // will be hidden in that directory after edit and user will see the edited
97 // Note that deletion on the edited image will also cause the deletion of
98 // the original image under auxiliary directory.
100 // There are several situations we need to consider:
101 // 1. User edit local image local01.jpg. A local02.jpg will be created in the
102 // same directory, and original image will be moved to auxiliary directory as
103 // ./.aux/local02.jpg.
104 // If user edit the local02.jpg, local03.jpg will be created in the local
105 // directory and ./.aux/local02.jpg will be renamed to ./.aux/local03.jpg
107 // 2. User edit remote image remote01.jpg from picassa or other server.
108 // remoteSavedLocal01.jpg will be saved under proper local directory.
109 // In remoteSavedLocal01.jpg, there will be a reference pointing to the
110 // remote01.jpg. There will be no local copy of remote01.jpg.
111 // If user edit remoteSavedLocal01.jpg, then a new remoteSavedLocal02.jpg
112 // will be generated and still pointing to the remote01.jpg
114 // 3. User delete any local image local.jpg.
115 // Since the filenames are kept consistent in auxiliary directory, every
116 // time a local.jpg get deleted, the files in auxiliary directory whose
117 // names starting with "local." will be deleted.
118 // This pattern will facilitate the multiple images deletion in the auxiliary
123 * @param sourceUri The Uri for the original image, which can be the hidden
124 * image under the auxiliary directory or the same as selectedImageUri.
125 * @param selectedImageUri The Uri for the image selected by the user.
126 * In most cases, it is a content Uri for local image or remote image.
127 * @param destination Destinaton File, if this is null, a new file will be
128 * created under the same directory as selectedImageUri.
129 * @param callback Let the caller know the saving has completed.
130 * @return the newSourceUri
132 public SaveImage(Context context, Uri sourceUri, Uri selectedImageUri,
133 File destination, Bitmap previewImage, Callback callback) {
135 mSourceUri = sourceUri;
136 mCallback = callback;
137 mPreviewImage = previewImage;
138 if (destination == null) {
139 mDestinationFile = getNewFile(context, selectedImageUri);
141 mDestinationFile = destination;
144 mSelectedImageUri = selectedImageUri;
147 public static File getFinalSaveDirectory(Context context, Uri sourceUri) {
148 File saveDirectory = SaveImage.getSaveDirectory(context, sourceUri);
149 if ((saveDirectory == null) || !saveDirectory.canWrite()) {
150 saveDirectory = new File(Environment.getExternalStorageDirectory(),
151 SaveImage.DEFAULT_SAVE_DIRECTORY);
153 // Create the directory if it doesn't exist
154 if (!saveDirectory.exists())
155 saveDirectory.mkdirs();
156 return saveDirectory;
159 public static File getNewFile(Context context, Uri sourceUri) {
160 File saveDirectory = getFinalSaveDirectory(context, sourceUri);
161 String filename = new SimpleDateFormat(TIME_STAMP_NAME).format(new Date(
162 System.currentTimeMillis()));
163 if (hasPanoPrefix(context, sourceUri)) {
164 return new File(saveDirectory, PREFIX_PANO + filename + POSTFIX_JPG);
166 return new File(saveDirectory, PREFIX_IMG + filename + POSTFIX_JPG);
170 * Remove the files in the auxiliary directory whose names are the same as
172 * @param contentResolver The application's contentResolver
173 * @param srcContentUri The content Uri for the source image.
175 public static void deleteAuxFiles(ContentResolver contentResolver,
177 final String[] fullPath = new String[1];
178 String[] queryProjection = new String[] { ImageColumns.DATA };
179 querySourceFromContentResolver(contentResolver,
180 srcContentUri, queryProjection,
181 new ContentResolverQueryCallback() {
183 public void onCursorResult(Cursor cursor) {
184 fullPath[0] = cursor.getString(0);
188 if (fullPath[0] != null) {
189 // Construct the auxiliary directory given the source file's path.
190 // Then select and delete all the files starting with the same name
191 // under the auxiliary directory.
192 File currentFile = new File(fullPath[0]);
194 String filename = currentFile.getName();
195 int firstDotPos = filename.indexOf(".");
196 final String filenameNoExt = (firstDotPos == -1) ? filename :
197 filename.substring(0, firstDotPos);
198 File auxDir = getLocalAuxDirectory(currentFile);
199 if (auxDir.exists()) {
200 FilenameFilter filter = new FilenameFilter() {
202 public boolean accept(File dir, String name) {
203 if (name.startsWith(filenameNoExt + ".")) {
211 // Delete all auxiliary files whose name is matching the
212 // current local image.
213 File[] auxFiles = auxDir.listFiles(filter);
214 for (File file : auxFiles) {
221 public Object getPanoramaXMPData(Uri source, ImagePreset preset) {
223 if (preset.isPanoramaSafe()) {
224 InputStream is = null;
226 is = mContext.getContentResolver().openInputStream(source);
227 xmp = XmpUtilHelper.extractXMPMeta(is);
228 } catch (FileNotFoundException e) {
229 Log.w(LOGTAG, "Failed to get XMP data from image: ", e);
231 Utils.closeSilently(is);
237 public boolean putPanoramaXMPData(File file, Object xmp) {
239 return XmpUtilHelper.writeXMPMeta(file.getAbsolutePath(), xmp);
244 public ExifInterface getExifData(Uri source) {
245 ExifInterface exif = new ExifInterface();
246 String mimeType = mContext.getContentResolver().getType(mSelectedImageUri);
247 if (mimeType == null) {
248 mimeType = ImageLoader.getMimeType(mSelectedImageUri);
250 if (mimeType.equals(ImageLoader.JPEG_MIME_TYPE)) {
251 InputStream inStream = null;
253 inStream = mContext.getContentResolver().openInputStream(source);
254 exif.readExif(inStream);
255 } catch (FileNotFoundException e) {
256 Log.w(LOGTAG, "Cannot find file: " + source, e);
257 } catch (IOException e) {
258 Log.w(LOGTAG, "Cannot read exif for: " + source, e);
260 Utils.closeSilently(inStream);
266 public boolean putExifData(File file, ExifInterface exif, Bitmap image,
267 int jpegCompressQuality) {
269 OutputStream s = null;
271 s = exif.getExifWriterStream(file.getAbsolutePath());
272 image.compress(Bitmap.CompressFormat.JPEG,
273 (jpegCompressQuality > 0) ? jpegCompressQuality : 1, s);
278 } catch (FileNotFoundException e) {
279 Log.w(LOGTAG, "File not found: " + file.getAbsolutePath(), e);
280 } catch (IOException e) {
281 Log.w(LOGTAG, "Could not write exif: ", e);
283 Utils.closeSilently(s);
288 private Uri resetToOriginalImageIfNeeded(ImagePreset preset, boolean doAuxBackup) {
290 if (!preset.hasModifications()) {
291 // This can happen only when preset has no modification but save
292 // button is enabled, it means the file is loaded with filters in
293 // the XMP, then all the filters are removed or restore to default.
294 // In this case, when mSourceUri exists, rename it to the
296 File srcFile = getLocalFileFromUri(mContext, mSourceUri);
297 // If the source is not a local file, then skip this renaming and
298 // create a local copy as usual.
299 if (srcFile != null) {
300 srcFile.renameTo(mDestinationFile);
301 uri = SaveImage.linkNewFileToUri(mContext, mSelectedImageUri,
302 mDestinationFile, System.currentTimeMillis(), doAuxBackup);
308 private void resetProgress() {
309 mCurrentProcessingStep = 0;
312 private void updateProgress() {
313 if (mCallback != null) {
314 mCallback.onProgress(MAX_PROCESSING_STEPS, ++mCurrentProcessingStep);
318 private void updateExifData(ExifInterface exif, long time) {
320 exif.addDateTimeStampTag(ExifInterface.TAG_DATE_TIME, time,
321 TimeZone.getDefault());
322 exif.setTag(exif.buildTag(ExifInterface.TAG_ORIENTATION,
323 ExifInterface.Orientation.TOP_LEFT));
324 // Remove old thumbnail
325 exif.removeCompressedThumbnail();
328 public Uri processAndSaveImage(ImagePreset preset, boolean flatten,
329 int quality, float sizeFactor, boolean exit) {
333 uri = resetToOriginalImageIfNeeded(preset, !flatten);
341 boolean noBitmap = true;
345 // If necessary, move the source file into the auxiliary directory,
346 // newSourceUri is then pointing to the new location.
347 // If no file is moved, newSourceUri will be the same as mSourceUri.
348 Uri newSourceUri = mSourceUri;
350 newSourceUri = moveSrcToAuxIfNeeded(mSourceUri, mDestinationFile);
353 Uri savedUri = mSelectedImageUri;
354 if (mPreviewImage != null) {
356 Object xmp = getPanoramaXMPData(newSourceUri, preset);
357 ExifInterface exif = getExifData(newSourceUri);
358 long time = System.currentTimeMillis();
359 updateExifData(exif, time);
360 if (putExifData(mDestinationFile, exif, mPreviewImage, quality)) {
361 putPanoramaXMPData(mDestinationFile, xmp);
362 ContentValues values = getContentValues(mContext, mSelectedImageUri, mDestinationFile, time);
363 Object result = mContext.getContentResolver().insert(
364 Images.Media.EXTERNAL_CONTENT_URI, values);
368 Object xmp = getPanoramaXMPData(newSourceUri, preset);
369 ExifInterface exif = getExifData(newSourceUri);
370 long time = System.currentTimeMillis();
371 updateExifData(exif, time);
372 // If we succeed in writing the bitmap as a jpeg, return a uri.
373 if (putExifData(mDestinationFile, exif, mPreviewImage, quality)) {
374 putPanoramaXMPData(mDestinationFile, xmp);
375 // mDestinationFile will save the newSourceUri info in the XMP.
377 XmpPresets.writeFilterXMP(mContext, newSourceUri,
378 mDestinationFile, preset);
380 // After this call, mSelectedImageUri will be actually
381 // pointing at the new file mDestinationFile.
382 savedUri = SaveImage.linkNewFileToUri(mContext, mSelectedImageUri,
383 mDestinationFile, time, !flatten);
386 if (mCallback != null) {
387 mCallback.onPreviewSaved(savedUri);
391 // Stopgap fix for low-memory devices.
395 // Try to do bitmap operations, downsample if low-memory
396 Bitmap bitmap = ImageLoader.loadOrientedBitmapWithBackouts(mContext, newSourceUri,
398 if (bitmap == null) {
401 if (sizeFactor != 1f) {
402 // if we have a valid size
403 int w = (int) (bitmap.getWidth() * sizeFactor);
404 int h = (int) (bitmap.getHeight() * sizeFactor);
405 if (w == 0 || h == 0) {
409 bitmap = Bitmap.createScaledBitmap(bitmap, w, h, true);
412 CachingPipeline pipeline = new CachingPipeline(FiltersManager.getManager(),
415 bitmap = pipeline.renderFinalImage(bitmap, preset);
418 Object xmp = getPanoramaXMPData(newSourceUri, preset);
419 ExifInterface exif = getExifData(newSourceUri);
420 long time = System.currentTimeMillis();
423 updateExifData(exif, time);
426 // If we succeed in writing the bitmap as a jpeg, return a uri.
427 if (putExifData(mDestinationFile, exif, bitmap, quality)) {
428 putPanoramaXMPData(mDestinationFile, xmp);
429 // mDestinationFile will save the newSourceUri info in the XMP.
431 XmpPresets.writeFilterXMP(mContext, newSourceUri,
432 mDestinationFile, preset);
433 uri = updateFile(mContext, savedUri, mDestinationFile, time);
437 ContentValues values = getContentValues(mContext, mSelectedImageUri, mDestinationFile, time);
438 Object result = mContext.getContentResolver().insert(
439 Images.Media.EXTERNAL_CONTENT_URI, values);
445 } catch (OutOfMemoryError e) {
446 // Try 5 times before failing for good.
447 if (++num_tries >= 5) {
459 * Move the source file to auxiliary directory if needed and return the Uri
460 * pointing to this new source file. If any file error happens, then just
461 * don't move into the auxiliary directory.
462 * @param srcUri Uri to the source image.
463 * @param dstFile Providing the destination file info to help to build the
464 * auxiliary directory and new source file's name.
465 * @return the newSourceUri pointing to the new source image.
467 private Uri moveSrcToAuxIfNeeded(Uri srcUri, File dstFile) {
468 File srcFile = getLocalFileFromUri(mContext, srcUri);
469 if (srcFile == null) {
470 Log.d(LOGTAG, "Source file is not a local file, no update.");
474 // Get the destination directory and create the auxilliary directory
476 File auxDiretory = getLocalAuxDirectory(dstFile);
477 if (!auxDiretory.exists()) {
478 boolean success = auxDiretory.mkdirs();
484 // Make sure there is a .nomedia file in the auxiliary directory, such
485 // that MediaScanner will not report those files under this directory.
486 File noMedia = new File(auxDiretory, ".nomedia");
487 if (!noMedia.exists()) {
489 noMedia.createNewFile();
490 } catch (IOException e) {
491 Log.e(LOGTAG, "Can't create the nomedia");
495 // We are using the destination file name such that photos sitting in
496 // the auxiliary directory are matching the parent directory.
497 File newSrcFile = new File(auxDiretory, dstFile.getName());
498 // Maintain the suffix during move
499 String to = newSrcFile.getName();
500 String from = srcFile.getName();
501 to = to.substring(to.lastIndexOf("."));
502 from = from.substring(from.lastIndexOf("."));
504 if (!to.equals(from)) {
505 String name = dstFile.getName();
506 name = name.substring(0, name.lastIndexOf(".")) + from;
507 newSrcFile = new File(auxDiretory, name);
510 if (!newSrcFile.exists()) {
511 boolean success = srcFile.renameTo(newSrcFile);
517 return Uri.fromFile(newSrcFile);
521 private static File getLocalAuxDirectory(File dstFile) {
522 File dstDirectory = dstFile.getParentFile();
523 File auxDiretory = new File(dstDirectory + "/" + AUX_DIR_NAME);
527 public static Uri makeAndInsertUri(Context context, Uri sourceUri) {
528 long time = System.currentTimeMillis();
529 String filename = new SimpleDateFormat(TIME_STAMP_NAME).format(new Date(time));
530 File saveDirectory = getFinalSaveDirectory(context, sourceUri);
531 File file = new File(saveDirectory, filename + ".JPG");
532 return linkNewFileToUri(context, sourceUri, file, time, false);
535 public static void saveImage(ImagePreset preset, final FilterShowActivity filterShowActivity,
537 Uri selectedImageUri = filterShowActivity.getSelectedImageUri();
538 Uri sourceImageUri = MasterImage.getImage().getUri();
539 boolean flatten = false;
540 if (preset.contains(FilterRepresentation.TYPE_TINYPLANET)){
543 Intent processIntent = ProcessingService.getSaveIntent(filterShowActivity, preset,
544 destination, selectedImageUri, sourceImageUri, flatten, 90, 1f, true);
546 filterShowActivity.startService(processIntent);
548 if (!filterShowActivity.isSimpleEditAction()) {
549 String toastMessage = filterShowActivity.getResources().getString(
550 R.string.save_and_processing);
551 Toast.makeText(filterShowActivity,
553 Toast.LENGTH_SHORT).show();
557 public static void querySource(Context context, Uri sourceUri, String[] projection,
558 ContentResolverQueryCallback callback) {
559 ContentResolver contentResolver = context.getContentResolver();
560 querySourceFromContentResolver(contentResolver, sourceUri, projection, callback);
563 private static void querySourceFromContentResolver(
564 ContentResolver contentResolver, Uri sourceUri, String[] projection,
565 ContentResolverQueryCallback callback) {
566 Cursor cursor = null;
568 cursor = contentResolver.query(sourceUri, projection, null, null,
570 if ((cursor != null) && cursor.moveToNext()) {
571 callback.onCursorResult(cursor);
573 } catch (Exception e) {
574 // Ignore error for lacking the data column from the source.
576 if (cursor != null) {
582 private static File getSaveDirectory(Context context, Uri sourceUri) {
583 File file = getLocalFileFromUri(context, sourceUri);
585 return file.getParentFile();
592 * Construct a File object based on the srcUri.
593 * @return The file object. Return null if srcUri is invalid or not a local
596 private static File getLocalFileFromUri(Context context, Uri srcUri) {
597 if (srcUri == null) {
598 Log.e(LOGTAG, "srcUri is null.");
602 String scheme = srcUri.getScheme();
603 if (scheme == null) {
604 Log.e(LOGTAG, "scheme is null.");
608 final File[] file = new File[1];
609 // sourceUri can be a file path or a content Uri, it need to be handled
611 if (scheme.equals(ContentResolver.SCHEME_CONTENT)) {
612 if (srcUri.getAuthority().equals(MediaStore.AUTHORITY)) {
613 querySource(context, srcUri, new String[] {
616 new ContentResolverQueryCallback() {
619 public void onCursorResult(Cursor cursor) {
620 file[0] = new File(cursor.getString(0));
624 } else if (scheme.equals(ContentResolver.SCHEME_FILE)) {
625 file[0] = new File(srcUri.getPath());
631 * Gets the actual filename for a Uri from Gallery's ContentProvider.
633 private static String getTrueFilename(Context context, Uri src) {
634 if (context == null || src == null) {
637 final String[] trueName = new String[1];
638 querySource(context, src, new String[] {
640 }, new ContentResolverQueryCallback() {
642 public void onCursorResult(Cursor cursor) {
643 trueName[0] = new File(cursor.getString(0)).getName();
650 * Checks whether the true filename has the panorama image prefix.
652 private static boolean hasPanoPrefix(Context context, Uri src) {
653 String name = getTrueFilename(context, src);
654 return name != null && name.startsWith(PREFIX_PANO);
658 * If the <code>sourceUri</code> is a local content Uri, update the
659 * <code>sourceUri</code> to point to the <code>file</code>.
660 * At the same time, the old file <code>sourceUri</code> used to point to
661 * will be removed if it is local.
662 * If the <code>sourceUri</code> is not a local content Uri, then the
663 * <code>file</code> will be inserted as a new content Uri.
664 * @return the final Uri referring to the <code>file</code>.
666 public static Uri linkNewFileToUri(Context context, Uri sourceUri,
667 File file, long time, boolean deleteOriginal) {
668 File oldSelectedFile = getLocalFileFromUri(context, sourceUri);
669 final ContentValues values = getContentValues(context, sourceUri, file, time);
671 Uri result = sourceUri;
673 // In the case of incoming Uri is just a local file Uri (like a cached
674 // file), we can't just update the Uri. We have to create a new Uri.
675 boolean fileUri = isFileUri(sourceUri);
677 if (fileUri || oldSelectedFile == null || !deleteOriginal) {
678 result = context.getContentResolver().insert(
679 Images.Media.EXTERNAL_CONTENT_URI, values);
681 context.getContentResolver().update(sourceUri, values, null, null);
682 if (oldSelectedFile.exists()) {
683 oldSelectedFile.delete();
689 public static Uri updateFile(Context context, Uri sourceUri, File file, long time) {
690 final ContentValues values = getContentValues(context, sourceUri, file, time);
691 context.getContentResolver().update(sourceUri, values, null, null);
695 private static ContentValues getContentValues(Context context, Uri sourceUri,
696 File file, long time) {
697 final ContentValues values = new ContentValues();
700 values.put(Images.Media.TITLE, file.getName());
701 values.put(Images.Media.DISPLAY_NAME, file.getName());
702 values.put(Images.Media.MIME_TYPE, "image/jpeg");
703 values.put(Images.Media.DATE_TAKEN, time);
704 values.put(Images.Media.DATE_MODIFIED, time);
705 values.put(Images.Media.DATE_ADDED, time);
706 values.put(Images.Media.ORIENTATION, 0);
707 values.put(Images.Media.DATA, file.getAbsolutePath());
708 values.put(Images.Media.SIZE, file.length());
709 // This is a workaround to trigger the MediaProvider to re-generate the
711 values.put(Images.Media.MINI_THUMB_MAGIC, 0);
713 final String[] projection = new String[] {
714 ImageColumns.DATE_TAKEN,
715 ImageColumns.LATITUDE, ImageColumns.LONGITUDE,
718 SaveImage.querySource(context, sourceUri, projection,
719 new ContentResolverQueryCallback() {
722 public void onCursorResult(Cursor cursor) {
723 values.put(Images.Media.DATE_TAKEN, cursor.getLong(0));
725 double latitude = cursor.getDouble(1);
726 double longitude = cursor.getDouble(2);
727 // TODO: Change || to && after the default location
729 if ((latitude != 0f) || (longitude != 0f)) {
730 values.put(Images.Media.LATITUDE, latitude);
731 values.put(Images.Media.LONGITUDE, longitude);
740 * @return true if the sourceUri is a local file Uri.
742 private static boolean isFileUri(Uri sourceUri) {
743 String scheme = sourceUri.getScheme();
744 if (scheme != null && scheme.equals(ContentResolver.SCHEME_FILE)) {