blob: c3cdea8dfe1cc2590ef4d511afe20f236e0ed53f [file] [log] [blame]
/*
* 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 android.widget.Toast;
import com.android.gallery3d.R;
import com.android.gallery3d.common.Utils;
import com.android.gallery3d.exif.ExifInterface;
import com.android.gallery3d.filtershow.FilterShowActivity;
import com.android.gallery3d.filtershow.cache.ImageLoader;
import com.android.gallery3d.filtershow.filters.FilterRepresentation;
import com.android.gallery3d.filtershow.filters.FiltersManager;
import com.android.gallery3d.filtershow.imageshow.MasterImage;
import com.android.gallery3d.filtershow.pipeline.CachingPipeline;
import com.android.gallery3d.filtershow.pipeline.ImagePreset;
import com.android.gallery3d.filtershow.pipeline.ProcessingService;
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.io.OutputStream;
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 onPreviewSaved(Uri uri);
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";
private final Context mContext;
private final Uri mSourceUri;
private final Callback mCallback;
private final File mDestinationFile;
private final Uri mSelectedImageUri;
private final Bitmap mPreviewImage;
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.
/**
* @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, Bitmap previewImage, Callback callback) {
mContext = context;
mSourceUri = sourceUri;
mCallback = callback;
mPreviewImage = previewImage;
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,
int jpegCompressQuality) {
boolean ret = false;
OutputStream s = null;
try {
s = exif.getExifWriterStream(file.getAbsolutePath());
image.compress(Bitmap.CompressFormat.JPEG,
(jpegCompressQuality > 0) ? jpegCompressQuality : 1, s);
s.flush();
s.close();
s = null;
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);
} finally {
Utils.closeSilently(s);
}
return ret;
}
private Uri resetToOriginalImageIfNeeded(ImagePreset preset, boolean doAuxBackup) {
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.linkNewFileToUri(mContext, mSelectedImageUri,
mDestinationFile, System.currentTimeMillis(), doAuxBackup);
}
}
return uri;
}
private void resetProgress() {
mCurrentProcessingStep = 0;
}
private void updateProgress() {
if (mCallback != null) {
mCallback.onProgress(MAX_PROCESSING_STEPS, ++mCurrentProcessingStep);
}
}
private void updateExifData(ExifInterface exif, long time) {
// Set tags
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();
}
public Uri processAndSaveImage(ImagePreset preset, boolean flatten,
int quality, float sizeFactor, boolean exit) {
Uri uri = null;
if (exit) {
uri = resetToOriginalImageIfNeeded(preset, !flatten);
}
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 = mSourceUri;
if (!flatten) {
newSourceUri = moveSrcToAuxIfNeeded(mSourceUri, mDestinationFile);
}
Uri savedUri = mSelectedImageUri;
if (mPreviewImage != null) {
if (flatten) {
Object xmp = getPanoramaXMPData(newSourceUri, preset);
ExifInterface exif = getExifData(newSourceUri);
long time = System.currentTimeMillis();
updateExifData(exif, time);
if (putExifData(mDestinationFile, exif, mPreviewImage, quality)) {
putPanoramaXMPData(mDestinationFile, xmp);
ContentValues values = getContentValues(mContext, mSelectedImageUri, mDestinationFile, time);
Object result = mContext.getContentResolver().insert(
Images.Media.EXTERNAL_CONTENT_URI, values);
}
} else {
Object xmp = getPanoramaXMPData(newSourceUri, preset);
ExifInterface exif = getExifData(newSourceUri);
long time = System.currentTimeMillis();
updateExifData(exif, time);
// If we succeed in writing the bitmap as a jpeg, return a uri.
if (putExifData(mDestinationFile, exif, mPreviewImage, quality)) {
putPanoramaXMPData(mDestinationFile, xmp);
// mDestinationFile will save the newSourceUri info in the XMP.
if (!flatten) {
XmpPresets.writeFilterXMP(mContext, newSourceUri,
mDestinationFile, preset);
}
// After this call, mSelectedImageUri will be actually
// pointing at the new file mDestinationFile.
savedUri = SaveImage.linkNewFileToUri(mContext, mSelectedImageUri,
mDestinationFile, time, !flatten);
}
}
if (mCallback != null) {
mCallback.onPreviewSaved(savedUri);
}
}
// 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;
}
if (sizeFactor != 1f) {
// if we have a valid size
int w = (int) (bitmap.getWidth() * sizeFactor);
int h = (int) (bitmap.getHeight() * sizeFactor);
if (w == 0 || h == 0) {
w = 1;
h = 1;
}
bitmap = Bitmap.createScaledBitmap(bitmap, w, h, true);
}
updateProgress();
CachingPipeline pipeline = new CachingPipeline(FiltersManager.getManager(),
"Saving");
bitmap = pipeline.renderFinalImage(bitmap, preset);
updateProgress();
Object xmp = getPanoramaXMPData(newSourceUri, preset);
ExifInterface exif = getExifData(newSourceUri);
long time = System.currentTimeMillis();
updateProgress();
updateExifData(exif, time);
updateProgress();
// If we succeed in writing the bitmap as a jpeg, return a uri.
if (putExifData(mDestinationFile, exif, bitmap, quality)) {
putPanoramaXMPData(mDestinationFile, xmp);
// mDestinationFile will save the newSourceUri info in the XMP.
if (!flatten) {
XmpPresets.writeFilterXMP(mContext, newSourceUri,
mDestinationFile, preset);
uri = updateFile(mContext, savedUri, mDestinationFile, time);
} else {
ContentValues values = getContentValues(mContext, mSelectedImageUri, mDestinationFile, time);
Object result = mContext.getContentResolver().insert(
Images.Media.EXTERNAL_CONTENT_URI, values);
}
}
updateProgress();
noBitmap = false;
} catch (OutOfMemoryError e) {
// Try 5 times before failing for good.
if (++num_tries >= 5) {
throw e;
}
System.gc();
sampleSize *= 2;
resetProgress();
}
}
return uri;
}
/**
* Move the source file to auxiliary directory if needed and return the Uri
* pointing to this new source file. If any file error happens, then just
* don't move into the auxiliary directory.
* @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()) {
boolean success = auxDiretory.mkdirs();
if (!success) {
return srcUri;
}
}
// 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());
// Maintain the suffix during move
String to = newSrcFile.getName();
String from = srcFile.getName();
to = to.substring(to.lastIndexOf("."));
from = from.substring(from.lastIndexOf("."));
if (!to.equals(from)) {
String name = dstFile.getName();
name = name.substring(0, name.lastIndexOf(".")) + from;
newSrcFile = new File(auxDiretory, name);
}
if (!newSrcFile.exists()) {
boolean success = srcFile.renameTo(newSrcFile);
if (!success) {
return srcUri;
}
}
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 linkNewFileToUri(context, sourceUri, file, time, false);
}
public static void saveImage(ImagePreset preset, final FilterShowActivity filterShowActivity,
File destination) {
Uri selectedImageUri = filterShowActivity.getSelectedImageUri();
Uri sourceImageUri = MasterImage.getImage().getUri();
boolean flatten = false;
if (preset.contains(FilterRepresentation.TYPE_TINYPLANET)){
flatten = true;
}
Intent processIntent = ProcessingService.getSaveIntent(filterShowActivity, preset,
destination, selectedImageUri, sourceImageUri, flatten, 90, 1f, true);
filterShowActivity.startService(processIntent);
if (!filterShowActivity.isSimpleEditAction()) {
String toastMessage = filterShowActivity.getResources().getString(
R.string.save_and_processing);
Toast.makeText(filterShowActivity,
toastMessage,
Toast.LENGTH_SHORT).show();
}
}
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);
}
/**
* If the <code>sourceUri</code> is a local content Uri, update the
* <code>sourceUri</code> to point to the <code>file</code>.
* At the same time, the old file <code>sourceUri</code> used to point to
* will be removed if it is local.
* If the <code>sourceUri</code> is not a local content Uri, then the
* <code>file</code> will be inserted as a new content Uri.
* @return the final Uri referring to the <code>file</code>.
*/
public static Uri linkNewFileToUri(Context context, Uri sourceUri,
File file, long time, boolean deleteOriginal) {
File oldSelectedFile = getLocalFileFromUri(context, sourceUri);
final ContentValues values = getContentValues(context, sourceUri, file, time);
Uri result = sourceUri;
// In the case of incoming Uri is just a local file Uri (like a cached
// file), we can't just update the Uri. We have to create a new Uri.
boolean fileUri = isFileUri(sourceUri);
if (fileUri || oldSelectedFile == null || !deleteOriginal) {
result = context.getContentResolver().insert(
Images.Media.EXTERNAL_CONTENT_URI, values);
} else {
context.getContentResolver().update(sourceUri, values, null, null);
if (oldSelectedFile.exists()) {
oldSelectedFile.delete();
}
}
return result;
}
public static Uri updateFile(Context context, Uri sourceUri, File file, long time) {
final ContentValues values = getContentValues(context, sourceUri, file, time);
context.getContentResolver().update(sourceUri, values, null, null);
return sourceUri;
}
private static ContentValues getContentValues(Context context, Uri sourceUri,
File file, long time) {
final ContentValues values = new ContentValues();
time /= 1000;
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 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 values;
}
/**
* @param sourceUri
* @return true if the sourceUri is a local file Uri.
*/
private static boolean isFileUri(Uri sourceUri) {
String scheme = sourceUri.getScheme();
if (scheme != null && scheme.equals(ContentResolver.SCHEME_FILE)) {
return true;
}
return false;
}
}