blob: 55602a98b8c57ea5679c2f7411a9604cbff130d8 [file] [log] [blame]
/*
* 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.android.internal.annotations.VisibleForTesting;
import com.google.common.util.concurrent.ListenableFuture;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
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.UUID;
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 publish entry. 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;
}
/**
* Writes the given Bitmap to outputFile.
*/
ListenableFuture<File> exportToRawFile(Executor executor, Bitmap bitmap,
final File outputFile) {
return CallbackToFutureAdapter.getFuture(
(completer) -> {
executor.execute(() -> {
try (FileOutputStream stream = new FileOutputStream(outputFile)) {
bitmap.compress(mCompressFormat, mQuality, stream);
completer.set(outputFile);
} catch (IOException e) {
if (outputFile.exists()) {
//noinspection ResultOfMethodCallIgnored
outputFile.delete();
}
completer.setException(e);
}
});
return "Bitmap#compress";
}
);
}
/**
* 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<Result> export(Executor executor, UUID requestId, Bitmap bitmap) {
return export(executor, requestId, bitmap, ZonedDateTime.now());
}
/**
* 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) {
final Task task = new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat,
mQuality, /* publish */ true);
return CallbackToFutureAdapter.getFuture(
(completer) -> {
executor.execute(() -> {
try {
completer.set(task.execute());
} catch (ImageExportException | InterruptedException e) {
completer.setException(e);
}
});
return task;
}
);
}
/**
* Delete the entry.
*
* @param executor the thread for execution
* @param uri the uri of the image to publish
*
* @return a listenable future result
*/
ListenableFuture<Result> delete(Executor executor, Uri uri) {
return CallbackToFutureAdapter.getFuture((completer) -> {
executor.execute(() -> {
mResolver.delete(uri, null);
Result result = new Result();
result.uri = uri;
result.deleted = true;
completer.set(result);
});
return "ContentResolver#delete";
});
}
static class Result {
Uri uri;
UUID requestId;
String fileName;
long timestamp;
CompressFormat format;
boolean published;
boolean deleted;
@Override
public String toString() {
final StringBuilder sb = new StringBuilder("Result{");
sb.append("uri=").append(uri);
sb.append(", requestId=").append(requestId);
sb.append(", fileName='").append(fileName).append('\'');
sb.append(", timestamp=").append(timestamp);
sb.append(", format=").append(format);
sb.append(", published=").append(published);
sb.append(", deleted=").append(deleted);
sb.append('}');
return sb.toString();
}
}
private static class Task {
private final ContentResolver mResolver;
private final UUID mRequestId;
private final Bitmap mBitmap;
private final ZonedDateTime mCaptureTime;
private final CompressFormat mFormat;
private final int mQuality;
private final String mFileName;
private final boolean mPublish;
Task(ContentResolver resolver, UUID requestId, Bitmap bitmap, ZonedDateTime captureTime,
CompressFormat format, int quality, boolean publish) {
mResolver = resolver;
mRequestId = requestId;
mBitmap = bitmap;
mCaptureTime = captureTime;
mFormat = format;
mQuality = quality;
mFileName = createFilename(mCaptureTime, mFormat);
mPublish = publish;
}
public Result execute() throws ImageExportException, InterruptedException {
Trace.beginSection("ImageExporter_execute");
Uri uri = null;
Instant start = null;
Result result = new Result();
try {
if (LogConfig.DEBUG_STORAGE) {
Log.d(TAG, "image export started");
start = Instant.now();
}
uri = createEntry(mResolver, mFormat, mCaptureTime, mFileName);
throwIfInterrupted();
writeImage(mResolver, mBitmap, mFormat, mQuality, uri);
throwIfInterrupted();
int width = mBitmap.getWidth();
int height = mBitmap.getHeight();
writeExif(mResolver, uri, mRequestId, width, height, mCaptureTime);
throwIfInterrupted();
if (mPublish) {
publishEntry(mResolver, uri);
result.published = true;
}
result.timestamp = mCaptureTime.toInstant().toEpochMilli();
result.requestId = mRequestId;
result.uri = uri;
result.fileName = mFileName;
result.format = mFormat;
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 result;
}
@Override
public String toString() {
return "export [" + mBitmap + "] to [" + mFormat + "] at quality " + mQuality;
}
}
private static Uri createEntry(ContentResolver resolver, CompressFormat format,
ZonedDateTime time, String fileName) throws ImageExportException {
Trace.beginSection("ImageExporter_createEntry");
try {
final ContentValues values = createMetadata(time, format, fileName);
Uri uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
if (uri == null) {
throw new ImageExportException(RESOLVER_INSERT_RETURNED_NULL);
}
return uri;
} finally {
Trace.endSection();
}
}
private static void writeImage(ContentResolver resolver, Bitmap bitmap, CompressFormat format,
int quality, Uri contentUri) throws ImageExportException {
Trace.beginSection("ImageExporter_writeImage");
try (OutputStream out = resolver.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();
}
}
private static void writeExif(ContentResolver resolver, Uri uri, UUID requestId, int width,
int height, ZonedDateTime captureTime) throws ImageExportException {
Trace.beginSection("ImageExporter_writeExif");
ParcelFileDescriptor pfd = null;
try {
pfd = resolver.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, requestId, 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();
}
}
private static void publishEntry(ContentResolver resolver, 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 = resolver.update(uri, values, /* extras */ null);
if (rowsUpdated < 1) {
throw new ImageExportException(RESOLVER_UPDATE_ZERO_ROWS);
}
} finally {
Trace.endSection();
}
}
@VisibleForTesting
static String createFilename(ZonedDateTime time, CompressFormat format) {
return String.format(FILENAME_PATTERN, time, fileExtension(format));
}
static ContentValues createMetadata(ZonedDateTime captureTime, CompressFormat format,
String fileName) {
ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.RELATIVE_PATH, SCREENSHOTS_PATH);
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
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, UUID uniqueId, int width, int height,
ZonedDateTime captureTime) {
exif.setAttribute(ExifInterface.TAG_IMAGE_UNIQUE_ID, uniqueId.toString());
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);
}
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);
}
}
}