From c2c5075bbfa0cf7e67e3f4db0a016878435a870f Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Tue, 15 Dec 2020 09:38:19 -0500 Subject: [PATCH] Screenshots: ImageExporter Performs the same image writing steps as SaveImageInBackgroundTask but with only image export functions, packaged in a neatly composable and testable form. Bug: 175744393 Test: atest SystemUITests:com.android.systemui.screenshot.ImageExporterTest Change-Id: Ifbdf53daaeaf366da6ae5a25b4756e289172ab66 --- .../systemui/screenshot/ImageExporter.java | 360 ++++++++++++++++++ .../screenshot/ImageExporterTest.java | 180 +++++++++ 2 files changed, 540 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/screenshot/ImageExporter.java create mode 100644 packages/SystemUI/tests/src/com/android/systemui/screenshot/ImageExporterTest.java diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ImageExporter.java b/packages/SystemUI/src/com/android/systemui/screenshot/ImageExporter.java new file mode 100644 index 0000000000000..2719d3e14d334 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/ImageExporter.java @@ -0,0 +1,360 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.screenshot; + +import static android.os.FileUtils.closeQuietly; + +import android.annotation.IntRange; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.ParcelFileDescriptor; +import android.os.SystemClock; +import android.os.Trace; +import android.provider.MediaStore; +import android.util.Log; + +import androidx.concurrent.futures.CallbackToFutureAdapter; +import androidx.exifinterface.media.ExifInterface; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStream; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.Executor; + +import javax.inject.Inject; + +class ImageExporter { + private static final String TAG = LogConfig.logTag(ImageExporter.class); + + static final Duration PENDING_ENTRY_TTL = Duration.ofHours(24); + + // ex: 'Screenshot_20201215-090626.png' + private static final String FILENAME_PATTERN = "Screenshot_%1$tY% export(Executor executor, Bitmap bitmap) { + return export(executor, bitmap, ZonedDateTime.now()); + } + + /** + * Export the image using the given executor. + * + * @param executor the thread for execution + * @param bitmap the bitmap to export + * + * @return a listenable future result + */ + ListenableFuture export(Executor executor, Bitmap bitmap, ZonedDateTime captureTime) { + final Task task = new Task(mResolver, bitmap, captureTime, mCompressFormat, mQuality); + return CallbackToFutureAdapter.getFuture( + (completer) -> { + executor.execute(() -> { + try { + completer.set(task.execute()); + } catch (ImageExportException | InterruptedException e) { + completer.setException(e); + } + }); + return task; + } + ); + } + + private static class Task { + private final ContentResolver mResolver; + private final ZonedDateTime mCaptureTime; + private final CompressFormat mFormat; + private final int mQuality; + private final Bitmap mBitmap; + + Task(ContentResolver resolver, Bitmap bitmap, ZonedDateTime captureTime, + CompressFormat format, int quality) { + mResolver = resolver; + mBitmap = bitmap; + mCaptureTime = captureTime; + mFormat = format; + mQuality = quality; + } + + public Uri execute() throws ImageExportException, InterruptedException { + Trace.beginSection("ImageExporter_execute"); + Uri uri = null; + Instant start = null; + try { + if (LogConfig.DEBUG_STORAGE) { + Log.d(TAG, "image export started"); + start = Instant.now(); + } + uri = createEntry(mFormat, mCaptureTime); + throwIfInterrupted(); + + writeImage(mBitmap, mFormat, mQuality, uri); + throwIfInterrupted(); + + writeExif(uri, mBitmap.getWidth(), mBitmap.getHeight(), mCaptureTime); + throwIfInterrupted(); + + publishEntry(uri); + + if (LogConfig.DEBUG_STORAGE) { + Log.d(TAG, "image export completed: " + + Duration.between(start, Instant.now()).toMillis() + " ms"); + } + } catch (ImageExportException e) { + if (uri != null) { + mResolver.delete(uri, null); + } + throw e; + } finally { + Trace.endSection(); + } + return uri; + } + + Uri createEntry(CompressFormat format, ZonedDateTime time) throws ImageExportException { + Trace.beginSection("ImageExporter_createEntry"); + try { + final ContentValues values = createMetadata(time, format); + Uri uri = mResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); + if (uri == null) { + throw new ImageExportException(RESOLVER_INSERT_RETURNED_NULL); + } + return uri; + } finally { + Trace.endSection(); + } + } + + void writeImage(Bitmap bitmap, CompressFormat format, int quality, + Uri contentUri) throws ImageExportException { + Trace.beginSection("ImageExporter_writeImage"); + try (OutputStream out = mResolver.openOutputStream(contentUri)) { + long start = SystemClock.elapsedRealtime(); + if (!bitmap.compress(format, quality, out)) { + throw new ImageExportException(IMAGE_COMPRESS_RETURNED_FALSE); + } else if (LogConfig.DEBUG_STORAGE) { + Log.d(TAG, "Bitmap.compress took " + + (SystemClock.elapsedRealtime() - start) + " ms"); + } + } catch (IOException ex) { + throw new ImageExportException(OPEN_OUTPUT_STREAM_EXCEPTION, ex); + } finally { + Trace.endSection(); + } + } + + void writeExif(Uri uri, int width, int height, ZonedDateTime captureTime) + throws ImageExportException { + Trace.beginSection("ImageExporter_writeExif"); + ParcelFileDescriptor pfd = null; + try { + pfd = mResolver.openFile(uri, "rw", null); + if (pfd == null) { + throw new ImageExportException(RESOLVER_OPEN_FILE_RETURNED_NULL); + } + ExifInterface exif; + try { + exif = new ExifInterface(pfd.getFileDescriptor()); + } catch (IOException e) { + throw new ImageExportException(EXIF_READ_EXCEPTION, e); + } + + updateExifAttributes(exif, width, height, captureTime); + try { + exif.saveAttributes(); + } catch (IOException e) { + throw new ImageExportException(EXIF_WRITE_EXCEPTION, e); + } + } catch (FileNotFoundException e) { + throw new ImageExportException(RESOLVER_OPEN_FILE_EXCEPTION, e); + } finally { + closeQuietly(pfd); + Trace.endSection(); + } + } + + void publishEntry(Uri uri) throws ImageExportException { + Trace.beginSection("ImageExporter_publishEntry"); + try { + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.IS_PENDING, 0); + values.putNull(MediaStore.MediaColumns.DATE_EXPIRES); + final int rowsUpdated = mResolver.update(uri, values, /* extras */ null); + if (rowsUpdated < 1) { + throw new ImageExportException(RESOLVER_UPDATE_ZERO_ROWS); + } + } finally { + Trace.endSection(); + } + } + + @Override + public String toString() { + return "compress [" + mBitmap + "] to [" + mFormat + "] at quality " + mQuality; + } + } + + static String createFilename(ZonedDateTime time, CompressFormat format) { + return String.format(FILENAME_PATTERN, time, fileExtension(format)); + } + + static ContentValues createMetadata(ZonedDateTime captureTime, CompressFormat format) { + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, SCREENSHOTS_PATH); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, createFilename(captureTime, format)); + values.put(MediaStore.MediaColumns.MIME_TYPE, getMimeType(format)); + values.put(MediaStore.MediaColumns.DATE_ADDED, captureTime.toEpochSecond()); + values.put(MediaStore.MediaColumns.DATE_MODIFIED, captureTime.toEpochSecond()); + values.put(MediaStore.MediaColumns.DATE_EXPIRES, + captureTime.plus(PENDING_ENTRY_TTL).toEpochSecond()); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + return values; + } + + static void updateExifAttributes(ExifInterface exif, int width, int height, + ZonedDateTime captureTime) { + exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Android " + Build.DISPLAY); + exif.setAttribute(ExifInterface.TAG_IMAGE_WIDTH, Integer.toString(width)); + exif.setAttribute(ExifInterface.TAG_IMAGE_LENGTH, Integer.toString(height)); + + String dateTime = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").format(captureTime); + String subSec = DateTimeFormatter.ofPattern("SSS").format(captureTime); + String timeZone = DateTimeFormatter.ofPattern("xxx").format(captureTime); + + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime); + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, subSec); + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, timeZone); + + exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, dateTime); + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, subSec); + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_DIGITIZED, timeZone); + } + + static String getMimeType(CompressFormat format) { + switch (format) { + case JPEG: + return "image/jpeg"; + case PNG: + return "image/png"; + case WEBP: + case WEBP_LOSSLESS: + case WEBP_LOSSY: + return "image/webp"; + default: + throw new IllegalArgumentException("Unknown CompressFormat!"); + } + } + + static String fileExtension(CompressFormat format) { + switch (format) { + case JPEG: + return "jpg"; + case PNG: + return "png"; + case WEBP: + case WEBP_LOSSY: + case WEBP_LOSSLESS: + return "webp"; + default: + throw new IllegalArgumentException("Unknown CompressFormat!"); + } + } + + private static void throwIfInterrupted() throws InterruptedException { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedException(); + } + } + + static final class ImageExportException extends IOException { + ImageExportException(String message) { + super(message); + } + + ImageExportException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/ImageExporterTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/ImageExporterTest.java new file mode 100644 index 0000000000000..f566880ee129e --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/ImageExporterTest.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.screenshot; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import static java.nio.charset.StandardCharsets.US_ASCII; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.net.Uri; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import android.provider.MediaStore; +import android.testing.AndroidTestingRunner; + +import androidx.exifinterface.media.ExifInterface; +import androidx.test.filters.MediumTest; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.systemui.SysuiTestCase; + +import com.google.common.util.concurrent.ListenableFuture; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; + +@RunWith(AndroidTestingRunner.class) +@MediumTest // file I/O +public class ImageExporterTest extends SysuiTestCase { + + /** Executes directly in the caller's thread */ + private static final Executor DIRECT_EXECUTOR = Runnable::run; + private static final byte[] EXIF_FILE_TAG = "Exif\u0000\u0000".getBytes(US_ASCII); + + private static final ZonedDateTime CAPTURE_TIME = + ZonedDateTime.of(LocalDateTime.of(2020, 12, 15, 13, 15), ZoneId.of("EST")); + + @Test + public void testImageFilename() { + assertEquals("image file name", "Screenshot_20201215-131500.png", + ImageExporter.createFilename(CAPTURE_TIME, CompressFormat.PNG)); + } + + @Test + public void testUpdateExifAttributes_timeZoneUTC() throws IOException { + ExifInterface exifInterface = new ExifInterface(new ByteArrayInputStream(EXIF_FILE_TAG), + ExifInterface.STREAM_TYPE_EXIF_DATA_ONLY); + + ImageExporter.updateExifAttributes(exifInterface, 100, 100, + ZonedDateTime.of(LocalDateTime.of(2020, 12, 15, 18, 15), ZoneId.of("UTC"))); + + assertEquals("Exif " + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+00:00", + exifInterface.getAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL)); + assertEquals("Exif " + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+00:00", + exifInterface.getAttribute(ExifInterface.TAG_OFFSET_TIME_DIGITIZED)); + } + + @Test + public void testImageExport() throws ExecutionException, InterruptedException, IOException { + Context context = InstrumentationRegistry.getInstrumentation().getContext(); + ContentResolver contentResolver = context.getContentResolver(); + ImageExporter exporter = new ImageExporter(contentResolver); + + Bitmap original = createCheckerBitmap(10, 10, 10); + + ListenableFuture direct = exporter.export(DIRECT_EXECUTOR, original, CAPTURE_TIME); + assertTrue("future should be done", direct.isDone()); + assertFalse("future should not be canceled", direct.isCancelled()); + Uri result = direct.get(); + + assertNotNull("Uri should not be null", result); + Bitmap decoded = null; + try (InputStream in = contentResolver.openInputStream(result)) { + decoded = BitmapFactory.decodeStream(in); + assertNotNull("decoded image should not be null", decoded); + assertTrue("original and decoded image should be identical", original.sameAs(decoded)); + + try (ParcelFileDescriptor pfd = contentResolver.openFile(result, "r", null)) { + assertNotNull(pfd); + ExifInterface exifInterface = new ExifInterface(pfd.getFileDescriptor()); + + assertEquals("Exif " + ExifInterface.TAG_SOFTWARE, "Android " + Build.DISPLAY, + exifInterface.getAttribute(ExifInterface.TAG_SOFTWARE)); + + assertEquals("Exif " + ExifInterface.TAG_IMAGE_WIDTH, 100, + exifInterface.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0)); + assertEquals("Exif " + ExifInterface.TAG_IMAGE_LENGTH, 100, + exifInterface.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0)); + + assertEquals("Exif " + ExifInterface.TAG_DATETIME_ORIGINAL, "2020:12:15 13:15:00", + exifInterface.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)); + assertEquals("Exif " + ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, "000", + exifInterface.getAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL)); + assertEquals("Exif " + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "-05:00", + exifInterface.getAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL)); + + assertEquals("Exif " + ExifInterface.TAG_DATETIME_DIGITIZED, "2020:12:15 13:15:00", + exifInterface.getAttribute(ExifInterface.TAG_DATETIME_DIGITIZED)); + assertEquals("Exif " + ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, "000", + exifInterface.getAttribute(ExifInterface.TAG_SUBSEC_TIME_DIGITIZED)); + assertEquals("Exif " + ExifInterface.TAG_OFFSET_TIME_DIGITIZED, "-05:00", + exifInterface.getAttribute(ExifInterface.TAG_OFFSET_TIME_DIGITIZED)); + } + } finally { + if (decoded != null) { + decoded.recycle(); + } + contentResolver.delete(result, null); + } + } + + @Test + public void testMediaStoreMetadata() { + ContentValues values = ImageExporter.createMetadata(CAPTURE_TIME, CompressFormat.PNG); + assertEquals("Pictures/Screenshots", + values.getAsString(MediaStore.MediaColumns.RELATIVE_PATH)); + assertEquals("Screenshot_20201215-131500.png", + values.getAsString(MediaStore.MediaColumns.DISPLAY_NAME)); + assertEquals("image/png", values.getAsString(MediaStore.MediaColumns.MIME_TYPE)); + assertEquals(Long.valueOf(1608056100L), + values.getAsLong(MediaStore.MediaColumns.DATE_ADDED)); + assertEquals(Long.valueOf(1608056100L), + values.getAsLong(MediaStore.MediaColumns.DATE_MODIFIED)); + assertEquals(Integer.valueOf(1), values.getAsInteger(MediaStore.MediaColumns.IS_PENDING)); + assertEquals(Long.valueOf(1608056100L + 86400L), // +1 day + values.getAsLong(MediaStore.MediaColumns.DATE_EXPIRES)); + } + + @SuppressWarnings("SameParameterValue") + private Bitmap createCheckerBitmap(int tileSize, int w, int h) { + Bitmap bitmap = Bitmap.createBitmap(w * tileSize, h * tileSize, Bitmap.Config.ARGB_8888); + Canvas c = new Canvas(bitmap); + Paint paint = new Paint(); + paint.setStyle(Paint.Style.FILL); + + for (int i = 0; i < h; i++) { + int top = i * tileSize; + for (int j = 0; j < w; j++) { + int left = j * tileSize; + paint.setColor(paint.getColor() == Color.WHITE ? Color.BLACK : Color.WHITE); + c.drawRect(left, top, left + tileSize, top + tileSize, paint); + } + } + return bitmap; + } +}