Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit f167275f authored by Mark Renouf's avatar Mark Renouf
Browse files

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
parent 3ef6970d
Loading
Loading
Loading
Loading
+0 −4
Original line number Diff line number Diff line
@@ -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) {
+6 −95
Original line number Diff line number Diff line
@@ -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(
+19 −6
Original line number Diff line number Diff line
@@ -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();
    }

+32 −106
Original line number Diff line number Diff line
@@ -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");
+0 −9
Original line number Diff line number Diff line
@@ -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) {
Loading