Home | History | Annotate | Download | only in crop
      1 /*
      2  * Copyright (C) 2013 The Android Open Source Project
      3  *
      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
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     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.
     15  */
     16 
     17 package com.android.camera.crop;
     18 
     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.net.Uri;
     25 import android.os.Environment;
     26 import android.provider.MediaStore;
     27 import android.provider.MediaStore.Images;
     28 import android.provider.MediaStore.Images.ImageColumns;
     29 import android.util.Log;
     30 
     31 import com.android.camera.exif.ExifInterface;
     32 
     33 import java.io.File;
     34 import java.io.FileNotFoundException;
     35 import java.io.FilenameFilter;
     36 import java.io.IOException;
     37 import java.io.InputStream;
     38 import java.io.OutputStream;
     39 import java.sql.Date;
     40 import java.text.SimpleDateFormat;
     41 import java.util.TimeZone;
     42 
     43 /**
     44  * Handles saving edited photo
     45  */
     46 public class SaveImage {
     47     private static final String LOGTAG = "SaveImage";
     48 
     49     /**
     50      * Callback for updates
     51      */
     52     public interface Callback {
     53         void onProgress(int max, int current);
     54     }
     55 
     56     public interface ContentResolverQueryCallback {
     57         void onCursorResult(Cursor cursor);
     58     }
     59 
     60     private static final String TIME_STAMP_NAME = "_yyyyMMdd_HHmmss";
     61     private static final String PREFIX_PANO = "PANO";
     62     private static final String PREFIX_IMG = "IMG";
     63     private static final String POSTFIX_JPG = ".jpg";
     64     private static final String AUX_DIR_NAME = ".aux";
     65 
     66     private final Context mContext;
     67     private final Uri mSourceUri;
     68     private final Callback mCallback;
     69     private final File mDestinationFile;
     70     private final Uri mSelectedImageUri;
     71     private final Bitmap mPreviewImage;
     72 
     73     private int mCurrentProcessingStep = 1;
     74 
     75     public static final int MAX_PROCESSING_STEPS = 6;
     76     public static final String DEFAULT_SAVE_DIRECTORY = "EditedOnlinePhotos";
     77 
     78     // In order to support the new edit-save behavior such that user won't see
     79     // the edited image together with the original image, we are adding a new
     80     // auxiliary directory for the edited image. Basically, the original image
     81     // will be hidden in that directory after edit and user will see the edited
     82     // image only.
     83     // Note that deletion on the edited image will also cause the deletion of
     84     // the original image under auxiliary directory.
     85     //
     86     // There are several situations we need to consider:
     87     // 1. User edit local image local01.jpg. A local02.jpg will be created in the
     88     // same directory, and original image will be moved to auxiliary directory as
     89     // ./.aux/local02.jpg.
     90     // If user edit the local02.jpg, local03.jpg will be created in the local
     91     // directory and ./.aux/local02.jpg will be renamed to ./.aux/local03.jpg
     92     //
     93     // 2. User edit remote image remote01.jpg from picassa or other server.
     94     // remoteSavedLocal01.jpg will be saved under proper local directory.
     95     // In remoteSavedLocal01.jpg, there will be a reference pointing to the
     96     // remote01.jpg. There will be no local copy of remote01.jpg.
     97     // If user edit remoteSavedLocal01.jpg, then a new remoteSavedLocal02.jpg
     98     // will be generated and still pointing to the remote01.jpg
     99     //
    100     // 3. User delete any local image local.jpg.
    101     // Since the filenames are kept consistent in auxiliary directory, every
    102     // time a local.jpg get deleted, the files in auxiliary directory whose
    103     // names starting with "local." will be deleted.
    104     // This pattern will facilitate the multiple images deletion in the auxiliary
    105     // directory.
    106 
    107     /**
    108      * @param context
    109      * @param sourceUri The Uri for the original image, which can be the hidden
    110      *  image under the auxiliary directory or the same as selectedImageUri.
    111      * @param selectedImageUri The Uri for the image selected by the user.
    112      *  In most cases, it is a content Uri for local image or remote image.
    113      * @param destination Destinaton File, if this is null, a new file will be
    114      *  created under the same directory as selectedImageUri.
    115      * @param callback Let the caller know the saving has completed.
    116      * @return the newSourceUri
    117      */
    118     public SaveImage(Context context, Uri sourceUri, Uri selectedImageUri,
    119                      File destination, Bitmap previewImage, Callback callback)  {
    120         mContext = context;
    121         mSourceUri = sourceUri;
    122         mCallback = callback;
    123         mPreviewImage = previewImage;
    124         if (destination == null) {
    125             mDestinationFile = getNewFile(context, selectedImageUri);
    126         } else {
    127             mDestinationFile = destination;
    128         }
    129 
    130         mSelectedImageUri = selectedImageUri;
    131     }
    132 
    133     public static File getFinalSaveDirectory(Context context, Uri sourceUri) {
    134         File saveDirectory = SaveImage.getSaveDirectory(context, sourceUri);
    135         if ((saveDirectory == null) || !saveDirectory.canWrite()) {
    136             saveDirectory = new File(Environment.getExternalStorageDirectory(),
    137                     SaveImage.DEFAULT_SAVE_DIRECTORY);
    138         }
    139         // Create the directory if it doesn't exist
    140         if (!saveDirectory.exists())
    141             saveDirectory.mkdirs();
    142         return saveDirectory;
    143     }
    144 
    145     public static File getNewFile(Context context, Uri sourceUri) {
    146         File saveDirectory = getFinalSaveDirectory(context, sourceUri);
    147         String filename = new SimpleDateFormat(TIME_STAMP_NAME).format(new Date(
    148                 System.currentTimeMillis()));
    149         if (hasPanoPrefix(context, sourceUri)) {
    150             return new File(saveDirectory, PREFIX_PANO + filename + POSTFIX_JPG);
    151         }
    152         return new File(saveDirectory, PREFIX_IMG + filename + POSTFIX_JPG);
    153     }
    154 
    155     /**
    156      * Remove the files in the auxiliary directory whose names are the same as
    157      * the source image.
    158      * @param contentResolver The application's contentResolver
    159      * @param srcContentUri The content Uri for the source image.
    160      */
    161     public static void deleteAuxFiles(ContentResolver contentResolver,
    162             Uri srcContentUri) {
    163         final String[] fullPath = new String[1];
    164         String[] queryProjection = new String[] { ImageColumns.DATA };
    165         querySourceFromContentResolver(contentResolver,
    166                 srcContentUri, queryProjection,
    167                 new ContentResolverQueryCallback() {
    168                     @Override
    169                     public void onCursorResult(Cursor cursor) {
    170                         fullPath[0] = cursor.getString(0);
    171                     }
    172                 }
    173         );
    174         if (fullPath[0] != null) {
    175             // Construct the auxiliary directory given the source file's path.
    176             // Then select and delete all the files starting with the same name
    177             // under the auxiliary directory.
    178             File currentFile = new File(fullPath[0]);
    179 
    180             String filename = currentFile.getName();
    181             int firstDotPos = filename.indexOf(".");
    182             final String filenameNoExt = (firstDotPos == -1) ? filename :
    183                 filename.substring(0, firstDotPos);
    184             File auxDir = getLocalAuxDirectory(currentFile);
    185             if (auxDir.exists()) {
    186                 FilenameFilter filter = new FilenameFilter() {
    187                     @Override
    188                     public boolean accept(File dir, String name) {
    189                         if (name.startsWith(filenameNoExt + ".")) {
    190                             return true;
    191                         } else {
    192                             return false;
    193                         }
    194                     }
    195                 };
    196 
    197                 // Delete all auxiliary files whose name is matching the
    198                 // current local image.
    199                 File[] auxFiles = auxDir.listFiles(filter);
    200                 for (File file : auxFiles) {
    201                     file.delete();
    202                 }
    203             }
    204         }
    205     }
    206 
    207     public ExifInterface getExifData(Uri source) {
    208         ExifInterface exif = new ExifInterface();
    209         String mimeType = mContext.getContentResolver().getType(mSelectedImageUri);
    210         if (mimeType == null) {
    211             mimeType = ImageLoader.getMimeType(mSelectedImageUri);
    212         }
    213         if (mimeType.equals(ImageLoader.JPEG_MIME_TYPE)) {
    214             InputStream inStream = null;
    215             try {
    216                 inStream = mContext.getContentResolver().openInputStream(source);
    217                 exif.readExif(inStream);
    218             } catch (FileNotFoundException e) {
    219                 Log.w(LOGTAG, "Cannot find file: " + source, e);
    220             } catch (IOException e) {
    221                 Log.w(LOGTAG, "Cannot read exif for: " + source, e);
    222             } finally {
    223                 Utils.closeSilently(inStream);
    224             }
    225         }
    226         return exif;
    227     }
    228 
    229     public boolean putExifData(File file, ExifInterface exif, Bitmap image,
    230             int jpegCompressQuality) {
    231         boolean ret = false;
    232         OutputStream s = null;
    233         try {
    234             s = exif.getExifWriterStream(file.getAbsolutePath());
    235             image.compress(Bitmap.CompressFormat.JPEG,
    236                     (jpegCompressQuality > 0) ? jpegCompressQuality : 1, s);
    237             s.flush();
    238             s.close();
    239             s = null;
    240             ret = true;
    241         } catch (FileNotFoundException e) {
    242             Log.w(LOGTAG, "File not found: " + file.getAbsolutePath(), e);
    243         } catch (IOException e) {
    244             Log.w(LOGTAG, "Could not write exif: ", e);
    245         } finally {
    246             Utils.closeSilently(s);
    247         }
    248         return ret;
    249     }
    250 
    251     private void resetProgress() {
    252         mCurrentProcessingStep = 0;
    253     }
    254 
    255     private void updateProgress() {
    256         if (mCallback != null) {
    257             mCallback.onProgress(MAX_PROCESSING_STEPS, ++mCurrentProcessingStep);
    258         }
    259     }
    260 
    261     private void updateExifData(ExifInterface exif, long time) {
    262         // Set tags
    263         exif.addDateTimeStampTag(ExifInterface.TAG_DATE_TIME, time,
    264                 TimeZone.getDefault());
    265         exif.setTag(exif.buildTag(ExifInterface.TAG_ORIENTATION,
    266                 ExifInterface.Orientation.TOP_LEFT));
    267         // Remove old thumbnail
    268         exif.removeCompressedThumbnail();
    269     }
    270 
    271     /**
    272      *  Move the source file to auxiliary directory if needed and return the Uri
    273      *  pointing to this new source file. If any file error happens, then just
    274      *  don't move into the auxiliary directory.
    275      * @param srcUri Uri to the source image.
    276      * @param dstFile Providing the destination file info to help to build the
    277      *  auxiliary directory and new source file's name.
    278      * @return the newSourceUri pointing to the new source image.
    279      */
    280     private Uri moveSrcToAuxIfNeeded(Uri srcUri, File dstFile) {
    281         File srcFile = getLocalFileFromUri(mContext, srcUri);
    282         if (srcFile == null) {
    283             Log.d(LOGTAG, "Source file is not a local file, no update.");
    284             return srcUri;
    285         }
    286 
    287         // Get the destination directory and create the auxilliary directory
    288         // if necessary.
    289         File auxDiretory = getLocalAuxDirectory(dstFile);
    290         if (!auxDiretory.exists()) {
    291             boolean success = auxDiretory.mkdirs();
    292             if (!success) {
    293                 return srcUri;
    294             }
    295         }
    296 
    297         // Make sure there is a .nomedia file in the auxiliary directory, such
    298         // that MediaScanner will not report those files under this directory.
    299         File noMedia = new File(auxDiretory, ".nomedia");
    300         if (!noMedia.exists()) {
    301             try {
    302                 noMedia.createNewFile();
    303             } catch (IOException e) {
    304                 Log.e(LOGTAG, "Can't create the nomedia");
    305                 return srcUri;
    306             }
    307         }
    308         // We are using the destination file name such that photos sitting in
    309         // the auxiliary directory are matching the parent directory.
    310         File newSrcFile = new File(auxDiretory, dstFile.getName());
    311         // Maintain the suffix during move
    312         String to = newSrcFile.getName();
    313         String from = srcFile.getName();
    314         to = to.substring(to.lastIndexOf("."));
    315         from = from.substring(from.lastIndexOf("."));
    316 
    317         if (!to.equals(from)) {
    318             String name = dstFile.getName();
    319             name = name.substring(0, name.lastIndexOf(".")) + from;
    320             newSrcFile = new File(auxDiretory, name);
    321         }
    322 
    323         if (!newSrcFile.exists()) {
    324             boolean success = srcFile.renameTo(newSrcFile);
    325             if (!success) {
    326                 return srcUri;
    327             }
    328         }
    329 
    330         return Uri.fromFile(newSrcFile);
    331 
    332     }
    333 
    334     private static File getLocalAuxDirectory(File dstFile) {
    335         File dstDirectory = dstFile.getParentFile();
    336         File auxDiretory = new File(dstDirectory + "/" + AUX_DIR_NAME);
    337         return auxDiretory;
    338     }
    339 
    340     public static Uri makeAndInsertUri(Context context, Uri sourceUri) {
    341         long time = System.currentTimeMillis();
    342         String filename = new SimpleDateFormat(TIME_STAMP_NAME).format(new Date(time));
    343         File saveDirectory = getFinalSaveDirectory(context, sourceUri);
    344         File file = new File(saveDirectory, filename  + ".JPG");
    345         return linkNewFileToUri(context, sourceUri, file, time, false);
    346     }
    347 
    348     public static void querySource(Context context, Uri sourceUri, String[] projection,
    349             ContentResolverQueryCallback callback) {
    350         ContentResolver contentResolver = context.getContentResolver();
    351         querySourceFromContentResolver(contentResolver, sourceUri, projection, callback);
    352     }
    353 
    354     private static void querySourceFromContentResolver(
    355             ContentResolver contentResolver, Uri sourceUri, String[] projection,
    356             ContentResolverQueryCallback callback) {
    357         Cursor cursor = null;
    358         try {
    359             cursor = contentResolver.query(sourceUri, projection, null, null,
    360                     null);
    361             if ((cursor != null) && cursor.moveToNext()) {
    362                 callback.onCursorResult(cursor);
    363             }
    364         } catch (Exception e) {
    365             // Ignore error for lacking the data column from the source.
    366         } finally {
    367             if (cursor != null) {
    368                 cursor.close();
    369             }
    370         }
    371     }
    372 
    373     private static File getSaveDirectory(Context context, Uri sourceUri) {
    374         File file = getLocalFileFromUri(context, sourceUri);
    375         if (file != null) {
    376             return file.getParentFile();
    377         } else {
    378             return null;
    379         }
    380     }
    381 
    382     /**
    383      * Construct a File object based on the srcUri.
    384      * @return The file object. Return null if srcUri is invalid or not a local
    385      * file.
    386      */
    387     private static File getLocalFileFromUri(Context context, Uri srcUri) {
    388         if (srcUri == null) {
    389             Log.e(LOGTAG, "srcUri is null.");
    390             return null;
    391         }
    392 
    393         String scheme = srcUri.getScheme();
    394         if (scheme == null) {
    395             Log.e(LOGTAG, "scheme is null.");
    396             return null;
    397         }
    398 
    399         final File[] file = new File[1];
    400         // sourceUri can be a file path or a content Uri, it need to be handled
    401         // differently.
    402         if (scheme.equals(ContentResolver.SCHEME_CONTENT)) {
    403             if (srcUri.getAuthority().equals(MediaStore.AUTHORITY)) {
    404                 querySource(context, srcUri, new String[] {
    405                         ImageColumns.DATA
    406                 },
    407                         new ContentResolverQueryCallback() {
    408 
    409                             @Override
    410                             public void onCursorResult(Cursor cursor) {
    411                                 file[0] = new File(cursor.getString(0));
    412                             }
    413                         });
    414             }
    415         } else if (scheme.equals(ContentResolver.SCHEME_FILE)) {
    416             file[0] = new File(srcUri.getPath());
    417         }
    418         return file[0];
    419     }
    420 
    421     /**
    422      * Gets the actual filename for a Uri from Gallery's ContentProvider.
    423      */
    424     private static String getTrueFilename(Context context, Uri src) {
    425         if (context == null || src == null) {
    426             return null;
    427         }
    428         final String[] trueName = new String[1];
    429         querySource(context, src, new String[] {
    430                 ImageColumns.DATA
    431         }, new ContentResolverQueryCallback() {
    432             @Override
    433             public void onCursorResult(Cursor cursor) {
    434                 trueName[0] = new File(cursor.getString(0)).getName();
    435             }
    436         });
    437         return trueName[0];
    438     }
    439 
    440     /**
    441      * Checks whether the true filename has the panorama image prefix.
    442      */
    443     private static boolean hasPanoPrefix(Context context, Uri src) {
    444         String name = getTrueFilename(context, src);
    445         return name != null && name.startsWith(PREFIX_PANO);
    446     }
    447 
    448     /**
    449      * If the <code>sourceUri</code> is a local content Uri, update the
    450      * <code>sourceUri</code> to point to the <code>file</code>.
    451      * At the same time, the old file <code>sourceUri</code> used to point to
    452      * will be removed if it is local.
    453      * If the <code>sourceUri</code> is not a local content Uri, then the
    454      * <code>file</code> will be inserted as a new content Uri.
    455      * @return the final Uri referring to the <code>file</code>.
    456      */
    457     public static Uri linkNewFileToUri(Context context, Uri sourceUri,
    458             File file, long time, boolean deleteOriginal) {
    459         File oldSelectedFile = getLocalFileFromUri(context, sourceUri);
    460         final ContentValues values = getContentValues(context, sourceUri, file, time);
    461 
    462         Uri result = sourceUri;
    463 
    464         // In the case of incoming Uri is just a local file Uri (like a cached
    465         // file), we can't just update the Uri. We have to create a new Uri.
    466         boolean fileUri = isFileUri(sourceUri);
    467 
    468         if (fileUri || oldSelectedFile == null || !deleteOriginal) {
    469             result = context.getContentResolver().insert(
    470                     Images.Media.EXTERNAL_CONTENT_URI, values);
    471         } else {
    472             context.getContentResolver().update(sourceUri, values, null, null);
    473             if (oldSelectedFile.exists()) {
    474                 oldSelectedFile.delete();
    475             }
    476         }
    477         return result;
    478     }
    479 
    480     public static Uri updateFile(Context context, Uri sourceUri, File file, long time) {
    481         final ContentValues values = getContentValues(context, sourceUri, file, time);
    482         context.getContentResolver().update(sourceUri, values, null, null);
    483         return sourceUri;
    484     }
    485 
    486     private static ContentValues getContentValues(Context context, Uri sourceUri,
    487                                                   File file, long time) {
    488         final ContentValues values = new ContentValues();
    489 
    490         time /= 1000;
    491         values.put(Images.Media.TITLE, file.getName());
    492         values.put(Images.Media.DISPLAY_NAME, file.getName());
    493         values.put(Images.Media.MIME_TYPE, "image/jpeg");
    494         values.put(Images.Media.DATE_TAKEN, time);
    495         values.put(Images.Media.DATE_MODIFIED, time);
    496         values.put(Images.Media.DATE_ADDED, time);
    497         values.put(Images.Media.ORIENTATION, 0);
    498         values.put(Images.Media.DATA, file.getAbsolutePath());
    499         values.put(Images.Media.SIZE, file.length());
    500 
    501         final String[] projection = new String[] {
    502                 ImageColumns.DATE_TAKEN,
    503                 ImageColumns.LATITUDE, ImageColumns.LONGITUDE,
    504         };
    505 
    506         SaveImage.querySource(context, sourceUri, projection,
    507                 new ContentResolverQueryCallback() {
    508 
    509                     @Override
    510                     public void onCursorResult(Cursor cursor) {
    511                         values.put(Images.Media.DATE_TAKEN, cursor.getLong(0));
    512 
    513                         double latitude = cursor.getDouble(1);
    514                         double longitude = cursor.getDouble(2);
    515                         // TODO: Change || to && after the default location
    516                         // issue is fixed.
    517                         if ((latitude != 0f) || (longitude != 0f)) {
    518                             values.put(Images.Media.LATITUDE, latitude);
    519                             values.put(Images.Media.LONGITUDE, longitude);
    520                         }
    521                     }
    522                 });
    523         return values;
    524     }
    525 
    526     /**
    527      * @param sourceUri
    528      * @return true if the sourceUri is a local file Uri.
    529      */
    530     private static boolean isFileUri(Uri sourceUri) {
    531         String scheme = sourceUri.getScheme();
    532         if (scheme != null && scheme.equals(ContentResolver.SCHEME_FILE)) {
    533             return true;
    534         }
    535         return false;
    536     }
    537 
    538 }
    539