From 41fd8171292a8a3248fe48eea362834ed5d25b90 Mon Sep 17 00:00:00 2001 From: ztenghui Date: Mon, 10 Jun 2013 15:51:42 -0700 Subject: Setup the saving framework to support hiding original photo for editor. This new approach will try to hide the original image into an auxiliary directory. User will only see one edited image, while being able to go back to the original image in the editor. Refactor SaveCopyTask for more private funcs and more comments. bug:9468909 Change-Id: I866321d23e6db0b3dbd08fec2a6a7e3142b17b65 --- src/com/android/gallery3d/data/LocalImage.java | 8 +- .../gallery3d/filtershow/FilterShowActivity.java | 29 ++- .../gallery3d/filtershow/cache/ImageLoader.java | 4 +- .../gallery3d/filtershow/tools/SaveCopyTask.java | 288 ++++++++++++++++++--- .../android/gallery3d/util/SaveVideoFileUtils.java | 8 +- 5 files changed, 274 insertions(+), 63 deletions(-) diff --git a/src/com/android/gallery3d/data/LocalImage.java b/src/com/android/gallery3d/data/LocalImage.java index 1ed67ecf4..a7c98af77 100644 --- a/src/com/android/gallery3d/data/LocalImage.java +++ b/src/com/android/gallery3d/data/LocalImage.java @@ -35,9 +35,9 @@ import com.android.gallery3d.app.PanoramaMetadataSupport; import com.android.gallery3d.app.StitchingProgressManager; import com.android.gallery3d.common.ApiHelper; import com.android.gallery3d.common.BitmapUtils; -import com.android.gallery3d.common.Utils; import com.android.gallery3d.exif.ExifInterface; import com.android.gallery3d.exif.ExifTag; +import com.android.gallery3d.filtershow.tools.SaveCopyTask; import com.android.gallery3d.util.GalleryUtils; import com.android.gallery3d.util.ThreadPool.Job; import com.android.gallery3d.util.ThreadPool.JobContext; @@ -46,8 +46,6 @@ import com.android.gallery3d.util.UpdateHelper; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.channels.FileChannel.MapMode; // LocalImage represents an image in the local storage. public class LocalImage extends LocalMediaItem { @@ -271,7 +269,9 @@ public class LocalImage extends LocalMediaItem { public void delete() { GalleryUtils.assertNotInRenderThread(); Uri baseUri = Images.Media.EXTERNAL_CONTENT_URI; - mApplication.getContentResolver().delete(baseUri, "_id=?", + ContentResolver contentResolver = mApplication.getContentResolver(); + SaveCopyTask.deleteAuxFiles(contentResolver, getContentUri()); + contentResolver.delete(baseUri, "_id=?", new String[]{String.valueOf(id)}); } diff --git a/src/com/android/gallery3d/filtershow/FilterShowActivity.java b/src/com/android/gallery3d/filtershow/FilterShowActivity.java index fbe09b999..13aa3aa85 100644 --- a/src/com/android/gallery3d/filtershow/FilterShowActivity.java +++ b/src/com/android/gallery3d/filtershow/FilterShowActivity.java @@ -136,6 +136,8 @@ public class FilterShowActivity extends FragmentActivity implements OnItemClickL private Uri mOriginalImageUri = null; private ImagePreset mOriginalPreset = null; + private Uri mSelectedImageUri = null; + private CategoryAdapter mCategoryLooksAdapter = null; private CategoryAdapter mCategoryBordersAdapter = null; private CategoryAdapter mCategoryGeometryAdapter = null; @@ -305,12 +307,13 @@ public class FilterShowActivity extends FragmentActivity implements OnItemClickL } mAction = intent.getAction(); - Uri srcUri = intent.getData(); + mSelectedImageUri = intent.getData(); + Uri loadUri = mSelectedImageUri; if (mOriginalImageUri != null) { - srcUri = mOriginalImageUri; + loadUri = mOriginalImageUri; } - if (srcUri != null) { - startLoadBitmap(srcUri); + if (loadUri != null) { + startLoadBitmap(loadUri); } else { pickImage(); } @@ -907,11 +910,13 @@ public class FilterShowActivity extends FragmentActivity implements OnItemClickL AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setMessage(R.string.unsaved).setTitle(R.string.save_before_exit); builder.setPositiveButton(R.string.save_and_exit, new DialogInterface.OnClickListener() { + @Override public void onClick(DialogInterface dialog, int id) { saveImage(); } }); builder.setNegativeButton(R.string.exit, new DialogInterface.OnClickListener() { + @Override public void onClick(DialogInterface dialog, int id) { done(); } @@ -965,7 +970,7 @@ public class FilterShowActivity extends FragmentActivity implements OnItemClickL public void saveImage() { if (mImageShow.hasModifications()) { // Get the name of the album, to which the image will be saved - File saveDir = SaveCopyTask.getFinalSaveDirectory(this, mImageLoader.getUri()); + File saveDir = SaveCopyTask.getFinalSaveDirectory(this, mSelectedImageUri); int bucketId = GalleryUtils.getBucketId(saveDir.getPath()); String albumName = LocalAlbum.getLocalizedName(getResources(), bucketId, null); showSavingProgress(albumName); @@ -981,10 +986,6 @@ public class FilterShowActivity extends FragmentActivity implements OnItemClickL finish(); } - static { - System.loadLibrary("jni_filtershow_filters"); - } - private void extractXMPData() { XMresults res = XmpPresets.extractXMPData( getBaseContext(), mMasterImage, getIntent().getData()); @@ -994,4 +995,14 @@ public class FilterShowActivity extends FragmentActivity implements OnItemClickL mOriginalImageUri = res.originalimage; mOriginalPreset = res.preset; } + + public Uri getSelectedImageUri() { + return mSelectedImageUri; + } + + static { + System.loadLibrary("jni_filtershow_filters"); + } + + } diff --git a/src/com/android/gallery3d/filtershow/cache/ImageLoader.java b/src/com/android/gallery3d/filtershow/cache/ImageLoader.java index 7ddd9bebe..491340e0d 100644 --- a/src/com/android/gallery3d/filtershow/cache/ImageLoader.java +++ b/src/com/android/gallery3d/filtershow/cache/ImageLoader.java @@ -394,7 +394,9 @@ public class ImageLoader { public void saveImage(ImagePreset preset, final FilterShowActivity filterShowActivity, File destination) { - new SaveCopyTask(mContext, mUri, destination, new SaveCopyTask.Callback() { + Uri selectedImageUri = filterShowActivity.getSelectedImageUri(); + new SaveCopyTask(mContext, mUri, selectedImageUri, destination, + new SaveCopyTask.Callback() { @Override public void onComplete(Uri result) { diff --git a/src/com/android/gallery3d/filtershow/tools/SaveCopyTask.java b/src/com/android/gallery3d/filtershow/tools/SaveCopyTask.java index 9f7cba30b..0d3d5ac06 100644 --- a/src/com/android/gallery3d/filtershow/tools/SaveCopyTask.java +++ b/src/com/android/gallery3d/filtershow/tools/SaveCopyTask.java @@ -25,6 +25,7 @@ import android.graphics.BitmapFactory; import android.net.Uri; import android.os.AsyncTask; import android.os.Environment; +import android.provider.MediaStore; import android.provider.MediaStore.Images; import android.provider.MediaStore.Images.ImageColumns; import android.util.Log; @@ -39,6 +40,7 @@ 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; @@ -60,7 +62,7 @@ public class SaveCopyTask extends AsyncTask { void onComplete(Uri result); } - private interface ContentResolverQueryCallback { + public interface ContentResolverQueryCallback { void onCursorResult(Cursor cursor); } @@ -69,26 +71,68 @@ public class SaveCopyTask extends AsyncTask { 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; + + // 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. - private final Context context; - private final Uri sourceUri; - private final Callback callback; - private final String saveFileName; - private final File destinationFile; - - public SaveCopyTask(Context context, Uri sourceUri, File destination, Callback callback) { - this.context = context; - this.sourceUri = sourceUri; - this.callback = callback; - + /** + * @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 SaveCopyTask(Context context, Uri sourceUri, Uri selectedImageUri, + File destination, Callback callback) { + mContext = context; + mSourceUri = sourceUri; + mCallback = callback; if (destination == null) { - this.destinationFile = getNewFile(context, sourceUri); + mDestinationFile = getNewFile(context, selectedImageUri); } else { - this.destinationFile = destination; + mDestinationFile = destination; } - saveFileName = PREFIX_IMG + new SimpleDateFormat(TIME_STAMP_NAME).format(new Date( - System.currentTimeMillis())); + mSelectedImageUri = selectedImageUri; } public static File getFinalSaveDirectory(Context context, Uri sourceUri) { @@ -113,12 +157,64 @@ public class SaveCopyTask extends AsyncTask { 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 = context.getContentResolver().openInputStream(source); + is = mContext.getContentResolver().openInputStream(source); xmp = XmpUtilHelper.extractXMPMeta(is); } catch (FileNotFoundException e) { Log.w(LOGTAG, "Failed to get XMP data from image: ", e); @@ -138,11 +234,11 @@ public class SaveCopyTask extends AsyncTask { public ExifInterface getExifData(Uri source) { ExifInterface exif = new ExifInterface(); - String mimeType = context.getContentResolver().getType(sourceUri); + String mimeType = mContext.getContentResolver().getType(mSelectedImageUri); if (mimeType.equals(ImageLoader.JPEG_MIME_TYPE)) { InputStream inStream = null; try { - inStream = context.getContentResolver().openInputStream(source); + inStream = mContext.getContentResolver().openInputStream(source); exif.readExif(inStream); } catch (FileNotFoundException e) { Log.w(LOGTAG, "Cannot find file: " + source, e); @@ -174,7 +270,7 @@ public class SaveCopyTask extends AsyncTask { @Override protected Uri doInBackground(ImagePreset... params) { // TODO: Support larger dimensions for photo saving. - if (params[0] == null || sourceUri == null) { + if (params[0] == null || mSourceUri == null || mSelectedImageUri == null) { return null; } ImagePreset preset = params[0]; @@ -182,19 +278,25 @@ public class SaveCopyTask extends AsyncTask { Uri uri = null; boolean noBitmap = true; int num_tries = 0; + + // 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 = moveSrcToAuxIfNeeded(mSourceUri, mDestinationFile); + // Stopgap fix for low-memory devices. while (noBitmap) { try { // Try to do bitmap operations, downsample if low-memory - Bitmap bitmap = ImageLoader.loadMutableBitmap(context, sourceUri, options); + Bitmap bitmap = ImageLoader.loadMutableBitmap(mContext, newSourceUri, options); if (bitmap == null) { return null; } CachingPipeline pipeline = new CachingPipeline(FiltersManager.getManager(), "Saving"); bitmap = pipeline.renderFinalImage(bitmap, preset); - Object xmp = getPanoramaXMPData(sourceUri, preset); - ExifInterface exif = getExifData(sourceUri); + Object xmp = getPanoramaXMPData(mSelectedImageUri, preset); + ExifInterface exif = getExifData(mSelectedImageUri); // Set tags long time = System.currentTimeMillis(); @@ -207,12 +309,23 @@ public class SaveCopyTask extends AsyncTask { exif.removeCompressedThumbnail(); // If we succeed in writing the bitmap as a jpeg, return a uri. - if (putExifData(this.destinationFile, exif, bitmap)) { - putPanoramaXMPData(this.destinationFile, xmp); - uri = insertContent(context, sourceUri, this.destinationFile, saveFileName, + if (putExifData(mDestinationFile, exif, bitmap)) { + putPanoramaXMPData(mDestinationFile, xmp); + uri = insertContent(mContext, mSelectedImageUri, mDestinationFile, time); } - XmpPresets.writeFilterXMP(context, sourceUri, this.destinationFile, preset); + + // mDestinationFile will save the newSourceUri info in the XMP. + XmpPresets.writeFilterXMP(mContext, newSourceUri, mDestinationFile, preset); + + // Since we have a new image inserted to media store, we can + // safely remove the old one which is selected by the user. + String scheme = mSelectedImageUri.getScheme(); + if (scheme != null && scheme.equals(ContentResolver.SCHEME_CONTENT)) { + if (mSelectedImageUri.getAuthority().equals(MediaStore.AUTHORITY)) { + mContext.getContentResolver().delete(mSelectedImageUri, null, null); + } + } noBitmap = false; } catch (java.lang.OutOfMemoryError e) { @@ -227,17 +340,73 @@ public class SaveCopyTask extends AsyncTask { return uri; } + /** + * 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 = getFileFromUri(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; + } @Override protected void onPostExecute(Uri result) { - if (callback != null) { - callback.onComplete(result); + if (mCallback != null) { + mCallback.onComplete(result); } } private 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, @@ -255,18 +424,51 @@ public class SaveCopyTask extends AsyncTask { } private static File getSaveDirectory(Context context, Uri sourceUri) { - final File[] dir = new File[1]; - querySource(context, sourceUri, new String[] { - ImageColumns.DATA - }, - new ContentResolverQueryCallback() { + File file = getFileFromUri(context, sourceUri); + if (file != null) { + return file.getParentFile(); + } else { + return null; + } + } - @Override - public void onCursorResult(Cursor cursor) { - dir[0] = new File(cursor.getString(0)).getParentFile(); - } - }); - return dir[0]; + /** + * 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 getFileFromUri(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]; } /** @@ -299,16 +501,16 @@ public class SaveCopyTask extends AsyncTask { /** * Insert the content (saved file) with proper source photo properties. */ - public static Uri insertContent(Context context, Uri sourceUri, File file, String saveFileName, + private static Uri insertContent(Context context, Uri sourceUri, File file, long time) { time /= 1000; final ContentValues values = new ContentValues(); - values.put(Images.Media.TITLE, saveFileName); + 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_MODIFIED, System.currentTimeMillis()); values.put(Images.Media.DATE_ADDED, time); values.put(Images.Media.ORIENTATION, 0); values.put(Images.Media.DATA, file.getAbsolutePath()); diff --git a/src/com/android/gallery3d/util/SaveVideoFileUtils.java b/src/com/android/gallery3d/util/SaveVideoFileUtils.java index e2c5f51b9..da0970b1d 100644 --- a/src/com/android/gallery3d/util/SaveVideoFileUtils.java +++ b/src/com/android/gallery3d/util/SaveVideoFileUtils.java @@ -25,17 +25,13 @@ import android.os.Environment; import android.provider.MediaStore.Video; import android.provider.MediaStore.Video.VideoColumns; +import com.android.gallery3d.filtershow.tools.SaveCopyTask.ContentResolverQueryCallback; + import java.io.File; import java.sql.Date; import java.text.SimpleDateFormat; public class SaveVideoFileUtils { - // Copy from SaveCopyTask.java in terms of how to handle the destination - // path and filename : querySource() and getSaveDirectory(). - public interface ContentResolverQueryCallback { - void onCursorResult(Cursor cursor); - } - // This function can decide which folder to save the video file, and generate // the needed information for the video file including filename. public static SaveVideoFileInfo getDstMp4FileInfo(String fileNameFormat, -- cgit v1.2.3