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.ContentResolver; 23 import android.content.ContentValues; 24 import android.graphics.Bitmap; 25 import android.graphics.Bitmap.CompressFormat; 26 import android.net.Uri; 27 import android.os.Build; 28 import android.os.Environment; 29 import android.os.ParcelFileDescriptor; 30 import android.os.SystemClock; 31 import android.os.Trace; 32 import android.provider.MediaStore; 33 import android.util.Log; 34 35 import androidx.concurrent.futures.CallbackToFutureAdapter; 36 import androidx.exifinterface.media.ExifInterface; 37 38 import com.android.internal.annotations.VisibleForTesting; 39 40 import com.google.common.util.concurrent.ListenableFuture; 41 42 import java.io.File; 43 import java.io.FileNotFoundException; 44 import java.io.FileOutputStream; 45 import java.io.IOException; 46 import java.io.OutputStream; 47 import java.time.Duration; 48 import java.time.Instant; 49 import java.time.ZonedDateTime; 50 import java.time.format.DateTimeFormatter; 51 import java.util.UUID; 52 import java.util.concurrent.Executor; 53 54 import javax.inject.Inject; 55 56 class ImageExporter { 57 private static final String TAG = LogConfig.logTag(ImageExporter.class); 58 59 static final Duration PENDING_ENTRY_TTL = Duration.ofHours(24); 60 61 // ex: 'Screenshot_20201215-090626.png' 62 private static final String FILENAME_PATTERN = "Screenshot_%1$tY%<tm%<td-%<tH%<tM%<tS.%2$s"; 63 private static final String SCREENSHOTS_PATH = Environment.DIRECTORY_PICTURES 64 + File.separator + Environment.DIRECTORY_SCREENSHOTS; 65 66 private static final String RESOLVER_INSERT_RETURNED_NULL = 67 "ContentResolver#insert returned null."; 68 private static final String RESOLVER_OPEN_FILE_RETURNED_NULL = 69 "ContentResolver#openFile returned null."; 70 private static final String RESOLVER_OPEN_FILE_EXCEPTION = 71 "ContentResolver#openFile threw an exception."; 72 private static final String OPEN_OUTPUT_STREAM_EXCEPTION = 73 "ContentResolver#openOutputStream threw an exception."; 74 private static final String EXIF_READ_EXCEPTION = 75 "ExifInterface threw an exception reading from the file descriptor."; 76 private static final String EXIF_WRITE_EXCEPTION = 77 "ExifInterface threw an exception writing to the file descriptor."; 78 private static final String RESOLVER_UPDATE_ZERO_ROWS = 79 "Failed to publish entry. ContentResolver#update reported no rows updated."; 80 private static final String IMAGE_COMPRESS_RETURNED_FALSE = 81 "Bitmap.compress returned false. (Failure unknown)"; 82 83 private final ContentResolver mResolver; 84 private CompressFormat mCompressFormat = CompressFormat.PNG; 85 private int mQuality = 100; 86 87 @Inject ImageExporter(ContentResolver resolver)88 ImageExporter(ContentResolver resolver) { 89 mResolver = resolver; 90 } 91 92 /** 93 * Adjusts the output image format. This also determines extension of the filename created. The 94 * default is {@link CompressFormat#PNG PNG}. 95 * 96 * @see CompressFormat 97 * 98 * @param format the image format for export 99 */ setFormat(CompressFormat format)100 void setFormat(CompressFormat format) { 101 mCompressFormat = format; 102 } 103 104 /** 105 * Sets the quality format. The exact meaning is dependent on the {@link CompressFormat} used. 106 * 107 * @param quality the 'quality' level between 0 and 100 108 */ setQuality(@ntRangefrom = 0, to = 100) int quality)109 void setQuality(@IntRange(from = 0, to = 100) int quality) { 110 mQuality = quality; 111 } 112 113 /** 114 * Writes the given Bitmap to outputFile. 115 */ exportToRawFile(Executor executor, Bitmap bitmap, final File outputFile)116 ListenableFuture<File> exportToRawFile(Executor executor, Bitmap bitmap, 117 final File outputFile) { 118 return CallbackToFutureAdapter.getFuture( 119 (completer) -> { 120 executor.execute(() -> { 121 try (FileOutputStream stream = new FileOutputStream(outputFile)) { 122 bitmap.compress(mCompressFormat, mQuality, stream); 123 completer.set(outputFile); 124 } catch (IOException e) { 125 if (outputFile.exists()) { 126 //noinspection ResultOfMethodCallIgnored 127 outputFile.delete(); 128 } 129 completer.setException(e); 130 } 131 }); 132 return "Bitmap#compress"; 133 } 134 ); 135 } 136 137 /** 138 * Export the image using the given executor. 139 * 140 * @param executor the thread for execution 141 * @param bitmap the bitmap to export 142 * 143 * @return a listenable future result 144 */ 145 ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap) { 146 return export(executor, requestId, bitmap, ZonedDateTime.now()); 147 } 148 149 /** 150 * Export the image to MediaStore and publish. 151 * 152 * @param executor the thread for execution 153 * @param bitmap the bitmap to export 154 * 155 * @return a listenable future result 156 */ 157 ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, 158 ZonedDateTime captureTime) { 159 160 final Task task = new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, 161 mQuality, /* publish */ true); 162 163 return CallbackToFutureAdapter.getFuture( 164 (completer) -> { 165 executor.execute(() -> { 166 try { 167 completer.set(task.execute()); 168 } catch (ImageExportException | InterruptedException e) { 169 completer.setException(e); 170 } 171 }); 172 return task; 173 } 174 ); 175 } 176 177 /** 178 * Delete the entry. 179 * 180 * @param executor the thread for execution 181 * @param uri the uri of the image to publish 182 * 183 * @return a listenable future result 184 */ 185 ListenableFuture<Result> delete(Executor executor, Uri uri) { 186 return CallbackToFutureAdapter.getFuture((completer) -> { 187 executor.execute(() -> { 188 mResolver.delete(uri, null); 189 190 Result result = new Result(); 191 result.uri = uri; 192 result.deleted = true; 193 completer.set(result); 194 }); 195 return "ContentResolver#delete"; 196 }); 197 } 198 199 static class Result { 200 Uri uri; 201 UUID requestId; 202 String fileName; 203 long timestamp; 204 CompressFormat format; 205 boolean published; 206 boolean deleted; 207 208 @Override 209 public String toString() { 210 final StringBuilder sb = new StringBuilder("Result{"); 211 sb.append("uri=").append(uri); 212 sb.append(", requestId=").append(requestId); 213 sb.append(", fileName='").append(fileName).append('\''); 214 sb.append(", timestamp=").append(timestamp); 215 sb.append(", format=").append(format); 216 sb.append(", published=").append(published); 217 sb.append(", deleted=").append(deleted); 218 sb.append('}'); 219 return sb.toString(); 220 } 221 } 222 223 private static class Task { 224 private final ContentResolver mResolver; 225 private final UUID mRequestId; 226 private final Bitmap mBitmap; 227 private final ZonedDateTime mCaptureTime; 228 private final CompressFormat mFormat; 229 private final int mQuality; 230 private final String mFileName; 231 private final boolean mPublish; 232 233 Task(ContentResolver resolver, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, 234 CompressFormat format, int quality, boolean publish) { 235 mResolver = resolver; 236 mRequestId = requestId; 237 mBitmap = bitmap; 238 mCaptureTime = captureTime; 239 mFormat = format; 240 mQuality = quality; 241 mFileName = createFilename(mCaptureTime, mFormat); 242 mPublish = publish; 243 } 244 245 public Result execute() throws ImageExportException, InterruptedException { 246 Trace.beginSection("ImageExporter_execute"); 247 Uri uri = null; 248 Instant start = null; 249 Result result = new Result(); 250 try { 251 if (LogConfig.DEBUG_STORAGE) { 252 Log.d(TAG, "image export started"); 253 start = Instant.now(); 254 } 255 256 uri = createEntry(mResolver, mFormat, mCaptureTime, mFileName); 257 throwIfInterrupted(); 258 259 writeImage(mResolver, mBitmap, mFormat, mQuality, uri); 260 throwIfInterrupted(); 261 262 int width = mBitmap.getWidth(); 263 int height = mBitmap.getHeight(); 264 writeExif(mResolver, uri, mRequestId, width, height, mCaptureTime); 265 throwIfInterrupted(); 266 267 if (mPublish) { 268 publishEntry(mResolver, uri); 269 result.published = true; 270 } 271 272 result.timestamp = mCaptureTime.toInstant().toEpochMilli(); 273 result.requestId = mRequestId; 274 result.uri = uri; 275 result.fileName = mFileName; 276 result.format = mFormat; 277 278 if (LogConfig.DEBUG_STORAGE) { 279 Log.d(TAG, "image export completed: " 280 + Duration.between(start, Instant.now()).toMillis() + " ms"); 281 } 282 } catch (ImageExportException e) { 283 if (uri != null) { 284 mResolver.delete(uri, null); 285 } 286 throw e; 287 } finally { 288 Trace.endSection(); 289 } 290 return result; 291 } 292 293 @Override 294 public String toString() { 295 return "export [" + mBitmap + "] to [" + mFormat + "] at quality " + mQuality; 296 } 297 } 298 299 private static Uri createEntry(ContentResolver resolver, CompressFormat format, 300 ZonedDateTime time, String fileName) throws ImageExportException { 301 Trace.beginSection("ImageExporter_createEntry"); 302 try { 303 final ContentValues values = createMetadata(time, format, fileName); 304 305 Uri uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); 306 if (uri == null) { 307 throw new ImageExportException(RESOLVER_INSERT_RETURNED_NULL); 308 } 309 return uri; 310 } finally { 311 Trace.endSection(); 312 } 313 } 314 315 private static void writeImage(ContentResolver resolver, Bitmap bitmap, CompressFormat format, 316 int quality, Uri contentUri) throws ImageExportException { 317 Trace.beginSection("ImageExporter_writeImage"); 318 try (OutputStream out = resolver.openOutputStream(contentUri)) { 319 long start = SystemClock.elapsedRealtime(); 320 if (!bitmap.compress(format, quality, out)) { 321 throw new ImageExportException(IMAGE_COMPRESS_RETURNED_FALSE); 322 } else if (LogConfig.DEBUG_STORAGE) { 323 Log.d(TAG, "Bitmap.compress took " 324 + (SystemClock.elapsedRealtime() - start) + " ms"); 325 } 326 } catch (IOException ex) { 327 throw new ImageExportException(OPEN_OUTPUT_STREAM_EXCEPTION, ex); 328 } finally { 329 Trace.endSection(); 330 } 331 } 332 333 private static void writeExif(ContentResolver resolver, Uri uri, UUID requestId, int width, 334 int height, ZonedDateTime captureTime) throws ImageExportException { 335 Trace.beginSection("ImageExporter_writeExif"); 336 ParcelFileDescriptor pfd = null; 337 try { 338 pfd = resolver.openFile(uri, "rw", null); 339 if (pfd == null) { 340 throw new ImageExportException(RESOLVER_OPEN_FILE_RETURNED_NULL); 341 } 342 ExifInterface exif; 343 try { 344 exif = new ExifInterface(pfd.getFileDescriptor()); 345 } catch (IOException e) { 346 throw new ImageExportException(EXIF_READ_EXCEPTION, e); 347 } 348 349 updateExifAttributes(exif, requestId, width, height, captureTime); 350 try { 351 exif.saveAttributes(); 352 } catch (IOException e) { 353 throw new ImageExportException(EXIF_WRITE_EXCEPTION, e); 354 } 355 } catch (FileNotFoundException e) { 356 throw new ImageExportException(RESOLVER_OPEN_FILE_EXCEPTION, e); 357 } finally { 358 closeQuietly(pfd); 359 Trace.endSection(); 360 } 361 } 362 363 private static void publishEntry(ContentResolver resolver, Uri uri) 364 throws ImageExportException { 365 Trace.beginSection("ImageExporter_publishEntry"); 366 try { 367 ContentValues values = new ContentValues(); 368 values.put(MediaStore.MediaColumns.IS_PENDING, 0); 369 values.putNull(MediaStore.MediaColumns.DATE_EXPIRES); 370 final int rowsUpdated = resolver.update(uri, values, /* extras */ null); 371 if (rowsUpdated < 1) { 372 throw new ImageExportException(RESOLVER_UPDATE_ZERO_ROWS); 373 } 374 } finally { 375 Trace.endSection(); 376 } 377 } 378 379 @VisibleForTesting 380 static String createFilename(ZonedDateTime time, CompressFormat format) { 381 return String.format(FILENAME_PATTERN, time, fileExtension(format)); 382 } 383 384 static ContentValues createMetadata(ZonedDateTime captureTime, CompressFormat format, 385 String fileName) { 386 ContentValues values = new ContentValues(); 387 values.put(MediaStore.MediaColumns.RELATIVE_PATH, SCREENSHOTS_PATH); 388 values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); 389 values.put(MediaStore.MediaColumns.MIME_TYPE, getMimeType(format)); 390 values.put(MediaStore.MediaColumns.DATE_ADDED, captureTime.toEpochSecond()); 391 values.put(MediaStore.MediaColumns.DATE_MODIFIED, captureTime.toEpochSecond()); 392 values.put(MediaStore.MediaColumns.DATE_EXPIRES, 393 captureTime.plus(PENDING_ENTRY_TTL).toEpochSecond()); 394 values.put(MediaStore.MediaColumns.IS_PENDING, 1); 395 return values; 396 } 397 398 static void updateExifAttributes(ExifInterface exif, UUID uniqueId, int width, int height, 399 ZonedDateTime captureTime) { 400 exif.setAttribute(ExifInterface.TAG_IMAGE_UNIQUE_ID, uniqueId.toString()); 401 402 exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Android " + Build.DISPLAY); 403 exif.setAttribute(ExifInterface.TAG_IMAGE_WIDTH, Integer.toString(width)); 404 exif.setAttribute(ExifInterface.TAG_IMAGE_LENGTH, Integer.toString(height)); 405 406 String dateTime = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").format(captureTime); 407 String subSec = DateTimeFormatter.ofPattern("SSS").format(captureTime); 408 String timeZone = DateTimeFormatter.ofPattern("xxx").format(captureTime); 409 410 exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime); 411 exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, subSec); 412 exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, timeZone); 413 } 414 415 static String getMimeType(CompressFormat format) { 416 switch (format) { 417 case JPEG: 418 return "image/jpeg"; 419 case PNG: 420 return "image/png"; 421 case WEBP: 422 case WEBP_LOSSLESS: 423 case WEBP_LOSSY: 424 return "image/webp"; 425 default: 426 throw new IllegalArgumentException("Unknown CompressFormat!"); 427 } 428 } 429 430 static String fileExtension(CompressFormat format) { 431 switch (format) { 432 case JPEG: 433 return "jpg"; 434 case PNG: 435 return "png"; 436 case WEBP: 437 case WEBP_LOSSY: 438 case WEBP_LOSSLESS: 439 return "webp"; 440 default: 441 throw new IllegalArgumentException("Unknown CompressFormat!"); 442 } 443 } 444 445 private static void throwIfInterrupted() throws InterruptedException { 446 if (Thread.currentThread().isInterrupted()) { 447 throw new InterruptedException(); 448 } 449 } 450 451 static final class ImageExportException extends IOException { 452 ImageExportException(String message) { 453 super(message); 454 } 455 456 ImageExportException(String message, Throwable cause) { 457 super(message, cause); 458 } 459 } 460 } 461