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