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

Commit 3cc7c97d authored by TreeHugger Robot's avatar TreeHugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Screenshots: ImageExporter"

parents 1f5c6134 c2c5075b
Loading
Loading
Loading
Loading
+360 −0
Original line number Diff line number Diff line
/*
 * 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%<tm%<td-%<tH%<tM%<tS.%2$s";
    private static final String SCREENSHOTS_PATH = Environment.DIRECTORY_PICTURES
            + File.separator + Environment.DIRECTORY_SCREENSHOTS;

    private static final String RESOLVER_INSERT_RETURNED_NULL =
            "ContentResolver#insert returned null.";
    private static final String RESOLVER_OPEN_FILE_RETURNED_NULL =
            "ContentResolver#openFile returned null.";
    private static final String RESOLVER_OPEN_FILE_EXCEPTION =
            "ContentResolver#openFile threw an exception.";
    private static final String OPEN_OUTPUT_STREAM_EXCEPTION =
            "ContentResolver#openOutputStream threw an exception.";
    private static final String EXIF_READ_EXCEPTION =
            "ExifInterface threw an exception reading from the file descriptor.";
    private static final String EXIF_WRITE_EXCEPTION =
            "ExifInterface threw an exception writing to the file descriptor.";
    private static final String RESOLVER_UPDATE_ZERO_ROWS =
            "Failed to publishEntry. ContentResolver#update reported no rows updated.";
    private static final String IMAGE_COMPRESS_RETURNED_FALSE =
            "Bitmap.compress returned false. (Failure unknown)";

    private final ContentResolver mResolver;
    private CompressFormat mCompressFormat = CompressFormat.PNG;
    private int mQuality = 100;

    @Inject
    ImageExporter(ContentResolver resolver) {
        mResolver = resolver;
    }

    /**
     * Adjusts the output image format. This also determines extension of the filename created. The
     * default is {@link CompressFormat#PNG PNG}.
     *
     * @see CompressFormat
     *
     * @param format the image format for export
     */
    void setFormat(CompressFormat format) {
        mCompressFormat = format;
    }

    /**
     * Sets the quality format. The exact meaning is dependent on the {@link CompressFormat} used.
     *
     * @param quality the 'quality' level between 0 and 100
     */
    void setQuality(@IntRange(from = 0, to = 100) int quality) {
        mQuality = quality;
    }

    /**
     * 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<Uri> 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<Uri> 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);
        }
    }
}
+180 −0
Original line number Diff line number Diff line
/*
 * 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<Uri> 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;
    }
}