summaryrefslogtreecommitdiffstats
path: root/src/com/android/gallery3d/filtershow/tools/SaveImage.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/gallery3d/filtershow/tools/SaveImage.java')
-rw-r--r--src/com/android/gallery3d/filtershow/tools/SaveImage.java628
1 files changed, 628 insertions, 0 deletions
diff --git a/src/com/android/gallery3d/filtershow/tools/SaveImage.java b/src/com/android/gallery3d/filtershow/tools/SaveImage.java
new file mode 100644
index 000000000..9b13af13d
--- /dev/null
+++ b/src/com/android/gallery3d/filtershow/tools/SaveImage.java
@@ -0,0 +1,628 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.filtershow.tools;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Environment;
+import android.provider.MediaStore;
+import android.provider.MediaStore.Images;
+import android.provider.MediaStore.Images.ImageColumns;
+import android.util.Log;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.PhotoPage;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.exif.ExifInterface;
+import com.android.gallery3d.filtershow.FilterShowActivity;
+import com.android.gallery3d.filtershow.pipeline.CachingPipeline;
+import com.android.gallery3d.filtershow.cache.ImageLoader;
+import com.android.gallery3d.filtershow.filters.FiltersManager;
+import com.android.gallery3d.filtershow.imageshow.MasterImage;
+import com.android.gallery3d.filtershow.pipeline.ImagePreset;
+import com.android.gallery3d.filtershow.pipeline.ProcessingService;
+import com.android.gallery3d.util.UsageStatistics;
+import com.android.gallery3d.util.XmpUtilHelper;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.sql.Date;
+import java.text.SimpleDateFormat;
+import java.util.TimeZone;
+
+/**
+ * Handles saving edited photo
+ */
+public class SaveImage {
+ private static final String LOGTAG = "SaveImage";
+
+ /**
+ * Callback for updates
+ */
+ public interface Callback {
+ void onProgress(int max, int current);
+ }
+
+ public interface ContentResolverQueryCallback {
+ void onCursorResult(Cursor cursor);
+ }
+
+ private static final String TIME_STAMP_NAME = "_yyyyMMdd_HHmmss";
+ private static final String PREFIX_PANO = "PANO";
+ private static final String PREFIX_IMG = "IMG";
+ private static final String POSTFIX_JPG = ".jpg";
+ private static final String AUX_DIR_NAME = ".aux";
+
+ // When this is true, the source file will be saved into auxiliary directory
+ // and hidden from MediaStore. Otherwise, the source will be kept as the
+ // same.
+ private static final boolean USE_AUX_DIR = true;
+
+ private final Context mContext;
+ private final Uri mSourceUri;
+ private final Callback mCallback;
+ private final File mDestinationFile;
+ private final Uri mSelectedImageUri;
+
+ private int mCurrentProcessingStep = 1;
+
+ public static final int MAX_PROCESSING_STEPS = 6;
+ public static final String DEFAULT_SAVE_DIRECTORY = "EditedOnlinePhotos";
+
+ // In order to support the new edit-save behavior such that user won't see
+ // the edited image together with the original image, we are adding a new
+ // auxiliary directory for the edited image. Basically, the original image
+ // will be hidden in that directory after edit and user will see the edited
+ // image only.
+ // Note that deletion on the edited image will also cause the deletion of
+ // the original image under auxiliary directory.
+ //
+ // There are several situations we need to consider:
+ // 1. User edit local image local01.jpg. A local02.jpg will be created in the
+ // same directory, and original image will be moved to auxiliary directory as
+ // ./.aux/local02.jpg.
+ // If user edit the local02.jpg, local03.jpg will be created in the local
+ // directory and ./.aux/local02.jpg will be renamed to ./.aux/local03.jpg
+ //
+ // 2. User edit remote image remote01.jpg from picassa or other server.
+ // remoteSavedLocal01.jpg will be saved under proper local directory.
+ // In remoteSavedLocal01.jpg, there will be a reference pointing to the
+ // remote01.jpg. There will be no local copy of remote01.jpg.
+ // If user edit remoteSavedLocal01.jpg, then a new remoteSavedLocal02.jpg
+ // will be generated and still pointing to the remote01.jpg
+ //
+ // 3. User delete any local image local.jpg.
+ // Since the filenames are kept consistent in auxiliary directory, every
+ // time a local.jpg get deleted, the files in auxiliary directory whose
+ // names starting with "local." will be deleted.
+ // This pattern will facilitate the multiple images deletion in the auxiliary
+ // directory.
+ //
+ // TODO: Move the saving into a background service.
+
+ /**
+ * @param context
+ * @param sourceUri The Uri for the original image, which can be the hidden
+ * image under the auxiliary directory or the same as selectedImageUri.
+ * @param selectedImageUri The Uri for the image selected by the user.
+ * In most cases, it is a content Uri for local image or remote image.
+ * @param destination Destinaton File, if this is null, a new file will be
+ * created under the same directory as selectedImageUri.
+ * @param callback Let the caller know the saving has completed.
+ * @return the newSourceUri
+ */
+ public SaveImage(Context context, Uri sourceUri, Uri selectedImageUri,
+ File destination, Callback callback) {
+ mContext = context;
+ mSourceUri = sourceUri;
+ mCallback = callback;
+ if (destination == null) {
+ mDestinationFile = getNewFile(context, selectedImageUri);
+ } else {
+ mDestinationFile = destination;
+ }
+
+ mSelectedImageUri = selectedImageUri;
+ }
+
+ public static File getFinalSaveDirectory(Context context, Uri sourceUri) {
+ File saveDirectory = SaveImage.getSaveDirectory(context, sourceUri);
+ if ((saveDirectory == null) || !saveDirectory.canWrite()) {
+ saveDirectory = new File(Environment.getExternalStorageDirectory(),
+ SaveImage.DEFAULT_SAVE_DIRECTORY);
+ }
+ // Create the directory if it doesn't exist
+ if (!saveDirectory.exists())
+ saveDirectory.mkdirs();
+ return saveDirectory;
+ }
+
+ public static File getNewFile(Context context, Uri sourceUri) {
+ File saveDirectory = getFinalSaveDirectory(context, sourceUri);
+ String filename = new SimpleDateFormat(TIME_STAMP_NAME).format(new Date(
+ System.currentTimeMillis()));
+ if (hasPanoPrefix(context, sourceUri)) {
+ return new File(saveDirectory, PREFIX_PANO + filename + POSTFIX_JPG);
+ }
+ return new File(saveDirectory, PREFIX_IMG + filename + POSTFIX_JPG);
+ }
+
+ /**
+ * Remove the files in the auxiliary directory whose names are the same as
+ * the source image.
+ * @param contentResolver The application's contentResolver
+ * @param srcContentUri The content Uri for the source image.
+ */
+ public static void deleteAuxFiles(ContentResolver contentResolver,
+ Uri srcContentUri) {
+ final String[] fullPath = new String[1];
+ String[] queryProjection = new String[] { ImageColumns.DATA };
+ querySourceFromContentResolver(contentResolver,
+ srcContentUri, queryProjection,
+ new ContentResolverQueryCallback() {
+ @Override
+ public void onCursorResult(Cursor cursor) {
+ fullPath[0] = cursor.getString(0);
+ }
+ }
+ );
+ if (fullPath[0] != null) {
+ // Construct the auxiliary directory given the source file's path.
+ // Then select and delete all the files starting with the same name
+ // under the auxiliary directory.
+ File currentFile = new File(fullPath[0]);
+
+ String filename = currentFile.getName();
+ int firstDotPos = filename.indexOf(".");
+ final String filenameNoExt = (firstDotPos == -1) ? filename :
+ filename.substring(0, firstDotPos);
+ File auxDir = getLocalAuxDirectory(currentFile);
+ if (auxDir.exists()) {
+ FilenameFilter filter = new FilenameFilter() {
+ @Override
+ public boolean accept(File dir, String name) {
+ if (name.startsWith(filenameNoExt + ".")) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+ };
+
+ // Delete all auxiliary files whose name is matching the
+ // current local image.
+ File[] auxFiles = auxDir.listFiles(filter);
+ for (File file : auxFiles) {
+ file.delete();
+ }
+ }
+ }
+ }
+
+ public Object getPanoramaXMPData(Uri source, ImagePreset preset) {
+ Object xmp = null;
+ if (preset.isPanoramaSafe()) {
+ InputStream is = null;
+ try {
+ is = mContext.getContentResolver().openInputStream(source);
+ xmp = XmpUtilHelper.extractXMPMeta(is);
+ } catch (FileNotFoundException e) {
+ Log.w(LOGTAG, "Failed to get XMP data from image: ", e);
+ } finally {
+ Utils.closeSilently(is);
+ }
+ }
+ return xmp;
+ }
+
+ public boolean putPanoramaXMPData(File file, Object xmp) {
+ if (xmp != null) {
+ return XmpUtilHelper.writeXMPMeta(file.getAbsolutePath(), xmp);
+ }
+ return false;
+ }
+
+ public ExifInterface getExifData(Uri source) {
+ ExifInterface exif = new ExifInterface();
+ String mimeType = mContext.getContentResolver().getType(mSelectedImageUri);
+ if (mimeType == null) {
+ mimeType = ImageLoader.getMimeType(mSelectedImageUri);
+ }
+ if (mimeType.equals(ImageLoader.JPEG_MIME_TYPE)) {
+ InputStream inStream = null;
+ try {
+ inStream = mContext.getContentResolver().openInputStream(source);
+ exif.readExif(inStream);
+ } catch (FileNotFoundException e) {
+ Log.w(LOGTAG, "Cannot find file: " + source, e);
+ } catch (IOException e) {
+ Log.w(LOGTAG, "Cannot read exif for: " + source, e);
+ } finally {
+ Utils.closeSilently(inStream);
+ }
+ }
+ return exif;
+ }
+
+ public boolean putExifData(File file, ExifInterface exif, Bitmap image) {
+ boolean ret = false;
+ try {
+ exif.writeExif(image, file.getAbsolutePath());
+ ret = true;
+ } catch (FileNotFoundException e) {
+ Log.w(LOGTAG, "File not found: " + file.getAbsolutePath(), e);
+ } catch (IOException e) {
+ Log.w(LOGTAG, "Could not write exif: ", e);
+ }
+ return ret;
+ }
+
+ private Uri resetToOriginalImageIfNeeded(ImagePreset preset) {
+ Uri uri = null;
+ if (!preset.hasModifications()) {
+ // This can happen only when preset has no modification but save
+ // button is enabled, it means the file is loaded with filters in
+ // the XMP, then all the filters are removed or restore to default.
+ // In this case, when mSourceUri exists, rename it to the
+ // destination file.
+ File srcFile = getLocalFileFromUri(mContext, mSourceUri);
+ // If the source is not a local file, then skip this renaming and
+ // create a local copy as usual.
+ if (srcFile != null) {
+ srcFile.renameTo(mDestinationFile);
+ uri = SaveImage.insertContent(mContext, mSelectedImageUri, mDestinationFile,
+ System.currentTimeMillis());
+ removeSelectedImage();
+ }
+ }
+ return uri;
+ }
+
+ private void resetProgress() {
+ mCurrentProcessingStep = 0;
+ }
+
+ private void updateProgress() {
+ if (mCallback != null) {
+ mCallback.onProgress(MAX_PROCESSING_STEPS, ++mCurrentProcessingStep);
+ }
+ }
+
+ public Uri processAndSaveImage(ImagePreset preset) {
+
+ Uri uri = resetToOriginalImageIfNeeded(preset);
+ if (uri != null) {
+ return null;
+ }
+
+ resetProgress();
+
+ boolean noBitmap = true;
+ int num_tries = 0;
+ int sampleSize = 1;
+
+ // If necessary, move the source file into the auxiliary directory,
+ // newSourceUri is then pointing to the new location.
+ // If no file is moved, newSourceUri will be the same as mSourceUri.
+ Uri newSourceUri;
+ if (USE_AUX_DIR) {
+ newSourceUri = moveSrcToAuxIfNeeded(mSourceUri, mDestinationFile);
+ } else {
+ newSourceUri = mSourceUri;
+ }
+ // Stopgap fix for low-memory devices.
+ while (noBitmap) {
+ try {
+ updateProgress();
+ // Try to do bitmap operations, downsample if low-memory
+ Bitmap bitmap = ImageLoader.loadOrientedBitmapWithBackouts(mContext, newSourceUri,
+ sampleSize);
+ if (bitmap == null) {
+ return null;
+ }
+ updateProgress();
+ CachingPipeline pipeline = new CachingPipeline(FiltersManager.getManager(),
+ "Saving");
+
+ bitmap = pipeline.renderFinalImage(bitmap, preset);
+ updateProgress();
+
+ Object xmp = getPanoramaXMPData(mSelectedImageUri, preset);
+ ExifInterface exif = getExifData(mSelectedImageUri);
+
+ // Set tags
+ long time = System.currentTimeMillis();
+ exif.addDateTimeStampTag(ExifInterface.TAG_DATE_TIME, time,
+ TimeZone.getDefault());
+ exif.setTag(exif.buildTag(ExifInterface.TAG_ORIENTATION,
+ ExifInterface.Orientation.TOP_LEFT));
+
+ // Remove old thumbnail
+ exif.removeCompressedThumbnail();
+
+ // If we succeed in writing the bitmap as a jpeg, return a uri.
+ if (putExifData(mDestinationFile, exif, bitmap)) {
+ putPanoramaXMPData(mDestinationFile, xmp);
+ uri = SaveImage.insertContent(mContext, mSelectedImageUri, mDestinationFile,
+ time);
+ }
+ updateProgress();
+
+ // mDestinationFile will save the newSourceUri info in the XMP.
+ XmpPresets.writeFilterXMP(mContext, newSourceUri, mDestinationFile, preset);
+ updateProgress();
+
+ // Since we have a new image inserted to media store, we can
+ // safely remove the old one which is selected by the user.
+ // TODO: we should fix that, do an update instead of insert+remove,
+ // as well as asking Gallery to update its cached version of the image
+ if (USE_AUX_DIR) {
+ removeSelectedImage();
+ }
+ updateProgress();
+ noBitmap = false;
+ UsageStatistics.onEvent(UsageStatistics.COMPONENT_EDITOR,
+ "SaveComplete", null);
+ } catch (OutOfMemoryError e) {
+ // Try 5 times before failing for good.
+ if (++num_tries >= 5) {
+ throw e;
+ }
+ System.gc();
+ sampleSize *= 2;
+ resetProgress();
+ }
+ }
+ return uri;
+ }
+
+ private void removeSelectedImage() {
+ String scheme = mSelectedImageUri.getScheme();
+ if (scheme != null && scheme.equals(ContentResolver.SCHEME_CONTENT)) {
+ if (mSelectedImageUri.getAuthority().equals(MediaStore.AUTHORITY)) {
+ mContext.getContentResolver().delete(mSelectedImageUri, null, null);
+ }
+ }
+ }
+
+ /**
+ * Move the source file to auxiliary directory if needed and return the Uri
+ * pointing to this new source file.
+ * @param srcUri Uri to the source image.
+ * @param dstFile Providing the destination file info to help to build the
+ * auxiliary directory and new source file's name.
+ * @return the newSourceUri pointing to the new source image.
+ */
+ private Uri moveSrcToAuxIfNeeded(Uri srcUri, File dstFile) {
+ File srcFile = getLocalFileFromUri(mContext, srcUri);
+ if (srcFile == null) {
+ Log.d(LOGTAG, "Source file is not a local file, no update.");
+ return srcUri;
+ }
+
+ // Get the destination directory and create the auxilliary directory
+ // if necessary.
+ File auxDiretory = getLocalAuxDirectory(dstFile);
+ if (!auxDiretory.exists()) {
+ auxDiretory.mkdirs();
+ }
+
+ // Make sure there is a .nomedia file in the auxiliary directory, such
+ // that MediaScanner will not report those files under this directory.
+ File noMedia = new File(auxDiretory, ".nomedia");
+ if (!noMedia.exists()) {
+ try {
+ noMedia.createNewFile();
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Can't create the nomedia");
+ return srcUri;
+ }
+ }
+ // We are using the destination file name such that photos sitting in
+ // the auxiliary directory are matching the parent directory.
+ File newSrcFile = new File(auxDiretory, dstFile.getName());
+
+ if (!newSrcFile.exists()) {
+ srcFile.renameTo(newSrcFile);
+ }
+
+ return Uri.fromFile(newSrcFile);
+
+ }
+
+ private static File getLocalAuxDirectory(File dstFile) {
+ File dstDirectory = dstFile.getParentFile();
+ File auxDiretory = new File(dstDirectory + "/" + AUX_DIR_NAME);
+ return auxDiretory;
+ }
+
+ public static Uri makeAndInsertUri(Context context, Uri sourceUri) {
+ long time = System.currentTimeMillis();
+ String filename = new SimpleDateFormat(TIME_STAMP_NAME).format(new Date(time));
+ File saveDirectory = getFinalSaveDirectory(context, sourceUri);
+ File file = new File(saveDirectory, filename + ".JPG");
+ return insertContent(context, sourceUri, file, time);
+ }
+
+ public static void saveImage(ImagePreset preset, final FilterShowActivity filterShowActivity,
+ File destination) {
+ Uri selectedImageUri = filterShowActivity.getSelectedImageUri();
+ Uri sourceImageUri = MasterImage.getImage().getUri();
+
+ Intent processIntent = ProcessingService.getSaveIntent(filterShowActivity, preset,
+ destination, selectedImageUri, sourceImageUri);
+
+ filterShowActivity.startService(processIntent);
+
+ if (!filterShowActivity.isSimpleEditAction()) {
+ // terminate for now
+ filterShowActivity.completeSaveImage(selectedImageUri);
+ }
+ }
+
+ public static void querySource(Context context, Uri sourceUri, String[] projection,
+ ContentResolverQueryCallback callback) {
+ ContentResolver contentResolver = context.getContentResolver();
+ querySourceFromContentResolver(contentResolver, sourceUri, projection, callback);
+ }
+
+ private static void querySourceFromContentResolver(
+ ContentResolver contentResolver, Uri sourceUri, String[] projection,
+ ContentResolverQueryCallback callback) {
+ Cursor cursor = null;
+ try {
+ cursor = contentResolver.query(sourceUri, projection, null, null,
+ null);
+ if ((cursor != null) && cursor.moveToNext()) {
+ callback.onCursorResult(cursor);
+ }
+ } catch (Exception e) {
+ // Ignore error for lacking the data column from the source.
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+
+ private static File getSaveDirectory(Context context, Uri sourceUri) {
+ File file = getLocalFileFromUri(context, sourceUri);
+ if (file != null) {
+ return file.getParentFile();
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Construct a File object based on the srcUri.
+ * @return The file object. Return null if srcUri is invalid or not a local
+ * file.
+ */
+ private static File getLocalFileFromUri(Context context, Uri srcUri) {
+ if (srcUri == null) {
+ Log.e(LOGTAG, "srcUri is null.");
+ return null;
+ }
+
+ String scheme = srcUri.getScheme();
+ if (scheme == null) {
+ Log.e(LOGTAG, "scheme is null.");
+ return null;
+ }
+
+ final File[] file = new File[1];
+ // sourceUri can be a file path or a content Uri, it need to be handled
+ // differently.
+ if (scheme.equals(ContentResolver.SCHEME_CONTENT)) {
+ if (srcUri.getAuthority().equals(MediaStore.AUTHORITY)) {
+ querySource(context, srcUri, new String[] {
+ ImageColumns.DATA
+ },
+ new ContentResolverQueryCallback() {
+
+ @Override
+ public void onCursorResult(Cursor cursor) {
+ file[0] = new File(cursor.getString(0));
+ }
+ });
+ }
+ } else if (scheme.equals(ContentResolver.SCHEME_FILE)) {
+ file[0] = new File(srcUri.getPath());
+ }
+ return file[0];
+ }
+
+ /**
+ * Gets the actual filename for a Uri from Gallery's ContentProvider.
+ */
+ private static String getTrueFilename(Context context, Uri src) {
+ if (context == null || src == null) {
+ return null;
+ }
+ final String[] trueName = new String[1];
+ querySource(context, src, new String[] {
+ ImageColumns.DATA
+ }, new ContentResolverQueryCallback() {
+ @Override
+ public void onCursorResult(Cursor cursor) {
+ trueName[0] = new File(cursor.getString(0)).getName();
+ }
+ });
+ return trueName[0];
+ }
+
+ /**
+ * Checks whether the true filename has the panorama image prefix.
+ */
+ private static boolean hasPanoPrefix(Context context, Uri src) {
+ String name = getTrueFilename(context, src);
+ return name != null && name.startsWith(PREFIX_PANO);
+ }
+
+ /**
+ * Insert the content (saved file) with proper source photo properties.
+ */
+ public static Uri insertContent(Context context, Uri sourceUri, File file, long time) {
+ time /= 1000;
+
+ final ContentValues values = new ContentValues();
+ values.put(Images.Media.TITLE, file.getName());
+ values.put(Images.Media.DISPLAY_NAME, file.getName());
+ values.put(Images.Media.MIME_TYPE, "image/jpeg");
+ values.put(Images.Media.DATE_TAKEN, time);
+ values.put(Images.Media.DATE_MODIFIED, time);
+ values.put(Images.Media.DATE_ADDED, time);
+ values.put(Images.Media.ORIENTATION, 0);
+ values.put(Images.Media.DATA, file.getAbsolutePath());
+ values.put(Images.Media.SIZE, file.length());
+
+ final String[] projection = new String[] {
+ ImageColumns.DATE_TAKEN,
+ ImageColumns.LATITUDE, ImageColumns.LONGITUDE,
+ };
+ SaveImage.querySource(context, sourceUri, projection,
+ new SaveImage.ContentResolverQueryCallback() {
+
+ @Override
+ public void onCursorResult(Cursor cursor) {
+ values.put(Images.Media.DATE_TAKEN, cursor.getLong(0));
+
+ double latitude = cursor.getDouble(1);
+ double longitude = cursor.getDouble(2);
+ // TODO: Change || to && after the default location
+ // issue is fixed.
+ if ((latitude != 0f) || (longitude != 0f)) {
+ values.put(Images.Media.LATITUDE, latitude);
+ values.put(Images.Media.LONGITUDE, longitude);
+ }
+ }
+ });
+
+ return context.getContentResolver().insert(
+ Images.Media.EXTERNAL_CONTENT_URI, values);
+ }
+
+}