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