Merge changes from topic "screenshot-permission"
* changes: Implement the new screenshot activities. WM changes to support new screenshot functionality. Add flag for the new App Clips screenshot feature. Add screenshot permission, action, and API.
This commit is contained in:
committed by
Android (Google) Code Review
commit
a3783bcb0d
@@ -121,6 +121,7 @@ package android {
|
||||
field public static final String INTERACT_ACROSS_PROFILES = "android.permission.INTERACT_ACROSS_PROFILES";
|
||||
field public static final String INTERNET = "android.permission.INTERNET";
|
||||
field public static final String KILL_BACKGROUND_PROCESSES = "android.permission.KILL_BACKGROUND_PROCESSES";
|
||||
field public static final String LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE = "android.permission.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE";
|
||||
field public static final String LAUNCH_MULTI_PANE_SETTINGS_DEEP_LINK = "android.permission.LAUNCH_MULTI_PANE_SETTINGS_DEEP_LINK";
|
||||
field public static final String LOADER_USAGE_STATS = "android.permission.LOADER_USAGE_STATS";
|
||||
field public static final String LOCATION_HARDWARE = "android.permission.LOCATION_HARDWARE";
|
||||
@@ -7197,6 +7198,7 @@ package android.app {
|
||||
}
|
||||
|
||||
public class StatusBarManager {
|
||||
method @RequiresPermission(android.Manifest.permission.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE) public boolean canLaunchCaptureContentActivityForNote(@NonNull android.app.Activity);
|
||||
method public void requestAddTileService(@NonNull android.content.ComponentName, @NonNull CharSequence, @NonNull android.graphics.drawable.Icon, @NonNull java.util.concurrent.Executor, @NonNull java.util.function.Consumer<java.lang.Integer>);
|
||||
field public static final int TILE_ADD_REQUEST_ERROR_APP_NOT_IN_FOREGROUND = 1004; // 0x3ec
|
||||
field public static final int TILE_ADD_REQUEST_ERROR_BAD_COMPONENT = 1002; // 0x3ea
|
||||
@@ -10711,6 +10713,7 @@ package android.content {
|
||||
field public static final String ACTION_INSERT_OR_EDIT = "android.intent.action.INSERT_OR_EDIT";
|
||||
field public static final String ACTION_INSTALL_FAILURE = "android.intent.action.INSTALL_FAILURE";
|
||||
field @Deprecated public static final String ACTION_INSTALL_PACKAGE = "android.intent.action.INSTALL_PACKAGE";
|
||||
field @RequiresPermission(android.Manifest.permission.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE) public static final String ACTION_LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE = "android.intent.action.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE";
|
||||
field public static final String ACTION_LOCALE_CHANGED = "android.intent.action.LOCALE_CHANGED";
|
||||
field public static final String ACTION_LOCKED_BOOT_COMPLETED = "android.intent.action.LOCKED_BOOT_COMPLETED";
|
||||
field public static final String ACTION_MAIN = "android.intent.action.MAIN";
|
||||
@@ -10804,6 +10807,11 @@ package android.content {
|
||||
field public static final String ACTION_VOICE_COMMAND = "android.intent.action.VOICE_COMMAND";
|
||||
field @Deprecated public static final String ACTION_WALLPAPER_CHANGED = "android.intent.action.WALLPAPER_CHANGED";
|
||||
field public static final String ACTION_WEB_SEARCH = "android.intent.action.WEB_SEARCH";
|
||||
field public static final int CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN = 4; // 0x4
|
||||
field public static final int CAPTURE_CONTENT_FOR_NOTE_FAILED = 1; // 0x1
|
||||
field public static final int CAPTURE_CONTENT_FOR_NOTE_SUCCESS = 0; // 0x0
|
||||
field public static final int CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED = 2; // 0x2
|
||||
field public static final int CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED = 3; // 0x3
|
||||
field public static final String CATEGORY_ACCESSIBILITY_SHORTCUT_TARGET = "android.intent.category.ACCESSIBILITY_SHORTCUT_TARGET";
|
||||
field public static final String CATEGORY_ALTERNATIVE = "android.intent.category.ALTERNATIVE";
|
||||
field public static final String CATEGORY_APP_BROWSER = "android.intent.category.APP_BROWSER";
|
||||
@@ -10859,6 +10867,7 @@ package android.content {
|
||||
field public static final String EXTRA_AUTO_LAUNCH_SINGLE_CHOICE = "android.intent.extra.AUTO_LAUNCH_SINGLE_CHOICE";
|
||||
field public static final String EXTRA_BCC = "android.intent.extra.BCC";
|
||||
field public static final String EXTRA_BUG_REPORT = "android.intent.extra.BUG_REPORT";
|
||||
field public static final String EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE = "android.intent.extra.CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE";
|
||||
field public static final String EXTRA_CC = "android.intent.extra.CC";
|
||||
field @Deprecated public static final String EXTRA_CHANGED_COMPONENT_NAME = "android.intent.extra.changed_component_name";
|
||||
field public static final String EXTRA_CHANGED_COMPONENT_NAME_LIST = "android.intent.extra.changed_component_name_list";
|
||||
|
||||
@@ -30,6 +30,7 @@ import android.compat.annotation.EnabledSince;
|
||||
import android.compat.annotation.UnsupportedAppUsage;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.drawable.Icon;
|
||||
import android.media.INearbyMediaDevicesProvider;
|
||||
import android.media.INearbyMediaDevicesUpdateCallback;
|
||||
@@ -47,6 +48,7 @@ import android.util.Pair;
|
||||
import android.util.Slog;
|
||||
import android.view.View;
|
||||
|
||||
import com.android.internal.statusbar.AppClipsServiceConnector;
|
||||
import com.android.internal.statusbar.IAddTileResultCallback;
|
||||
import com.android.internal.statusbar.IStatusBarService;
|
||||
import com.android.internal.statusbar.IUndoMediaTransferCallback;
|
||||
@@ -1190,6 +1192,37 @@ public class StatusBarManager {
|
||||
return CompatChanges.isChangeEnabled(MEDIA_CONTROL_SESSION_ACTIONS, packageName, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the supplied activity can {@link Activity#startActivityForResult(Intent, int)}
|
||||
* a system activity that captures content on the screen to take a screenshot.
|
||||
*
|
||||
* <p>Note: The result should not be cached.
|
||||
*
|
||||
* <p>The system activity displays an editing tool that allows user to edit the screenshot, save
|
||||
* it on device, and return the edited screenshot as {@link android.net.Uri} to the calling
|
||||
* activity. User interaction is required to return the edited screenshot to the calling
|
||||
* activity.
|
||||
*
|
||||
* <p>When {@code true}, callers can use {@link Activity#startActivityForResult(Intent, int)}
|
||||
* to start start the content capture activity using
|
||||
* {@link Intent#ACTION_LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE}.
|
||||
*
|
||||
* @param activity Calling activity
|
||||
* @return true if the activity supports launching the capture content activity for note.
|
||||
*
|
||||
* @see Intent#ACTION_LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE
|
||||
* @see Manifest.permission#LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE
|
||||
* @see android.app.role.RoleManager#ROLE_NOTES
|
||||
*/
|
||||
@RequiresPermission(Manifest.permission.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE)
|
||||
public boolean canLaunchCaptureContentActivityForNote(@NonNull Activity activity) {
|
||||
Objects.requireNonNull(activity);
|
||||
IBinder activityToken = activity.getActivityToken();
|
||||
int taskId = ActivityClient.getInstance().getTaskForActivity(activityToken, false);
|
||||
return new AppClipsServiceConnector(mContext)
|
||||
.canLaunchCaptureContentActivityForNote(taskId);
|
||||
}
|
||||
|
||||
/** @hide */
|
||||
public static String windowStateToString(int state) {
|
||||
if (state == WINDOW_STATE_HIDING) return "WINDOW_STATE_HIDING";
|
||||
|
||||
@@ -31,8 +31,10 @@ import android.annotation.SdkConstant.SdkConstantType;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.annotation.SystemApi;
|
||||
import android.annotation.TestApi;
|
||||
import android.app.Activity;
|
||||
import android.app.ActivityThread;
|
||||
import android.app.AppGlobals;
|
||||
import android.app.StatusBarManager;
|
||||
import android.bluetooth.BluetoothDevice;
|
||||
import android.compat.annotation.UnsupportedAppUsage;
|
||||
import android.content.pm.ActivityInfo;
|
||||
@@ -5136,6 +5138,86 @@ public class Intent implements Parcelable, Cloneable {
|
||||
*/
|
||||
public static final String EXTRA_USE_STYLUS_MODE = "android.intent.extra.USE_STYLUS_MODE";
|
||||
|
||||
/**
|
||||
* Activity Action: Use with startActivityForResult to start a system activity that captures
|
||||
* content on the screen to take a screenshot and present it to the user for editing. The
|
||||
* edited screenshot is saved on device and returned to the calling activity as a {@link Uri}
|
||||
* through {@link #getData()}. User interaction is required to return the edited screenshot to
|
||||
* the calling activity.
|
||||
*
|
||||
* <p>This intent action requires the permission
|
||||
* {@link android.Manifest.permission#LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE}.
|
||||
*
|
||||
* <p>Callers should query
|
||||
* {@link StatusBarManager#canLaunchCaptureContentActivityForNote(Activity)} before showing a UI
|
||||
* element that allows users to trigger this flow.
|
||||
*/
|
||||
@RequiresPermission(Manifest.permission.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE)
|
||||
@SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
|
||||
public static final String ACTION_LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE =
|
||||
"android.intent.action.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE";
|
||||
|
||||
/**
|
||||
* An int extra used by activity started with
|
||||
* {@link #ACTION_LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE} to indicate status of the response.
|
||||
* This extra is used along with result code set to {@link android.app.Activity#RESULT_OK}.
|
||||
*
|
||||
* <p>The value for this extra can be one of the following:
|
||||
* <ul>
|
||||
* <li>{@link #CAPTURE_CONTENT_FOR_NOTE_SUCCESS}</li>
|
||||
* <li>{@link #CAPTURE_CONTENT_FOR_NOTE_FAILED}</li>
|
||||
* <li>{@link #CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED}</li>
|
||||
* <li>{@link #CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED}</li>
|
||||
* <li>{@link #CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN}</li>
|
||||
* </ul>
|
||||
*/
|
||||
public static final String EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE =
|
||||
"android.intent.extra.CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE";
|
||||
|
||||
/**
|
||||
* A response code used with {@link #EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE} to indicate
|
||||
* that the request was a success.
|
||||
*
|
||||
* <p>This code will only be returned after the user has interacted with the system screenshot
|
||||
* activity to consent to sharing the data with the note.
|
||||
*
|
||||
* <p>The captured screenshot is returned as a {@link Uri} through {@link #getData()}.
|
||||
*/
|
||||
public static final int CAPTURE_CONTENT_FOR_NOTE_SUCCESS = 0;
|
||||
|
||||
/**
|
||||
* A response code used with {@link #EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE} to indicate
|
||||
* that something went wrong.
|
||||
*/
|
||||
public static final int CAPTURE_CONTENT_FOR_NOTE_FAILED = 1;
|
||||
|
||||
/**
|
||||
* A response code used with {@link #EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE} to indicate
|
||||
* that user canceled the content capture flow.
|
||||
*/
|
||||
public static final int CAPTURE_CONTENT_FOR_NOTE_USER_CANCELED = 2;
|
||||
|
||||
/**
|
||||
* A response code used with {@link #EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE} to indicate
|
||||
* that the intent action {@link #ACTION_LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE} was started
|
||||
* by an activity that is running in a non-supported window mode.
|
||||
*/
|
||||
public static final int CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED = 3;
|
||||
|
||||
/**
|
||||
* A response code used with {@link #EXTRA_CAPTURE_CONTENT_FOR_NOTE_STATUS_CODE} to indicate
|
||||
* that screenshot is blocked by IT admin.
|
||||
*/
|
||||
public static final int CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN = 4;
|
||||
|
||||
/** @hide */
|
||||
@IntDef(value = {
|
||||
CAPTURE_CONTENT_FOR_NOTE_SUCCESS, CAPTURE_CONTENT_FOR_NOTE_FAILED,
|
||||
CAPTURE_CONTENT_FOR_NOTE_WINDOW_MODE_UNSUPPORTED,
|
||||
CAPTURE_CONTENT_FOR_NOTE_BLOCKED_BY_ADMIN})
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface CaptureContentForNoteStatusCodes {}
|
||||
|
||||
/**
|
||||
* Broadcast Action: Sent to the integrity component when a package
|
||||
* needs to be verified. The data contains the package URI along with other relevant
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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.internal.statusbar;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* A helper class to communicate with the App Clips service running in SystemUI.
|
||||
*/
|
||||
public class AppClipsServiceConnector {
|
||||
|
||||
private static final String TAG = AppClipsServiceConnector.class.getSimpleName();
|
||||
|
||||
private final Context mContext;
|
||||
private final Handler mHandler;
|
||||
|
||||
public AppClipsServiceConnector(Context context) {
|
||||
mContext = context;
|
||||
HandlerThread handlerThread = new HandlerThread(TAG);
|
||||
handlerThread.start();
|
||||
mHandler = handlerThread.getThreadHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if the task represented by {@code taskId} can launch App Clips screenshot flow,
|
||||
* false otherwise.
|
||||
*/
|
||||
public boolean canLaunchCaptureContentActivityForNote(int taskId) {
|
||||
try {
|
||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||
connectToServiceAndProcessRequest(taskId, future);
|
||||
return future.get();
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Exception from service\n" + e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void connectToServiceAndProcessRequest(int taskId, CompletableFuture<Boolean> future) {
|
||||
ServiceConnection serviceConnection = new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName name, IBinder service) {
|
||||
try {
|
||||
future.complete(IAppClipsService.Stub.asInterface(
|
||||
service).canLaunchCaptureContentActivityForNote(taskId));
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Exception from service\n" + e);
|
||||
}
|
||||
future.complete(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
if (!future.isDone()) {
|
||||
future.complete(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
final ComponentName serviceComponent = ComponentName.unflattenFromString(
|
||||
mContext.getResources().getString(
|
||||
com.android.internal.R.string.config_screenshotAppClipsServiceComponent));
|
||||
final Intent serviceIntent = new Intent();
|
||||
serviceIntent.setComponent(serviceComponent);
|
||||
|
||||
boolean bindService = mContext.bindServiceAsUser(serviceIntent, serviceConnection,
|
||||
Context.BIND_AUTO_CREATE | Context.BIND_FOREGROUND_SERVICE, mHandler,
|
||||
mContext.getUser());
|
||||
|
||||
// Complete the future early if service not bound.
|
||||
if (!bindService) {
|
||||
future.complete(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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.internal.statusbar;
|
||||
|
||||
/**
|
||||
* A service that runs in SystemUI and helps determine if App Clips flow is supported in the
|
||||
* current state of device. This service needs to run in SystemUI in order to communicate with the
|
||||
* instance of app bubbles.
|
||||
*/
|
||||
interface IAppClipsService {
|
||||
boolean canLaunchCaptureContentActivityForNote(in int taskId);
|
||||
}
|
||||
@@ -2579,6 +2579,14 @@
|
||||
<!-- ==================================================== -->
|
||||
<eat-comment />
|
||||
|
||||
<!-- Allows an application to capture screen content to perform a screenshot using the intent
|
||||
action {@link android.content.Intent#ACTION_LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE}.
|
||||
<p>Protection level: internal|role
|
||||
<p>Intended for use by ROLE_NOTES only.
|
||||
-->
|
||||
<permission android:name="android.permission.LAUNCH_CAPTURE_CONTENT_ACTIVITY_FOR_NOTE"
|
||||
android:protectionLevel="internal|role" />
|
||||
|
||||
<!-- Allows an application to get notified when a screen capture of its windows is attempted.
|
||||
<p>Protection level: normal
|
||||
-->
|
||||
|
||||
@@ -3233,6 +3233,12 @@
|
||||
<string name="config_somnambulatorComponent" translatable="false"
|
||||
>com.android.systemui/com.android.systemui.Somnambulator</string>
|
||||
|
||||
<!-- The component name of the screenshot App Clips service that communicates with SystemUI to
|
||||
evaluate certain aspects of App Clips flow such as whether a calling activity can launch
|
||||
capture content for note activity. -->
|
||||
<string name="config_screenshotAppClipsServiceComponent" translatable="false"
|
||||
>com.android.systemui/com.android.systemui.screenshot.appclips.AppClipsService</string>
|
||||
|
||||
<!-- The component name of a special dock app that merely launches a dream.
|
||||
We don't want to launch this app when docked because it causes an unnecessary
|
||||
activity transition. We just want to start the dream.. -->
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
<java-symbol type="string" name="config_controlsPackage" />
|
||||
<java-symbol type="string" name="config_screenRecorderComponent" />
|
||||
<java-symbol type="string" name="config_somnambulatorComponent" />
|
||||
<java-symbol type="string" name="config_screenshotAppClipsServiceComponent" />
|
||||
<java-symbol type="string" name="config_screenshotServiceComponent" />
|
||||
<java-symbol type="string" name="config_screenshotErrorReceiverComponent" />
|
||||
<java-symbol type="string" name="config_slicePermissionComponent" />
|
||||
|
||||
@@ -68,10 +68,14 @@ import android.service.notification.NotificationListenerService.RankingMap;
|
||||
import android.util.Log;
|
||||
import android.util.Pair;
|
||||
import android.util.SparseArray;
|
||||
import android.view.IWindowManager;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowManager;
|
||||
import android.window.ScreenCapture;
|
||||
import android.window.ScreenCapture.ScreenCaptureListener;
|
||||
import android.window.ScreenCapture.ScreenshotSync;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -143,6 +147,7 @@ public class BubbleController implements ConfigurationChangeListener {
|
||||
private final SyncTransactionQueue mSyncQueue;
|
||||
private final ShellController mShellController;
|
||||
private final ShellCommandHandler mShellCommandHandler;
|
||||
private final IWindowManager mWmService;
|
||||
|
||||
// Used to post to main UI thread
|
||||
private final ShellExecutor mMainExecutor;
|
||||
@@ -237,7 +242,8 @@ public class BubbleController implements ConfigurationChangeListener {
|
||||
@ShellMainThread Handler mainHandler,
|
||||
@ShellBackgroundThread ShellExecutor bgExecutor,
|
||||
TaskViewTransitions taskViewTransitions,
|
||||
SyncTransactionQueue syncQueue) {
|
||||
SyncTransactionQueue syncQueue,
|
||||
IWindowManager wmService) {
|
||||
mContext = context;
|
||||
mShellCommandHandler = shellCommandHandler;
|
||||
mShellController = shellController;
|
||||
@@ -269,6 +275,7 @@ public class BubbleController implements ConfigurationChangeListener {
|
||||
mOneHandedOptional = oneHandedOptional;
|
||||
mDragAndDropController = dragAndDropController;
|
||||
mSyncQueue = syncQueue;
|
||||
mWmService = wmService;
|
||||
shellInit.addInitCallback(this::onInit, this);
|
||||
}
|
||||
|
||||
@@ -1036,6 +1043,21 @@ public class BubbleController implements ConfigurationChangeListener {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a screenshot that may exclude the bubble layer, if one is present. The screenshot
|
||||
* can be access via the supplied {@link ScreenshotSync#get()} asynchronously.
|
||||
*
|
||||
* TODO(b/267324693): Implement the exclude layer functionality in screenshot.
|
||||
*/
|
||||
public void getScreenshotExcludingBubble(int displayId,
|
||||
Pair<ScreenCaptureListener, ScreenshotSync> screenCaptureListener) {
|
||||
try {
|
||||
mWmService.captureDisplay(displayId, null, screenCaptureListener.first);
|
||||
} catch (RemoteException e) {
|
||||
Log.e(TAG, "Failed to capture screenshot");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the overflow bubbles by loading them from disk.
|
||||
*/
|
||||
@@ -1749,6 +1771,25 @@ public class BubbleController implements ConfigurationChangeListener {
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAppBubbleTaskId(int taskId) {
|
||||
Bubble appBubble = mBubbleData.getBubbleInStackWithKey(KEY_APP_BUBBLE);
|
||||
return appBubble != null && appBubble.getTaskId() == taskId;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public ScreenshotSync getScreenshotExcludingBubble(int displayId) {
|
||||
Pair<ScreenCaptureListener, ScreenshotSync> screenCaptureListener =
|
||||
ScreenCapture.createSyncCaptureListener();
|
||||
|
||||
mMainExecutor.execute(
|
||||
() -> BubbleController.this.getScreenshotExcludingBubble(displayId,
|
||||
screenCaptureListener));
|
||||
|
||||
return screenCaptureListener.second;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handleDismissalInterception(BubbleEntry entry,
|
||||
@Nullable List<BubbleEntry> children, IntConsumer removeCallback,
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
package com.android.wm.shell.bubbles;
|
||||
|
||||
import static android.window.ScreenCapture.ScreenshotSync;
|
||||
|
||||
import static java.lang.annotation.ElementType.FIELD;
|
||||
import static java.lang.annotation.ElementType.LOCAL_VARIABLE;
|
||||
import static java.lang.annotation.ElementType.PARAMETER;
|
||||
@@ -24,11 +26,13 @@ import static java.lang.annotation.RetentionPolicy.SOURCE;
|
||||
import android.app.NotificationChannel;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.UserInfo;
|
||||
import android.hardware.HardwareBuffer;
|
||||
import android.os.UserHandle;
|
||||
import android.service.notification.NotificationListenerService;
|
||||
import android.service.notification.NotificationListenerService.RankingMap;
|
||||
import android.util.Pair;
|
||||
import android.util.SparseArray;
|
||||
import android.window.ScreenCapture.ScreenshotHardwareBuffer;
|
||||
|
||||
import androidx.annotation.IntDef;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -132,6 +136,18 @@ public interface Bubbles {
|
||||
*/
|
||||
void showOrHideAppBubble(Intent intent);
|
||||
|
||||
/** @return true if the specified {@code taskId} corresponds to app bubble's taskId. */
|
||||
boolean isAppBubbleTaskId(int taskId);
|
||||
|
||||
/**
|
||||
* @return a {@link ScreenshotSync} after performing a screenshot that may exclude the bubble
|
||||
* layer, if one is present. The underlying {@link ScreenshotHardwareBuffer} can be access via
|
||||
* {@link ScreenshotSync#get()} asynchronously and care should be taken to
|
||||
* {@link HardwareBuffer#close()} the associated
|
||||
* {@link ScreenshotHardwareBuffer#getHardwareBuffer()} when no longer required.
|
||||
*/
|
||||
ScreenshotSync getScreenshotExcludingBubble(int displayId);
|
||||
|
||||
/**
|
||||
* @return a bubble that matches the provided shortcutId, if one exists.
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.content.pm.LauncherApps;
|
||||
import android.os.Handler;
|
||||
import android.os.UserManager;
|
||||
import android.view.Choreographer;
|
||||
import android.view.IWindowManager;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import com.android.internal.jank.InteractionJankMonitor;
|
||||
@@ -170,14 +171,15 @@ public abstract class WMShellModule {
|
||||
@ShellMainThread Handler mainHandler,
|
||||
@ShellBackgroundThread ShellExecutor bgExecutor,
|
||||
TaskViewTransitions taskViewTransitions,
|
||||
SyncTransactionQueue syncQueue) {
|
||||
SyncTransactionQueue syncQueue,
|
||||
IWindowManager wmService) {
|
||||
return new BubbleController(context, shellInit, shellCommandHandler, shellController, data,
|
||||
null /* synchronizer */, floatingContentCoordinator,
|
||||
new BubbleDataRepository(context, launcherApps, mainExecutor),
|
||||
statusBarService, windowManager, windowManagerShellWrapper, userManager,
|
||||
launcherApps, logger, taskStackListener, organizer, positioner, displayController,
|
||||
oneHandedOptional, dragAndDropController, mainExecutor, mainHandler, bgExecutor,
|
||||
taskViewTransitions, syncQueue);
|
||||
taskViewTransitions, syncQueue, wmService);
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -516,6 +516,9 @@ object Flags {
|
||||
// TODO(b/266955521): Tracking bug
|
||||
@JvmField val SCREENSHOT_DETECTION = unreleasedFlag(1303, "screenshot_detection")
|
||||
|
||||
// TODO(b/251205791): Tracking Bug
|
||||
@JvmField val SCREENSHOT_APP_CLIPS = releasedFlag(1304, "screenshot_app_clips")
|
||||
|
||||
// 1400 - columbus
|
||||
// TODO(b/254512756): Tracking Bug
|
||||
val QUICK_TAP_IN_PCC = releasedFlag(1400, "quick_tap_in_pcc")
|
||||
@@ -549,7 +552,7 @@ object Flags {
|
||||
@JvmField val DUAL_SHADE = releasedFlag(1801, "dual_shade")
|
||||
|
||||
// 1900
|
||||
@JvmField val NOTE_TASKS = unreleasedFlag(1900, "keycode_flag")
|
||||
@JvmField val NOTE_TASKS = releasedFlag(1900, "keycode_flag")
|
||||
|
||||
// 2000 - device controls
|
||||
@Keep @JvmField val USE_APP_PANELS = releasedFlag(2000, "use_app_panels", teamfood = true)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ import android.testing.AndroidTestingRunner;
|
||||
import android.testing.TestableLooper;
|
||||
import android.util.Pair;
|
||||
import android.util.SparseArray;
|
||||
import android.view.IWindowManager;
|
||||
import android.view.View;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.WindowManager;
|
||||
@@ -392,7 +393,8 @@ public class BubblesTest extends SysuiTestCase {
|
||||
syncExecutor,
|
||||
mock(Handler.class),
|
||||
mTaskViewTransitions,
|
||||
mock(SyncTransactionQueue.class));
|
||||
mock(SyncTransactionQueue.class),
|
||||
mock(IWindowManager.class));
|
||||
mBubbleController.setExpandListener(mBubbleExpandListener);
|
||||
spyOn(mBubbleController);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import android.content.Context;
|
||||
import android.content.pm.LauncherApps;
|
||||
import android.os.Handler;
|
||||
import android.os.UserManager;
|
||||
import android.view.IWindowManager;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import com.android.internal.statusbar.IStatusBarService;
|
||||
@@ -72,13 +73,14 @@ public class TestableBubbleController extends BubbleController {
|
||||
ShellExecutor shellMainExecutor,
|
||||
Handler shellMainHandler,
|
||||
TaskViewTransitions taskViewTransitions,
|
||||
SyncTransactionQueue syncQueue) {
|
||||
SyncTransactionQueue syncQueue,
|
||||
IWindowManager wmService) {
|
||||
super(context, shellInit, shellCommandHandler, shellController, data, Runnable::run,
|
||||
floatingContentCoordinator, dataRepository, statusBarService, windowManager,
|
||||
windowManagerShellWrapper, userManager, launcherApps, bubbleLogger,
|
||||
taskStackListener, shellTaskOrganizer, positioner, displayController,
|
||||
oneHandedOptional, dragAndDropController, shellMainExecutor, shellMainHandler,
|
||||
new SyncExecutor(), taskViewTransitions, syncQueue);
|
||||
new SyncExecutor(), taskViewTransitions, syncQueue, wmService);
|
||||
setInflateSynchronously(true);
|
||||
onInit();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user