1 /* 2 * Copyright (C) 2020 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.systemui.screenshot; 18 19 import static android.os.FileUtils.closeQuietly; 20 21 import android.annotation.IntRange; 22 import android.content.ContentProvider; 23 import android.content.ContentResolver; 24 import android.content.ContentUris; 25 import android.content.ContentValues; 26 import android.database.Cursor; 27 import android.graphics.Bitmap; 28 import android.graphics.Bitmap.CompressFormat; 29 import android.net.Uri; 30 import android.os.Build; 31 import android.os.Environment; 32 import android.os.ParcelFileDescriptor; 33 import android.os.SystemClock; 34 import android.os.Trace; 35 import android.os.UserHandle; 36 import android.provider.MediaStore; 37 import android.util.Log; 38 import android.view.Display; 39 40 import androidx.concurrent.futures.CallbackToFutureAdapter; 41 import androidx.exifinterface.media.ExifInterface; 42 43 import com.android.internal.annotations.VisibleForTesting; 44 import com.android.systemui.flags.FeatureFlags; 45 46 import com.google.common.util.concurrent.ListenableFuture; 47 48 import java.io.File; 49 import java.io.FileNotFoundException; 50 import java.io.FileOutputStream; 51 import java.io.IOException; 52 import java.io.OutputStream; 53 import java.time.Duration; 54 import java.time.Instant; 55 import java.time.ZoneId; 56 import java.time.ZonedDateTime; 57 import java.time.format.DateTimeFormatter; 58 import java.util.UUID; 59 import java.util.concurrent.Executor; 60 61 import javax.inject.Inject; 62 63 /** A class to help with exporting screenshot to storage. */ 64 public class ImageExporter { 65 private static final String TAG = LogConfig.logTag(ImageExporter.class); 66 67 static final Duration PENDING_ENTRY_TTL = Duration.ofHours(24); 68 69 // ex: 'Screenshot_20201215-090626.png' 70 private static final String FILENAME_PATTERN = "Screenshot_%1$tY%<tm%<td-%<tH%<tM%<tS.%2$s"; 71 // ex: 'Screenshot_20201215-090626-display-1.png' 72 private static final String CONNECTED_DISPLAY_FILENAME_PATTERN = 73 "Screenshot_%1$tY%<tm%<td-%<tH%<tM%<tS-display-%2$d.%3$s"; 74 private static final String SCREENSHOTS_PATH = Environment.DIRECTORY_PICTURES 75 + File.separator + Environment.DIRECTORY_SCREENSHOTS; 76 77 private static final String RESOLVER_INSERT_RETURNED_NULL = 78 "ContentResolver#insert returned null."; 79 private static final String RESOLVER_OPEN_FILE_RETURNED_NULL = 80 "ContentResolver#openFile returned null."; 81 private static final String RESOLVER_OPEN_FILE_EXCEPTION = 82 "ContentResolver#openFile threw an exception."; 83 private static final String OPEN_OUTPUT_STREAM_EXCEPTION = 84 "ContentResolver#openOutputStream threw an exception."; 85 private static final String EXIF_READ_EXCEPTION = 86 "ExifInterface threw an exception reading from the file descriptor."; 87 private static final String EXIF_WRITE_EXCEPTION = 88 "ExifInterface threw an exception writing to the file descriptor."; 89 private static final String RESOLVER_UPDATE_ZERO_ROWS = 90 "Failed to publish entry. ContentResolver#update reported no rows updated."; 91 private static final String IMAGE_COMPRESS_RETURNED_FALSE = 92 "Bitmap.compress returned false. (Failure unknown)"; 93 94 private final ContentResolver mResolver; 95 private CompressFormat mCompressFormat = CompressFormat.PNG; 96 private int mQuality = 100; 97 private final FeatureFlags mFlags; 98 99 @Inject ImageExporter(ContentResolver resolver, FeatureFlags flags)100 public ImageExporter(ContentResolver resolver, FeatureFlags flags) { 101 mResolver = resolver; 102 mFlags = flags; 103 } 104 105 /** 106 * Adjusts the output image format. This also determines extension of the filename created. The 107 * default is {@link CompressFormat#PNG PNG}. 108 * 109 * @see CompressFormat 110 * 111 * @param format the image format for export 112 */ setFormat(CompressFormat format)113 void setFormat(CompressFormat format) { 114 mCompressFormat = format; 115 } 116 117 /** 118 * Sets the quality format. The exact meaning is dependent on the {@link CompressFormat} used. 119 * 120 * @param quality the 'quality' level between 0 and 100 121 */ setQuality(@ntRangefrom = 0, to = 100) int quality)122 void setQuality(@IntRange(from = 0, to = 100) int quality) { 123 mQuality = quality; 124 } 125 126 /** 127 * Writes the given Bitmap to outputFile. 128 */ exportToRawFile(Executor executor, Bitmap bitmap, final File outputFile)129 public ListenableFuture<File> exportToRawFile(Executor executor, Bitmap bitmap, 130 final File outputFile) { 131 return CallbackToFutureAdapter.getFuture( 132 (completer) -> { 133 executor.execute(() -> { 134 try (FileOutputStream stream = new FileOutputStream(outputFile)) { 135 bitmap.compress(mCompressFormat, mQuality, stream); 136 completer.set(outputFile); 137 } catch (IOException e) { 138 if (outputFile.exists()) { 139 //noinspection ResultOfMethodCallIgnored 140 outputFile.delete(); 141 } 142 completer.setException(e); 143 } 144 }); 145 return "Bitmap#compress"; 146 } 147 ); 148 } 149 150 /** 151 * Export the image using the given executor with an auto-generated file name based on display 152 * id. 153 * 154 * @param executor the thread for execution 155 * @param bitmap the bitmap to export 156 * @param displayId the display id the bitmap comes from. 157 * @return a listenable future result 158 */ 159 public ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, 160 UserHandle owner, int displayId) { 161 ZonedDateTime captureTime = ZonedDateTime.now(ZoneId.systemDefault()); 162 return export(executor, 163 new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, 164 mQuality, /* publish */ true, owner, mFlags, 165 createFilename(captureTime, mCompressFormat, displayId))); 166 } 167 168 /** 169 * Export the image using the given executor with a specified file name. 170 * 171 * @param executor the thread for execution 172 * @param bitmap the bitmap to export 173 * @param format the compress format of {@code bitmap} e.g. {@link CompressFormat.PNG} 174 * @param fileName a specified name for the exported file. No need to include file extension in 175 * file name. The extension will be internally appended based on 176 * {@code format} 177 * @return a listenable future result 178 */ 179 public ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, 180 CompressFormat format, UserHandle owner, String fileName) { 181 return export(executor, 182 new Task(mResolver, 183 requestId, 184 bitmap, 185 ZonedDateTime.now(ZoneId.systemDefault()), 186 format, 187 mQuality, /* publish */ true, owner, mFlags, 188 createSystemFileDisplayName(fileName, format), 189 true /* allowOverwrite */)); 190 } 191 192 /** 193 * Export the image to MediaStore and publish. 194 * 195 * @param executor the thread for execution 196 * @param bitmap the bitmap to export 197 * @return a listenable future result 198 */ 199 public ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, 200 ZonedDateTime captureTime, UserHandle owner, int displayId) { 201 return export(executor, new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, 202 mQuality, /* publish */ true, owner, mFlags, 203 createFilename(captureTime, mCompressFormat, displayId))); 204 } 205 206 /** 207 * Export the image to MediaStore and publish. 208 * 209 * @param executor the thread for execution 210 * @param bitmap the bitmap to export 211 * @return a listenable future result 212 */ 213 ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, 214 ZonedDateTime captureTime, UserHandle owner, String fileName) { 215 return export(executor, new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, 216 mQuality, /* publish */ true, owner, mFlags, 217 createSystemFileDisplayName(fileName, mCompressFormat))); 218 } 219 220 /** 221 * Export the image to MediaStore and publish. 222 * 223 * @param executor the thread for execution 224 * @param task the exporting image {@link Task}. 225 * 226 * @return a listenable future result 227 */ 228 private ListenableFuture<Result> export(Executor executor, Task task) { 229 return CallbackToFutureAdapter.getFuture( 230 (completer) -> { 231 executor.execute(() -> { 232 // save images as quickly as possible on the background thread 233 Thread.currentThread().setPriority(Thread.MAX_PRIORITY); 234 try { 235 completer.set(task.execute()); 236 } catch (ImageExportException | InterruptedException e) { 237 completer.setException(e); 238 } 239 }); 240 return task; 241 } 242 ); 243 } 244 245 /** The result returned by the task exporting screenshots to storage. */ 246 public static class Result { 247 public Uri uri; 248 public UUID requestId; 249 public String fileName; 250 public long timestamp; 251 public CompressFormat format; 252 public boolean published; 253 254 @Override 255 public String toString() { 256 final StringBuilder sb = new StringBuilder("Result{"); 257 sb.append("uri=").append(uri); 258 sb.append(", requestId=").append(requestId); 259 sb.append(", fileName='").append(fileName).append('\''); 260 sb.append(", timestamp=").append(timestamp); 261 sb.append(", format=").append(format); 262 sb.append(", published=").append(published); 263 sb.append('}'); 264 return sb.toString(); 265 } 266 } 267 268 private static class Task { 269 private final ContentResolver mResolver; 270 private final UUID mRequestId; 271 private final Bitmap mBitmap; 272 private final ZonedDateTime mCaptureTime; 273 private final CompressFormat mFormat; 274 private final int mQuality; 275 private final UserHandle mOwner; 276 private final String mFileName; 277 private final boolean mPublish; 278 private final FeatureFlags mFlags; 279 280 /** 281 * This variable specifies the behavior when a file to be exported has a same name and 282 * format as one of the file on disk. If this is set to true, the new file overwrite the 283 * old file; otherwise, the system adds a number to the end of the newly exported file. For 284 * example, if the file is screenshot.png, the newly exported file's display name will be 285 * screenshot(1).png. 286 */ 287 private final boolean mAllowOverwrite; 288 289 Task(ContentResolver resolver, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, 290 CompressFormat format, int quality, boolean publish, UserHandle owner, 291 FeatureFlags flags, String fileName) { 292 this(resolver, requestId, bitmap, captureTime, format, quality, publish, owner, flags, 293 fileName, false /* allowOverwrite */); 294 } 295 296 Task(ContentResolver resolver, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, 297 CompressFormat format, int quality, boolean publish, UserHandle owner, 298 FeatureFlags flags, String fileName, boolean allowOverwrite) { 299 mResolver = resolver; 300 mRequestId = requestId; 301 mBitmap = bitmap; 302 mCaptureTime = captureTime; 303 mFormat = format; 304 mQuality = quality; 305 mOwner = owner; 306 mFileName = fileName; 307 mPublish = publish; 308 mFlags = flags; 309 mAllowOverwrite = allowOverwrite; 310 } 311 312 public Result execute() throws ImageExportException, InterruptedException { 313 Trace.beginSection("ImageExporter_execute"); 314 Uri uri = null; 315 Instant start = null; 316 Result result = new Result(); 317 try { 318 if (LogConfig.DEBUG_STORAGE) { 319 Log.d(TAG, "image export started"); 320 start = Instant.now(); 321 } 322 323 uri = createEntry(mResolver, mFormat, mCaptureTime, mFileName, mOwner, mFlags, 324 mAllowOverwrite); 325 throwIfInterrupted(); 326 327 writeImage(mResolver, mBitmap, mFormat, mQuality, uri); 328 throwIfInterrupted(); 329 330 int width = mBitmap.getWidth(); 331 int height = mBitmap.getHeight(); 332 writeExif(mResolver, uri, mRequestId, width, height, mCaptureTime); 333 throwIfInterrupted(); 334 335 if (mPublish) { 336 publishEntry(mResolver, uri); 337 result.published = true; 338 } 339 340 result.timestamp = mCaptureTime.toInstant().toEpochMilli(); 341 result.requestId = mRequestId; 342 result.uri = uri; 343 result.fileName = mFileName; 344 result.format = mFormat; 345 346 if (LogConfig.DEBUG_STORAGE) { 347 Log.d(TAG, "image export completed: " 348 + Duration.between(start, Instant.now()).toMillis() + " ms"); 349 } 350 } catch (ImageExportException e) { 351 if (uri != null) { 352 mResolver.delete(uri, null); 353 } 354 throw e; 355 } finally { 356 Trace.endSection(); 357 } 358 return result; 359 } 360 361 @Override 362 public String toString() { 363 return "export [" + mBitmap + "] to [" + mFormat + "] at quality " + mQuality; 364 } 365 } 366 367 private static Uri createEntry(ContentResolver resolver, CompressFormat format, 368 ZonedDateTime time, String fileName, UserHandle owner, FeatureFlags flags, 369 boolean allowOverwrite) throws ImageExportException { 370 Trace.beginSection("ImageExporter_createEntry"); 371 try { 372 final ContentValues values = createMetadata(time, format, fileName); 373 374 Uri baseUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; 375 Uri uriWithUserId = ContentProvider.maybeAddUserId(baseUri, owner.getIdentifier()); 376 Uri resultUri = null; 377 378 if (allowOverwrite) { 379 // Query to check if there is existing file with the same name and format. 380 Cursor cursor = resolver.query( 381 baseUri, 382 null, 383 MediaStore.MediaColumns.DISPLAY_NAME + "=? AND " 384 + MediaStore.MediaColumns.MIME_TYPE + "=?", 385 new String[]{fileName, getMimeType(format)}, 386 null /* CancellationSignal */); 387 if (cursor != null) { 388 if (cursor.moveToFirst()) { 389 // If there is existing file, update the meta-data of its entry. The Entry's 390 // corresponding uri is composed of volume base-uri(or with user-id) and 391 // its row's unique ID. 392 int idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID); 393 resultUri = ContentUris.withAppendedId(uriWithUserId, 394 cursor.getLong(idIndex)); 395 resolver.update(resultUri, values, null); 396 Log.d(TAG, "Updated existing URI: " + resultUri); 397 } 398 cursor.close(); 399 } 400 } 401 402 if (resultUri == null) { 403 // If file overwriting is disabled or there is no existing file to overwrite, create 404 // and insert a new entry. 405 resultUri = resolver.insert(uriWithUserId, values); 406 Log.d(TAG, "Inserted new URI: " + resultUri); 407 } 408 409 if (resultUri == null) { 410 throw new ImageExportException(RESOLVER_INSERT_RETURNED_NULL); 411 } 412 return resultUri; 413 } finally { 414 Trace.endSection(); 415 } 416 } 417 418 private static void writeImage(ContentResolver resolver, Bitmap bitmap, CompressFormat format, 419 int quality, Uri contentUri) throws ImageExportException { 420 Trace.beginSection("ImageExporter_writeImage"); 421 try (OutputStream out = resolver.openOutputStream(contentUri)) { 422 long start = SystemClock.elapsedRealtime(); 423 if (!bitmap.compress(format, quality, out)) { 424 throw new ImageExportException(IMAGE_COMPRESS_RETURNED_FALSE); 425 } else if (LogConfig.DEBUG_STORAGE) { 426 Log.d(TAG, "Bitmap.compress took " 427 + (SystemClock.elapsedRealtime() - start) + " ms"); 428 } 429 } catch (IOException ex) { 430 throw new ImageExportException(OPEN_OUTPUT_STREAM_EXCEPTION, ex); 431 } finally { 432 Trace.endSection(); 433 } 434 } 435 436 private static void writeExif(ContentResolver resolver, Uri uri, UUID requestId, int width, 437 int height, ZonedDateTime captureTime) throws ImageExportException { 438 Trace.beginSection("ImageExporter_writeExif"); 439 ParcelFileDescriptor pfd = null; 440 try { 441 pfd = resolver.openFile(uri, "rw", null); 442 if (pfd == null) { 443 throw new ImageExportException(RESOLVER_OPEN_FILE_RETURNED_NULL); 444 } 445 ExifInterface exif; 446 try { 447 exif = new ExifInterface(pfd.getFileDescriptor()); 448 } catch (IOException e) { 449 throw new ImageExportException(EXIF_READ_EXCEPTION, e); 450 } 451 452 updateExifAttributes(exif, requestId, width, height, captureTime); 453 try { 454 exif.saveAttributes(); 455 } catch (IOException e) { 456 throw new ImageExportException(EXIF_WRITE_EXCEPTION, e); 457 } 458 } catch (FileNotFoundException e) { 459 throw new ImageExportException(RESOLVER_OPEN_FILE_EXCEPTION, e); 460 } finally { 461 closeQuietly(pfd); 462 Trace.endSection(); 463 } 464 } 465 466 private static void publishEntry(ContentResolver resolver, Uri uri) 467 throws ImageExportException { 468 Trace.beginSection("ImageExporter_publishEntry"); 469 try { 470 ContentValues values = new ContentValues(); 471 values.put(MediaStore.MediaColumns.IS_PENDING, 0); 472 values.putNull(MediaStore.MediaColumns.DATE_EXPIRES); 473 final int rowsUpdated = resolver.update(uri, values, /* extras */ null); 474 if (rowsUpdated < 1) { 475 throw new ImageExportException(RESOLVER_UPDATE_ZERO_ROWS); 476 } 477 } finally { 478 Trace.endSection(); 479 } 480 } 481 482 @VisibleForTesting 483 static String createFilename(ZonedDateTime time, CompressFormat format, int displayId) { 484 if (displayId == Display.DEFAULT_DISPLAY) { 485 return String.format(FILENAME_PATTERN, time, fileExtension(format)); 486 } 487 return String.format(CONNECTED_DISPLAY_FILENAME_PATTERN, time, displayId, 488 fileExtension(format)); 489 } 490 491 @VisibleForTesting 492 static String createSystemFileDisplayName(String originalDisplayName, CompressFormat format) { 493 return originalDisplayName + "." + fileExtension(format); 494 } 495 496 static ContentValues createMetadata(ZonedDateTime captureTime, CompressFormat format, 497 String fileName) { 498 ContentValues values = new ContentValues(); 499 values.put(MediaStore.MediaColumns.RELATIVE_PATH, SCREENSHOTS_PATH); 500 values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); 501 values.put(MediaStore.MediaColumns.MIME_TYPE, getMimeType(format)); 502 values.put(MediaStore.MediaColumns.DATE_ADDED, captureTime.toEpochSecond()); 503 values.put(MediaStore.MediaColumns.DATE_MODIFIED, captureTime.toEpochSecond()); 504 values.put(MediaStore.MediaColumns.DATE_EXPIRES, 505 captureTime.plus(PENDING_ENTRY_TTL).toEpochSecond()); 506 values.put(MediaStore.MediaColumns.IS_PENDING, 1); 507 return values; 508 } 509 510 static void updateExifAttributes(ExifInterface exif, UUID uniqueId, int width, int height, 511 ZonedDateTime captureTime) { 512 exif.setAttribute(ExifInterface.TAG_IMAGE_UNIQUE_ID, uniqueId.toString()); 513 514 exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Android " + Build.DISPLAY); 515 exif.setAttribute(ExifInterface.TAG_IMAGE_WIDTH, Integer.toString(width)); 516 exif.setAttribute(ExifInterface.TAG_IMAGE_LENGTH, Integer.toString(height)); 517 518 String dateTime = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").format(captureTime); 519 String subSec = DateTimeFormatter.ofPattern("SSS").format(captureTime); 520 String timeZone = DateTimeFormatter.ofPattern("xxx").format(captureTime); 521 522 exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime); 523 exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, subSec); 524 exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, timeZone); 525 } 526 527 static String getMimeType(CompressFormat format) { 528 switch (format) { 529 case JPEG: 530 return "image/jpeg"; 531 case PNG: 532 return "image/png"; 533 case WEBP: 534 case WEBP_LOSSLESS: 535 case WEBP_LOSSY: 536 return "image/webp"; 537 default: 538 throw new IllegalArgumentException("Unknown CompressFormat!"); 539 } 540 } 541 542 static String fileExtension(CompressFormat format) { 543 switch (format) { 544 case JPEG: 545 return "jpg"; 546 case PNG: 547 return "png"; 548 case WEBP: 549 case WEBP_LOSSY: 550 case WEBP_LOSSLESS: 551 return "webp"; 552 default: 553 throw new IllegalArgumentException("Unknown CompressFormat!"); 554 } 555 } 556 557 private static void throwIfInterrupted() throws InterruptedException { 558 if (Thread.currentThread().isInterrupted()) { 559 throw new InterruptedException(); 560 } 561 } 562 563 static final class ImageExportException extends IOException { 564 ImageExportException(String message) { 565 super(message); 566 } 567 568 ImageExportException(String message, Throwable cause) { 569 super(message, cause); 570 } 571 } 572 } 573