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