• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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