Merge "Implement screenshot smart actions with new action provider" into main
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/LegacyScreenshotViewProxy.kt b/packages/SystemUI/src/com/android/systemui/screenshot/LegacyScreenshotViewProxy.kt
index a1481f6..4cf18fb 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/LegacyScreenshotViewProxy.kt
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/LegacyScreenshotViewProxy.kt
@@ -34,9 +34,9 @@
import com.android.internal.logging.UiEventLogger
import com.android.systemui.flags.FeatureFlags
import com.android.systemui.res.R
-import com.android.systemui.screenshot.scroll.ScrollCaptureController
import com.android.systemui.screenshot.LogConfig.DEBUG_DISMISS
import com.android.systemui.screenshot.ScreenshotEvent.SCREENSHOT_DISMISSED_OTHER
+import com.android.systemui.screenshot.scroll.ScrollCaptureController
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -113,7 +113,7 @@
override fun setChipIntents(imageData: ScreenshotController.SavedImageData) =
view.setChipIntents(imageData)
- override fun requestDismissal(event: ScreenshotEvent) {
+ override fun requestDismissal(event: ScreenshotEvent?) {
if (DEBUG_DISMISS) {
Log.d(TAG, "screenshot dismissal requested")
}
@@ -124,7 +124,7 @@
}
return
}
- logger.log(event, 0, packageName)
+ event?.let { logger.log(event, 0, packageName) }
view.animateDismissal()
}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/SaveImageInBackgroundTask.java b/packages/SystemUI/src/com/android/systemui/screenshot/SaveImageInBackgroundTask.java
index bbf7ed5..4914409 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/SaveImageInBackgroundTask.java
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/SaveImageInBackgroundTask.java
@@ -165,6 +165,7 @@
mQuickShareData.quickShareAction, mScreenshotId, uri, mImageTime, image,
mParams.owner);
mImageData.subject = getSubjectString(mImageTime);
+ mImageData.imageTime = mImageTime;
mParams.mActionsReadyListener.onActionsReady(mImageData);
if (DEBUG_CALLBACK) {
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotActionsProvider.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotActionsProvider.kt
index 5019a6f..ca0a539 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotActionsProvider.kt
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotActionsProvider.kt
@@ -17,24 +17,37 @@
package com.android.systemui.screenshot
import android.app.ActivityOptions
+import android.app.BroadcastOptions
import android.app.ExitTransitionCoordinator
+import android.app.PendingIntent
import android.content.Context
import android.content.Intent
+import android.os.Process
+import android.os.UserHandle
+import android.provider.DeviceConfig
import android.util.Log
import android.util.Pair
import androidx.appcompat.content.res.AppCompatResources
import com.android.app.tracing.coroutines.launch
+import com.android.internal.config.sysui.SystemUiDeviceConfigFlags
+import com.android.internal.logging.UiEventLogger
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.log.DebugLogger.debugLog
import com.android.systemui.res.R
import com.android.systemui.screenshot.ActionIntentCreator.createEdit
import com.android.systemui.screenshot.ActionIntentCreator.createShareWithSubject
import com.android.systemui.screenshot.ScreenshotController.SavedImageData
+import com.android.systemui.screenshot.ScreenshotEvent.SCREENSHOT_EDIT_TAPPED
+import com.android.systemui.screenshot.ScreenshotEvent.SCREENSHOT_PREVIEW_TAPPED
+import com.android.systemui.screenshot.ScreenshotEvent.SCREENSHOT_SHARE_TAPPED
+import com.android.systemui.screenshot.ScreenshotEvent.SCREENSHOT_SMART_ACTION_TAPPED
import com.android.systemui.screenshot.ui.viewmodel.ActionButtonViewModel
import com.android.systemui.screenshot.ui.viewmodel.ScreenshotViewModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
+import java.text.DateFormat
+import java.util.Date
import kotlinx.coroutines.CoroutineScope
/**
@@ -48,7 +61,9 @@
interface Factory {
fun create(
request: ScreenshotData,
+ requestId: String,
windowTransition: () -> Pair<ActivityOptions, ExitTransitionCoordinator>,
+ requestDismissal: () -> Unit,
): ScreenshotActionsProvider
}
}
@@ -59,9 +74,13 @@
private val context: Context,
private val viewModel: ScreenshotViewModel,
private val actionExecutor: ActionIntentExecutor,
+ private val smartActionsProvider: SmartActionsProvider,
+ private val uiEventLogger: UiEventLogger,
@Application private val applicationScope: CoroutineScope,
@Assisted val request: ScreenshotData,
+ @Assisted val requestId: String,
@Assisted val windowTransition: () -> Pair<ActivityOptions, ExitTransitionCoordinator>,
+ @Assisted val requestDismissal: () -> Unit,
) : ScreenshotActionsProvider {
private var pendingAction: ((SavedImageData) -> Unit)? = null
private var result: SavedImageData? = null
@@ -70,6 +89,7 @@
init {
viewModel.setPreviewAction {
debugLog(LogConfig.DEBUG_ACTIONS) { "Preview tapped" }
+ uiEventLogger.log(SCREENSHOT_PREVIEW_TAPPED, 0, request.packageNameString)
onDeferrableActionTapped { result ->
startSharedTransition(createEdit(result.uri, context), true)
}
@@ -81,6 +101,7 @@
context.resources.getString(R.string.screenshot_edit_description),
) {
debugLog(LogConfig.DEBUG_ACTIONS) { "Edit tapped" }
+ uiEventLogger.log(SCREENSHOT_EDIT_TAPPED, 0, request.packageNameString)
onDeferrableActionTapped { result ->
startSharedTransition(createEdit(result.uri, context), true)
}
@@ -93,11 +114,46 @@
context.resources.getString(R.string.screenshot_share_description),
) {
debugLog(LogConfig.DEBUG_ACTIONS) { "Share tapped" }
+ uiEventLogger.log(SCREENSHOT_SHARE_TAPPED, 0, request.packageNameString)
onDeferrableActionTapped { result ->
startSharedTransition(createShareWithSubject(result.uri, result.subject), false)
}
}
)
+ if (smartActionsEnabled(request.userHandle ?: Process.myUserHandle())) {
+ smartActionsProvider.requestQuickShare(request, requestId) { quickShare ->
+ if (!quickShare.actionIntent.isImmutable) {
+ viewModel.addAction(
+ ActionButtonViewModel(
+ quickShare.getIcon().loadDrawable(context),
+ quickShare.title,
+ quickShare.title
+ ) {
+ debugLog(LogConfig.DEBUG_ACTIONS) { "Quickshare tapped" }
+ onDeferrableActionTapped { result ->
+ uiEventLogger.log(
+ SCREENSHOT_SMART_ACTION_TAPPED,
+ 0,
+ request.packageNameString
+ )
+ sendPendingIntent(
+ smartActionsProvider
+ .wrapIntent(
+ quickShare,
+ result.uri,
+ result.subject,
+ requestId
+ )
+ .actionIntent
+ )
+ }
+ }
+ )
+ } else {
+ Log.w(TAG, "Received immutable quick share pending intent; ignoring")
+ }
+ }
+ }
}
override fun setCompletedScreenshot(result: SavedImageData) {
@@ -105,12 +161,30 @@
Log.e(TAG, "Got a second completed screenshot for existing request!")
return
}
- if (result.uri == null || result.owner == null || result.subject == null) {
+ if (result.uri == null || result.owner == null || result.imageTime == null) {
Log.e(TAG, "Invalid result provided!")
return
}
+ if (result.subject == null) {
+ result.subject = getSubjectString(result.imageTime)
+ }
this.result = result
pendingAction?.invoke(result)
+ if (smartActionsEnabled(result.owner)) {
+ smartActionsProvider.requestSmartActions(request, requestId, result) { smartActions ->
+ viewModel.addActions(
+ smartActions.map {
+ ActionButtonViewModel(
+ it.getIcon().loadDrawable(context),
+ it.title,
+ it.title
+ ) {
+ sendPendingIntent(it.actionIntent)
+ }
+ }
+ )
+ }
+ }
}
override fun isPendingSharedTransition(): Boolean {
@@ -134,15 +208,47 @@
}
}
+ private fun sendPendingIntent(pendingIntent: PendingIntent) {
+ try {
+ val options = BroadcastOptions.makeBasic()
+ options.setInteractive(true)
+ options.setPendingIntentBackgroundActivityStartMode(
+ ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
+ )
+ pendingIntent.send(options.toBundle())
+ requestDismissal.invoke()
+ } catch (e: PendingIntent.CanceledException) {
+ Log.e(TAG, "Intent cancelled", e)
+ }
+ }
+
+ private fun smartActionsEnabled(user: UserHandle): Boolean {
+ val savingToOtherUser = user != Process.myUserHandle()
+ return !savingToOtherUser &&
+ DeviceConfig.getBoolean(
+ DeviceConfig.NAMESPACE_SYSTEMUI,
+ SystemUiDeviceConfigFlags.ENABLE_SCREENSHOT_NOTIFICATION_SMART_ACTIONS,
+ true
+ )
+ }
+
+ private fun getSubjectString(imageTime: Long): String {
+ val subjectDate = DateFormat.getDateTimeInstance().format(Date(imageTime))
+ return String.format(SCREENSHOT_SHARE_SUBJECT_TEMPLATE, subjectDate)
+ }
+
@AssistedFactory
interface Factory : ScreenshotActionsProvider.Factory {
override fun create(
request: ScreenshotData,
+ requestId: String,
windowTransition: () -> Pair<ActivityOptions, ExitTransitionCoordinator>,
+ requestDismissal: () -> Unit,
): DefaultScreenshotActionsProvider
}
companion object {
private const val TAG = "ScreenshotActionsProvider"
+ private const val SCREENSHOT_SHARE_SUBJECT_TEMPLATE = "Screenshot (%s)"
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java
index b43137f..70d1129 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java
@@ -96,7 +96,10 @@
import dagger.assisted.AssistedFactory;
import dagger.assisted.AssistedInject;
+import kotlin.Unit;
+
import java.util.List;
+import java.util.UUID;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
@@ -167,6 +170,7 @@
public Notification.Action quickShareAction;
public UserHandle owner;
public String subject; // Title for sharing
+ public Long imageTime; // Time at which screenshot was saved
/**
* Used to reset the return data on error
@@ -176,6 +180,7 @@
smartActions = null;
quickShareAction = null;
subject = null;
+ imageTime = null;
}
}
@@ -261,11 +266,9 @@
private SaveImageInBackgroundTask mSaveInBgTask;
private boolean mScreenshotTakenInPortrait;
private boolean mBlockAttach;
-
- private ScreenshotActionsProvider mActionsProvider;
-
private Animator mScreenshotAnimation;
private RequestCallback mCurrentRequestCallback;
+ private ScreenshotActionsProvider mActionsProvider;
private String mPackageName = "";
private final BroadcastReceiver mCopyBroadcastReceiver;
@@ -317,6 +320,7 @@
@Assisted boolean showUIOnExternalDisplay
) {
mScreenshotSmartActions = screenshotSmartActions;
+ mActionsProviderFactory = actionsProviderFactory;
mNotificationsController = screenshotNotificationsControllerFactory.create(displayId);
mScrollCaptureClient = scrollCaptureClient;
mUiEventLogger = uiEventLogger;
@@ -347,7 +351,6 @@
mAssistContentRequester = assistContentRequester;
mViewProxy = viewProxyFactory.getProxy(mContext, mDisplayId);
- mActionsProviderFactory = actionsProviderFactory;
mScreenshotHandler.setOnTimeoutRunnable(() -> {
if (DEBUG_UI) {
@@ -441,8 +444,19 @@
return;
}
- saveScreenshotInWorkerThread(screenshot.getUserHandle(), finisher,
- this::showUiOnActionsReady, this::showUiOnQuickShareActionReady);
+ if (screenshotShelfUi()) {
+ final UUID requestId = UUID.randomUUID();
+ final String screenshotId = String.format("Screenshot_%s", requestId);
+ mActionsProvider = mActionsProviderFactory.create(screenshot, screenshotId,
+ this::createWindowTransition, () -> {
+ mViewProxy.requestDismissal(null);
+ return Unit.INSTANCE;
+ });
+ saveScreenshotInBackground(screenshot, requestId, finisher);
+ } else {
+ saveScreenshotInWorkerThread(screenshot.getUserHandle(), finisher,
+ this::showUiOnActionsReady, this::showUiOnQuickShareActionReady);
+ }
// The window is focusable by default
setWindowFocusable(true);
@@ -477,7 +491,9 @@
// ignore system bar insets for the purpose of window layout
mWindow.getDecorView().setOnApplyWindowInsetsListener(
(v, insets) -> WindowInsets.CONSUMED);
- mScreenshotHandler.cancelTimeout(); // restarted after animation
+ if (!screenshotShelfUi()) {
+ mScreenshotHandler.cancelTimeout(); // restarted after animation
+ }
}
private boolean shouldShowUi() {
@@ -497,11 +513,6 @@
mViewProxy.reset();
- if (screenshotShelfUi()) {
- mActionsProvider =
- mActionsProviderFactory.create(screenshot, this::createWindowTransition);
- }
-
if (mViewProxy.isAttachedToWindow()) {
// if we didn't already dismiss for another reason
if (!mViewProxy.isDismissing()) {
@@ -921,6 +932,39 @@
mScreenshotHandler.cancelTimeout();
}
+ private void saveScreenshotInBackground(
+ ScreenshotData screenshot, UUID requestId, Consumer<Uri> finisher) {
+ ListenableFuture<ImageExporter.Result> future = mImageExporter.export(mBgExecutor,
+ requestId, screenshot.getBitmap(), screenshot.getUserHandle(), mDisplayId);
+ future.addListener(() -> {
+ try {
+ ImageExporter.Result result = future.get();
+ Log.d(TAG, "Saved screenshot: " + result);
+ logScreenshotResultStatus(result.uri, screenshot.getUserHandle());
+ mScreenshotHandler.resetTimeout();
+ if (result.uri != null) {
+ final SavedImageData savedImageData = new SavedImageData();
+ savedImageData.uri = result.uri;
+ savedImageData.owner = screenshot.getUserHandle();
+ savedImageData.imageTime = result.timestamp;
+ mActionsProvider.setCompletedScreenshot(savedImageData);
+ mViewProxy.setChipIntents(savedImageData);
+ }
+ if (DEBUG_CALLBACK) {
+ Log.d(TAG, "finished background processing, Calling (Consumer<Uri>) "
+ + "finisher.accept(\"" + result.uri + "\"");
+ }
+ finisher.accept(result.uri);
+ } catch (Exception e) {
+ Log.d(TAG, "Failed to store screenshot", e);
+ if (DEBUG_CALLBACK) {
+ Log.d(TAG, "Calling (Consumer<Uri>) finisher.accept(null)");
+ }
+ finisher.accept(null);
+ }
+ }, mMainExecutor);
+ }
+
/**
* Creates a new worker thread and saves the screenshot to the media store.
*/
@@ -958,11 +1002,6 @@
logSuccessOnActionsReady(imageData);
mScreenshotHandler.resetTimeout();
- if (screenshotShelfUi()) {
- mActionsProvider.setCompletedScreenshot(imageData);
- return;
- }
-
if (imageData.uri != null) {
if (DEBUG_UI) {
Log.d(TAG, "Showing UI actions");
@@ -1014,20 +1053,27 @@
/**
* Logs success/failure of the screenshot saving task, and shows an error if it failed.
*/
- private void logSuccessOnActionsReady(ScreenshotController.SavedImageData imageData) {
- if (imageData.uri == null) {
+ private void logScreenshotResultStatus(Uri uri, UserHandle owner) {
+ if (uri == null) {
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_NOT_SAVED, 0, mPackageName);
mNotificationsController.notifyScreenshotError(
R.string.screenshot_failed_to_save_text);
} else {
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_SAVED, 0, mPackageName);
- if (mUserManager.isManagedProfile(imageData.owner.getIdentifier())) {
+ if (mUserManager.isManagedProfile(owner.getIdentifier())) {
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_SAVED_TO_WORK_PROFILE, 0,
mPackageName);
}
}
}
+ /**
+ * Logs success/failure of the screenshot saving task, and shows an error if it failed.
+ */
+ private void logSuccessOnActionsReady(ScreenshotController.SavedImageData imageData) {
+ logScreenshotResultStatus(imageData.uri, imageData.owner);
+ }
+
private boolean isUserSetupComplete(UserHandle owner) {
return Settings.Secure.getInt(mContext.createContextAsUser(owner, 0)
.getContentResolver(), SETTINGS_SECURE_USER_SETUP_COMPLETE, 0) == 1;
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotShelfViewProxy.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotShelfViewProxy.kt
index defddc3..6b9332b 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotShelfViewProxy.kt
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotShelfViewProxy.kt
@@ -98,7 +98,7 @@
override fun setChipIntents(imageData: SavedImageData) {}
- override fun requestDismissal(event: ScreenshotEvent) {
+ override fun requestDismissal(event: ScreenshotEvent?) {
debugLog(DEBUG_DISMISS) { "screenshot dismissal requested: $event" }
// If we're already animating out, don't restart the animation
@@ -106,7 +106,7 @@
debugLog(DEBUG_DISMISS) { "Already dismissing, ignoring duplicate command $event" }
return
}
- logger.log(event, 0, packageName)
+ event?.let { logger.log(it, 0, packageName) }
val animator = animationController.getExitAnimation()
animator.addListener(
object : AnimatorListenerAdapter() {
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotViewProxy.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotViewProxy.kt
index 6be32a9..a4069d1 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotViewProxy.kt
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotViewProxy.kt
@@ -46,7 +46,7 @@
fun createScreenshotDropInAnimation(screenRect: Rect, showFlash: Boolean): Animator
fun addQuickShareChip(quickShareAction: Notification.Action)
fun setChipIntents(imageData: ScreenshotController.SavedImageData)
- fun requestDismissal(event: ScreenshotEvent)
+ fun requestDismissal(event: ScreenshotEvent?)
fun showScrollChip(packageName: String, onClick: Runnable)
fun hideScrollChip()
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/SmartActionsProvider.kt b/packages/SystemUI/src/com/android/systemui/screenshot/SmartActionsProvider.kt
new file mode 100644
index 0000000..2eaff86
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/SmartActionsProvider.kt
@@ -0,0 +1,285 @@
+/*
+ * Copyright (C) 2024 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 android.app.Notification
+import android.app.PendingIntent
+import android.content.ClipData
+import android.content.ClipDescription
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Bundle
+import android.os.Process
+import android.os.SystemClock
+import android.os.UserHandle
+import android.provider.DeviceConfig
+import android.util.Log
+import com.android.internal.config.sysui.SystemUiDeviceConfigFlags
+import com.android.systemui.log.DebugLogger.debugLog
+import com.android.systemui.screenshot.LogConfig.DEBUG_ACTIONS
+import com.android.systemui.screenshot.ScreenshotNotificationSmartActionsProvider.ScreenshotSmartActionType.QUICK_SHARE_ACTION
+import com.android.systemui.screenshot.ScreenshotNotificationSmartActionsProvider.ScreenshotSmartActionType.REGULAR_SMART_ACTIONS
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+import javax.inject.Inject
+import kotlin.random.Random
+
+/**
+ * Handle requesting smart/quickshare actions from the provider and executing an action when the
+ * action futures complete.
+ */
+class SmartActionsProvider
+@Inject
+constructor(
+ private val context: Context,
+ private val smartActions: ScreenshotNotificationSmartActionsProvider,
+) {
+ /**
+ * Requests quick share action for a given screenshot.
+ *
+ * @param data the ScreenshotData request
+ * @param id the request id for the screenshot
+ * @param onAction callback to run when quick share action is returned
+ */
+ fun requestQuickShare(
+ data: ScreenshotData,
+ id: String,
+ onAction: (Notification.Action) -> Unit
+ ) {
+ val bitmap = data.bitmap ?: return
+ val user = data.userHandle ?: return
+ val component = data.topComponent ?: ComponentName("", "")
+ requestQuickShareAction(id, bitmap, component, user) { quickShareAction ->
+ onAction(quickShareAction)
+ }
+ }
+
+ /**
+ * Requests smart actions for a given screenshot.
+ *
+ * @param data the ScreenshotData request
+ * @param id the request id for the screenshot
+ * @param result the data for the saved image
+ * @param onActions callback to run when actions are returned
+ */
+ fun requestSmartActions(
+ data: ScreenshotData,
+ id: String,
+ result: ScreenshotController.SavedImageData,
+ onActions: (List<Notification.Action>) -> Unit
+ ) {
+ val bitmap = data.bitmap ?: return
+ val user = data.userHandle ?: return
+ val uri = result.uri ?: return
+ val component = data.topComponent ?: ComponentName("", "")
+ requestSmartActions(id, bitmap, component, user, uri, REGULAR_SMART_ACTIONS) { actions ->
+ onActions(actions)
+ }
+ }
+
+ /**
+ * Wraps the given quick share action in a broadcast intent.
+ *
+ * @param quickShare the quick share action to wrap
+ * @param uri the URI of the saved screenshot
+ * @param subject the subject/title for the screenshot
+ * @param id the request ID of the screenshot
+ * @return the wrapped action
+ */
+ fun wrapIntent(
+ quickShare: Notification.Action,
+ uri: Uri,
+ subject: String,
+ id: String
+ ): Notification.Action {
+ val wrappedIntent: Intent =
+ Intent(context, SmartActionsReceiver::class.java)
+ .putExtra(ScreenshotController.EXTRA_ACTION_INTENT, quickShare.actionIntent)
+ .putExtra(
+ ScreenshotController.EXTRA_ACTION_INTENT_FILLIN,
+ createFillInIntent(uri, subject)
+ )
+ .addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
+ val extras: Bundle = quickShare.extras
+ val actionType =
+ extras.getString(
+ ScreenshotNotificationSmartActionsProvider.ACTION_TYPE,
+ ScreenshotNotificationSmartActionsProvider.DEFAULT_ACTION_TYPE
+ )
+ // We only query for quick share actions when smart actions are enabled, so we can assert
+ // that it's true here.
+ wrappedIntent
+ .putExtra(ScreenshotController.EXTRA_ACTION_TYPE, actionType)
+ .putExtra(ScreenshotController.EXTRA_ID, id)
+ .putExtra(ScreenshotController.EXTRA_SMART_ACTIONS_ENABLED, true)
+ val broadcastIntent =
+ PendingIntent.getBroadcast(
+ context,
+ Random.nextInt(),
+ wrappedIntent,
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ return Notification.Action.Builder(quickShare.getIcon(), quickShare.title, broadcastIntent)
+ .setContextual(true)
+ .addExtras(extras)
+ .build()
+ }
+
+ private fun createFillInIntent(uri: Uri, subject: String): Intent {
+ val fillIn = Intent()
+ fillIn.setType("image/png")
+ fillIn.putExtra(Intent.EXTRA_STREAM, uri)
+ fillIn.putExtra(Intent.EXTRA_SUBJECT, subject)
+ // Include URI in ClipData also, so that grantPermission picks it up.
+ // We don't use setData here because some apps interpret this as "to:".
+ val clipData =
+ ClipData(ClipDescription("content", arrayOf("image/png")), ClipData.Item(uri))
+ fillIn.clipData = clipData
+ fillIn.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ return fillIn
+ }
+
+ private fun requestQuickShareAction(
+ id: String,
+ image: Bitmap,
+ component: ComponentName,
+ user: UserHandle,
+ timeoutMs: Long = 500,
+ onAction: (Notification.Action) -> Unit
+ ) {
+ requestSmartActions(id, image, component, user, null, QUICK_SHARE_ACTION, timeoutMs) {
+ it.firstOrNull()?.let { action -> onAction(action) }
+ }
+ }
+
+ private fun requestSmartActions(
+ id: String,
+ image: Bitmap,
+ component: ComponentName,
+ user: UserHandle,
+ uri: Uri?,
+ actionType: ScreenshotNotificationSmartActionsProvider.ScreenshotSmartActionType,
+ timeoutMs: Long = 500,
+ onActions: (List<Notification.Action>) -> Unit
+ ) {
+ val enabled = isSmartActionsEnabled(user)
+ debugLog(DEBUG_ACTIONS) {
+ ("getSmartActionsFuture id=$id, uri=$uri, provider=$smartActions, " +
+ "actionType=$actionType, smartActionsEnabled=$enabled, userHandle=$user")
+ }
+ if (!enabled) {
+ debugLog(DEBUG_ACTIONS) { "Screenshot Intelligence not enabled, returning empty list" }
+ onActions(listOf())
+ return
+ }
+ if (image.config != Bitmap.Config.HARDWARE) {
+ debugLog(DEBUG_ACTIONS) {
+ "Bitmap expected: Hardware, Bitmap found: ${image.config}. Returning empty list."
+ }
+ onActions(listOf())
+ return
+ }
+ var smartActionsFuture: CompletableFuture<List<Notification.Action>>
+ val startTimeMs = SystemClock.uptimeMillis()
+ try {
+ smartActionsFuture =
+ smartActions.getActions(id, uri, image, component, actionType, user)
+ } catch (e: Throwable) {
+ val waitTimeMs = SystemClock.uptimeMillis() - startTimeMs
+ debugLog(DEBUG_ACTIONS, error = e) {
+ "Failed to get future for screenshot notification smart actions."
+ }
+ notifyScreenshotOp(
+ id,
+ ScreenshotNotificationSmartActionsProvider.ScreenshotOp.REQUEST_SMART_ACTIONS,
+ ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus.ERROR,
+ waitTimeMs
+ )
+ onActions(listOf())
+ return
+ }
+ try {
+ val actions = smartActionsFuture.get(timeoutMs, TimeUnit.MILLISECONDS)
+ val waitTimeMs = SystemClock.uptimeMillis() - startTimeMs
+ debugLog(DEBUG_ACTIONS) {
+ ("Got ${actions.size} smart actions. Wait time: $waitTimeMs ms, " +
+ "actionType=$actionType")
+ }
+ notifyScreenshotOp(
+ id,
+ ScreenshotNotificationSmartActionsProvider.ScreenshotOp.WAIT_FOR_SMART_ACTIONS,
+ ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus.SUCCESS,
+ waitTimeMs
+ )
+ onActions(actions)
+ } catch (e: Throwable) {
+ val waitTimeMs = SystemClock.uptimeMillis() - startTimeMs
+ debugLog(DEBUG_ACTIONS, error = e) {
+ "Error getting smart actions. Wait time: $waitTimeMs ms, actionType=$actionType"
+ }
+ val status =
+ if (e is TimeoutException) {
+ ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus.TIMEOUT
+ } else {
+ ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus.ERROR
+ }
+ notifyScreenshotOp(
+ id,
+ ScreenshotNotificationSmartActionsProvider.ScreenshotOp.WAIT_FOR_SMART_ACTIONS,
+ status,
+ waitTimeMs
+ )
+ onActions(listOf())
+ }
+ }
+
+ private fun notifyScreenshotOp(
+ screenshotId: String,
+ op: ScreenshotNotificationSmartActionsProvider.ScreenshotOp,
+ status: ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus,
+ durationMs: Long
+ ) {
+ debugLog(DEBUG_ACTIONS) {
+ "$smartActions notifyOp: $op id=$screenshotId, status=$status, durationMs=$durationMs"
+ }
+ try {
+ smartActions.notifyOp(screenshotId, op, status, durationMs)
+ } catch (e: Throwable) {
+ Log.e(TAG, "Error in notifyScreenshotOp: ", e)
+ }
+ }
+ private fun isSmartActionsEnabled(user: UserHandle): Boolean {
+ // Smart actions don't yet work for cross-user saves.
+ val savingToOtherUser = user !== Process.myUserHandle()
+ val actionsEnabled =
+ DeviceConfig.getBoolean(
+ DeviceConfig.NAMESPACE_SYSTEMUI,
+ SystemUiDeviceConfigFlags.ENABLE_SCREENSHOT_NOTIFICATION_SMART_ACTIONS,
+ true
+ )
+ return !savingToOtherUser && actionsEnabled
+ }
+
+ companion object {
+ private const val TAG = "SmartActionsProvider"
+ private const val SCREENSHOT_SHARE_SUBJECT_TEMPLATE = "Screenshot (%s)"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ui/binder/ScreenshotShelfViewBinder.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ui/binder/ScreenshotShelfViewBinder.kt
index ea05884..b191a1a 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/ui/binder/ScreenshotShelfViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ui/binder/ScreenshotShelfViewBinder.kt
@@ -16,6 +16,7 @@
package com.android.systemui.screenshot.ui.binder
+import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/DefaultScreenshotActionsProviderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/screenshot/DefaultScreenshotActionsProviderTest.kt
index f49c6e2..4a5cf57 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/screenshot/DefaultScreenshotActionsProviderTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/DefaultScreenshotActionsProviderTest.kt
@@ -18,13 +18,18 @@
import android.app.ActivityOptions
import android.app.ExitTransitionCoordinator
+import android.app.Notification
+import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import android.os.UserHandle
import android.testing.AndroidTestingRunner
import android.view.accessibility.AccessibilityManager
import androidx.test.filters.SmallTest
+import com.android.internal.logging.UiEventLogger
import com.android.systemui.SysuiTestCase
+import com.android.systemui.clipboardoverlay.EditTextActivity
+import com.android.systemui.res.R
import com.android.systemui.screenshot.ui.viewmodel.ScreenshotViewModel
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.capture
@@ -41,7 +46,11 @@
import org.junit.Before
import org.junit.runner.RunWith
import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.kotlin.any
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyBlocking
+import org.mockito.kotlin.whenever
@RunWith(AndroidTestingRunner::class)
@SmallTest
@@ -52,15 +61,19 @@
private val actionIntentExecutor = mock<ActionIntentExecutor>()
private val accessibilityManager = mock<AccessibilityManager>()
+ private val uiEventLogger = mock<UiEventLogger>()
+ private val smartActionsProvider = mock<SmartActionsProvider>()
private val transition = mock<android.util.Pair<ActivityOptions, ExitTransitionCoordinator>>()
+ private val requestDismissal = mock<() -> Unit>()
private val request = ScreenshotData.forTesting()
private val invalidResult = ScreenshotController.SavedImageData()
private val validResult =
ScreenshotController.SavedImageData().apply {
uri = Uri.EMPTY
- owner = UserHandle.CURRENT
+ owner = UserHandle.OWNER
subject = "Test"
+ imageTime = 0
}
private lateinit var viewModel: ScreenshotViewModel
@@ -69,20 +82,13 @@
@Before
fun setUp() {
viewModel = ScreenshotViewModel(accessibilityManager)
- actionsProvider =
- DefaultScreenshotActionsProvider(
- context,
- viewModel,
- actionIntentExecutor,
- testScope,
- request
- ) {
- transition
- }
+ request.userHandle = UserHandle.OWNER
}
@Test
fun previewActionAccessed_beforeScreenshotCompleted_doesNothing() {
+ actionsProvider = createActionsProvider()
+
assertNotNull(viewModel.previewAction.value)
viewModel.previewAction.value!!.invoke()
verifyNoMoreInteractions(actionIntentExecutor)
@@ -90,6 +96,8 @@
@Test
fun actionButtonsAccessed_beforeScreenshotCompleted_doesNothing() {
+ actionsProvider = createActionsProvider()
+
assertThat(viewModel.actions.value.size).isEqualTo(2)
val firstAction = viewModel.actions.value[0]
assertThat(firstAction.onClicked).isNotNull()
@@ -102,6 +110,8 @@
@Test
fun actionAccessed_withInvalidResult_doesNothing() {
+ actionsProvider = createActionsProvider()
+
actionsProvider.setCompletedScreenshot(invalidResult)
viewModel.previewAction.value!!.invoke()
viewModel.actions.value[1].onClicked!!.invoke()
@@ -112,10 +122,13 @@
@Test
@Ignore("b/332526567")
fun actionAccessed_withResult_launchesIntent() = runTest {
+ actionsProvider = createActionsProvider()
+
actionsProvider.setCompletedScreenshot(validResult)
viewModel.actions.value[0].onClicked!!.invoke()
scheduler.advanceUntilIdle()
+ verify(uiEventLogger).log(eq(ScreenshotEvent.SCREENSHOT_EDIT_TAPPED), eq(0), eq(""))
val intentCaptor = argumentCaptor<Intent>()
verifyBlocking(actionIntentExecutor) {
launchIntent(capture(intentCaptor), eq(transition), eq(UserHandle.CURRENT), eq(true))
@@ -126,16 +139,65 @@
@Test
@Ignore("b/332526567")
fun actionAccessed_whilePending_launchesMostRecentAction() = runTest {
+ actionsProvider = createActionsProvider()
+
viewModel.actions.value[0].onClicked!!.invoke()
viewModel.previewAction.value!!.invoke()
viewModel.actions.value[1].onClicked!!.invoke()
actionsProvider.setCompletedScreenshot(validResult)
scheduler.advanceUntilIdle()
+ verify(uiEventLogger).log(eq(ScreenshotEvent.SCREENSHOT_SHARE_TAPPED), eq(0), eq(""))
val intentCaptor = argumentCaptor<Intent>()
verifyBlocking(actionIntentExecutor) {
launchIntent(capture(intentCaptor), eq(transition), eq(UserHandle.CURRENT), eq(false))
}
assertThat(intentCaptor.value.action).isEqualTo(Intent.ACTION_CHOOSER)
}
+
+ @Test
+ fun quickShareTapped_wrapsAndSendsIntent() = runTest {
+ val quickShare =
+ Notification.Action(
+ R.drawable.ic_screenshot_edit,
+ "TestQuickShare",
+ PendingIntent.getActivity(
+ context,
+ 0,
+ Intent(context, EditTextActivity::class.java),
+ PendingIntent.FLAG_MUTABLE
+ )
+ )
+ whenever(smartActionsProvider.requestQuickShare(any(), any(), any())).then {
+ (it.getArgument(2) as ((Notification.Action) -> Unit)).invoke(quickShare)
+ }
+ whenever(smartActionsProvider.wrapIntent(any(), any(), any(), any())).thenAnswer {
+ it.getArgument(0)
+ }
+ actionsProvider = createActionsProvider()
+
+ viewModel.actions.value[2].onClicked?.invoke()
+ verify(uiEventLogger, never())
+ .log(eq(ScreenshotEvent.SCREENSHOT_SMART_ACTION_TAPPED), any(), any())
+ verify(smartActionsProvider, never()).wrapIntent(any(), any(), any(), any())
+ actionsProvider.setCompletedScreenshot(validResult)
+ verify(smartActionsProvider)
+ .wrapIntent(eq(quickShare), eq(validResult.uri), eq(validResult.subject), eq("testid"))
+ verify(uiEventLogger).log(eq(ScreenshotEvent.SCREENSHOT_SMART_ACTION_TAPPED), eq(0), eq(""))
+ }
+
+ private fun createActionsProvider(): ScreenshotActionsProvider {
+ return DefaultScreenshotActionsProvider(
+ context,
+ viewModel,
+ actionIntentExecutor,
+ smartActionsProvider,
+ uiEventLogger,
+ testScope,
+ request,
+ "testid",
+ { transition },
+ requestDismissal,
+ )
+ }
}