Screenshots: use ImageExporter

Use a single tested module for image export steps which is
now shared between the standard flow and the long screenshot
feature.

Test: ImageExporterTest android.assist.cts.ScreenshotTest
      ScreenshotNotificationSmartActionsTest
Bug: 175744393
Fix: 175744393
Change-Id: Id08716736edb4ac1d00c13224e7763e8ca53c979
This commit is contained in:
Mark Renouf
2020-12-16 01:11:04 -05:00
parent 3ef6970d1a
commit f167275f60
6 changed files with 60 additions and 223 deletions

View File

@@ -306,10 +306,6 @@ class ImageExporter {
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) {

View File

@@ -27,8 +27,6 @@ import android.app.PendingIntent;
import android.content.ClipData;
import android.content.ClipDescription;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
@@ -36,43 +34,26 @@ import android.graphics.Bitmap;
import android.graphics.drawable.Icon;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
import android.os.UserHandle;
import android.os.UserManager;
import android.provider.DeviceConfig;
import android.provider.MediaStore;
import android.provider.MediaStore.MediaColumns;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Log;
import androidx.exifinterface.media.ExifInterface;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
import com.android.systemui.R;
import com.android.systemui.SystemUIFactory;
import com.android.systemui.screenshot.ScreenshotController.SavedImageData.ShareTransition;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -99,14 +80,17 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
private final boolean mSmartActionsEnabled;
private final Random mRandom = new Random();
private final Supplier<ShareTransition> mSharedElementTransition;
private final ImageExporter mImageExporter;
SaveImageInBackgroundTask(Context context, ScreenshotSmartActions screenshotSmartActions,
SaveImageInBackgroundTask(Context context, ImageExporter exporter,
ScreenshotSmartActions screenshotSmartActions,
ScreenshotController.SaveImageInBackgroundData data,
Supplier<ShareTransition> sharedElementTransition) {
mContext = context;
mScreenshotSmartActions = screenshotSmartActions;
mImageData = new ScreenshotController.SavedImageData();
mSharedElementTransition = sharedElementTransition;
mImageExporter = exporter;
// Prepare all the output metadata
mParams = data;
@@ -139,90 +123,17 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
}
Thread.currentThread().setPriority(Thread.MAX_PRIORITY);
ContentResolver resolver = mContext.getContentResolver();
Bitmap image = mParams.image;
try {
// Save the screenshot to the MediaStore
final ContentValues values = new ContentValues();
values.put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES
+ File.separator + Environment.DIRECTORY_SCREENSHOTS);
values.put(MediaColumns.DISPLAY_NAME, mImageFileName);
values.put(MediaColumns.MIME_TYPE, "image/png");
values.put(MediaColumns.DATE_ADDED, mImageTime / 1000);
values.put(MediaColumns.DATE_MODIFIED, mImageTime / 1000);
values.put(MediaColumns.DATE_EXPIRES, (mImageTime + DateUtils.DAY_IN_MILLIS) / 1000);
values.put(MediaColumns.IS_PENDING, 1);
final Uri uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
// Call synchronously here since already on a background thread.
Uri uri = mImageExporter.export(Runnable::run, image).get();
CompletableFuture<List<Notification.Action>> smartActionsFuture =
mScreenshotSmartActions.getSmartActionsFuture(
mScreenshotId, uri, image, mSmartActionsProvider,
mSmartActionsEnabled, getUserHandle(mContext));
try {
// First, write the actual data for our screenshot
try (OutputStream out = resolver.openOutputStream(uri)) {
if (DEBUG_STORAGE) {
Log.d(TAG, "Compressing PNG:"
+ " w=" + image.getWidth() + " h=" + image.getHeight());
}
if (!image.compress(Bitmap.CompressFormat.PNG, 100, out)) {
if (DEBUG_STORAGE) {
Log.d(TAG, "Bitmap.compress returned false");
}
throw new IOException("Failed to compress");
}
if (DEBUG_STORAGE) {
Log.d(TAG, "Done compressing PNG");
}
}
// Next, write metadata to help index the screenshot
try (ParcelFileDescriptor pfd = resolver.openFile(uri, "rw", null)) {
final ExifInterface exif = new ExifInterface(pfd.getFileDescriptor());
exif.setAttribute(ExifInterface.TAG_SOFTWARE,
"Android " + Build.DISPLAY);
exif.setAttribute(ExifInterface.TAG_IMAGE_WIDTH,
Integer.toString(image.getWidth()));
exif.setAttribute(ExifInterface.TAG_IMAGE_LENGTH,
Integer.toString(image.getHeight()));
final ZonedDateTime time = ZonedDateTime.ofInstant(
Instant.ofEpochMilli(mImageTime), ZoneId.systemDefault());
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL,
DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").format(time));
exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL,
DateTimeFormatter.ofPattern("SSS").format(time));
if (Objects.equals(time.getOffset(), ZoneOffset.UTC)) {
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+00:00");
} else {
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL,
DateTimeFormatter.ofPattern("XXX").format(time));
}
if (DEBUG_STORAGE) {
Log.d(TAG, "Writing EXIF metadata");
}
exif.saveAttributes();
}
// Everything went well above, publish it!
values.clear();
values.put(MediaColumns.IS_PENDING, 0);
values.putNull(MediaColumns.DATE_EXPIRES);
resolver.update(uri, values, null, null);
if (DEBUG_STORAGE) {
Log.d(TAG, "Completed writing to ContentManager");
}
} catch (Exception e) {
resolver.delete(uri, null);
throw e;
}
List<Notification.Action> smartActions = new ArrayList<>();
if (mSmartActionsEnabled) {
int timeoutMs = DeviceConfig.getInt(

View File

@@ -78,10 +78,13 @@ import com.android.internal.logging.UiEventLogger;
import com.android.internal.policy.PhoneWindow;
import com.android.settingslib.applications.InterestingConfigChanges;
import com.android.systemui.R;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.screenshot.ScreenshotController.SavedImageData.ShareTransition;
import com.android.systemui.util.DeviceConfigProxy;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
import java.util.function.Supplier;
@@ -166,6 +169,9 @@ public class ScreenshotController {
private final ScreenshotNotificationsController mNotificationsController;
private final ScreenshotSmartActions mScreenshotSmartActions;
private final UiEventLogger mUiEventLogger;
private final ImageExporter mImageExporter;
private final Executor mMainExecutor;
private final Executor mBgExecutor;
private final WindowManager mWindowManager;
private final WindowManager.LayoutParams mWindowLayoutParams;
@@ -219,11 +225,17 @@ public class ScreenshotController {
ScreenshotNotificationsController screenshotNotificationsController,
ScrollCaptureClient scrollCaptureClient,
UiEventLogger uiEventLogger,
DeviceConfigProxy configProxy) {
DeviceConfigProxy configProxy,
ImageExporter imageExporter,
@Main Executor mainExecutor,
@Background Executor bgExecutor) {
mScreenshotSmartActions = screenshotSmartActions;
mNotificationsController = screenshotNotificationsController;
mScrollCaptureClient = scrollCaptureClient;
mUiEventLogger = uiEventLogger;
mImageExporter = imageExporter;
mMainExecutor = mainExecutor;
mBgExecutor = bgExecutor;
final DisplayManager dm = requireNonNull(context.getSystemService(DisplayManager.class));
final Display display = dm.getDisplay(DEFAULT_DISPLAY);
@@ -502,9 +514,10 @@ public class ScreenshotController {
}
}
private void runScrollCapture(ScrollCaptureClient.Connection connection,
Runnable after) {
new ScrollCaptureController(mContext, connection).run(after);
private void runScrollCapture(ScrollCaptureClient.Connection connection, Runnable andThen) {
ScrollCaptureController controller = new ScrollCaptureController(mContext, connection,
mMainExecutor, mBgExecutor, mImageExporter);
controller.run(andThen);
}
/**
@@ -604,8 +617,8 @@ public class ScreenshotController {
mSaveInBgTask.setActionsReadyListener(this::logSuccessOnActionsReady);
}
mSaveInBgTask = new SaveImageInBackgroundTask(mContext, mScreenshotSmartActions, data,
getShareTransitionSupplier());
mSaveInBgTask = new SaveImageInBackgroundTask(mContext, mImageExporter,
mScreenshotSmartActions, data, getShareTransitionSupplier());
mSaveInBgTask.execute();
}

View File

@@ -18,8 +18,6 @@ package com.android.systemui.screenshot;
import static android.graphics.ColorSpace.Named.SRGB;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
@@ -27,33 +25,19 @@ import android.graphics.Canvas;
import android.graphics.ColorSpace;
import android.graphics.Picture;
import android.graphics.Rect;
import android.media.ExifInterface;
import android.media.Image;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.os.UserHandle;
import android.provider.MediaStore;
import android.text.format.DateUtils;
import android.util.Log;
import android.widget.Toast;
import com.android.systemui.screenshot.ScrollCaptureClient.Connection;
import com.android.systemui.screenshot.ScrollCaptureClient.Session;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.sql.Date;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
import java.util.UUID;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
/**
@@ -67,11 +51,19 @@ public class ScrollCaptureController {
private final Connection mConnection;
private final Context mContext;
private final Executor mUiExecutor;
private final Executor mBgExecutor;
private final ImageExporter mImageExporter;
private Picture mPicture;
public ScrollCaptureController(Context context, Connection connection) {
public ScrollCaptureController(Context context, Connection connection, Executor uiExecutor,
Executor bgExecutor, ImageExporter exporter) {
mContext = context;
mConnection = connection;
mUiExecutor = uiExecutor;
mBgExecutor = bgExecutor;
mImageExporter = exporter;
}
/**
@@ -83,7 +75,7 @@ public class ScrollCaptureController {
mConnection.start(MAX_PAGES, (session) -> startCapture(session, after));
}
private void startCapture(Session session, final Runnable after) {
private void startCapture(Session session, final Runnable onDismiss) {
Rect requestRect = new Rect(0, 0,
session.getMaxTileWidth(), session.getMaxTileHeight());
Consumer<ScrollCaptureClient.CaptureResult> consumer =
@@ -101,20 +93,11 @@ public class ScrollCaptureController {
}
if (emptyFrame || mFrameCount >= MAX_PAGES
|| requestRect.bottom > MAX_HEIGHT) {
Uri uri = null;
if (mPicture != null) {
// This is probably on a binder thread right now ¯\_(ツ)_/¯
uri = writeImage(Bitmap.createBitmap(mPicture));
// Release those buffers!
mPicture.close();
}
if (uri != null) {
launchViewer(uri);
exportToFile(mPicture, session, onDismiss);
} else {
Toast.makeText(mContext, "Failed to create tall screenshot",
Toast.LENGTH_SHORT).show();
session.end(onDismiss);
}
session.end(after); // end session, close connection, after.run()
return;
}
requestRect.offset(0, session.getMaxTileHeight());
@@ -126,6 +109,22 @@ public class ScrollCaptureController {
session.requestTile(requestRect, consumer);
};
void exportToFile(Picture picture, Session session, Runnable afterEnd) {
mImageExporter.setFormat(Bitmap.CompressFormat.PNG);
mImageExporter.setQuality(6);
ListenableFuture<Uri> future =
mImageExporter.export(mBgExecutor, Bitmap.createBitmap(picture));
future.addListener(() -> {
picture.close(); // release resources
try {
launchViewer(future.get());
} catch (InterruptedException | ExecutionException e) {
Toast.makeText(mContext, "Failed to write image", Toast.LENGTH_SHORT).show();
Log.e(TAG, "Error storing screenshot to media store", e.getCause());
}
session.end(afterEnd); // end session, close connection, afterEnd.run()
}, mUiExecutor);
}
/**
* Combine the top {@link Picture} with an {@link Image} by appending the image directly
@@ -162,79 +161,6 @@ public class ScrollCaptureController {
return combined;
}
Uri writeImage(Bitmap image) {
ContentResolver resolver = mContext.getContentResolver();
long mImageTime = System.currentTimeMillis();
String imageDate = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date(mImageTime));
String mImageFileName = String.format("tall_Screenshot_%s.png", imageDate);
String mScreenshotId = String.format("Screenshot_%s", UUID.randomUUID());
try {
// Save the screenshot to the MediaStore
final ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES
+ File.separator + Environment.DIRECTORY_SCREENSHOTS);
values.put(MediaStore.MediaColumns.DISPLAY_NAME, mImageFileName);
values.put(MediaStore.MediaColumns.MIME_TYPE, "image/png");
values.put(MediaStore.MediaColumns.DATE_ADDED, mImageTime / 1000);
values.put(MediaStore.MediaColumns.DATE_MODIFIED, mImageTime / 1000);
values.put(
MediaStore.MediaColumns.DATE_EXPIRES,
(mImageTime + DateUtils.DAY_IN_MILLIS) / 1000);
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
final Uri uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
values);
try {
try (OutputStream out = resolver.openOutputStream(uri)) {
if (!image.compress(Bitmap.CompressFormat.PNG, 100, out)) {
throw new IOException("Failed to compress");
}
}
// Next, write metadata to help index the screenshot
try (ParcelFileDescriptor pfd = resolver.openFile(uri, "rw", null)) {
final ExifInterface exif = new ExifInterface(pfd.getFileDescriptor());
exif.setAttribute(ExifInterface.TAG_SOFTWARE,
"Android " + Build.DISPLAY);
exif.setAttribute(ExifInterface.TAG_IMAGE_WIDTH,
Integer.toString(image.getWidth()));
exif.setAttribute(ExifInterface.TAG_IMAGE_LENGTH,
Integer.toString(image.getHeight()));
final ZonedDateTime time = ZonedDateTime.ofInstant(
Instant.ofEpochMilli(mImageTime), ZoneId.systemDefault());
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL,
DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").format(time));
exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL,
DateTimeFormatter.ofPattern("SSS").format(time));
if (Objects.equals(time.getOffset(), ZoneOffset.UTC)) {
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+00:00");
} else {
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL,
DateTimeFormatter.ofPattern("XXX").format(time));
}
exif.saveAttributes();
}
// Everything went well above, publish it!
values.clear();
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
values.putNull(MediaStore.MediaColumns.DATE_EXPIRES);
resolver.update(uri, values, null, null);
return uri;
} catch (Exception e) {
resolver.delete(uri, null);
throw e;
}
} catch (Exception e) {
Log.e(TAG, "unable to save screenshot", e);
}
return null;
}
void launchViewer(Uri uri) {
Intent editIntent = new Intent(Intent.ACTION_VIEW);
editIntent.setType("image/png");

View File

@@ -85,8 +85,6 @@ public class ImageExporterTest extends SysuiTestCase {
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
@@ -127,13 +125,6 @@ public class ImageExporterTest extends SysuiTestCase {
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) {

View File

@@ -176,7 +176,7 @@ public class ScreenshotNotificationSmartActionsTest extends SysuiTestCase {
data.finisher = null;
data.mActionsReadyListener = null;
SaveImageInBackgroundTask task =
new SaveImageInBackgroundTask(mContext, mScreenshotSmartActions, data,
new SaveImageInBackgroundTask(mContext, null, mScreenshotSmartActions, data,
ShareTransition::new);
Notification.Action shareAction = task.createShareAction(mContext, mContext.getResources(),
@@ -204,7 +204,7 @@ public class ScreenshotNotificationSmartActionsTest extends SysuiTestCase {
data.finisher = null;
data.mActionsReadyListener = null;
SaveImageInBackgroundTask task =
new SaveImageInBackgroundTask(mContext, mScreenshotSmartActions, data,
new SaveImageInBackgroundTask(mContext, null, mScreenshotSmartActions, data,
ShareTransition::new);
Notification.Action editAction = task.createEditAction(mContext, mContext.getResources(),
@@ -232,7 +232,7 @@ public class ScreenshotNotificationSmartActionsTest extends SysuiTestCase {
data.finisher = null;
data.mActionsReadyListener = null;
SaveImageInBackgroundTask task =
new SaveImageInBackgroundTask(mContext, mScreenshotSmartActions, data,
new SaveImageInBackgroundTask(mContext, null, mScreenshotSmartActions, data,
ShareTransition::new);
Notification.Action deleteAction = task.createDeleteAction(mContext,