Implement the new screenshot activities.

Bug: 251205791
Test: CTS test
Change-Id: I63bebff540c450c106fa48d7e9c5a221f5cd51a3
This commit is contained in:
Ajinkya Chalke
2023-01-25 01:01:10 +00:00
parent d263cdca17
commit 618e46f67b
21 changed files with 1943 additions and 0 deletions

View File

@@ -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"/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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));
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
};
}
}

View File

@@ -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);
}
};
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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"

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}