/* * Copyright (C) 2013 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.camera.crop; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; 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.camera.exif.ExifInterface; 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 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 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 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(); } /** * 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 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 sourceUri is a local content Uri, update the * sourceUri to point to the file. * At the same time, the old file sourceUri used to point to * will be removed if it is local. * If the sourceUri is not a local content Uri, then the * file will be inserted as a new content Uri. * @return the final Uri referring to the file. */ 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; } }