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