Loading packages/SystemUI/src/com/android/systemui/screenshot/ImageExporter.java +125 −17 Original line number Diff line number Diff line Loading @@ -21,7 +21,9 @@ import static android.os.FileUtils.closeQuietly; import android.annotation.IntRange; import android.content.ContentProvider; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Bitmap.CompressFormat; import android.net.Uri; Loading Loading @@ -50,6 +52,7 @@ import java.io.IOException; import java.io.OutputStream; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.UUID; Loading Loading @@ -145,7 +148,8 @@ public class ImageExporter { } /** * Export the image using the given executor. * Export the image using the given executor with an auto-generated file name based on display * id. * * @param executor the thread for execution * @param bitmap the bitmap to export Loading @@ -154,23 +158,74 @@ public class ImageExporter { */ public ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, UserHandle owner, int displayId) { return export(executor, requestId, bitmap, ZonedDateTime.now(), owner, displayId); ZonedDateTime captureTime = ZonedDateTime.now(ZoneId.systemDefault()); return export(executor, new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, mQuality, /* publish */ true, owner, mFlags, createFilename(captureTime, mCompressFormat, displayId))); } /** * Export the image to MediaStore and publish. * Export the image using the given executor with a specified file name. * * @param executor the thread for execution * @param bitmap the bitmap to export * @param format the compress format of {@code bitmap} e.g. {@link CompressFormat.PNG} * @param fileName a specified name for the exported file. No need to include file extension in * file name. The extension will be internally appended based on * {@code format} * @return a listenable future result */ public ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, CompressFormat format, UserHandle owner, String fileName) { return export(executor, new Task(mResolver, requestId, bitmap, ZonedDateTime.now(ZoneId.systemDefault()), format, mQuality, /* publish */ true, owner, mFlags, createSystemFileDisplayName(fileName, format), true /* allowOverwrite */)); } /** * Export the image to MediaStore and publish. * * @param executor the thread for execution * @param bitmap the bitmap to export * @return a listenable future result */ ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, UserHandle owner, int displayId) { return export(executor, new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, mQuality, /* publish */ true, owner, mFlags, createFilename(captureTime, mCompressFormat, displayId))); } final Task task = new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, mQuality, /* publish */ true, owner, mFlags, displayId); /** * Export the image to MediaStore and publish. * * @param executor the thread for execution * @param bitmap the bitmap to export * @return a listenable future result */ ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, UserHandle owner, String fileName) { return export(executor, new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, mQuality, /* publish */ true, owner, mFlags, createSystemFileDisplayName(fileName, mCompressFormat))); } /** * Export the image to MediaStore and publish. * * @param executor the thread for execution * @param task the exporting image {@link Task}. * * @return a listenable future result */ private ListenableFuture<Result> export(Executor executor, Task task) { return CallbackToFutureAdapter.getFuture( (completer) -> { executor.execute(() -> { Loading Loading @@ -220,9 +275,25 @@ public class ImageExporter { private final boolean mPublish; private final FeatureFlags mFlags; /** * This variable specifies the behavior when a file to be exported has a same name and * format as one of the file on disk. If this is set to true, the new file overwrite the * old file; otherwise, the system adds a number to the end of the newly exported file. For * example, if the file is screenshot.png, the newly exported file's display name will be * screenshot(1).png. */ private final boolean mAllowOverwrite; Task(ContentResolver resolver, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, CompressFormat format, int quality, boolean publish, UserHandle owner, FeatureFlags flags, int displayId) { FeatureFlags flags, String fileName) { this(resolver, requestId, bitmap, captureTime, format, quality, publish, owner, flags, fileName, false /* allowOverwrite */); } Task(ContentResolver resolver, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, CompressFormat format, int quality, boolean publish, UserHandle owner, FeatureFlags flags, String fileName, boolean allowOverwrite) { mResolver = resolver; mRequestId = requestId; mBitmap = bitmap; Loading @@ -230,9 +301,10 @@ public class ImageExporter { mFormat = format; mQuality = quality; mOwner = owner; mFileName = createFilename(mCaptureTime, mFormat, displayId); mFileName = fileName; mPublish = publish; mFlags = flags; mAllowOverwrite = allowOverwrite; } public Result execute() throws ImageExportException, InterruptedException { Loading @@ -246,7 +318,8 @@ public class ImageExporter { start = Instant.now(); } uri = createEntry(mResolver, mFormat, mCaptureTime, mFileName, mOwner, mFlags); uri = createEntry(mResolver, mFormat, mCaptureTime, mFileName, mOwner, mFlags, mAllowOverwrite); throwIfInterrupted(); writeImage(mResolver, mBitmap, mFormat, mQuality, uri); Loading Loading @@ -290,21 +363,51 @@ public class ImageExporter { } private static Uri createEntry(ContentResolver resolver, CompressFormat format, ZonedDateTime time, String fileName, UserHandle owner, FeatureFlags flags) throws ImageExportException { ZonedDateTime time, String fileName, UserHandle owner, FeatureFlags flags, boolean allowOverwrite) throws ImageExportException { Trace.beginSection("ImageExporter_createEntry"); try { final ContentValues values = createMetadata(time, format, fileName); Uri baseUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; Uri uriWithUserId = ContentProvider.maybeAddUserId(baseUri, owner.getIdentifier()); Uri uri = resolver.insert(uriWithUserId, values); if (uri == null) { Uri resultUri = null; if (allowOverwrite) { // Query to check if there is existing file with the same name and format. Cursor cursor = resolver.query( baseUri, null, MediaStore.MediaColumns.DISPLAY_NAME + "=? AND " + MediaStore.MediaColumns.MIME_TYPE + "=?", new String[]{fileName, getMimeType(format)}, null /* CancellationSignal */); if (cursor != null) { if (cursor.moveToFirst()) { // If there is existing file, update the meta-data of its entry. The Entry's // corresponding uri is composed of volume base-uri(or with user-id) and // its row's unique ID. int idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID); resultUri = ContentUris.withAppendedId(uriWithUserId, cursor.getLong(idIndex)); resolver.update(resultUri, values, null); Log.d(TAG, "Updated existing URI: " + resultUri); } cursor.close(); } } if (resultUri == null) { // If file overwriting is disabled or there is no existing file to overwrite, create // and insert a new entry. resultUri = resolver.insert(uriWithUserId, values); Log.d(TAG, "Inserted new URI: " + resultUri); } if (resultUri == null) { throw new ImageExportException(RESOLVER_INSERT_RETURNED_NULL); } Log.d(TAG, "Inserted new URI: " + uri); return uri; return resultUri; } finally { Trace.endSection(); } Loading Loading @@ -383,6 +486,11 @@ public class ImageExporter { fileExtension(format)); } @VisibleForTesting static String createSystemFileDisplayName(String originalDisplayName, CompressFormat format) { return originalDisplayName + "." + fileExtension(format); } static ContentValues createMetadata(ZonedDateTime captureTime, CompressFormat format, String fileName) { ContentValues values = new ContentValues(); Loading packages/SystemUI/tests/src/com/android/systemui/screenshot/ImageExporterTest.java +26 −0 Original line number Diff line number Diff line Loading @@ -16,6 +16,8 @@ package com.android.systemui.screenshot; import static com.android.systemui.screenshot.ImageExporter.createSystemFileDisplayName; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; Loading Loading @@ -181,6 +183,30 @@ public class ImageExporterTest extends SysuiTestCase { } } @Test public void testImageExport_customizedFileName() throws ExecutionException, InterruptedException { // This test only asserts the file name for the case when user specifies a file name, // instead of using the auto-generated name by ImageExporter::createFileName. Other // metadata are not affected by the specified file name. final String customizedFileName = "customized_file_name"; ContentResolver contentResolver = mContext.getContentResolver(); ImageExporter exporter = new ImageExporter(contentResolver, mFeatureFlags); UUID requestId = UUID.fromString("3c11da99-9284-4863-b1d5-6f3684976814"); Bitmap original = createCheckerBitmap(10, 10, 10); ListenableFuture<ImageExporter.Result> direct = exporter.export(DIRECT_EXECUTOR, requestId, original, CAPTURE_TIME, Process.myUserHandle(), customizedFileName); assertTrue("future should be done", direct.isDone()); assertFalse("future should not be canceled", direct.isCancelled()); ImageExporter.Result result = direct.get(); assertEquals("Filename should contain the correct filename", createSystemFileDisplayName(customizedFileName, CompressFormat.PNG), result.fileName); } @Test public void testMediaStoreMetadata() { String name = ImageExporter.createFilename(CAPTURE_TIME, CompressFormat.PNG, Loading Loading
packages/SystemUI/src/com/android/systemui/screenshot/ImageExporter.java +125 −17 Original line number Diff line number Diff line Loading @@ -21,7 +21,9 @@ import static android.os.FileUtils.closeQuietly; import android.annotation.IntRange; import android.content.ContentProvider; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Bitmap.CompressFormat; import android.net.Uri; Loading Loading @@ -50,6 +52,7 @@ import java.io.IOException; import java.io.OutputStream; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.UUID; Loading Loading @@ -145,7 +148,8 @@ public class ImageExporter { } /** * Export the image using the given executor. * Export the image using the given executor with an auto-generated file name based on display * id. * * @param executor the thread for execution * @param bitmap the bitmap to export Loading @@ -154,23 +158,74 @@ public class ImageExporter { */ public ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, UserHandle owner, int displayId) { return export(executor, requestId, bitmap, ZonedDateTime.now(), owner, displayId); ZonedDateTime captureTime = ZonedDateTime.now(ZoneId.systemDefault()); return export(executor, new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, mQuality, /* publish */ true, owner, mFlags, createFilename(captureTime, mCompressFormat, displayId))); } /** * Export the image to MediaStore and publish. * Export the image using the given executor with a specified file name. * * @param executor the thread for execution * @param bitmap the bitmap to export * @param format the compress format of {@code bitmap} e.g. {@link CompressFormat.PNG} * @param fileName a specified name for the exported file. No need to include file extension in * file name. The extension will be internally appended based on * {@code format} * @return a listenable future result */ public ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, CompressFormat format, UserHandle owner, String fileName) { return export(executor, new Task(mResolver, requestId, bitmap, ZonedDateTime.now(ZoneId.systemDefault()), format, mQuality, /* publish */ true, owner, mFlags, createSystemFileDisplayName(fileName, format), true /* allowOverwrite */)); } /** * Export the image to MediaStore and publish. * * @param executor the thread for execution * @param bitmap the bitmap to export * @return a listenable future result */ ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, UserHandle owner, int displayId) { return export(executor, new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, mQuality, /* publish */ true, owner, mFlags, createFilename(captureTime, mCompressFormat, displayId))); } final Task task = new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, mQuality, /* publish */ true, owner, mFlags, displayId); /** * Export the image to MediaStore and publish. * * @param executor the thread for execution * @param bitmap the bitmap to export * @return a listenable future result */ ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, UserHandle owner, String fileName) { return export(executor, new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat, mQuality, /* publish */ true, owner, mFlags, createSystemFileDisplayName(fileName, mCompressFormat))); } /** * Export the image to MediaStore and publish. * * @param executor the thread for execution * @param task the exporting image {@link Task}. * * @return a listenable future result */ private ListenableFuture<Result> export(Executor executor, Task task) { return CallbackToFutureAdapter.getFuture( (completer) -> { executor.execute(() -> { Loading Loading @@ -220,9 +275,25 @@ public class ImageExporter { private final boolean mPublish; private final FeatureFlags mFlags; /** * This variable specifies the behavior when a file to be exported has a same name and * format as one of the file on disk. If this is set to true, the new file overwrite the * old file; otherwise, the system adds a number to the end of the newly exported file. For * example, if the file is screenshot.png, the newly exported file's display name will be * screenshot(1).png. */ private final boolean mAllowOverwrite; Task(ContentResolver resolver, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, CompressFormat format, int quality, boolean publish, UserHandle owner, FeatureFlags flags, int displayId) { FeatureFlags flags, String fileName) { this(resolver, requestId, bitmap, captureTime, format, quality, publish, owner, flags, fileName, false /* allowOverwrite */); } Task(ContentResolver resolver, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime, CompressFormat format, int quality, boolean publish, UserHandle owner, FeatureFlags flags, String fileName, boolean allowOverwrite) { mResolver = resolver; mRequestId = requestId; mBitmap = bitmap; Loading @@ -230,9 +301,10 @@ public class ImageExporter { mFormat = format; mQuality = quality; mOwner = owner; mFileName = createFilename(mCaptureTime, mFormat, displayId); mFileName = fileName; mPublish = publish; mFlags = flags; mAllowOverwrite = allowOverwrite; } public Result execute() throws ImageExportException, InterruptedException { Loading @@ -246,7 +318,8 @@ public class ImageExporter { start = Instant.now(); } uri = createEntry(mResolver, mFormat, mCaptureTime, mFileName, mOwner, mFlags); uri = createEntry(mResolver, mFormat, mCaptureTime, mFileName, mOwner, mFlags, mAllowOverwrite); throwIfInterrupted(); writeImage(mResolver, mBitmap, mFormat, mQuality, uri); Loading Loading @@ -290,21 +363,51 @@ public class ImageExporter { } private static Uri createEntry(ContentResolver resolver, CompressFormat format, ZonedDateTime time, String fileName, UserHandle owner, FeatureFlags flags) throws ImageExportException { ZonedDateTime time, String fileName, UserHandle owner, FeatureFlags flags, boolean allowOverwrite) throws ImageExportException { Trace.beginSection("ImageExporter_createEntry"); try { final ContentValues values = createMetadata(time, format, fileName); Uri baseUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; Uri uriWithUserId = ContentProvider.maybeAddUserId(baseUri, owner.getIdentifier()); Uri uri = resolver.insert(uriWithUserId, values); if (uri == null) { Uri resultUri = null; if (allowOverwrite) { // Query to check if there is existing file with the same name and format. Cursor cursor = resolver.query( baseUri, null, MediaStore.MediaColumns.DISPLAY_NAME + "=? AND " + MediaStore.MediaColumns.MIME_TYPE + "=?", new String[]{fileName, getMimeType(format)}, null /* CancellationSignal */); if (cursor != null) { if (cursor.moveToFirst()) { // If there is existing file, update the meta-data of its entry. The Entry's // corresponding uri is composed of volume base-uri(or with user-id) and // its row's unique ID. int idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID); resultUri = ContentUris.withAppendedId(uriWithUserId, cursor.getLong(idIndex)); resolver.update(resultUri, values, null); Log.d(TAG, "Updated existing URI: " + resultUri); } cursor.close(); } } if (resultUri == null) { // If file overwriting is disabled or there is no existing file to overwrite, create // and insert a new entry. resultUri = resolver.insert(uriWithUserId, values); Log.d(TAG, "Inserted new URI: " + resultUri); } if (resultUri == null) { throw new ImageExportException(RESOLVER_INSERT_RETURNED_NULL); } Log.d(TAG, "Inserted new URI: " + uri); return uri; return resultUri; } finally { Trace.endSection(); } Loading Loading @@ -383,6 +486,11 @@ public class ImageExporter { fileExtension(format)); } @VisibleForTesting static String createSystemFileDisplayName(String originalDisplayName, CompressFormat format) { return originalDisplayName + "." + fileExtension(format); } static ContentValues createMetadata(ZonedDateTime captureTime, CompressFormat format, String fileName) { ContentValues values = new ContentValues(); Loading
packages/SystemUI/tests/src/com/android/systemui/screenshot/ImageExporterTest.java +26 −0 Original line number Diff line number Diff line Loading @@ -16,6 +16,8 @@ package com.android.systemui.screenshot; import static com.android.systemui.screenshot.ImageExporter.createSystemFileDisplayName; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; Loading Loading @@ -181,6 +183,30 @@ public class ImageExporterTest extends SysuiTestCase { } } @Test public void testImageExport_customizedFileName() throws ExecutionException, InterruptedException { // This test only asserts the file name for the case when user specifies a file name, // instead of using the auto-generated name by ImageExporter::createFileName. Other // metadata are not affected by the specified file name. final String customizedFileName = "customized_file_name"; ContentResolver contentResolver = mContext.getContentResolver(); ImageExporter exporter = new ImageExporter(contentResolver, mFeatureFlags); UUID requestId = UUID.fromString("3c11da99-9284-4863-b1d5-6f3684976814"); Bitmap original = createCheckerBitmap(10, 10, 10); ListenableFuture<ImageExporter.Result> direct = exporter.export(DIRECT_EXECUTOR, requestId, original, CAPTURE_TIME, Process.myUserHandle(), customizedFileName); assertTrue("future should be done", direct.isDone()); assertFalse("future should not be canceled", direct.isCancelled()); ImageExporter.Result result = direct.get(); assertEquals("Filename should contain the correct filename", createSystemFileDisplayName(customizedFileName, CompressFormat.PNG), result.fileName); } @Test public void testMediaStoreMetadata() { String name = ImageExporter.createFilename(CAPTURE_TIME, CompressFormat.PNG, Loading