summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/AndroidManifest.xml28
-rw-r--r--packages/SystemUI/res/layout/app_clips_screenshot.xml105
-rw-r--r--packages/SystemUI/res/values/config.xml7
-rw-r--r--packages/SystemUI/res/values/strings.xml2
-rw-r--r--packages/SystemUI/res/values/styles.xml12
-rw-r--r--packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java14
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsActivity.java289
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsCrossProcessHelper.java65
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperService.java69
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsService.java110
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivity.java233
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsViewModel.java184
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/appclips/IAppClipsScreenshotHelperService.aidl29
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.aidl19
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.java96
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java11
-rw-r--r--packages/SystemUI/tests/AndroidManifest.xml6
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperServiceTest.java105
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsServiceTest.java148
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivityTest.java269
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsViewModelTest.java142
21 files changed, 1943 insertions, 0 deletions
diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml
index 8e98d2d00951..64e1bc22ea93 100644
--- a/packages/SystemUI/AndroidManifest.xml
+++ b/packages/SystemUI/AndroidManifest.xml
@@ -418,6 +418,34 @@
android:permission="com.android.systemui.permission.SELF"
android:exported="false" />
+ <activity android:name=".screenshot.AppClipsTrampolineActivity"
+ android:theme="@style/AppClipsTrampolineActivity"
+ android:label="@string/screenshot_preview_description"
+ android:permission="android.permission.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE"
+ android:exported="true">
+ <intent-filter android:priority="1">
+ <action android:name="android.intent.action.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ </activity>
+
+ <activity android:name=".screenshot.AppClipsActivity"
+ android:theme="@style/AppClipsActivity"
+ android:process=":appclips.screenshot"
+ android:label="@string/screenshot_preview_description"
+ android:permission="com.android.systemui.permission.SELF"
+ android:excludeFromRecents="true"
+ android:exported="false"
+ android:noHistory="true" />
+
+ <service android:name=".screenshot.appclips.AppClipsScreenshotHelperService"
+ android:permission="com.android.systemui.permission.SELF"
+ android:exported="false" />
+
+ <service android:name=".screenshot.appclips.AppClipsService"
+ android:permission="android.permission.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE"
+ android:exported="true" />
+
<service android:name=".screenrecord.RecordingService"
android:foregroundServiceType="systemExempted"/>
diff --git a/packages/SystemUI/res/layout/app_clips_screenshot.xml b/packages/SystemUI/res/layout/app_clips_screenshot.xml
new file mode 100644
index 000000000000..5155b77a6bee
--- /dev/null
+++ b/packages/SystemUI/res/layout/app_clips_screenshot.xml
@@ -0,0 +1,105 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2023 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="@null"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <Button
+ android:id="@+id/save"
+ style="@android:style/Widget.DeviceDefault.Button.Colored"
+ android:layout_width="wrap_content"
+ android:layout_height="48dp"
+ android:text="@string/app_clips_save_add_to_note"
+ android:layout_marginStart="8dp"
+ android:background="@drawable/overlay_button_background"
+ android:textColor="?android:textColorSecondary"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintBottom_toTopOf="@id/preview" />
+
+ <Button
+ android:id="@+id/cancel"
+ style="@android:style/Widget.DeviceDefault.Button.Colored"
+ android:layout_width="wrap_content"
+ android:layout_height="48dp"
+ android:text="@android:string/cancel"
+ android:layout_marginStart="6dp"
+ android:background="@drawable/overlay_button_background"
+ android:textColor="?android:textColorSecondary"
+ app:layout_constraintStart_toEndOf="@id/save"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintBottom_toTopOf="@id/preview" />
+
+ <ImageView
+ android:id="@+id/preview"
+ android:layout_width="0px"
+ android:layout_height="0px"
+ android:paddingHorizontal="48dp"
+ android:paddingTop="8dp"
+ android:paddingBottom="42dp"
+ android:contentDescription="@string/screenshot_preview_description"
+ app:layout_constrainedHeight="true"
+ app:layout_constrainedWidth="true"
+ app:layout_constraintTop_toBottomOf="@id/save"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent"
+ tools:background="?android:colorBackground"
+ tools:minHeight="100dp"
+ tools:minWidth="100dp" />
+
+ <com.android.systemui.screenshot.CropView
+ android:id="@+id/crop_view"
+ android:layout_width="0px"
+ android:layout_height="0px"
+ android:paddingTop="8dp"
+ android:paddingBottom="42dp"
+ app:layout_constrainedHeight="true"
+ app:layout_constrainedWidth="true"
+ app:layout_constraintTop_toTopOf="@id/preview"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:handleThickness="@dimen/screenshot_crop_handle_thickness"
+ app:handleColor="?android:attr/colorAccent"
+ app:scrimColor="?android:colorBackgroundFloating"
+ app:scrimAlpha="128"
+ app:containerBackgroundColor="?android:colorBackgroundFloating"
+ tools:background="?android:colorBackground"
+ tools:minHeight="100dp"
+ tools:minWidth="100dp" />
+
+ <com.android.systemui.screenshot.MagnifierView
+ android:id="@+id/magnifier"
+ android:visibility="invisible"
+ android:layout_width="200dp"
+ android:layout_height="200dp"
+ android:elevation="2dp"
+ app:layout_constraintTop_toTopOf="@id/preview"
+ app:layout_constraintLeft_toLeftOf="parent"
+ app:handleThickness="@dimen/screenshot_crop_handle_thickness"
+ app:handleColor="?android:attr/colorAccent"
+ app:scrimColor="?android:colorBackgroundFloating"
+ app:scrimAlpha="128"
+ app:borderThickness="4dp"
+ app:borderColor="#fff" />
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/packages/SystemUI/res/values/config.xml b/packages/SystemUI/res/values/config.xml
index f5db5eccc2e0..32ff65c391f9 100644
--- a/packages/SystemUI/res/values/config.xml
+++ b/packages/SystemUI/res/values/config.xml
@@ -445,6 +445,13 @@
screenshot has been saved to work profile. If blank, a default icon will be shown. -->
<string name="config_sceenshotWorkProfileFilesApp" translatable="false"></string>
+ <!-- The component name of the screenshot editing activity that provides the App Clips flow.
+ The App Clips flow includes taking a screenshot, showing user screenshot cropping activity
+ and finally letting user send the screenshot to the calling notes app. This activity
+ should not send the screenshot to the calling activity without user consent. -->
+ <string name="config_screenshotAppClipsActivityComponent" translatable="false"
+ >com.android.systemui/com.android.systemui.screenshot.AppClipsActivity</string>
+
<!-- Remote copy default activity. Must handle REMOTE_COPY_ACTION intents.
This name is in the ComponentName flattened format (package/class) -->
<string name="config_remoteCopyPackage" translatable="false"></string>
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index 0f9462866cd4..da93a562c54d 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -247,6 +247,8 @@
<string name="screenshot_detected_template"><xliff:g id="appName" example="Google Chrome">%1$s</xliff:g> detected this screenshot.</string>
<!-- A notice shown to the user to indicate that multiple apps have detected the screenshot that the user has just taken. [CHAR LIMIT=75] -->
<string name="screenshot_detected_multiple_template"><xliff:g id="appName" example="Google Chrome">%1$s</xliff:g> and other open apps detected this screenshot.</string>
+ <!-- Add to note button used in App Clips flow to return the saved screenshot image to notes app. [CHAR LIMIT=NONE] -->
+ <string name="app_clips_save_add_to_note">Add to note</string>
<!-- Notification title displayed for screen recording [CHAR LIMIT=50]-->
<string name="screenrecord_name">Screen Recorder</string>
diff --git a/packages/SystemUI/res/values/styles.xml b/packages/SystemUI/res/values/styles.xml
index dd87e914eefe..4998d68e057b 100644
--- a/packages/SystemUI/res/values/styles.xml
+++ b/packages/SystemUI/res/values/styles.xml
@@ -758,6 +758,18 @@
</style>
<!-- Screenshots -->
+ <style name="AppClipsTrampolineActivity">
+ <item name="android:windowIsTranslucent">true</item>
+ <item name="android:windowNoTitle">true</item>
+ <item name="android:windowIsFloating">true</item>
+ <item name="android:backgroundDimEnabled">true</item>
+ </style>
+
+ <style name="AppClipsActivity" parent="LongScreenshotActivity">
+ <item name="android:windowBackground">@android:color/transparent</item>
+ <item name="android:windowIsTranslucent">true</item>
+ </style>
+
<style name="LongScreenshotActivity" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowNoTitle">true</item>
<item name="android:windowLightStatusBar">true</item>
diff --git a/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java b/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java
index 4eb444e3f652..a5beb4e85058 100644
--- a/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java
+++ b/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java
@@ -23,6 +23,8 @@ import com.android.systemui.hdmi.HdmiCecSetMenuLanguageActivity;
import com.android.systemui.keyguard.WorkLockActivity;
import com.android.systemui.people.PeopleSpaceActivity;
import com.android.systemui.people.widget.LaunchConversationActivity;
+import com.android.systemui.screenshot.AppClipsActivity;
+import com.android.systemui.screenshot.AppClipsTrampolineActivity;
import com.android.systemui.screenshot.LongScreenshotActivity;
import com.android.systemui.sensorprivacy.SensorUseStartedActivity;
import com.android.systemui.sensorprivacy.television.TvSensorPrivacyChangedActivity;
@@ -119,6 +121,18 @@ public abstract class DefaultActivityBinder {
@ClassKey(LongScreenshotActivity.class)
public abstract Activity bindLongScreenshotActivity(LongScreenshotActivity activity);
+ /** Inject into AppClipsTrampolineActivity. */
+ @Binds
+ @IntoMap
+ @ClassKey(AppClipsTrampolineActivity.class)
+ public abstract Activity bindAppClipsTrampolineActivity(AppClipsTrampolineActivity activity);
+
+ /** Inject into AppClipsActivity. */
+ @Binds
+ @IntoMap
+ @ClassKey(AppClipsActivity.class)
+ public abstract Activity bindAppClipsActivity(AppClipsActivity activity);
+
/** Inject into LaunchConversationActivity. */
@Binds
@IntoMap
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsActivity.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsActivity.java
new file mode 100644
index 000000000000..f8d86a0367cf
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsActivity.java
@@ -0,0 +1,289 @@
+/*
+ * Copyright (C) 2023 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 com.android.systemui.screenshot.AppClipsTrampolineActivity.ACTION_FINISH_FROM_TRAMPOLINE;
+import static com.android.systemui.screenshot.AppClipsTrampolineActivity.EXTRA_RESULT_RECEIVER;
+import static com.android.systemui.screenshot.AppClipsTrampolineActivity.EXTRA_SCREENSHOT_URI;
+import static com.android.systemui.screenshot.AppClipsTrampolineActivity.PERMISSION_SELF;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.graphics.Bitmap;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.ResultReceiver;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ImageView;
+
+import androidx.activity.ComponentActivity;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.android.settingslib.Utils;
+import com.android.systemui.R;
+
+import javax.inject.Inject;
+
+/**
+ * An {@link Activity} to take a screenshot for the App Clips flow and presenting a screenshot
+ * editing tool.
+ *
+ * <p>An App Clips flow includes:
+ * <ul>
+ * <li>Checking if calling activity meets the prerequisites. This is done by
+ * {@link AppClipsTrampolineActivity}.
+ * <li>Performing the screenshot.
+ * <li>Showing a screenshot editing tool.
+ * <li>Returning the screenshot to the {@link AppClipsTrampolineActivity} so that it can return
+ * the screenshot to the calling activity after explicit user consent.
+ * </ul>
+ *
+ * <p>This {@link Activity} runs in its own separate process to isolate memory intensive image
+ * editing from SysUI process.
+ *
+ * TODO(b/267309532): Polish UI and animations.
+ */
+public final class AppClipsActivity extends ComponentActivity {
+
+ private final AppClipsViewModel.Factory mViewModelFactory;
+ private final BroadcastReceiver mBroadcastReceiver;
+ private final IntentFilter mIntentFilter;
+
+ private View mLayout;
+ private View mRoot;
+ private ImageView mPreview;
+ private CropView mCropView;
+ private MagnifierView mMagnifierView;
+ private Button mSave;
+ private Button mCancel;
+ private AppClipsViewModel mViewModel;
+
+ private ResultReceiver mResultReceiver;
+
+ @Inject
+ public AppClipsActivity(AppClipsViewModel.Factory viewModelFactory) {
+ mViewModelFactory = viewModelFactory;
+
+ mBroadcastReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // Trampoline activity was dismissed so finish this activity.
+ if (ACTION_FINISH_FROM_TRAMPOLINE.equals(intent.getAction())) {
+ if (!isFinishing()) {
+ // Nullify the ResultReceiver so that result cannot be sent as trampoline
+ // activity is already finishing.
+ mResultReceiver = null;
+ finish();
+ }
+ }
+ }
+ };
+
+ mIntentFilter = new IntentFilter(ACTION_FINISH_FROM_TRAMPOLINE);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ overridePendingTransition(0, 0);
+ super.onCreate(savedInstanceState);
+
+ // Register the broadcast receiver that informs when the trampoline activity is dismissed.
+ registerReceiver(mBroadcastReceiver, mIntentFilter, PERMISSION_SELF, null,
+ RECEIVER_NOT_EXPORTED);
+
+ Intent intent = getIntent();
+ mResultReceiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER, ResultReceiver.class);
+ if (mResultReceiver == null) {
+ setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ return;
+ }
+
+ // Inflate layout but don't add it yet as it should be added after the screenshot is ready
+ // for preview.
+ mLayout = getLayoutInflater().inflate(R.layout.app_clips_screenshot, null);
+ mRoot = mLayout.findViewById(R.id.root);
+
+ mSave = mLayout.findViewById(R.id.save);
+ mCancel = mLayout.findViewById(R.id.cancel);
+ mSave.setOnClickListener(this::onClick);
+ mCancel.setOnClickListener(this::onClick);
+
+ mMagnifierView = mLayout.findViewById(R.id.magnifier);
+ mCropView = mLayout.findViewById(R.id.crop_view);
+ mCropView.setCropInteractionListener(mMagnifierView);
+
+ mPreview = mLayout.findViewById(R.id.preview);
+ mPreview.addOnLayoutChangeListener(
+ (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) ->
+ updateImageDimensions());
+
+ mViewModel = new ViewModelProvider(this, mViewModelFactory).get(AppClipsViewModel.class);
+ mViewModel.getScreenshot().observe(this, this::setScreenshot);
+ mViewModel.getResultLiveData().observe(this, this::setResultThenFinish);
+ mViewModel.getErrorLiveData().observe(this, this::setErrorThenFinish);
+
+ if (savedInstanceState == null) {
+ mViewModel.performScreenshot();
+ }
+ }
+
+ @Override
+ public void finish() {
+ super.finish();
+ overridePendingTransition(0, 0);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
+ unregisterReceiver(mBroadcastReceiver);
+
+ // If neither error nor result was set, it implies that the activity is finishing due to
+ // some other reason such as user dismissing this activity using back gesture. Inform error.
+ if (isFinishing() && mViewModel.getErrorLiveData().getValue() == null
+ && mViewModel.getResultLiveData().getValue() == null) {
+ // Set error but don't finish as the activity is already finishing.
+ setError(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ }
+ }
+
+ private void setScreenshot(Bitmap screenshot) {
+ // Set background, status and navigation bar colors as the activity is no longer
+ // translucent.
+ int colorBackgroundFloating = Utils.getColorAttr(this,
+ android.R.attr.colorBackgroundFloating).getDefaultColor();
+ mRoot.setBackgroundColor(colorBackgroundFloating);
+
+ BitmapDrawable drawable = new BitmapDrawable(getResources(), screenshot);
+ mPreview.setImageDrawable(drawable);
+ mPreview.setAlpha(1f);
+
+ mMagnifierView.setDrawable(drawable, screenshot.getWidth(), screenshot.getHeight());
+
+ // Screenshot is now available so set content view.
+ setContentView(mLayout);
+ }
+
+ private void onClick(View view) {
+ mSave.setEnabled(false);
+ mCancel.setEnabled(false);
+
+ int id = view.getId();
+ if (id == R.id.save) {
+ saveScreenshotThenFinish();
+ } else {
+ setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED);
+ }
+ }
+
+ private void saveScreenshotThenFinish() {
+ Drawable drawable = mPreview.getDrawable();
+ if (drawable == null) {
+ setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ return;
+ }
+
+ Rect bounds = mCropView.getCropBoundaries(drawable.getIntrinsicWidth(),
+ drawable.getIntrinsicHeight());
+
+ if (bounds.isEmpty()) {
+ setErrorThenFinish(Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ return;
+ }
+
+ updateImageDimensions();
+ mViewModel.saveScreenshotThenFinish(drawable, bounds);
+ }
+
+ private void setResultThenFinish(Uri uri) {
+ if (mResultReceiver == null) {
+ return;
+ }
+
+ Bundle data = new Bundle();
+ data.putInt(Intent.EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE,
+ Intent.CAPTURE_CONTENT_FOR_NOTE_SUCCESS);
+ data.putParcelable(EXTRA_SCREENSHOT_URI, uri);
+ try {
+ mResultReceiver.send(Activity.RESULT_OK, data);
+ } catch (Exception e) {
+ // Do nothing.
+ }
+
+ // Nullify the ResultReceiver before finishing to avoid resending the result.
+ mResultReceiver = null;
+ finish();
+ }
+
+ private void setErrorThenFinish(int errorCode) {
+ setError(errorCode);
+ finish();
+ }
+
+ private void setError(int errorCode) {
+ if (mResultReceiver == null) {
+ return;
+ }
+
+ Bundle data = new Bundle();
+ data.putInt(Intent.EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, errorCode);
+ try {
+ mResultReceiver.send(RESULT_OK, data);
+ } catch (Exception e) {
+ // Do nothing.
+ }
+
+ // Nullify the ResultReceiver to avoid resending the result.
+ mResultReceiver = null;
+ }
+
+ private void updateImageDimensions() {
+ Drawable drawable = mPreview.getDrawable();
+ if (drawable == null) {
+ return;
+ }
+
+ Rect bounds = drawable.getBounds();
+ float imageRatio = bounds.width() / (float) bounds.height();
+ int previewWidth = mPreview.getWidth() - mPreview.getPaddingLeft()
+ - mPreview.getPaddingRight();
+ int previewHeight = mPreview.getHeight() - mPreview.getPaddingTop()
+ - mPreview.getPaddingBottom();
+ float viewRatio = previewWidth / (float) previewHeight;
+
+ if (imageRatio > viewRatio) {
+ // Image is full width and height is constrained, compute extra padding to inform
+ // CropView.
+ int imageHeight = (int) (previewHeight * viewRatio / imageRatio);
+ int extraPadding = (previewHeight - imageHeight) / 2;
+ mCropView.setExtraPadding(extraPadding, extraPadding);
+ mCropView.setImageWidth(previewWidth);
+ } else {
+ // Image is full height.
+ mCropView.setExtraPadding(mPreview.getPaddingTop(), mPreview.getPaddingBottom());
+ mCropView.setImageWidth((int) (previewHeight * imageRatio));
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsCrossProcessHelper.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsCrossProcessHelper.java
new file mode 100644
index 000000000000..65fb4c9bfb0d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsCrossProcessHelper.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2023 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.appclips;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.view.Display;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.infra.AndroidFuture;
+import com.android.internal.infra.ServiceConnector;
+import com.android.systemui.dagger.SysUISingleton;
+import com.android.systemui.dagger.qualifiers.Application;
+
+import javax.inject.Inject;
+
+/** An intermediary singleton object to help communicating with the cross process service. */
+@SysUISingleton
+public class AppClipsCrossProcessHelper {
+
+ private final ServiceConnector<IAppClipsScreenshotHelperService> mProxyConnector;
+
+ @Inject
+ public AppClipsCrossProcessHelper(@Application Context context) {
+ mProxyConnector = new ServiceConnector.Impl<IAppClipsScreenshotHelperService>(context,
+ new Intent(context, AppClipsScreenshotHelperService.class),
+ Context.BIND_AUTO_CREATE | Context.BIND_WAIVE_PRIORITY
+ | Context.BIND_NOT_VISIBLE, context.getUserId(),
+ IAppClipsScreenshotHelperService.Stub::asInterface);
+ }
+
+ /**
+ * Returns a {@link Bitmap} captured in the SysUI process, {@code null} in case of an error.
+ *
+ * <p>Note: The SysUI process captures a {@link ScreenshotHardwareBufferInternal} which is ok to
+ * pass around but not a {@link Bitmap}.
+ */
+ @Nullable
+ public Bitmap takeScreenshot() {
+ try {
+ AndroidFuture<ScreenshotHardwareBufferInternal> future =
+ mProxyConnector.postForResult(
+ service -> service.takeScreenshot(Display.DEFAULT_DISPLAY));
+ return future.get().createBitmapThenCloseBuffer();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperService.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperService.java
new file mode 100644
index 000000000000..6f8c36595c74
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperService.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2023 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.appclips;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+import android.window.ScreenCapture.ScreenshotHardwareBuffer;
+import android.window.ScreenCapture.ScreenshotSync;
+
+import androidx.annotation.Nullable;
+
+import com.android.systemui.screenshot.AppClipsActivity;
+import com.android.wm.shell.bubbles.Bubbles;
+
+import java.util.Optional;
+
+import javax.inject.Inject;
+
+/**
+ * A helper service that runs in SysUI process and helps {@link AppClipsActivity} which runs in its
+ * own separate process take a screenshot.
+ */
+public class AppClipsScreenshotHelperService extends Service {
+
+ private final Optional<Bubbles> mOptionalBubbles;
+
+ @Inject
+ public AppClipsScreenshotHelperService(Optional<Bubbles> optionalBubbles) {
+ mOptionalBubbles = optionalBubbles;
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return new IAppClipsScreenshotHelperService.Stub() {
+ @Override
+ @Nullable
+ public ScreenshotHardwareBufferInternal takeScreenshot(int displayId) {
+ if (mOptionalBubbles.isEmpty()) {
+ return null;
+ }
+
+ ScreenshotSync screenshotSync =
+ mOptionalBubbles.get().getScreenshotExcludingBubble(displayId);
+ ScreenshotHardwareBuffer screenshotHardwareBuffer = screenshotSync.get();
+ if (screenshotHardwareBuffer == null) {
+ return null;
+ }
+
+ return new ScreenshotHardwareBufferInternal(screenshotHardwareBuffer);
+ }
+ };
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsService.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsService.java
new file mode 100644
index 000000000000..d0b7ad3e9dd5
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsService.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2023 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.appclips;
+
+import static com.android.systemui.flags.Flags.SCREENSHOT_APP_CLIPS;
+
+import android.app.Activity;
+import android.app.Service;
+import android.app.StatusBarManager;
+import android.app.admin.DevicePolicyManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.os.IBinder;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.statusbar.IAppClipsService;
+import com.android.systemui.R;
+import com.android.systemui.dagger.qualifiers.Application;
+import com.android.systemui.flags.FeatureFlags;
+import com.android.wm.shell.bubbles.Bubbles;
+
+import java.util.Optional;
+
+import javax.inject.Inject;
+
+/**
+ * A service that communicates with {@link StatusBarManager} to support the
+ * {@link StatusBarManager#canLaunchCaptureContentActivityForNote(Activity)} API.
+ */
+public class AppClipsService extends Service {
+
+ @Application private final Context mContext;
+ private final FeatureFlags mFeatureFlags;
+ private final Optional<Bubbles> mOptionalBubbles;
+ private final DevicePolicyManager mDevicePolicyManager;
+ private final boolean mAreTaskAndTimeIndependentPrerequisitesMet;
+
+ @Inject
+ public AppClipsService(@Application Context context, FeatureFlags featureFlags,
+ Optional<Bubbles> optionalBubbles, DevicePolicyManager devicePolicyManager) {
+ mContext = context;
+ mFeatureFlags = featureFlags;
+ mOptionalBubbles = optionalBubbles;
+ mDevicePolicyManager = devicePolicyManager;
+
+ mAreTaskAndTimeIndependentPrerequisitesMet = checkIndependentVariables();
+ }
+
+ private boolean checkIndependentVariables() {
+ if (!mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)) {
+ return false;
+ }
+
+ if (mOptionalBubbles.isEmpty()) {
+ return false;
+ }
+
+ return isComponentValid();
+ }
+
+ private boolean isComponentValid() {
+ ComponentName componentName;
+ try {
+ componentName = ComponentName.unflattenFromString(
+ mContext.getString(R.string.config_screenshotAppClipsActivityComponent));
+ } catch (Resources.NotFoundException e) {
+ return false;
+ }
+
+ return componentName != null
+ && !componentName.getPackageName().isEmpty()
+ && !componentName.getClassName().isEmpty();
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return new IAppClipsService.Stub() {
+ @Override
+ public boolean canLaunchCaptureContentActivityForNote(int taskId) {
+ if (!mAreTaskAndTimeIndependentPrerequisitesMet) {
+ return false;
+ }
+
+ if (!mOptionalBubbles.get().isAppBubbleTaskId(taskId)) {
+ return false;
+ }
+
+ return !mDevicePolicyManager.getScreenCaptureDisabled(null);
+ }
+ };
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivity.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivity.java
new file mode 100644
index 000000000000..4759cc62e1f2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivity.java
@@ -0,0 +1,233 @@
+/*
+ * Copyright (C) 2023 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.content.Intent.CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN;
+import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED;
+import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_SUCCESS;
+import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED;
+import static android.content.Intent.EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE;
+import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION;
+
+import static com.android.systemui.flags.Flags.SCREENSHOT_APP_CLIPS;
+
+import android.app.Activity;
+import android.app.admin.DevicePolicyManager;
+import android.content.ActivityNotFoundException;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Parcel;
+import android.os.ResultReceiver;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.systemui.R;
+import com.android.systemui.dagger.qualifiers.Main;
+import com.android.systemui.flags.FeatureFlags;
+import com.android.systemui.notetask.NoteTaskController;
+import com.android.wm.shell.bubbles.Bubbles;
+
+import java.util.Optional;
+
+import javax.inject.Inject;
+
+/**
+ * A trampoline activity that is responsible for:
+ * <ul>
+ * <li>Performing precondition checks before starting the actual screenshot activity.
+ * <li>Communicating with the screenshot activity and the calling activity.
+ * </ul>
+ *
+ * <p>As this activity is started in a bubble app, the windowing for this activity is restricted
+ * to the parent bubble app. The screenshot editing activity, see {@link AppClipsActivity}, is
+ * started in a regular activity window using {@link Intent#FLAG_ACTIVITY_NEW_TASK}. However,
+ * {@link Activity#startActivityForResult(Intent, int)} is not compatible with
+ * {@link Intent#FLAG_ACTIVITY_NEW_TASK}. So, this activity acts as a trampoline activity to
+ * abstract the complexity of communication with the screenshot editing activity for a simpler
+ * developer experience.
+ *
+ * TODO(b/267309532): Polish UI and animations.
+ */
+public class AppClipsTrampolineActivity extends Activity {
+
+ private static final String TAG = AppClipsTrampolineActivity.class.getSimpleName();
+ public static final String PERMISSION_SELF = "com.android.systemui.permission.SELF";
+ public static final String EXTRA_SCREENSHOT_URI = TAG + "SCREENSHOT_URI";
+ public static final String ACTION_FINISH_FROM_TRAMPOLINE = TAG + "FINISH_FROM_TRAMPOLINE";
+ static final String EXTRA_RESULT_RECEIVER = TAG + "RESULT_RECEIVER";
+
+ private final DevicePolicyManager mDevicePolicyManager;
+ private final FeatureFlags mFeatureFlags;
+ private final Optional<Bubbles> mOptionalBubbles;
+ private final NoteTaskController mNoteTaskController;
+ private final ResultReceiver mResultReceiver;
+
+ private Intent mKillAppClipsBroadcastIntent;
+
+ @Inject
+ public AppClipsTrampolineActivity(DevicePolicyManager devicePolicyManager, FeatureFlags flags,
+ Optional<Bubbles> optionalBubbles, NoteTaskController noteTaskController,
+ @Main Handler mainHandler) {
+ mDevicePolicyManager = devicePolicyManager;
+ mFeatureFlags = flags;
+ mOptionalBubbles = optionalBubbles;
+ mNoteTaskController = noteTaskController;
+
+ mResultReceiver = createResultReceiver(mainHandler);
+ }
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (savedInstanceState != null) {
+ return;
+ }
+
+ if (!mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)) {
+ finish();
+ return;
+ }
+
+ if (mOptionalBubbles.isEmpty()) {
+ setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ return;
+ }
+
+ if (!mOptionalBubbles.get().isAppBubbleTaskId(getTaskId())) {
+ setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED);
+ return;
+ }
+
+ if (mDevicePolicyManager.getScreenCaptureDisabled(null)) {
+ setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN);
+ return;
+ }
+
+ ComponentName componentName;
+ try {
+ componentName = ComponentName.unflattenFromString(
+ getString(R.string.config_screenshotAppClipsActivityComponent));
+ } catch (Resources.NotFoundException e) {
+ setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ return;
+ }
+
+ if (componentName == null || componentName.getPackageName().isEmpty()
+ || componentName.getClassName().isEmpty()) {
+ setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ return;
+ }
+
+ Intent intent = new Intent().setComponent(componentName).addFlags(
+ Intent.FLAG_ACTIVITY_NEW_TASK).putExtra(EXTRA_RESULT_RECEIVER, mResultReceiver);
+ try {
+ // Start the App Clips activity.
+ startActivity(intent);
+
+ // Set up the broadcast intent that will inform the above App Clips activity to finish
+ // when this trampoline activity is finished.
+ mKillAppClipsBroadcastIntent =
+ new Intent(ACTION_FINISH_FROM_TRAMPOLINE)
+ .setComponent(componentName)
+ .setPackage(componentName.getPackageName());
+ } catch (ActivityNotFoundException e) {
+ setErrorResultAndFinish(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
+ if (isFinishing() && mKillAppClipsBroadcastIntent != null) {
+ sendBroadcast(mKillAppClipsBroadcastIntent, PERMISSION_SELF);
+ }
+ }
+
+ private void setErrorResultAndFinish(int errorCode) {
+ setResult(RESULT_OK,
+ new Intent().putExtra(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, errorCode));
+ finish();
+ }
+
+ private class AppClipsResultReceiver extends ResultReceiver {
+
+ AppClipsResultReceiver(Handler handler) {
+ super(handler);
+ }
+
+ @Override
+ protected void onReceiveResult(int resultCode, Bundle resultData) {
+ if (isFinishing()) {
+ // It's too late, trampoline activity is finishing or already finished.
+ // Return early.
+ return;
+ }
+
+ // Package the response that should be sent to the calling activity.
+ Intent convertedData = new Intent();
+ int statusCode = CAPTURE_CONTENT_FOR_NOTE_FAILED;
+ if (resultData != null) {
+ statusCode = resultData.getInt(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE,
+ CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ }
+ convertedData.putExtra(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, statusCode);
+
+ if (statusCode == CAPTURE_CONTENT_FOR_NOTE_SUCCESS) {
+ Uri uri = resultData.getParcelable(EXTRA_SCREENSHOT_URI, Uri.class);
+ convertedData.setData(uri).addFlags(FLAG_GRANT_READ_URI_PERMISSION);
+ }
+
+ // Broadcast no longer required, setting it to null.
+ mKillAppClipsBroadcastIntent = null;
+
+ // Expand the note bubble before returning the result. As App Clips API is only
+ // available when in a bubble, isInMultiWindowMode is always false below.
+ mNoteTaskController.showNoteTask(false);
+ setResult(RESULT_OK, convertedData);
+ finish();
+ }
+ }
+
+ /**
+ * @return a {@link ResultReceiver} by initializing an {@link AppClipsResultReceiver} and
+ * converting it into a generic {@link ResultReceiver} to pass across a different but trusted
+ * process.
+ */
+ private ResultReceiver createResultReceiver(@Main Handler handler) {
+ AppClipsResultReceiver appClipsResultReceiver = new AppClipsResultReceiver(handler);
+ Parcel parcel = Parcel.obtain();
+ appClipsResultReceiver.writeToParcel(parcel, 0);
+ parcel.setDataPosition(0);
+
+ ResultReceiver resultReceiver = ResultReceiver.CREATOR.createFromParcel(parcel);
+ parcel.recycle();
+ return resultReceiver;
+ }
+
+ /** This is a test only API for mocking response from {@link AppClipsActivity}. */
+ @VisibleForTesting
+ public ResultReceiver getResultReceiverForTest() {
+ return mResultReceiver;
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsViewModel.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsViewModel.java
new file mode 100644
index 000000000000..5a7b5f94ea4f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/AppClipsViewModel.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright (C) 2023 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.content.Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.HardwareRenderer;
+import android.graphics.RecordingCanvas;
+import android.graphics.Rect;
+import android.graphics.RenderNode;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Process;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.android.systemui.dagger.qualifiers.Background;
+import com.android.systemui.dagger.qualifiers.Main;
+import com.android.systemui.screenshot.appclips.AppClipsCrossProcessHelper;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.time.ZonedDateTime;
+import java.util.UUID;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+
+import javax.inject.Inject;
+
+/** A {@link ViewModel} to help with the App Clips screenshot flow. */
+final class AppClipsViewModel extends ViewModel {
+
+ private final AppClipsCrossProcessHelper mAppClipsCrossProcessHelper;
+ private final ImageExporter mImageExporter;
+ @Main
+ private final Executor mMainExecutor;
+ @Background
+ private final Executor mBgExecutor;
+
+ private final MutableLiveData<Bitmap> mScreenshotLiveData;
+ private final MutableLiveData<Uri> mResultLiveData;
+ private final MutableLiveData<Integer> mErrorLiveData;
+
+ AppClipsViewModel(AppClipsCrossProcessHelper appClipsCrossProcessHelper,
+ ImageExporter imageExporter, @Main Executor mainExecutor,
+ @Background Executor bgExecutor) {
+ mAppClipsCrossProcessHelper = appClipsCrossProcessHelper;
+ mImageExporter = imageExporter;
+ mMainExecutor = mainExecutor;
+ mBgExecutor = bgExecutor;
+
+ mScreenshotLiveData = new MutableLiveData<>();
+ mResultLiveData = new MutableLiveData<>();
+ mErrorLiveData = new MutableLiveData<>();
+ }
+
+ /** Grabs a screenshot and updates the {@link Bitmap} set in screenshot {@link LiveData}. */
+ void performScreenshot() {
+ mBgExecutor.execute(() -> {
+ Bitmap screenshot = mAppClipsCrossProcessHelper.takeScreenshot();
+ mMainExecutor.execute(() -> {
+ if (screenshot == null) {
+ mErrorLiveData.setValue(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ } else {
+ mScreenshotLiveData.setValue(screenshot);
+ }
+ });
+ });
+ }
+
+ /** Returns a {@link LiveData} that holds the captured screenshot. */
+ LiveData<Bitmap> getScreenshot() {
+ return mScreenshotLiveData;
+ }
+
+ /** Returns a {@link LiveData} that holds the {@link Uri} where screenshot is saved. */
+ LiveData<Uri> getResultLiveData() {
+ return mResultLiveData;
+ }
+
+ /**
+ * Returns a {@link LiveData} that holds the error codes for
+ * {@link Intent#EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE}.
+ */
+ LiveData<Integer> getErrorLiveData() {
+ return mErrorLiveData;
+ }
+
+ /**
+ * Saves the provided {@link Drawable} to storage then informs the result {@link Uri} to
+ * {@link LiveData}.
+ */
+ void saveScreenshotThenFinish(Drawable screenshotDrawable, Rect bounds) {
+ mBgExecutor.execute(() -> {
+ // Render the screenshot bitmap in background.
+ Bitmap screenshotBitmap = renderBitmap(screenshotDrawable, bounds);
+
+ // Export and save the screenshot in background.
+ // TODO(b/267310185): Save to work profile UserHandle.
+ ListenableFuture<ImageExporter.Result> exportFuture = mImageExporter.export(
+ mBgExecutor, UUID.randomUUID(), screenshotBitmap, ZonedDateTime.now(),
+ Process.myUserHandle());
+
+ // Get the result and update state on main thread.
+ exportFuture.addListener(() -> {
+ try {
+ ImageExporter.Result result = exportFuture.get();
+ if (result.uri == null) {
+ mErrorLiveData.setValue(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ return;
+ }
+
+ mResultLiveData.setValue(result.uri);
+ } catch (CancellationException | InterruptedException | ExecutionException e) {
+ mErrorLiveData.setValue(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ }
+ }, mMainExecutor);
+ });
+ }
+
+ private static Bitmap renderBitmap(Drawable drawable, Rect bounds) {
+ final RenderNode output = new RenderNode("Screenshot save");
+ output.setPosition(0, 0, bounds.width(), bounds.height());
+ RecordingCanvas canvas = output.beginRecording();
+ canvas.translate(-bounds.left, -bounds.top);
+ canvas.clipRect(bounds);
+ drawable.draw(canvas);
+ output.endRecording();
+ return HardwareRenderer.createHardwareBitmap(output, bounds.width(), bounds.height());
+ }
+
+ /** Helper factory to help with injecting {@link AppClipsViewModel}. */
+ static final class Factory implements ViewModelProvider.Factory {
+
+ private final AppClipsCrossProcessHelper mAppClipsCrossProcessHelper;
+ private final ImageExporter mImageExporter;
+ @Main
+ private final Executor mMainExecutor;
+ @Background
+ private final Executor mBgExecutor;
+
+ @Inject
+ Factory(AppClipsCrossProcessHelper appClipsCrossProcessHelper, ImageExporter imageExporter,
+ @Main Executor mainExecutor, @Background Executor bgExecutor) {
+ mAppClipsCrossProcessHelper = appClipsCrossProcessHelper;
+ mImageExporter = imageExporter;
+ mMainExecutor = mainExecutor;
+ mBgExecutor = bgExecutor;
+ }
+
+ @NonNull
+ @Override
+ public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
+ if (modelClass != AppClipsViewModel.class) {
+ throw new IllegalArgumentException();
+ }
+
+ //noinspection unchecked
+ return (T) new AppClipsViewModel(mAppClipsCrossProcessHelper, mImageExporter,
+ mMainExecutor, mBgExecutor);
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/IAppClipsScreenshotHelperService.aidl b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/IAppClipsScreenshotHelperService.aidl
new file mode 100644
index 000000000000..640e7420cc2e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/IAppClipsScreenshotHelperService.aidl
@@ -0,0 +1,29 @@
+/**
+ * Copyright (C) 2023, 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.appclips;
+
+import android.os.Bundle;
+
+import com.android.systemui.screenshot.appclips.ScreenshotHardwareBufferInternal;
+
+/**
+ * A helper service that runs in SysUI process and helps {@link AppClipsActivity} which runs in its
+ * own separate process take a screenshot.
+ */
+interface IAppClipsScreenshotHelperService {
+ @nullable ScreenshotHardwareBufferInternal takeScreenshot(in int displayId);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.aidl b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.aidl
new file mode 100644
index 000000000000..3a7b94452687
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.aidl
@@ -0,0 +1,19 @@
+/**
+ * Copyright (C) 2023, 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.appclips;
+
+parcelable ScreenshotHardwareBufferInternal;
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.java b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.java
new file mode 100644
index 000000000000..3b107f101088
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/appclips/ScreenshotHardwareBufferInternal.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2023 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.appclips;
+
+import android.graphics.Bitmap;
+import android.graphics.ColorSpace;
+import android.graphics.ParcelableColorSpace;
+import android.hardware.HardwareBuffer;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.window.ScreenCapture.ScreenshotHardwareBuffer;
+
+/**
+ * An internal version of {@link ScreenshotHardwareBuffer} that helps with parceling the information
+ * necessary for creating a {@link Bitmap}.
+ */
+public class ScreenshotHardwareBufferInternal implements Parcelable {
+
+ public static final Creator<ScreenshotHardwareBufferInternal> CREATOR =
+ new Creator<>() {
+ @Override
+ public ScreenshotHardwareBufferInternal createFromParcel(Parcel in) {
+ return new ScreenshotHardwareBufferInternal(in);
+ }
+
+ @Override
+ public ScreenshotHardwareBufferInternal[] newArray(int size) {
+ return new ScreenshotHardwareBufferInternal[size];
+ }
+ };
+ private final HardwareBuffer mHardwareBuffer;
+ private final ParcelableColorSpace mParcelableColorSpace;
+
+ public ScreenshotHardwareBufferInternal(
+ ScreenshotHardwareBuffer screenshotHardwareBuffer) {
+ mHardwareBuffer = screenshotHardwareBuffer.getHardwareBuffer();
+ mParcelableColorSpace = new ParcelableColorSpace(
+ screenshotHardwareBuffer.getColorSpace());
+ }
+
+ private ScreenshotHardwareBufferInternal(Parcel in) {
+ mHardwareBuffer = in.readParcelable(HardwareBuffer.class.getClassLoader(),
+ HardwareBuffer.class);
+ mParcelableColorSpace = in.readParcelable(ParcelableColorSpace.class.getClassLoader(),
+ ParcelableColorSpace.class);
+ }
+
+ /**
+ * Returns a {@link Bitmap} represented by the underlying data and successively closes the
+ * internal {@link HardwareBuffer}. See,
+ * {@link Bitmap#wrapHardwareBuffer(HardwareBuffer, ColorSpace)} and
+ * {@link HardwareBuffer#close()} for more information.
+ */
+ public Bitmap createBitmapThenCloseBuffer() {
+ Bitmap bitmap = Bitmap.wrapHardwareBuffer(mHardwareBuffer,
+ mParcelableColorSpace.getColorSpace());
+ mHardwareBuffer.close();
+ return bitmap;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeParcelable(mHardwareBuffer, flags);
+ dest.writeParcelable(mParcelableColorSpace, flags);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof ScreenshotHardwareBufferInternal)) {
+ return false;
+ }
+
+ ScreenshotHardwareBufferInternal other = (ScreenshotHardwareBufferInternal) obj;
+ return mHardwareBuffer.equals(other.mHardwareBuffer) && mParcelableColorSpace.equals(
+ other.mParcelableColorSpace);
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java b/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java
index fdb01000b837..22e238c0f2ad 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/dagger/ScreenshotModule.java
@@ -24,6 +24,8 @@ import com.android.systemui.screenshot.ScreenshotPolicy;
import com.android.systemui.screenshot.ScreenshotPolicyImpl;
import com.android.systemui.screenshot.ScreenshotProxyService;
import com.android.systemui.screenshot.TakeScreenshotService;
+import com.android.systemui.screenshot.appclips.AppClipsScreenshotHelperService;
+import com.android.systemui.screenshot.appclips.AppClipsService;
import dagger.Binds;
import dagger.Module;
@@ -52,4 +54,13 @@ public abstract class ScreenshotModule {
@Binds
abstract ImageCapture bindImageCaptureImpl(ImageCaptureImpl capture);
+ @Binds
+ @IntoMap
+ @ClassKey(AppClipsScreenshotHelperService.class)
+ abstract Service bindAppClipsScreenshotHelperService(AppClipsScreenshotHelperService service);
+
+ @Binds
+ @IntoMap
+ @ClassKey(AppClipsService.class)
+ abstract Service bindAppClipsService(AppClipsService service);
}
diff --git a/packages/SystemUI/tests/AndroidManifest.xml b/packages/SystemUI/tests/AndroidManifest.xml
index 2c1e68146cce..ed2772af4e3f 100644
--- a/packages/SystemUI/tests/AndroidManifest.xml
+++ b/packages/SystemUI/tests/AndroidManifest.xml
@@ -159,6 +159,12 @@
android:enabled="false"
tools:replace="android:authorities"
android:grantUriPermissions="true" />
+
+ <activity
+ android:name="com.android.systemui.screenshot.appclips.AppClipsTrampolineActivityTest$AppClipsTrampolineActivityTestable"
+ android:exported="false"
+ android:permission="com.android.systemui.permission.SELF"
+ android:excludeFromRecents="true" />
</application>
<instrumentation android:name="android.testing.TestableInstrumentation"
diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperServiceTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperServiceTest.java
new file mode 100644
index 000000000000..6e8f5fe60774
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsScreenshotHelperServiceTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2023 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.appclips;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import android.content.Intent;
+import android.graphics.ColorSpace;
+import android.hardware.HardwareBuffer;
+import android.os.RemoteException;
+import android.view.Display;
+import android.window.ScreenCapture.ScreenshotHardwareBuffer;
+import android.window.ScreenCapture.ScreenshotSync;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.systemui.SysuiTestCase;
+import com.android.wm.shell.bubbles.Bubbles;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Optional;
+
+@RunWith(AndroidJUnit4.class)
+public final class AppClipsScreenshotHelperServiceTest extends SysuiTestCase {
+
+ private static final Intent FAKE_INTENT = new Intent();
+ private static final int DEFAULT_DISPLAY = Display.DEFAULT_DISPLAY;
+ private static final HardwareBuffer FAKE_HARDWARE_BUFFER =
+ HardwareBuffer.create(1, 1, HardwareBuffer.RGBA_8888, 1,
+ HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE);
+ private static final ColorSpace FAKE_COLOR_SPACE = ColorSpace.get(ColorSpace.Named.SRGB);
+ private static final ScreenshotHardwareBufferInternal EXPECTED_SCREENSHOT_BUFFER =
+ new ScreenshotHardwareBufferInternal(
+ new ScreenshotHardwareBuffer(FAKE_HARDWARE_BUFFER, FAKE_COLOR_SPACE, false,
+ false));
+
+ @Mock private Optional<Bubbles> mBubblesOptional;
+ @Mock private Bubbles mBubbles;
+ @Mock private ScreenshotHardwareBuffer mScreenshotHardwareBuffer;
+ @Mock private ScreenshotSync mScreenshotSync;
+
+ private AppClipsScreenshotHelperService mAppClipsScreenshotHelperService;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ mAppClipsScreenshotHelperService = new AppClipsScreenshotHelperService(mBubblesOptional);
+ }
+
+ @Test
+ public void emptyBubbles_shouldReturnNull() throws RemoteException {
+ when(mBubblesOptional.isEmpty()).thenReturn(true);
+
+ assertThat(getInterface().takeScreenshot(DEFAULT_DISPLAY)).isNull();
+ }
+
+ @Test
+ public void bubblesPresent_screenshotFailed_ShouldReturnNull() throws RemoteException {
+ when(mBubblesOptional.isEmpty()).thenReturn(false);
+ when(mBubblesOptional.get()).thenReturn(mBubbles);
+ when(mBubbles.getScreenshotExcludingBubble(DEFAULT_DISPLAY)).thenReturn(mScreenshotSync);
+ when(mScreenshotSync.get()).thenReturn(null);
+
+ assertThat(getInterface().takeScreenshot(DEFAULT_DISPLAY)).isNull();
+ }
+
+ @Test
+ public void bubblesPresent_screenshotSuccess_shouldReturnScreenshot() throws RemoteException {
+ when(mBubblesOptional.isEmpty()).thenReturn(false);
+ when(mBubblesOptional.get()).thenReturn(mBubbles);
+ when(mBubbles.getScreenshotExcludingBubble(DEFAULT_DISPLAY)).thenReturn(mScreenshotSync);
+ when(mScreenshotSync.get()).thenReturn(mScreenshotHardwareBuffer);
+ when(mScreenshotHardwareBuffer.getHardwareBuffer()).thenReturn(FAKE_HARDWARE_BUFFER);
+ when(mScreenshotHardwareBuffer.getColorSpace()).thenReturn(FAKE_COLOR_SPACE);
+
+ assertThat(getInterface().takeScreenshot(DEFAULT_DISPLAY)).isEqualTo(
+ EXPECTED_SCREENSHOT_BUFFER);
+ }
+
+ private IAppClipsScreenshotHelperService getInterface() {
+ return IAppClipsScreenshotHelperService.Stub.asInterface(
+ mAppClipsScreenshotHelperService.onBind(FAKE_INTENT));
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsServiceTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsServiceTest.java
new file mode 100644
index 000000000000..b55fe3677256
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsServiceTest.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2023 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.appclips;
+
+import static com.android.systemui.flags.Flags.SCREENSHOT_APP_CLIPS;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+import android.app.admin.DevicePolicyManager;
+import android.content.Context;
+import android.content.Intent;
+import android.os.IBinder;
+import android.os.RemoteException;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.internal.statusbar.IAppClipsService;
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.dagger.qualifiers.Application;
+import com.android.systemui.flags.FeatureFlags;
+import com.android.wm.shell.bubbles.Bubbles;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Optional;
+
+@RunWith(AndroidJUnit4.class)
+public final class AppClipsServiceTest extends SysuiTestCase {
+
+ private static final Intent FAKE_INTENT = new Intent();
+ private static final int FAKE_TASK_ID = 42;
+ private static final String EMPTY = "";
+
+ @Mock @Application private Context mMockContext;
+ @Mock private FeatureFlags mFeatureFlags;
+ @Mock private Optional<Bubbles> mOptionalBubbles;
+ @Mock private Bubbles mBubbles;
+ @Mock private DevicePolicyManager mDevicePolicyManager;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ }
+
+ @Test
+ public void flagOff_shouldReturnFalse() throws RemoteException {
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(false);
+
+ assertThat(getInterfaceWithRealContext()
+ .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isFalse();
+ }
+
+ @Test
+ public void emptyBubbles_shouldReturnFalse() throws RemoteException {
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true);
+ when(mOptionalBubbles.isEmpty()).thenReturn(true);
+
+ assertThat(getInterfaceWithRealContext()
+ .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isFalse();
+ }
+
+ @Test
+ public void taskIdNotAppBubble_shouldReturnFalse() throws RemoteException {
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true);
+ when(mOptionalBubbles.isEmpty()).thenReturn(false);
+ when(mOptionalBubbles.get()).thenReturn(mBubbles);
+ when(mBubbles.isAppBubbleTaskId(eq((FAKE_TASK_ID)))).thenReturn(false);
+
+ assertThat(getInterfaceWithRealContext()
+ .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isFalse();
+ }
+
+ @Test
+ public void dpmScreenshotBlocked_shouldReturnFalse() throws RemoteException {
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true);
+ when(mOptionalBubbles.isEmpty()).thenReturn(false);
+ when(mOptionalBubbles.get()).thenReturn(mBubbles);
+ when(mBubbles.isAppBubbleTaskId(eq((FAKE_TASK_ID)))).thenReturn(true);
+ when(mDevicePolicyManager.getScreenCaptureDisabled(eq(null))).thenReturn(true);
+
+ assertThat(getInterfaceWithRealContext()
+ .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isFalse();
+ }
+
+ @Test
+ public void configComponentNameNotValid_shouldReturnFalse() throws RemoteException {
+ when(mMockContext.getString(anyInt())).thenReturn(EMPTY);
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true);
+ when(mOptionalBubbles.isEmpty()).thenReturn(false);
+ when(mOptionalBubbles.get()).thenReturn(mBubbles);
+ when(mBubbles.isAppBubbleTaskId(eq((FAKE_TASK_ID)))).thenReturn(true);
+ when(mDevicePolicyManager.getScreenCaptureDisabled(eq(null))).thenReturn(false);
+
+ assertThat(getInterfaceWithMockContext()
+ .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isFalse();
+ }
+
+ @Test
+ public void allPrerequisitesSatisfy_shouldReturnTrue() throws RemoteException {
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true);
+ when(mOptionalBubbles.isEmpty()).thenReturn(false);
+ when(mOptionalBubbles.get()).thenReturn(mBubbles);
+ when(mBubbles.isAppBubbleTaskId(eq((FAKE_TASK_ID)))).thenReturn(true);
+ when(mDevicePolicyManager.getScreenCaptureDisabled(eq(null))).thenReturn(false);
+
+ assertThat(getInterfaceWithRealContext()
+ .canLaunchCaptureContentActivityForNote(FAKE_TASK_ID)).isTrue();
+ }
+
+ private IAppClipsService getInterfaceWithRealContext() {
+ AppClipsService appClipsService = new AppClipsService(getContext(), mFeatureFlags,
+ mOptionalBubbles, mDevicePolicyManager);
+ return getInterfaceFromService(appClipsService);
+ }
+
+ private IAppClipsService getInterfaceWithMockContext() {
+ AppClipsService appClipsService = new AppClipsService(mMockContext, mFeatureFlags,
+ mOptionalBubbles, mDevicePolicyManager);
+ return getInterfaceFromService(appClipsService);
+ }
+
+ private static IAppClipsService getInterfaceFromService(AppClipsService appClipsService) {
+ IBinder iBinder = appClipsService.onBind(FAKE_INTENT);
+ return IAppClipsService.Stub.asInterface(iBinder);
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivityTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivityTest.java
new file mode 100644
index 000000000000..295d127c2494
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsTrampolineActivityTest.java
@@ -0,0 +1,269 @@
+/*
+ * Copyright (C) 2023 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.appclips;
+
+import static android.app.Instrumentation.ActivityResult;
+import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN;
+import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED;
+import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_SUCCESS;
+import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED;
+import static android.content.Intent.CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED;
+import static android.content.Intent.EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE;
+
+import static com.android.systemui.flags.Flags.SCREENSHOT_APP_CLIPS;
+import static com.android.systemui.screenshot.AppClipsTrampolineActivity.ACTION_FINISH_FROM_TRAMPOLINE;
+import static com.android.systemui.screenshot.AppClipsTrampolineActivity.EXTRA_SCREENSHOT_URI;
+import static com.android.systemui.screenshot.AppClipsTrampolineActivity.PERMISSION_SELF;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+import android.app.admin.DevicePolicyManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.testing.AndroidTestingRunner;
+
+import androidx.test.rule.ActivityTestRule;
+import androidx.test.runner.intercepting.SingleActivityFactory;
+
+import com.android.systemui.R;
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.dagger.qualifiers.Main;
+import com.android.systemui.flags.FeatureFlags;
+import com.android.systemui.notetask.NoteTaskController;
+import com.android.systemui.screenshot.AppClipsTrampolineActivity;
+import com.android.wm.shell.bubbles.Bubbles;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Optional;
+
+@RunWith(AndroidTestingRunner.class)
+public final class AppClipsTrampolineActivityTest extends SysuiTestCase {
+
+ private static final String TEST_URI_STRING = "www.test-uri.com";
+ private static final Uri TEST_URI = Uri.parse(TEST_URI_STRING);
+ private static final int TIME_OUT = 5000;
+
+ @Mock
+ private DevicePolicyManager mDevicePolicyManager;
+ @Mock
+ private FeatureFlags mFeatureFlags;
+ @Mock
+ private Optional<Bubbles> mOptionalBubbles;
+ @Mock
+ private Bubbles mBubbles;
+ @Mock
+ private NoteTaskController mNoteTaskController;
+ @Main
+ private Handler mMainHandler;
+
+ // Using the deprecated ActivityTestRule and SingleActivityFactory to help with injecting mocks
+ // and getting result from activity both of which are difficult to do in newer APIs.
+ private final SingleActivityFactory<AppClipsTrampolineActivityTestable> mFactory =
+ new SingleActivityFactory<>(AppClipsTrampolineActivityTestable.class) {
+ @Override
+ protected AppClipsTrampolineActivityTestable create(Intent unUsed) {
+ return new AppClipsTrampolineActivityTestable(mDevicePolicyManager,
+ mFeatureFlags, mOptionalBubbles, mNoteTaskController, mMainHandler);
+ }
+ };
+
+ @Rule
+ public final ActivityTestRule<AppClipsTrampolineActivityTestable> mActivityRule =
+ new ActivityTestRule<>(mFactory, false, false);
+
+ private Context mContext;
+ private Intent mActivityIntent;
+ private ComponentName mExpectedComponentName;
+ private Intent mKillAppClipsActivityBroadcast;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ mContext = getContext();
+ mMainHandler = mContext.getMainThreadHandler();
+
+ mActivityIntent = new Intent(mContext, AppClipsTrampolineActivityTestable.class);
+ mExpectedComponentName = ComponentName.unflattenFromString(
+ mContext.getString(
+ R.string.config_screenshotAppClipsActivityComponent));
+ mKillAppClipsActivityBroadcast = new Intent(ACTION_FINISH_FROM_TRAMPOLINE)
+ .setComponent(mExpectedComponentName)
+ .setPackage(mExpectedComponentName.getPackageName());
+ }
+
+ @After
+ public void tearDown() {
+ mContext.sendBroadcast(mKillAppClipsActivityBroadcast, PERMISSION_SELF);
+ mActivityRule.finishActivity();
+ }
+
+ @Test
+ public void configComponentName_shouldResolve() {
+ // Verify component name is setup - has package and class name.
+ assertThat(mExpectedComponentName).isNotNull();
+ assertThat(mExpectedComponentName.getPackageName()).isNotEmpty();
+ assertThat(mExpectedComponentName.getClassName()).isNotEmpty();
+
+ // Verify an intent when launched with above component resolves to the same component to
+ // confirm that component from above is available in framework.
+ Intent appClipsActivityIntent = new Intent();
+ appClipsActivityIntent.setComponent(mExpectedComponentName);
+ ResolveInfo resolveInfo = getContext().getPackageManager().resolveActivity(
+ appClipsActivityIntent, PackageManager.ResolveInfoFlags.of(0));
+ ActivityInfo activityInfo = resolveInfo.activityInfo;
+
+ assertThat(activityInfo.packageName).isEqualTo(
+ mExpectedComponentName.getPackageName());
+ assertThat(activityInfo.name).isEqualTo(mExpectedComponentName.getClassName());
+ }
+
+ @Test
+ public void flagOff_shouldFinishWithResultCancel() {
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(false);
+
+ mActivityRule.launchActivity(mActivityIntent);
+
+ assertThat(mActivityRule.getActivityResult().getResultCode())
+ .isEqualTo(Activity.RESULT_CANCELED);
+ }
+
+ @Test
+ public void bubblesEmpty_shouldFinishWithFailed() {
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true);
+ when(mOptionalBubbles.isEmpty()).thenReturn(true);
+
+ mActivityRule.launchActivity(mActivityIntent);
+
+ ActivityResult actualResult = mActivityRule.getActivityResult();
+ assertThat(actualResult.getResultCode()).isEqualTo(Activity.RESULT_OK);
+ assertThat(getStatusCodeExtra(actualResult.getResultData()))
+ .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ }
+
+ @Test
+ public void taskIdNotAppBubble_shouldFinishWithWindowModeUnsupported() {
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true);
+ when(mOptionalBubbles.isEmpty()).thenReturn(false);
+ when(mOptionalBubbles.get()).thenReturn(mBubbles);
+ when(mBubbles.isAppBubbleTaskId(anyInt())).thenReturn(false);
+
+ mActivityRule.launchActivity(mActivityIntent);
+
+ ActivityResult actualResult = mActivityRule.getActivityResult();
+ assertThat(actualResult.getResultCode()).isEqualTo(Activity.RESULT_OK);
+ assertThat(getStatusCodeExtra(actualResult.getResultData()))
+ .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED);
+ }
+
+ @Test
+ public void dpmScreenshotBlocked_shouldFinishWithBlockedByAdmin() {
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true);
+ when(mOptionalBubbles.isEmpty()).thenReturn(false);
+ when(mOptionalBubbles.get()).thenReturn(mBubbles);
+ when(mBubbles.isAppBubbleTaskId(anyInt())).thenReturn(true);
+ when(mDevicePolicyManager.getScreenCaptureDisabled(eq(null))).thenReturn(true);
+
+ mActivityRule.launchActivity(mActivityIntent);
+
+ ActivityResult actualResult = mActivityRule.getActivityResult();
+ assertThat(actualResult.getResultCode()).isEqualTo(Activity.RESULT_OK);
+ assertThat(getStatusCodeExtra(actualResult.getResultData()))
+ .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN);
+ }
+
+ @Test
+ public void startAppClipsActivity_userCanceled_shouldReturnUserCanceled() {
+ mockToSatisfyAllPrerequisites();
+
+ AppClipsTrampolineActivityTestable activity = mActivityRule.launchActivity(mActivityIntent);
+ waitForIdleSync();
+
+ Bundle bundle = new Bundle();
+ bundle.putInt(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE,
+ CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED);
+ activity.getResultReceiverForTest().send(Activity.RESULT_OK, bundle);
+ waitForIdleSync();
+
+ ActivityResult actualResult = mActivityRule.getActivityResult();
+ assertThat(actualResult.getResultCode()).isEqualTo(Activity.RESULT_OK);
+ assertThat(getStatusCodeExtra(actualResult.getResultData()))
+ .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED);
+ }
+
+ @Test
+ public void startAppClipsActivity_shouldReturnSuccess() {
+ mockToSatisfyAllPrerequisites();
+
+ AppClipsTrampolineActivityTestable activity = mActivityRule.launchActivity(mActivityIntent);
+ waitForIdleSync();
+
+ Bundle bundle = new Bundle();
+ bundle.putParcelable(EXTRA_SCREENSHOT_URI, TEST_URI);
+ bundle.putInt(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, CAPTURE_CONTENT_FOR_NOTE_SUCCESS);
+ activity.getResultReceiverForTest().send(Activity.RESULT_OK, bundle);
+ waitForIdleSync();
+
+ ActivityResult actualResult = mActivityRule.getActivityResult();
+ assertThat(actualResult.getResultCode()).isEqualTo(Activity.RESULT_OK);
+ assertThat(getStatusCodeExtra(actualResult.getResultData()))
+ .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_SUCCESS);
+ assertThat(actualResult.getResultData().getData()).isEqualTo(TEST_URI);
+ }
+
+ private void mockToSatisfyAllPrerequisites() {
+ when(mFeatureFlags.isEnabled(SCREENSHOT_APP_CLIPS)).thenReturn(true);
+ when(mOptionalBubbles.isEmpty()).thenReturn(false);
+ when(mOptionalBubbles.get()).thenReturn(mBubbles);
+ when(mBubbles.isAppBubbleTaskId(anyInt())).thenReturn(true);
+ when(mDevicePolicyManager.getScreenCaptureDisabled(eq(null))).thenReturn(false);
+ }
+
+ public static final class AppClipsTrampolineActivityTestable extends
+ AppClipsTrampolineActivity {
+
+ public AppClipsTrampolineActivityTestable(DevicePolicyManager devicePolicyManager,
+ FeatureFlags flags,
+ Optional<Bubbles> optionalBubbles,
+ NoteTaskController noteTaskController,
+ @Main Handler mainHandler) {
+ super(devicePolicyManager, flags, optionalBubbles, noteTaskController, mainHandler);
+ }
+ }
+
+ private static int getStatusCodeExtra(Intent intent) {
+ return intent.getIntExtra(EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE, -100);
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsViewModelTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsViewModelTest.java
new file mode 100644
index 000000000000..d5af7ce1d346
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/appclips/AppClipsViewModelTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2023 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.content.Intent.CAPTURE_CONTENT_FOR_NOTE_FAILED;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.Bitmap;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.ShapeDrawable;
+import android.net.Uri;
+import android.os.UserHandle;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.screenshot.appclips.AppClipsCrossProcessHelper;
+
+import com.google.common.util.concurrent.Futures;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.time.ZonedDateTime;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+
+@RunWith(AndroidJUnit4.class)
+public final class AppClipsViewModelTest extends SysuiTestCase {
+
+ private static final Bitmap FAKE_BITMAP = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+ private static final Drawable FAKE_DRAWABLE = new ShapeDrawable();
+ private static final Rect FAKE_RECT = new Rect();
+ private static final Uri FAKE_URI = Uri.parse("www.test-uri.com");
+
+ @Mock private AppClipsCrossProcessHelper mAppClipsCrossProcessHelper;
+ @Mock private ImageExporter mImageExporter;
+
+ private com.android.systemui.screenshot.AppClipsViewModel mViewModel;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ mViewModel = new AppClipsViewModel.Factory(mAppClipsCrossProcessHelper, mImageExporter,
+ getContext().getMainExecutor(), directExecutor()).create(AppClipsViewModel.class);
+ }
+
+ @Test
+ public void performScreenshot_fails_shouldUpdateErrorWithFailed() {
+ when(mAppClipsCrossProcessHelper.takeScreenshot()).thenReturn(null);
+
+ mViewModel.performScreenshot();
+ waitForIdleSync();
+
+ verify(mAppClipsCrossProcessHelper).takeScreenshot();
+ assertThat(mViewModel.getErrorLiveData().getValue())
+ .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ assertThat(mViewModel.getResultLiveData().getValue()).isNull();
+ }
+
+ @Test
+ public void performScreenshot_succeeds_shouldUpdateScreenshotWithBitmap() {
+ when(mAppClipsCrossProcessHelper.takeScreenshot()).thenReturn(FAKE_BITMAP);
+
+ mViewModel.performScreenshot();
+ waitForIdleSync();
+
+ verify(mAppClipsCrossProcessHelper).takeScreenshot();
+ assertThat(mViewModel.getErrorLiveData().getValue()).isNull();
+ assertThat(mViewModel.getScreenshot().getValue()).isEqualTo(FAKE_BITMAP);
+ }
+
+ @Test
+ public void saveScreenshot_throwsError_shouldUpdateErrorWithFailed() {
+ when(mImageExporter.export(any(Executor.class), any(UUID.class), eq(null), any(
+ ZonedDateTime.class), any(UserHandle.class))).thenReturn(
+ Futures.immediateFailedFuture(new ExecutionException(new Throwable())));
+
+ mViewModel.saveScreenshotThenFinish(FAKE_DRAWABLE, FAKE_RECT);
+ waitForIdleSync();
+
+ assertThat(mViewModel.getErrorLiveData().getValue())
+ .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ assertThat(mViewModel.getResultLiveData().getValue()).isNull();
+ }
+
+ @Test
+ public void saveScreenshot_failsSilently_shouldUpdateErrorWithFailed() {
+ when(mImageExporter.export(any(Executor.class), any(UUID.class), eq(null), any(
+ ZonedDateTime.class), any(UserHandle.class))).thenReturn(
+ Futures.immediateFuture(new ImageExporter.Result()));
+
+ mViewModel.saveScreenshotThenFinish(FAKE_DRAWABLE, FAKE_RECT);
+ waitForIdleSync();
+
+ assertThat(mViewModel.getErrorLiveData().getValue())
+ .isEqualTo(CAPTURE_CONTENT_FOR_NOTE_FAILED);
+ assertThat(mViewModel.getResultLiveData().getValue()).isNull();
+ }
+
+ @Test
+ public void saveScreenshot_succeeds_shouldUpdateResultWithUri() {
+ ImageExporter.Result result = new ImageExporter.Result();
+ result.uri = FAKE_URI;
+ when(mImageExporter.export(any(Executor.class), any(UUID.class), eq(null), any(
+ ZonedDateTime.class), any(UserHandle.class))).thenReturn(
+ Futures.immediateFuture(result));
+
+ mViewModel.saveScreenshotThenFinish(FAKE_DRAWABLE, FAKE_RECT);
+ waitForIdleSync();
+
+ assertThat(mViewModel.getErrorLiveData().getValue()).isNull();
+ assertThat(mViewModel.getResultLiveData().getValue()).isEqualTo(FAKE_URI);
+ }
+}