Implement the new screenshot activities.
Bug: 251205791 Test: CTS test Change-Id: I63bebff540c450c106fa48d7e9c5a221f5cd51a3
This commit is contained in:
@@ -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"/>
|
||||
|
||||
|
||||
105
packages/SystemUI/res/layout/app_clips_screenshot.xml
Normal file
105
packages/SystemUI/res/layout/app_clips_screenshot.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user