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.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;
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;
43 import java.io.FileNotFoundException;
44 import java.io.FilenameFilter;
45 import java.io.IOException;
46 import java.io.InputStream;
48 import java.text.SimpleDateFormat;
49 import java.util.TimeZone;
52 * Asynchronous task for saving edited photo as a new copy.
54 public class SaveCopyTask extends AsyncTask<ImagePreset, Void, Uri> {
56 private static final String LOGTAG = "SaveCopyTask";
59 * Callback for the completed asynchronous task.
61 public interface Callback {
63 void onComplete(Uri result);
66 public interface ContentResolverQueryCallback {
68 void onCursorResult(Cursor cursor);
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";
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
80 private static final boolean USE_AUX_DIR = true;
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;
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
93 // Note that deletion on the edited image will also cause the deletion of
94 // the original image under auxiliary directory.
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
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
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
117 // TODO: Move the saving into a background service.
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
130 public SaveCopyTask(Context context, Uri sourceUri, Uri selectedImageUri,
131 File destination, Callback callback) {
133 mSourceUri = sourceUri;
134 mCallback = callback;
135 if (destination == null) {
136 mDestinationFile = getNewFile(context, selectedImageUri);
138 mDestinationFile = destination;
141 mSelectedImageUri = selectedImageUri;
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);
150 // Create the directory if it doesn't exist
151 if (!saveDirectory.exists())
152 saveDirectory.mkdirs();
153 return saveDirectory;
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);
163 return new File(saveDirectory, PREFIX_IMG + filename + POSTFIX_JPG);
167 * Remove the files in the auxiliary directory whose names are the same as
169 * @param contentResolver The application's contentResolver
170 * @param srcContentUri The content Uri for the source image.
172 public static void deleteAuxFiles(ContentResolver contentResolver,
174 final String[] fullPath = new String[1];
175 String[] queryProjection = new String[] { ImageColumns.DATA };
176 querySourceFromContentResolver(contentResolver,
177 srcContentUri, queryProjection,
178 new ContentResolverQueryCallback() {
180 public void onCursorResult(Cursor cursor) {
181 fullPath[0] = cursor.getString(0);
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]);
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() {
199 public boolean accept(File dir, String name) {
200 if (name.startsWith(filenameNoExt + ".")) {
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) {
218 public Object getPanoramaXMPData(Uri source, ImagePreset preset) {
220 if (preset.isPanoramaSafe()) {
221 InputStream is = null;
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);
228 Utils.closeSilently(is);
234 public boolean putPanoramaXMPData(File file, Object xmp) {
236 return XmpUtilHelper.writeXMPMeta(file.getAbsolutePath(), xmp);
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;
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);
254 Utils.closeSilently(inStream);
260 public boolean putExifData(File file, ExifInterface exif, Bitmap image) {
263 exif.writeExif(image, file.getAbsolutePath());
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);
274 * The task should be executed with one given bitmap to be saved.
277 protected Uri doInBackground(ImagePreset... params) {
278 // TODO: Support larger dimensions for photo saving.
279 if (params[0] == null || mSourceUri == null || mSelectedImageUri == null) {
282 ImagePreset preset = params[0];
283 BitmapFactory.Options options = new BitmapFactory.Options();
285 boolean noBitmap = true;
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.
293 newSourceUri = moveSrcToAuxIfNeeded(mSourceUri, mDestinationFile);
295 newSourceUri = mSourceUri;
297 // Stopgap fix for low-memory devices.
300 // Try to do bitmap operations, downsample if low-memory
301 Bitmap bitmap = ImageLoader.loadMutableBitmap(mContext, newSourceUri, options);
302 if (bitmap == null) {
305 CachingPipeline pipeline = new CachingPipeline(FiltersManager.getManager(), "Saving");
306 bitmap = pipeline.renderFinalImage(bitmap, preset);
308 Object xmp = getPanoramaXMPData(mSelectedImageUri, preset);
309 ExifInterface exif = getExifData(mSelectedImageUri);
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));
318 // Remove old thumbnail
319 exif.removeCompressedThumbnail();
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,
328 // mDestinationFile will save the newSourceUri info in the XMP.
329 XmpPresets.writeFilterXMP(mContext, newSourceUri, mDestinationFile, preset);
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.
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);
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) {
350 options.inSampleSize *= 2;
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.
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.");
371 // Get the destination directory and create the auxilliary directory
373 File auxDiretory = getLocalAuxDirectory(dstFile);
374 if (!auxDiretory.exists()) {
375 auxDiretory.mkdirs();
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()) {
383 noMedia.createNewFile();
384 } catch (IOException e) {
385 Log.e(LOGTAG, "Can't create the nomedia");
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());
393 if (!newSrcFile.exists()) {
394 srcFile.renameTo(newSrcFile);
397 return Uri.fromFile(newSrcFile);
401 private static File getLocalAuxDirectory(File dstFile) {
402 File dstDirectory = dstFile.getParentFile();
403 File auxDiretory = new File(dstDirectory + "/" + AUX_DIR_NAME);
408 protected void onPostExecute(Uri result) {
409 if (mCallback != null) {
410 mCallback.onComplete(result);
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);
420 private static void querySourceFromContentResolver(
421 ContentResolver contentResolver, Uri sourceUri, String[] projection,
422 ContentResolverQueryCallback callback) {
423 Cursor cursor = null;
425 cursor = contentResolver.query(sourceUri, projection, null, null,
427 if ((cursor != null) && cursor.moveToNext()) {
428 callback.onCursorResult(cursor);
430 } catch (Exception e) {
431 // Ignore error for lacking the data column from the source.
433 if (cursor != null) {
439 private static File getSaveDirectory(Context context, Uri sourceUri) {
440 File file = getFileFromUri(context, sourceUri);
442 return file.getParentFile();
449 * Construct a File object based on the srcUri.
450 * @return The file object. Return null if srcUri is invalid or not a local
453 private static File getFileFromUri(Context context, Uri srcUri) {
454 if (srcUri == null) {
455 Log.e(LOGTAG, "srcUri is null.");
459 String scheme = srcUri.getScheme();
460 if (scheme == null) {
461 Log.e(LOGTAG, "scheme is null.");
465 final File[] file = new File[1];
466 // sourceUri can be a file path or a content Uri, it need to be handled
468 if (scheme.equals(ContentResolver.SCHEME_CONTENT)) {
469 if (srcUri.getAuthority().equals(MediaStore.AUTHORITY)) {
470 querySource(context, srcUri, new String[] {
473 new ContentResolverQueryCallback() {
476 public void onCursorResult(Cursor cursor) {
477 file[0] = new File(cursor.getString(0));
481 } else if (scheme.equals(ContentResolver.SCHEME_FILE)) {
482 file[0] = new File(srcUri.getPath());
488 * Gets the actual filename for a Uri from Gallery's ContentProvider.
490 private static String getTrueFilename(Context context, Uri src) {
491 if (context == null || src == null) {
494 final String[] trueName = new String[1];
495 querySource(context, src, new String[] {
497 }, new ContentResolverQueryCallback() {
499 public void onCursorResult(Cursor cursor) {
500 trueName[0] = new File(cursor.getString(0)).getName();
507 * Checks whether the true filename has the panorama image prefix.
509 private static boolean hasPanoPrefix(Context context, Uri src) {
510 String name = getTrueFilename(context, src);
511 return name != null && name.startsWith(PREFIX_PANO);
515 * Insert the content (saved file) with proper source photo properties.
517 private static Uri insertContent(Context context, Uri sourceUri, File file,
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());
532 final String[] projection = new String[] {
533 ImageColumns.DATE_TAKEN,
534 ImageColumns.LATITUDE, ImageColumns.LONGITUDE,
536 querySource(context, sourceUri, projection,
537 new ContentResolverQueryCallback() {
540 public void onCursorResult(Cursor cursor) {
541 values.put(Images.Media.DATE_TAKEN, cursor.getLong(0));
543 double latitude = cursor.getDouble(1);
544 double longitude = cursor.getDouble(2);
545 // TODO: Change || to && after the default location
547 if ((latitude != 0f) || (longitude != 0f)) {
548 values.put(Images.Media.LATITUDE, latitude);
549 values.put(Images.Media.LONGITUDE, longitude);
554 return context.getContentResolver().insert(
555 Images.Media.EXTERNAL_CONTENT_URI, values);