Merge "Implements initial long screenshot preview UI" into sc-dev
This commit is contained in:
committed by
Android (Google) Code Review
commit
e07bbc9889
84
packages/SystemUI/res/layout/long_screenshot.xml
Normal file
84
packages/SystemUI/res/layout/long_screenshot.xml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright (C) 2021 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.
|
||||||
|
-->
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:background="?android:colorBackgroundFloating"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/preview"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginVertical="8dp"
|
||||||
|
android:layout_marginHorizontal="48dp"
|
||||||
|
android:adjustViewBounds="true"
|
||||||
|
app:layout_constrainedHeight="true"
|
||||||
|
app:layout_constrainedWidth="true"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/guideline"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:background="?android:colorBackground"
|
||||||
|
tools:minHeight="100dp"
|
||||||
|
tools:minWidth="100dp" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Guideline
|
||||||
|
android:id="@+id/guideline"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
app:layout_constraintGuide_percent="0.9" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/close"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:text="Close"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/edit"
|
||||||
|
app:layout_constraintHorizontal_bias="0.5"
|
||||||
|
app:layout_constraintHorizontal_chainStyle="packed"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/guideline" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/edit"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:text="Edit"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/share"
|
||||||
|
app:layout_constraintHorizontal_bias="0.5"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/close"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/guideline" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/share"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:text="Share"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0.5"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/edit"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/guideline" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ import static android.graphics.ColorSpace.Named.SRGB;
|
|||||||
|
|
||||||
import static java.util.Objects.requireNonNull;
|
import static java.util.Objects.requireNonNull;
|
||||||
|
|
||||||
|
import android.annotation.NonNull;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.graphics.ColorSpace;
|
import android.graphics.ColorSpace;
|
||||||
import android.graphics.RecordingCanvas;
|
import android.graphics.RecordingCanvas;
|
||||||
@@ -50,14 +51,13 @@ class ImageTile implements AutoCloseable {
|
|||||||
* @param image an image containing a hardware buffer
|
* @param image an image containing a hardware buffer
|
||||||
* @param location the captured area represented by image tile (virtual coordinates)
|
* @param location the captured area represented by image tile (virtual coordinates)
|
||||||
*/
|
*/
|
||||||
ImageTile(Image image, Rect location) {
|
ImageTile(@NonNull Image image, @NonNull Rect location) {
|
||||||
mImage = requireNonNull(image, "image");
|
mImage = requireNonNull(image, "image");
|
||||||
mLocation = location;
|
mLocation = requireNonNull(location);
|
||||||
|
|
||||||
requireNonNull(mImage.getHardwareBuffer(), "image must be a hardware image");
|
requireNonNull(mImage.getHardwareBuffer(), "image must be a hardware image");
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderNode getDisplayList() {
|
synchronized RenderNode getDisplayList() {
|
||||||
if (mNode == null) {
|
if (mNode == null) {
|
||||||
mNode = new RenderNode("Tile{" + Integer.toHexString(mImage.hashCode()) + "}");
|
mNode = new RenderNode("Tile{" + Integer.toHexString(mImage.hashCode()) + "}");
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,6 @@ class ImageTile implements AutoCloseable {
|
|||||||
mNode.setPosition(0, 0, w, h);
|
mNode.setPosition(0, 0, w, h);
|
||||||
|
|
||||||
RecordingCanvas canvas = mNode.beginRecording(w, h);
|
RecordingCanvas canvas = mNode.beginRecording(w, h);
|
||||||
Rect rect = new Rect(0, 0, w, h);
|
|
||||||
canvas.save();
|
canvas.save();
|
||||||
canvas.clipRect(0, 0, mLocation.right, mLocation.bottom);
|
canvas.clipRect(0, 0, mLocation.right, mLocation.bottom);
|
||||||
canvas.drawBitmap(Bitmap.wrapHardwareBuffer(mImage.getHardwareBuffer(), COLOR_SPACE),
|
canvas.drawBitmap(Bitmap.wrapHardwareBuffer(mImage.getHardwareBuffer(), COLOR_SPACE),
|
||||||
@@ -100,9 +99,11 @@ class ImageTile implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public synchronized void close() {
|
||||||
mImage.close();
|
mImage.close();
|
||||||
mNode.discardDisplayList();
|
if (mNode != null) {
|
||||||
|
mNode.discardDisplayList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -603,7 +603,18 @@ public class ScreenshotController {
|
|||||||
cancelTimeout();
|
cancelTimeout();
|
||||||
ScrollCaptureController controller = new ScrollCaptureController(mContext, connection,
|
ScrollCaptureController controller = new ScrollCaptureController(mContext, connection,
|
||||||
mMainExecutor, mBgExecutor, mImageExporter);
|
mMainExecutor, mBgExecutor, mImageExporter);
|
||||||
controller.start(/* onDismiss */ () -> dismissScreenshot(false));
|
controller.attach(mWindow);
|
||||||
|
controller.start(new TakeScreenshotService.RequestCallback() {
|
||||||
|
@Override
|
||||||
|
public void reportError() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFinish() {
|
||||||
|
Log.d(TAG, "onFinish from ScrollCaptureController");
|
||||||
|
finishDismiss();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,16 +16,24 @@
|
|||||||
|
|
||||||
package com.android.systemui.screenshot;
|
package com.android.systemui.screenshot;
|
||||||
|
|
||||||
|
import android.annotation.IdRes;
|
||||||
|
import android.annotation.UiThread;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.graphics.Bitmap;
|
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.UserHandle;
|
import android.os.UserHandle;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.widget.Toast;
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewTreeObserver.InternalInsetsInfo;
|
||||||
|
import android.view.ViewTreeObserver.OnComputeInternalInsetsListener;
|
||||||
|
import android.view.Window;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
|
||||||
|
import com.android.systemui.R;
|
||||||
import com.android.systemui.screenshot.ScrollCaptureClient.Connection;
|
import com.android.systemui.screenshot.ScrollCaptureClient.Connection;
|
||||||
import com.android.systemui.screenshot.ScrollCaptureClient.Session;
|
import com.android.systemui.screenshot.ScrollCaptureClient.Session;
|
||||||
|
import com.android.systemui.screenshot.TakeScreenshotService.RequestCallback;
|
||||||
|
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
||||||
@@ -38,11 +46,9 @@ import java.util.function.Consumer;
|
|||||||
/**
|
/**
|
||||||
* Interaction controller between the UI and ScrollCaptureClient.
|
* Interaction controller between the UI and ScrollCaptureClient.
|
||||||
*/
|
*/
|
||||||
public class ScrollCaptureController {
|
public class ScrollCaptureController implements OnComputeInternalInsetsListener {
|
||||||
private static final String TAG = "ScrollCaptureController";
|
private static final String TAG = "ScrollCaptureController";
|
||||||
|
|
||||||
private static final boolean USE_TILED_IMAGE = false;
|
|
||||||
|
|
||||||
public static final int MAX_PAGES = 5;
|
public static final int MAX_PAGES = 5;
|
||||||
public static final int MAX_HEIGHT = 12000;
|
public static final int MAX_HEIGHT = 12000;
|
||||||
|
|
||||||
@@ -53,9 +59,19 @@ public class ScrollCaptureController {
|
|||||||
private final Executor mBgExecutor;
|
private final Executor mBgExecutor;
|
||||||
private final ImageExporter mImageExporter;
|
private final ImageExporter mImageExporter;
|
||||||
private final ImageTileSet mImageTileSet;
|
private final ImageTileSet mImageTileSet;
|
||||||
|
private final LayoutInflater mLayoutInflater;
|
||||||
|
|
||||||
private ZonedDateTime mCaptureTime;
|
private ZonedDateTime mCaptureTime;
|
||||||
private UUID mRequestId;
|
private UUID mRequestId;
|
||||||
|
private RequestCallback mCallback;
|
||||||
|
private Window mWindow;
|
||||||
|
private ImageView mPreview;
|
||||||
|
private View mClose;
|
||||||
|
private View mEdit;
|
||||||
|
private View mShare;
|
||||||
|
|
||||||
|
private ListenableFuture<ImageExporter.Result> mExportFuture;
|
||||||
|
private Runnable mPendingAction;
|
||||||
|
|
||||||
public ScrollCaptureController(Context context, Connection connection, Executor uiExecutor,
|
public ScrollCaptureController(Context context, Connection connection, Executor uiExecutor,
|
||||||
Executor bgExecutor, ImageExporter exporter) {
|
Executor bgExecutor, ImageExporter exporter) {
|
||||||
@@ -65,20 +81,126 @@ public class ScrollCaptureController {
|
|||||||
mBgExecutor = bgExecutor;
|
mBgExecutor = bgExecutor;
|
||||||
mImageExporter = exporter;
|
mImageExporter = exporter;
|
||||||
mImageTileSet = new ImageTileSet();
|
mImageTileSet = new ImageTileSet();
|
||||||
|
mLayoutInflater = mContext.getSystemService(LayoutInflater.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param window the window to display the preview
|
||||||
|
*/
|
||||||
|
public void attach(Window window) {
|
||||||
|
mWindow = window;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run scroll capture!
|
* Run scroll capture!
|
||||||
*
|
*
|
||||||
* @param after action to take after the flow is complete
|
* @param callback request callback to report back to the service
|
||||||
*/
|
*/
|
||||||
public void start(final Runnable after) {
|
public void start(RequestCallback callback) {
|
||||||
mCaptureTime = ZonedDateTime.now();
|
mCaptureTime = ZonedDateTime.now();
|
||||||
mRequestId = UUID.randomUUID();
|
mRequestId = UUID.randomUUID();
|
||||||
mConnection.start((session) -> startCapture(session, after));
|
mCallback = callback;
|
||||||
|
|
||||||
|
setContentView(R.layout.long_screenshot);
|
||||||
|
mWindow.getDecorView().getViewTreeObserver()
|
||||||
|
.addOnComputeInternalInsetsListener(this);
|
||||||
|
mPreview = findViewById(R.id.preview);
|
||||||
|
|
||||||
|
mClose = findViewById(R.id.close);
|
||||||
|
mEdit = findViewById(R.id.edit);
|
||||||
|
mShare = findViewById(R.id.share);
|
||||||
|
|
||||||
|
mClose.setOnClickListener(this::onClicked);
|
||||||
|
mEdit.setOnClickListener(this::onClicked);
|
||||||
|
mShare.setOnClickListener(this::onClicked);
|
||||||
|
|
||||||
|
mPreview.setImageDrawable(mImageTileSet.getDrawable());
|
||||||
|
mConnection.start(this::startCapture);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startCapture(Session session, final Runnable onDismiss) {
|
|
||||||
|
/** Ensure the entire window is touchable */
|
||||||
|
public void onComputeInternalInsets(InternalInsetsInfo inoutInfo) {
|
||||||
|
inoutInfo.setTouchableInsets(InternalInsetsInfo.TOUCHABLE_INSETS_FRAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
void disableButtons() {
|
||||||
|
mClose.setEnabled(false);
|
||||||
|
mEdit.setEnabled(false);
|
||||||
|
mShare.setEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onClicked(View v) {
|
||||||
|
Log.d(TAG, "button clicked!");
|
||||||
|
|
||||||
|
int id = v.getId();
|
||||||
|
if (id == R.id.close) {
|
||||||
|
v.setPressed(true);
|
||||||
|
disableButtons();
|
||||||
|
finish();
|
||||||
|
} else if (id == R.id.edit) {
|
||||||
|
v.setPressed(true);
|
||||||
|
disableButtons();
|
||||||
|
edit();
|
||||||
|
} else if (id == R.id.share) {
|
||||||
|
v.setPressed(true);
|
||||||
|
disableButtons();
|
||||||
|
share();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finish() {
|
||||||
|
if (mExportFuture == null) {
|
||||||
|
doFinish();
|
||||||
|
} else {
|
||||||
|
mExportFuture.addListener(this::doFinish, mUiExecutor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doFinish() {
|
||||||
|
mPreview.setImageDrawable(null);
|
||||||
|
mImageTileSet.clear();
|
||||||
|
mCallback.onFinish();
|
||||||
|
mWindow.getDecorView().getViewTreeObserver()
|
||||||
|
.removeOnComputeInternalInsetsListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void edit() {
|
||||||
|
sendIntentWhenReady(Intent.ACTION_EDIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void share() {
|
||||||
|
sendIntentWhenReady(Intent.ACTION_SEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendIntentWhenReady(String action) {
|
||||||
|
if (mExportFuture != null) {
|
||||||
|
mExportFuture.addListener(() -> {
|
||||||
|
try {
|
||||||
|
ImageExporter.Result result = mExportFuture.get();
|
||||||
|
sendIntent(action, result.uri);
|
||||||
|
mCallback.onFinish();
|
||||||
|
} catch (InterruptedException | ExecutionException e) {
|
||||||
|
Log.e(TAG, "failed to export", e);
|
||||||
|
mCallback.onFinish();
|
||||||
|
}
|
||||||
|
|
||||||
|
}, mUiExecutor);
|
||||||
|
} else {
|
||||||
|
mPendingAction = this::edit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setContentView(@IdRes int id) {
|
||||||
|
mWindow.setContentView(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
<T extends View> T findViewById(@IdRes int res) {
|
||||||
|
return mWindow.findViewById(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startCapture(Session session) {
|
||||||
|
Log.d(TAG, "startCapture");
|
||||||
Consumer<ScrollCaptureClient.CaptureResult> consumer =
|
Consumer<ScrollCaptureClient.CaptureResult> consumer =
|
||||||
new Consumer<ScrollCaptureClient.CaptureResult>() {
|
new Consumer<ScrollCaptureClient.CaptureResult>() {
|
||||||
|
|
||||||
@@ -91,17 +213,17 @@ public class ScrollCaptureController {
|
|||||||
|
|
||||||
boolean emptyFrame = result.captured.height() == 0;
|
boolean emptyFrame = result.captured.height() == 0;
|
||||||
if (!emptyFrame) {
|
if (!emptyFrame) {
|
||||||
mImageTileSet.addTile(new ImageTile(result.image, result.captured));
|
ImageTile tile = new ImageTile(result.image, result.captured);
|
||||||
|
Log.d(TAG, "Adding tile: " + tile);
|
||||||
|
mImageTileSet.addTile(tile);
|
||||||
|
Log.d(TAG, "New dimens: w=" + mImageTileSet.getWidth() + ", "
|
||||||
|
+ "h=" + mImageTileSet.getHeight());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emptyFrame || mFrameCount >= MAX_PAGES
|
if (emptyFrame || mFrameCount >= MAX_PAGES
|
||||||
|| mTop + session.getTileHeight() > MAX_HEIGHT) {
|
|| mTop + session.getTileHeight() > MAX_HEIGHT) {
|
||||||
if (!mImageTileSet.isEmpty()) {
|
|
||||||
exportToFile(mImageTileSet.toBitmap(), session, onDismiss);
|
mUiExecutor.execute(() -> afterCaptureComplete(session));
|
||||||
mImageTileSet.clear();
|
|
||||||
} else {
|
|
||||||
session.end(onDismiss);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mTop += result.captured.height();
|
mTop += result.captured.height();
|
||||||
@@ -113,25 +235,24 @@ public class ScrollCaptureController {
|
|||||||
session.requestTile(0, consumer);
|
session.requestTile(0, consumer);
|
||||||
};
|
};
|
||||||
|
|
||||||
void exportToFile(Bitmap bitmap, Session session, Runnable afterEnd) {
|
@UiThread
|
||||||
mImageExporter.setFormat(Bitmap.CompressFormat.PNG);
|
void afterCaptureComplete(Session session) {
|
||||||
mImageExporter.setQuality(6);
|
Log.d(TAG, "afterCaptureComplete");
|
||||||
ListenableFuture<ImageExporter.Result> future =
|
|
||||||
mImageExporter.export(mBgExecutor, mRequestId, bitmap, mCaptureTime);
|
if (mImageTileSet.isEmpty()) {
|
||||||
future.addListener(() -> {
|
session.end(mCallback::onFinish);
|
||||||
try {
|
} else {
|
||||||
ImageExporter.Result result = future.get();
|
mExportFuture = mImageExporter.export(
|
||||||
launchViewer(result.uri);
|
mBgExecutor, mRequestId, mImageTileSet.toBitmap(), mCaptureTime);
|
||||||
} catch (InterruptedException | ExecutionException e) {
|
// The user chose an action already, link it to the result
|
||||||
Toast.makeText(mContext, "Failed to write image", Toast.LENGTH_SHORT).show();
|
if (mPendingAction != null) {
|
||||||
Log.e(TAG, "Error storing screenshot to media store", e.getCause());
|
mExportFuture.addListener(mPendingAction, mUiExecutor);
|
||||||
}
|
}
|
||||||
session.end(afterEnd); // end session, close connection, afterEnd.run()
|
}
|
||||||
}, mUiExecutor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void launchViewer(Uri uri) {
|
void sendIntent(String action, Uri uri) {
|
||||||
Intent editIntent = new Intent(Intent.ACTION_VIEW);
|
Intent editIntent = new Intent(action);
|
||||||
editIntent.setType("image/png");
|
editIntent.setType("image/png");
|
||||||
editIntent.setData(uri);
|
editIntent.setData(uri);
|
||||||
editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
|||||||
Reference in New Issue
Block a user