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;
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;
47 import java.io.FileNotFoundException;
48 import java.io.FilenameFilter;
49 import java.io.IOException;
50 import java.io.InputStream;
52 import java.text.SimpleDateFormat;
53 import java.util.TimeZone;
56 * Handles saving edited photo
58 public class SaveImage {
59 private static final String LOGTAG = "SaveImage";
62 * Callback for updates
64 public interface Callback {
65 void onProgress(int max, int current);
68 public interface ContentResolverQueryCallback {
69 void onCursorResult(Cursor cursor);
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";
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
81 private static final boolean USE_AUX_DIR = true;
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;
89 private int mCurrentProcessingStep = 1;
91 public static final int MAX_PROCESSING_STEPS = 6;
92 public static final String DEFAULT_SAVE_DIRECTORY = "EditedOnlinePhotos";
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
99 // Note that deletion on the edited image will also cause the deletion of
100 // the original image under auxiliary directory.
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
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
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
123 // TODO: Move the saving into a background service.
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
136 public SaveImage(Context context, Uri sourceUri, Uri selectedImageUri,
137 File destination, Callback callback) {
139 mSourceUri = sourceUri;
140 mCallback = callback;
141 if (destination == null) {
142 mDestinationFile = getNewFile(context, selectedImageUri);
144 mDestinationFile = destination;
147 mSelectedImageUri = selectedImageUri;
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);
156 // Create the directory if it doesn't exist
157 if (!saveDirectory.exists())
158 saveDirectory.mkdirs();
159 return saveDirectory;
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);
169 return new File(saveDirectory, PREFIX_IMG + filename + POSTFIX_JPG);
173 * Remove the files in the auxiliary directory whose names are the same as
175 * @param contentResolver The application's contentResolver
176 * @param srcContentUri The content Uri for the source image.
178 public static void deleteAuxFiles(ContentResolver contentResolver,
180 final String[] fullPath = new String[1];
181 String[] queryProjection = new String[] { ImageColumns.DATA };
182 querySourceFromContentResolver(contentResolver,
183 srcContentUri, queryProjection,
184 new ContentResolverQueryCallback() {
186 public void onCursorResult(Cursor cursor) {
187 fullPath[0] = cursor.getString(0);
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]);
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() {
205 public boolean accept(File dir, String name) {
206 if (name.startsWith(filenameNoExt + ".")) {
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) {
224 public Object getPanoramaXMPData(Uri source, ImagePreset preset) {
226 if (preset.isPanoramaSafe()) {
227 InputStream is = null;
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);
234 Utils.closeSilently(is);
240 public boolean putPanoramaXMPData(File file, Object xmp) {
242 return XmpUtilHelper.writeXMPMeta(file.getAbsolutePath(), xmp);
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);
253 if (mimeType.equals(ImageLoader.JPEG_MIME_TYPE)) {
254 InputStream inStream = null;
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);
263 Utils.closeSilently(inStream);
269 public boolean putExifData(File file, ExifInterface exif, Bitmap image) {
272 exif.writeExif(image, file.getAbsolutePath());
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);
282 private Uri resetToOriginalImageIfNeeded(ImagePreset preset) {
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
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();
303 private void resetProgress() {
304 mCurrentProcessingStep = 0;
307 private void updateProgress() {
308 if (mCallback != null) {
309 mCallback.onProgress(MAX_PROCESSING_STEPS, ++mCurrentProcessingStep);
313 public Uri processAndSaveImage(ImagePreset preset) {
315 Uri uri = resetToOriginalImageIfNeeded(preset);
322 boolean noBitmap = true;
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.
331 newSourceUri = moveSrcToAuxIfNeeded(mSourceUri, mDestinationFile);
333 newSourceUri = mSourceUri;
335 // Stopgap fix for low-memory devices.
339 // Try to do bitmap operations, downsample if low-memory
340 Bitmap bitmap = ImageLoader.loadOrientedBitmapWithBackouts(mContext, newSourceUri,
342 if (bitmap == null) {
346 CachingPipeline pipeline = new CachingPipeline(FiltersManager.getManager(),
349 bitmap = pipeline.renderFinalImage(bitmap, preset);
352 Object xmp = getPanoramaXMPData(mSelectedImageUri, preset);
353 ExifInterface exif = getExifData(mSelectedImageUri);
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));
362 // Remove old thumbnail
363 exif.removeCompressedThumbnail();
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,
373 // mDestinationFile will save the newSourceUri info in the XMP.
374 XmpPresets.writeFilterXMP(mContext, newSourceUri, mDestinationFile, preset);
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
382 removeSelectedImage();
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) {
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);
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.
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.");
425 // Get the destination directory and create the auxilliary directory
427 File auxDiretory = getLocalAuxDirectory(dstFile);
428 if (!auxDiretory.exists()) {
429 auxDiretory.mkdirs();
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()) {
437 noMedia.createNewFile();
438 } catch (IOException e) {
439 Log.e(LOGTAG, "Can't create the nomedia");
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());
447 if (!newSrcFile.exists()) {
448 srcFile.renameTo(newSrcFile);
451 return Uri.fromFile(newSrcFile);
455 private static File getLocalAuxDirectory(File dstFile) {
456 File dstDirectory = dstFile.getParentFile();
457 File auxDiretory = new File(dstDirectory + "/" + AUX_DIR_NAME);
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);
469 public static void saveImage(ImagePreset preset, final FilterShowActivity filterShowActivity,
471 Uri selectedImageUri = filterShowActivity.getSelectedImageUri();
472 Uri sourceImageUri = MasterImage.getImage().getUri();
474 Intent processIntent = ProcessingService.getSaveIntent(filterShowActivity, preset,
475 destination, selectedImageUri, sourceImageUri);
477 filterShowActivity.startService(processIntent);
479 if (!filterShowActivity.isSimpleEditAction()) {
481 filterShowActivity.completeSaveImage(selectedImageUri);
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);
491 private static void querySourceFromContentResolver(
492 ContentResolver contentResolver, Uri sourceUri, String[] projection,
493 ContentResolverQueryCallback callback) {
494 Cursor cursor = null;
496 cursor = contentResolver.query(sourceUri, projection, null, null,
498 if ((cursor != null) && cursor.moveToNext()) {
499 callback.onCursorResult(cursor);
501 } catch (Exception e) {
502 // Ignore error for lacking the data column from the source.
504 if (cursor != null) {
510 private static File getSaveDirectory(Context context, Uri sourceUri) {
511 File file = getLocalFileFromUri(context, sourceUri);
513 return file.getParentFile();
520 * Construct a File object based on the srcUri.
521 * @return The file object. Return null if srcUri is invalid or not a local
524 private static File getLocalFileFromUri(Context context, Uri srcUri) {
525 if (srcUri == null) {
526 Log.e(LOGTAG, "srcUri is null.");
530 String scheme = srcUri.getScheme();
531 if (scheme == null) {
532 Log.e(LOGTAG, "scheme is null.");
536 final File[] file = new File[1];
537 // sourceUri can be a file path or a content Uri, it need to be handled
539 if (scheme.equals(ContentResolver.SCHEME_CONTENT)) {
540 if (srcUri.getAuthority().equals(MediaStore.AUTHORITY)) {
541 querySource(context, srcUri, new String[] {
544 new ContentResolverQueryCallback() {
547 public void onCursorResult(Cursor cursor) {
548 file[0] = new File(cursor.getString(0));
552 } else if (scheme.equals(ContentResolver.SCHEME_FILE)) {
553 file[0] = new File(srcUri.getPath());
559 * Gets the actual filename for a Uri from Gallery's ContentProvider.
561 private static String getTrueFilename(Context context, Uri src) {
562 if (context == null || src == null) {
565 final String[] trueName = new String[1];
566 querySource(context, src, new String[] {
568 }, new ContentResolverQueryCallback() {
570 public void onCursorResult(Cursor cursor) {
571 trueName[0] = new File(cursor.getString(0)).getName();
578 * Checks whether the true filename has the panorama image prefix.
580 private static boolean hasPanoPrefix(Context context, Uri src) {
581 String name = getTrueFilename(context, src);
582 return name != null && name.startsWith(PREFIX_PANO);
586 * Insert the content (saved file) with proper source photo properties.
588 public static Uri insertContent(Context context, Uri sourceUri, File file, long time) {
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());
602 final String[] projection = new String[] {
603 ImageColumns.DATE_TAKEN,
604 ImageColumns.LATITUDE, ImageColumns.LONGITUDE,
606 SaveImage.querySource(context, sourceUri, projection,
607 new SaveImage.ContentResolverQueryCallback() {
610 public void onCursorResult(Cursor cursor) {
611 values.put(Images.Media.DATE_TAKEN, cursor.getLong(0));
613 double latitude = cursor.getDouble(1);
614 double longitude = cursor.getDouble(2);
615 // TODO: Change || to && after the default location
617 if ((latitude != 0f) || (longitude != 0f)) {
618 values.put(Images.Media.LATITUDE, latitude);
619 values.put(Images.Media.LONGITUDE, longitude);
624 return context.getContentResolver().insert(
625 Images.Media.EXTERNAL_CONTENT_URI, values);