Passing feedback for screenshots in SysUI
Test: Took a screenshot and verified that AiAi gets notified for share/edit/delete/smart action clicked and exceptions thrown. Ran tests- 'atest ScreenshotNotificationSmartActionsTest' 'atest ScreenshotNotificationSmartActionsGoogleTest' Bug: 142669323 Change-Id: Ief6400549b30cf1c0c8a374aa443cf6347f84875
This commit is contained in:
@@ -372,6 +372,10 @@
|
||||
<receiver android:name=".screenshot.GlobalScreenshot$DeleteScreenshotReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Callback for invoking a smart action from the screenshot notification. -->
|
||||
<receiver android:name=".screenshot.GlobalScreenshot$SmartActionsReceiver"
|
||||
android:exported="false"/>
|
||||
|
||||
<!-- started from UsbDeviceSettingsManager -->
|
||||
<activity android:name=".usb.UsbConfirmActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -45,6 +45,8 @@ import com.android.systemui.statusbar.phone.NotificationIconAreaController;
|
||||
import com.android.systemui.statusbar.phone.StatusBar;
|
||||
import com.android.systemui.statusbar.policy.KeyguardStateController;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import dagger.Module;
|
||||
import dagger.Provides;
|
||||
|
||||
@@ -124,7 +126,9 @@ public class SystemUIFactory {
|
||||
* This method is overridden in vendor specific implementation of Sys UI.
|
||||
*/
|
||||
public ScreenshotNotificationSmartActionsProvider
|
||||
createScreenshotNotificationSmartActionsProvider() {
|
||||
createScreenshotNotificationSmartActionsProvider(Context context,
|
||||
Executor executor,
|
||||
Handler uiHandler) {
|
||||
return new ScreenshotNotificationSmartActionsProvider();
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ import com.android.internal.annotations.VisibleForTesting;
|
||||
import com.android.internal.messages.nano.SystemMessageProto.SystemMessage;
|
||||
import com.android.systemui.R;
|
||||
import com.android.systemui.SystemUI;
|
||||
import com.android.systemui.SystemUIFactory;
|
||||
import com.android.systemui.dagger.qualifiers.MainResources;
|
||||
import com.android.systemui.shared.system.ActivityManagerWrapper;
|
||||
import com.android.systemui.statusbar.phone.StatusBar;
|
||||
@@ -125,8 +126,17 @@ public class GlobalScreenshot {
|
||||
}
|
||||
}
|
||||
|
||||
static final String SCREENSHOT_URI_ID = "android:screenshot_uri_id";
|
||||
// These strings are used for communicating the action invoked to
|
||||
// ScreenshotNotificationSmartActionsProvider.
|
||||
static final String EXTRA_ACTION_TYPE = "android:screenshot_action_type";
|
||||
static final String EXTRA_ID = "android:screenshot_id";
|
||||
static final String ACTION_TYPE_DELETE = "Delete";
|
||||
static final String ACTION_TYPE_SHARE = "Share";
|
||||
static final String ACTION_TYPE_EDIT = "Edit";
|
||||
static final String EXTRA_SMART_ACTIONS_ENABLED = "android:smart_actions_enabled";
|
||||
static final String EXTRA_ACTION_INTENT = "android:screenshot_action_intent";
|
||||
|
||||
static final String SCREENSHOT_URI_ID = "android:screenshot_uri_id";
|
||||
static final String EXTRA_CANCEL_NOTIFICATION = "android:screenshot_cancel_notification";
|
||||
static final String EXTRA_DISALLOW_ENTER_PIP = "android:screenshot_disallow_enter_pip";
|
||||
|
||||
@@ -688,9 +698,9 @@ public class GlobalScreenshot {
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static CompletableFuture<List<Notification.Action>> getSmartActionsFuture(Context context,
|
||||
static CompletableFuture<List<Notification.Action>> getSmartActionsFuture(String screenshotId,
|
||||
Bitmap image, ScreenshotNotificationSmartActionsProvider smartActionsProvider,
|
||||
Handler handler, boolean smartActionsEnabled, boolean isManagedProfile) {
|
||||
boolean smartActionsEnabled, boolean isManagedProfile) {
|
||||
if (!smartActionsEnabled) {
|
||||
Slog.i(TAG, "Screenshot Intelligence not enabled, returning empty list.");
|
||||
return CompletableFuture.completedFuture(Collections.emptyList());
|
||||
@@ -704,6 +714,7 @@ public class GlobalScreenshot {
|
||||
|
||||
Slog.d(TAG, "Screenshot from a managed profile: " + isManagedProfile);
|
||||
CompletableFuture<List<Notification.Action>> smartActionsFuture;
|
||||
long startTimeMs = SystemClock.uptimeMillis();
|
||||
try {
|
||||
ActivityManager.RunningTaskInfo runningTask =
|
||||
ActivityManagerWrapper.getInstance().getRunningTask();
|
||||
@@ -711,34 +722,74 @@ public class GlobalScreenshot {
|
||||
(runningTask != null && runningTask.topActivity != null)
|
||||
? runningTask.topActivity
|
||||
: new ComponentName("", "");
|
||||
smartActionsFuture = smartActionsProvider.getActions(image, context,
|
||||
THREAD_POOL_EXECUTOR,
|
||||
handler,
|
||||
smartActionsFuture = smartActionsProvider.getActions(screenshotId, image,
|
||||
componentName,
|
||||
isManagedProfile);
|
||||
} catch (Throwable e) {
|
||||
long waitTimeMs = SystemClock.uptimeMillis() - startTimeMs;
|
||||
smartActionsFuture = CompletableFuture.completedFuture(Collections.emptyList());
|
||||
Slog.e(TAG, "Failed to get future for screenshot notification smart actions.", e);
|
||||
notifyScreenshotOp(screenshotId, smartActionsProvider,
|
||||
ScreenshotNotificationSmartActionsProvider.ScreenshotOp.REQUEST_SMART_ACTIONS,
|
||||
ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus.ERROR,
|
||||
waitTimeMs);
|
||||
}
|
||||
return smartActionsFuture;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static List<Notification.Action> getSmartActions(
|
||||
CompletableFuture<List<Notification.Action>> smartActionsFuture, int timeoutMs) {
|
||||
static List<Notification.Action> getSmartActions(String screenshotId,
|
||||
CompletableFuture<List<Notification.Action>> smartActionsFuture, int timeoutMs,
|
||||
ScreenshotNotificationSmartActionsProvider smartActionsProvider) {
|
||||
long startTimeMs = SystemClock.uptimeMillis();
|
||||
try {
|
||||
long startTimeMs = SystemClock.uptimeMillis();
|
||||
List<Notification.Action> actions = smartActionsFuture.get(timeoutMs,
|
||||
TimeUnit.MILLISECONDS);
|
||||
long waitTimeMs = SystemClock.uptimeMillis() - startTimeMs;
|
||||
Slog.d(TAG, String.format("Wait time for smart actions: %d ms",
|
||||
SystemClock.uptimeMillis() - startTimeMs));
|
||||
waitTimeMs));
|
||||
notifyScreenshotOp(screenshotId, smartActionsProvider,
|
||||
ScreenshotNotificationSmartActionsProvider.ScreenshotOp.WAIT_FOR_SMART_ACTIONS,
|
||||
ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus.SUCCESS,
|
||||
waitTimeMs);
|
||||
return actions;
|
||||
} catch (Throwable e) {
|
||||
Slog.e(TAG, "Failed to obtain screenshot notification smart actions.", e);
|
||||
long waitTimeMs = SystemClock.uptimeMillis() - startTimeMs;
|
||||
Slog.d(TAG, "Failed to obtain screenshot notification smart actions.", e);
|
||||
ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus status =
|
||||
(e instanceof TimeoutException)
|
||||
? ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus.TIMEOUT
|
||||
: ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus.ERROR;
|
||||
notifyScreenshotOp(screenshotId, smartActionsProvider,
|
||||
ScreenshotNotificationSmartActionsProvider.ScreenshotOp.WAIT_FOR_SMART_ACTIONS,
|
||||
status, waitTimeMs);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
static void notifyScreenshotOp(String screenshotId,
|
||||
ScreenshotNotificationSmartActionsProvider smartActionsProvider,
|
||||
ScreenshotNotificationSmartActionsProvider.ScreenshotOp op,
|
||||
ScreenshotNotificationSmartActionsProvider.ScreenshotOpStatus status, long durationMs) {
|
||||
try {
|
||||
smartActionsProvider.notifyOp(screenshotId, op, status, durationMs);
|
||||
} catch (Throwable e) {
|
||||
Slog.e(TAG, "Error in notifyScreenshotOp: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
static void notifyScreenshotAction(Context context, String screenshotId, String action,
|
||||
boolean isSmartAction) {
|
||||
try {
|
||||
ScreenshotNotificationSmartActionsProvider provider =
|
||||
SystemUIFactory.getInstance().createScreenshotNotificationSmartActionsProvider(
|
||||
context, THREAD_POOL_EXECUTOR, new Handler());
|
||||
provider.notifyAction(screenshotId, action, isSmartAction);
|
||||
} catch (Throwable e) {
|
||||
Slog.e(TAG, "Error in notifyScreenshotAction: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver to proxy the share or edit intent, used to clean up the notification and send
|
||||
* appropriate signals to the system (ie. to dismiss the keyguard if necessary).
|
||||
@@ -782,6 +833,13 @@ public class GlobalScreenshot {
|
||||
} else {
|
||||
startActivityRunnable.run();
|
||||
}
|
||||
|
||||
if (intent.getBooleanExtra(EXTRA_SMART_ACTIONS_ENABLED, false)) {
|
||||
String actionType = Intent.ACTION_EDIT.equals(intent.getAction()) ? ACTION_TYPE_EDIT
|
||||
: ACTION_TYPE_SHARE;
|
||||
notifyScreenshotAction(context, intent.getStringExtra(EXTRA_ID),
|
||||
actionType, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,6 +870,29 @@ public class GlobalScreenshot {
|
||||
// And delete the image from the media store
|
||||
final Uri uri = Uri.parse(intent.getStringExtra(SCREENSHOT_URI_ID));
|
||||
new DeleteImageInBackgroundTask(context).execute(uri);
|
||||
if (intent.getBooleanExtra(EXTRA_SMART_ACTIONS_ENABLED, false)) {
|
||||
notifyScreenshotAction(context, intent.getStringExtra(EXTRA_ID),
|
||||
ACTION_TYPE_DELETE,
|
||||
false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the smart action tapped by the user in the notification.
|
||||
*/
|
||||
public static class SmartActionsReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
PendingIntent actionIntent = intent.getParcelableExtra(EXTRA_ACTION_INTENT);
|
||||
ActivityOptions opts = ActivityOptions.makeBasic();
|
||||
context.startActivityAsUser(actionIntent.getIntent(), opts.toBundle(),
|
||||
UserHandle.CURRENT);
|
||||
|
||||
Slog.d(TAG, "Screenshot notification smart action is invoked.");
|
||||
notifyScreenshotAction(context, intent.getStringExtra(EXTRA_ID),
|
||||
intent.getStringExtra(EXTRA_ACTION_TYPE),
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import android.media.ExifInterface;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
@@ -50,6 +51,7 @@ import android.provider.MediaStore;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Slog;
|
||||
|
||||
import com.android.internal.annotations.VisibleForTesting;
|
||||
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
|
||||
import com.android.internal.messages.nano.SystemMessageProto;
|
||||
import com.android.systemui.R;
|
||||
@@ -69,9 +71,12 @@ import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
@@ -81,6 +86,7 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
|
||||
private static final String TAG = "SaveImageInBackgroundTask";
|
||||
|
||||
private static final String SCREENSHOT_FILE_NAME_TEMPLATE = "Screenshot_%s.png";
|
||||
private static final String SCREENSHOT_ID_TEMPLATE = "Screenshot_%s";
|
||||
private static final String SCREENSHOT_SHARE_SUBJECT_TEMPLATE = "Screenshot (%s)";
|
||||
|
||||
private final GlobalScreenshot.SaveImageInBackgroundData mParams;
|
||||
@@ -91,8 +97,10 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
|
||||
private final Notification.BigPictureStyle mNotificationStyle;
|
||||
private final int mImageWidth;
|
||||
private final int mImageHeight;
|
||||
private final Handler mHandler;
|
||||
private final ScreenshotNotificationSmartActionsProvider mSmartActionsProvider;
|
||||
private final String mScreenshotId;
|
||||
private final boolean mSmartActionsEnabled;
|
||||
private final Random mRandom = new Random();
|
||||
|
||||
SaveImageInBackgroundTask(Context context, GlobalScreenshot.SaveImageInBackgroundData data,
|
||||
NotificationManager nManager) {
|
||||
@@ -103,11 +111,20 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
|
||||
mImageTime = System.currentTimeMillis();
|
||||
String imageDate = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date(mImageTime));
|
||||
mImageFileName = String.format(SCREENSHOT_FILE_NAME_TEMPLATE, imageDate);
|
||||
mScreenshotId = String.format(SCREENSHOT_ID_TEMPLATE, UUID.randomUUID());
|
||||
|
||||
// Initialize screenshot notification smart actions provider.
|
||||
mHandler = new Handler();
|
||||
mSmartActionsProvider =
|
||||
SystemUIFactory.getInstance().createScreenshotNotificationSmartActionsProvider();
|
||||
mSmartActionsEnabled = DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI,
|
||||
SystemUiDeviceConfigFlags.ENABLE_SCREENSHOT_NOTIFICATION_SMART_ACTIONS, false);
|
||||
if (mSmartActionsEnabled) {
|
||||
mSmartActionsProvider =
|
||||
SystemUIFactory.getInstance()
|
||||
.createScreenshotNotificationSmartActionsProvider(
|
||||
context, THREAD_POOL_EXECUTOR, new Handler());
|
||||
} else {
|
||||
// If smart actions is not enabled use empty implementation.
|
||||
mSmartActionsProvider = new ScreenshotNotificationSmartActionsProvider();
|
||||
}
|
||||
|
||||
// Create the large notification icon
|
||||
mImageWidth = data.image.getWidth();
|
||||
@@ -201,6 +218,38 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
|
||||
return info.isManagedProfile();
|
||||
}
|
||||
|
||||
private List<Notification.Action> buildSmartActions(
|
||||
List<Notification.Action> actions, Context context) {
|
||||
List<Notification.Action> broadcastActions = new ArrayList<>();
|
||||
for (Notification.Action action : actions) {
|
||||
// Proxy smart actions through {@link GlobalScreenshot.SmartActionsReceiver}
|
||||
// for logging smart actions.
|
||||
Bundle extras = action.getExtras();
|
||||
String actionType = extras.getString(
|
||||
ScreenshotNotificationSmartActionsProvider.ACTION_TYPE,
|
||||
ScreenshotNotificationSmartActionsProvider.DEFAULT_ACTION_TYPE);
|
||||
Intent intent = new Intent(context,
|
||||
GlobalScreenshot.SmartActionsReceiver.class).putExtra(
|
||||
GlobalScreenshot.EXTRA_ACTION_INTENT, action.actionIntent);
|
||||
addIntentExtras(mScreenshotId, intent, actionType, mSmartActionsEnabled);
|
||||
PendingIntent broadcastIntent = PendingIntent.getBroadcast(context,
|
||||
mRandom.nextInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT);
|
||||
broadcastActions.add(new Notification.Action.Builder(action.getIcon(), action.title,
|
||||
broadcastIntent).setContextual(true).addExtras(extras).build());
|
||||
}
|
||||
return broadcastActions;
|
||||
}
|
||||
|
||||
private static void addIntentExtras(String screenshotId, Intent intent, String actionType,
|
||||
boolean smartActionsEnabled) {
|
||||
intent
|
||||
.putExtra(GlobalScreenshot.EXTRA_ACTION_TYPE, actionType)
|
||||
.putExtra(GlobalScreenshot.EXTRA_ID, screenshotId)
|
||||
.putExtra(GlobalScreenshot.EXTRA_SMART_ACTIONS_ENABLED, smartActionsEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new hardware bitmap with specified values, copying the content from the
|
||||
* passed in bitmap.
|
||||
@@ -227,16 +276,13 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
|
||||
|
||||
Context context = mParams.context;
|
||||
Bitmap image = mParams.image;
|
||||
boolean smartActionsEnabled = DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI,
|
||||
SystemUiDeviceConfigFlags.ENABLE_SCREENSHOT_NOTIFICATION_SMART_ACTIONS, true);
|
||||
CompletableFuture<List<Notification.Action>>
|
||||
smartActionsFuture = GlobalScreenshot.getSmartActionsFuture(
|
||||
context, image, mSmartActionsProvider, mHandler, smartActionsEnabled,
|
||||
isManagedProfile(context));
|
||||
|
||||
Resources r = context.getResources();
|
||||
|
||||
try {
|
||||
CompletableFuture<List<Notification.Action>> smartActionsFuture =
|
||||
GlobalScreenshot.getSmartActionsFuture(mScreenshotId, image,
|
||||
mSmartActionsProvider, mSmartActionsEnabled, isManagedProfile(context));
|
||||
|
||||
// Save the screenshot to the MediaStore
|
||||
final MediaStore.PendingParams params = new MediaStore.PendingParams(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mImageFileName, "image/png");
|
||||
@@ -289,104 +335,11 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
|
||||
IoUtils.closeQuietly(session);
|
||||
}
|
||||
|
||||
// Note: Both the share and edit actions are proxied through ActionProxyReceiver in
|
||||
// order to do some common work like dismissing the keyguard and sending
|
||||
// closeSystemWindows
|
||||
|
||||
// Create a share intent, this will always go through the chooser activity first
|
||||
// which should not trigger auto-enter PiP
|
||||
String subjectDate = DateFormat.getDateTimeInstance().format(new Date(mImageTime));
|
||||
String subject = String.format(SCREENSHOT_SHARE_SUBJECT_TEMPLATE, subjectDate);
|
||||
Intent sharingIntent = new Intent(Intent.ACTION_SEND);
|
||||
sharingIntent.setType("image/png");
|
||||
sharingIntent.putExtra(Intent.EXTRA_STREAM, uri);
|
||||
// Include URI in ClipData also, so that grantPermission picks it up.
|
||||
// We don't use setData here because some apps interpret this as "to:".
|
||||
ClipData clipdata = new ClipData(new ClipDescription("content",
|
||||
new String[]{ClipDescription.MIMETYPE_TEXT_PLAIN}),
|
||||
new ClipData.Item(uri));
|
||||
sharingIntent.setClipData(clipdata);
|
||||
sharingIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
|
||||
sharingIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
// Make sure pending intents for the system user are still unique across users
|
||||
// by setting the (otherwise unused) request code to the current user id.
|
||||
int requestCode = context.getUserId();
|
||||
|
||||
PendingIntent chooserAction = PendingIntent.getBroadcast(context, requestCode,
|
||||
new Intent(context, GlobalScreenshot.TargetChosenReceiver.class),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_ONE_SHOT);
|
||||
Intent sharingChooserIntent = Intent.createChooser(sharingIntent, null,
|
||||
chooserAction.getIntentSender())
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
// Create a share action for the notification
|
||||
PendingIntent shareAction = PendingIntent.getBroadcastAsUser(context, requestCode,
|
||||
new Intent(context, GlobalScreenshot.ActionProxyReceiver.class)
|
||||
.putExtra(GlobalScreenshot.EXTRA_ACTION_INTENT, sharingChooserIntent)
|
||||
.putExtra(GlobalScreenshot.EXTRA_DISALLOW_ENTER_PIP, true)
|
||||
.setAction(Intent.ACTION_SEND),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT, UserHandle.SYSTEM);
|
||||
Notification.Action.Builder shareActionBuilder = new Notification.Action.Builder(
|
||||
R.drawable.ic_screenshot_share,
|
||||
r.getString(com.android.internal.R.string.share), shareAction);
|
||||
mNotificationBuilder.addAction(shareActionBuilder.build());
|
||||
|
||||
// Create an edit intent, if a specific package is provided as the editor, then
|
||||
// launch that directly
|
||||
String editorPackage = context.getString(R.string.config_screenshotEditor);
|
||||
Intent editIntent = new Intent(Intent.ACTION_EDIT);
|
||||
if (!TextUtils.isEmpty(editorPackage)) {
|
||||
editIntent.setComponent(ComponentName.unflattenFromString(editorPackage));
|
||||
}
|
||||
editIntent.setType("image/png");
|
||||
editIntent.setData(uri);
|
||||
editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
editIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
|
||||
// Create a edit action
|
||||
PendingIntent editAction = PendingIntent.getBroadcastAsUser(context, requestCode,
|
||||
new Intent(context, GlobalScreenshot.ActionProxyReceiver.class)
|
||||
.putExtra(GlobalScreenshot.EXTRA_ACTION_INTENT, editIntent)
|
||||
.putExtra(GlobalScreenshot.EXTRA_CANCEL_NOTIFICATION,
|
||||
editIntent.getComponent() != null)
|
||||
.setAction(Intent.ACTION_EDIT),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT, UserHandle.SYSTEM);
|
||||
Notification.Action.Builder editActionBuilder = new Notification.Action.Builder(
|
||||
R.drawable.ic_screenshot_edit,
|
||||
r.getString(com.android.internal.R.string.screenshot_edit), editAction);
|
||||
mNotificationBuilder.addAction(editActionBuilder.build());
|
||||
if (editAction != null && mParams.onEditReady != null) {
|
||||
mParams.onEditReady.apply(editAction);
|
||||
}
|
||||
|
||||
// Create a delete action for the notification
|
||||
PendingIntent deleteAction = PendingIntent.getBroadcast(context, requestCode,
|
||||
new Intent(context, GlobalScreenshot.DeleteScreenshotReceiver.class)
|
||||
.putExtra(GlobalScreenshot.SCREENSHOT_URI_ID, uri.toString()),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_ONE_SHOT);
|
||||
Notification.Action.Builder deleteActionBuilder = new Notification.Action.Builder(
|
||||
R.drawable.ic_screenshot_delete,
|
||||
r.getString(com.android.internal.R.string.delete), deleteAction);
|
||||
mNotificationBuilder.addAction(deleteActionBuilder.build());
|
||||
populateNotificationActions(context, r, uri, smartActionsFuture, mNotificationBuilder);
|
||||
|
||||
mParams.imageUri = uri;
|
||||
mParams.image = null;
|
||||
mParams.errorMsgResId = 0;
|
||||
|
||||
if (smartActionsEnabled) {
|
||||
int timeoutMs = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI,
|
||||
SystemUiDeviceConfigFlags
|
||||
.SCREENSHOT_NOTIFICATION_SMART_ACTIONS_TIMEOUT_MS,
|
||||
1000);
|
||||
List<Notification.Action> smartActions = GlobalScreenshot.getSmartActions(
|
||||
smartActionsFuture,
|
||||
timeoutMs);
|
||||
for (Notification.Action action : smartActions) {
|
||||
mNotificationBuilder.addAction(action);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// IOException/UnsupportedOperationException may be thrown if external storage is
|
||||
// not mounted
|
||||
@@ -403,6 +356,115 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
|
||||
return null;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
void populateNotificationActions(Context context, Resources r, Uri uri,
|
||||
CompletableFuture<List<Notification.Action>> smartActionsFuture,
|
||||
Notification.Builder notificationBuilder) {
|
||||
// Note: Both the share and edit actions are proxied through ActionProxyReceiver in
|
||||
// order to do some common work like dismissing the keyguard and sending
|
||||
// closeSystemWindows
|
||||
|
||||
// Create a share intent, this will always go through the chooser activity first
|
||||
// which should not trigger auto-enter PiP
|
||||
String subjectDate = DateFormat.getDateTimeInstance().format(new Date(mImageTime));
|
||||
String subject = String.format(SCREENSHOT_SHARE_SUBJECT_TEMPLATE, subjectDate);
|
||||
Intent sharingIntent = new Intent(Intent.ACTION_SEND);
|
||||
sharingIntent.setType("image/png");
|
||||
sharingIntent.putExtra(Intent.EXTRA_STREAM, uri);
|
||||
// Include URI in ClipData also, so that grantPermission picks it up.
|
||||
// We don't use setData here because some apps interpret this as "to:".
|
||||
ClipData clipdata = new ClipData(new ClipDescription("content",
|
||||
new String[]{ClipDescription.MIMETYPE_TEXT_PLAIN}),
|
||||
new ClipData.Item(uri));
|
||||
sharingIntent.setClipData(clipdata);
|
||||
sharingIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
|
||||
sharingIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
// Make sure pending intents for the system user are still unique across users
|
||||
// by setting the (otherwise unused) request code to the current user id.
|
||||
int requestCode = context.getUserId();
|
||||
|
||||
PendingIntent chooserAction = PendingIntent.getBroadcast(context, requestCode,
|
||||
new Intent(context, GlobalScreenshot.TargetChosenReceiver.class),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_ONE_SHOT);
|
||||
Intent sharingChooserIntent = Intent.createChooser(sharingIntent, null,
|
||||
chooserAction.getIntentSender())
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
// Create a share action for the notification
|
||||
PendingIntent shareAction = PendingIntent.getBroadcastAsUser(context, requestCode,
|
||||
new Intent(context, GlobalScreenshot.ActionProxyReceiver.class)
|
||||
.putExtra(GlobalScreenshot.EXTRA_ACTION_INTENT, sharingChooserIntent)
|
||||
.putExtra(GlobalScreenshot.EXTRA_DISALLOW_ENTER_PIP, true)
|
||||
.putExtra(GlobalScreenshot.EXTRA_ID, mScreenshotId)
|
||||
.putExtra(GlobalScreenshot.EXTRA_SMART_ACTIONS_ENABLED,
|
||||
mSmartActionsEnabled)
|
||||
.setAction(Intent.ACTION_SEND),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT, UserHandle.SYSTEM);
|
||||
Notification.Action.Builder shareActionBuilder = new Notification.Action.Builder(
|
||||
R.drawable.ic_screenshot_share,
|
||||
r.getString(com.android.internal.R.string.share), shareAction);
|
||||
notificationBuilder.addAction(shareActionBuilder.build());
|
||||
|
||||
// Create an edit intent, if a specific package is provided as the editor, then
|
||||
// launch that directly
|
||||
String editorPackage = context.getString(R.string.config_screenshotEditor);
|
||||
Intent editIntent = new Intent(Intent.ACTION_EDIT);
|
||||
if (!TextUtils.isEmpty(editorPackage)) {
|
||||
editIntent.setComponent(ComponentName.unflattenFromString(editorPackage));
|
||||
}
|
||||
editIntent.setType("image/png");
|
||||
editIntent.setData(uri);
|
||||
editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
editIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
|
||||
// Create a edit action
|
||||
PendingIntent editAction = PendingIntent.getBroadcastAsUser(context, requestCode,
|
||||
new Intent(context, GlobalScreenshot.ActionProxyReceiver.class)
|
||||
.putExtra(GlobalScreenshot.EXTRA_ACTION_INTENT, editIntent)
|
||||
.putExtra(GlobalScreenshot.EXTRA_CANCEL_NOTIFICATION,
|
||||
editIntent.getComponent() != null)
|
||||
.putExtra(GlobalScreenshot.EXTRA_ID, mScreenshotId)
|
||||
.putExtra(GlobalScreenshot.EXTRA_SMART_ACTIONS_ENABLED,
|
||||
mSmartActionsEnabled)
|
||||
.setAction(Intent.ACTION_EDIT),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT, UserHandle.SYSTEM);
|
||||
Notification.Action.Builder editActionBuilder = new Notification.Action.Builder(
|
||||
R.drawable.ic_screenshot_edit,
|
||||
r.getString(com.android.internal.R.string.screenshot_edit), editAction);
|
||||
notificationBuilder.addAction(editActionBuilder.build());
|
||||
if (editAction != null && mParams.onEditReady != null) {
|
||||
mParams.onEditReady.apply(editAction);
|
||||
}
|
||||
|
||||
// Create a delete action for the notification
|
||||
PendingIntent deleteAction = PendingIntent.getBroadcast(context, requestCode,
|
||||
new Intent(context, GlobalScreenshot.DeleteScreenshotReceiver.class)
|
||||
.putExtra(GlobalScreenshot.SCREENSHOT_URI_ID, uri.toString())
|
||||
.putExtra(GlobalScreenshot.EXTRA_ID, mScreenshotId)
|
||||
.putExtra(GlobalScreenshot.EXTRA_SMART_ACTIONS_ENABLED,
|
||||
mSmartActionsEnabled),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_ONE_SHOT);
|
||||
Notification.Action.Builder deleteActionBuilder = new Notification.Action.Builder(
|
||||
R.drawable.ic_screenshot_delete,
|
||||
r.getString(com.android.internal.R.string.delete), deleteAction);
|
||||
notificationBuilder.addAction(deleteActionBuilder.build());
|
||||
|
||||
if (mSmartActionsEnabled) {
|
||||
int timeoutMs = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI,
|
||||
SystemUiDeviceConfigFlags
|
||||
.SCREENSHOT_NOTIFICATION_SMART_ACTIONS_TIMEOUT_MS,
|
||||
1000);
|
||||
List<Notification.Action> smartActions = buildSmartActions(
|
||||
GlobalScreenshot.getSmartActions(mScreenshotId, smartActionsFuture,
|
||||
timeoutMs, mSmartActionsProvider), context);
|
||||
for (Notification.Action action : smartActions) {
|
||||
notificationBuilder.addAction(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Void params) {
|
||||
if (mParams.errorMsgResId != 0) {
|
||||
|
||||
@@ -18,41 +18,84 @@ package com.android.systemui.screenshot;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.os.Handler;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* This class can be overridden by a vendor-specific sys UI implementation,
|
||||
* in order to provide smart actions in the screenshot notification.
|
||||
*/
|
||||
public class ScreenshotNotificationSmartActionsProvider {
|
||||
/* Key provided in the notification action to get the type of smart action. */
|
||||
public static final String ACTION_TYPE = "action_type";
|
||||
public static final String DEFAULT_ACTION_TYPE = "Smart Action";
|
||||
|
||||
/* Define phases of screenshot execution. */
|
||||
protected enum ScreenshotOp {
|
||||
OP_UNKNOWN,
|
||||
RETRIEVE_SMART_ACTIONS,
|
||||
REQUEST_SMART_ACTIONS,
|
||||
WAIT_FOR_SMART_ACTIONS
|
||||
}
|
||||
|
||||
/* Enum to report success or failure for screenshot execution phases. */
|
||||
protected enum ScreenshotOpStatus {
|
||||
OP_STATUS_UNKNOWN,
|
||||
SUCCESS,
|
||||
ERROR,
|
||||
TIMEOUT
|
||||
}
|
||||
|
||||
private static final String TAG = "ScreenshotActions";
|
||||
|
||||
/**
|
||||
* Default implementation that returns an empty list.
|
||||
* This method is overridden in vendor-specific Sys UI implementation.
|
||||
*
|
||||
* @param screenshotId A generated random unique id for the screenshot.
|
||||
* @param bitmap The bitmap of the screenshot. The bitmap config must be {@link
|
||||
* HARDWARE}.
|
||||
* @param context The current app {@link Context}.
|
||||
* @param executor A {@link Executor} that can be used to execute tasks in parallel.
|
||||
* @param handler A {@link Handler} to possibly run UI-thread code.
|
||||
* @param componentName Contains package and activity class names where the screenshot was
|
||||
* taken. This is used as an additional signal to generate and rank more
|
||||
* relevant actions.
|
||||
* @param isManagedProfile The screenshot was taken for a work profile app.
|
||||
*/
|
||||
public CompletableFuture<List<Notification.Action>> getActions(Bitmap bitmap, Context context,
|
||||
Executor executor, Handler handler, ComponentName componentName,
|
||||
public CompletableFuture<List<Notification.Action>> getActions(
|
||||
String screenshotId,
|
||||
Bitmap bitmap,
|
||||
ComponentName componentName,
|
||||
boolean isManagedProfile) {
|
||||
Log.d(TAG, "Returning empty smart action list.");
|
||||
return CompletableFuture.completedFuture(Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify exceptions and latency encountered during generating smart actions.
|
||||
* This method is overridden in vendor-specific Sys UI implementation.
|
||||
*
|
||||
* @param screenshotId Unique id of the screenshot.
|
||||
* @param op screenshot execution phase defined in {@link ScreenshotOp}
|
||||
* @param status {@link ScreenshotOpStatus} to report success or failure.
|
||||
* @param durationMs latency experienced in different phases of screenshots.
|
||||
*/
|
||||
public void notifyOp(String screenshotId, ScreenshotOp op, ScreenshotOpStatus status,
|
||||
long durationMs) {
|
||||
Log.d(TAG, "Return without notify.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify screenshot notification action invoked.
|
||||
* This method is overridden in vendor-specific Sys UI implementation.
|
||||
*
|
||||
* @param screenshotId Unique id of the screenshot.
|
||||
* @param action type of notification action invoked.
|
||||
* @param isSmartAction whether action invoked was a smart action.
|
||||
*/
|
||||
public void notifyAction(String screenshotId, String action, boolean isSmartAction) {
|
||||
Log.d(TAG, "Return without notify.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,12 @@
|
||||
|
||||
package com.android.systemui.screenshot;
|
||||
|
||||
import static android.content.Context.NOTIFICATION_SERVICE;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -25,14 +29,20 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.testing.AndroidTestingRunner;
|
||||
|
||||
import androidx.test.filters.SmallTest;
|
||||
|
||||
import com.android.systemui.SystemUIFactory;
|
||||
import com.android.systemui.SysuiTestCase;
|
||||
import com.android.systemui.util.NotificationChannels;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
@@ -70,12 +80,12 @@ public class ScreenshotNotificationSmartActionsTest extends SysuiTestCase {
|
||||
when(bitmap.getConfig()).thenReturn(Bitmap.Config.HARDWARE);
|
||||
ScreenshotNotificationSmartActionsProvider smartActionsProvider = mock(
|
||||
ScreenshotNotificationSmartActionsProvider.class);
|
||||
when(smartActionsProvider.getActions(any(), any(), any(), any(), any(),
|
||||
when(smartActionsProvider.getActions(any(), any(), any(),
|
||||
eq(false))).thenThrow(
|
||||
RuntimeException.class);
|
||||
CompletableFuture<List<Notification.Action>> smartActionsFuture =
|
||||
GlobalScreenshot.getSmartActionsFuture(mContext, bitmap,
|
||||
smartActionsProvider, mHandler, true, false);
|
||||
GlobalScreenshot.getSmartActionsFuture("", bitmap,
|
||||
smartActionsProvider, true, false);
|
||||
Assert.assertNotNull(smartActionsFuture);
|
||||
List<Notification.Action> smartActions = smartActionsFuture.get(5, TimeUnit.MILLISECONDS);
|
||||
Assert.assertEquals(Collections.emptyList(), smartActions);
|
||||
@@ -92,10 +102,19 @@ public class ScreenshotNotificationSmartActionsTest extends SysuiTestCase {
|
||||
when(smartActionsFuture.get(timeoutMs, TimeUnit.MILLISECONDS)).thenThrow(
|
||||
RuntimeException.class);
|
||||
List<Notification.Action> actions = GlobalScreenshot.getSmartActions(
|
||||
smartActionsFuture, timeoutMs);
|
||||
"", smartActionsFuture, timeoutMs, mSmartActionsProvider);
|
||||
Assert.assertEquals(Collections.emptyList(), actions);
|
||||
}
|
||||
|
||||
// Tests any exception thrown in notifying feedback does not affect regular screenshot flow.
|
||||
@Test
|
||||
public void testExceptionHandlingInNotifyingFeedback()
|
||||
throws Exception {
|
||||
doThrow(RuntimeException.class).when(mSmartActionsProvider).notifyOp(any(), any(), any(),
|
||||
anyLong());
|
||||
GlobalScreenshot.notifyScreenshotOp(null, mSmartActionsProvider, null, null, -1);
|
||||
}
|
||||
|
||||
// Tests for a non-hardware bitmap, ScreenshotNotificationSmartActionsProvider is never invoked
|
||||
// and a completed future is returned.
|
||||
@Test
|
||||
@@ -104,9 +123,9 @@ public class ScreenshotNotificationSmartActionsTest extends SysuiTestCase {
|
||||
Bitmap bitmap = mock(Bitmap.class);
|
||||
when(bitmap.getConfig()).thenReturn(Bitmap.Config.RGB_565);
|
||||
CompletableFuture<List<Notification.Action>> smartActionsFuture =
|
||||
GlobalScreenshot.getSmartActionsFuture(mContext, bitmap,
|
||||
mSmartActionsProvider, mHandler, true, true);
|
||||
verify(mSmartActionsProvider, never()).getActions(any(), any(), any(), any(), any(),
|
||||
GlobalScreenshot.getSmartActionsFuture("", bitmap,
|
||||
mSmartActionsProvider, true, true);
|
||||
verify(mSmartActionsProvider, never()).getActions(any(), any(), any(),
|
||||
eq(false));
|
||||
Assert.assertNotNull(smartActionsFuture);
|
||||
List<Notification.Action> smartActions = smartActionsFuture.get(5, TimeUnit.MILLISECONDS);
|
||||
@@ -118,10 +137,10 @@ public class ScreenshotNotificationSmartActionsTest extends SysuiTestCase {
|
||||
public void testScreenshotNotificationSmartActionsProviderInvokedOnce() {
|
||||
Bitmap bitmap = mock(Bitmap.class);
|
||||
when(bitmap.getConfig()).thenReturn(Bitmap.Config.HARDWARE);
|
||||
GlobalScreenshot.getSmartActionsFuture(mContext, bitmap, mSmartActionsProvider,
|
||||
mHandler, true, true);
|
||||
GlobalScreenshot.getSmartActionsFuture("", bitmap, mSmartActionsProvider,
|
||||
true, true);
|
||||
verify(mSmartActionsProvider, times(1))
|
||||
.getActions(any(), any(), any(), any(), any(), eq(true));
|
||||
.getActions(any(), any(), any(), eq(true));
|
||||
}
|
||||
|
||||
// Tests for a hardware bitmap, a completed future is returned.
|
||||
@@ -131,13 +150,65 @@ public class ScreenshotNotificationSmartActionsTest extends SysuiTestCase {
|
||||
Bitmap bitmap = mock(Bitmap.class);
|
||||
when(bitmap.getConfig()).thenReturn(Bitmap.Config.HARDWARE);
|
||||
ScreenshotNotificationSmartActionsProvider actionsProvider =
|
||||
SystemUIFactory.getInstance().createScreenshotNotificationSmartActionsProvider();
|
||||
SystemUIFactory.getInstance().createScreenshotNotificationSmartActionsProvider(
|
||||
mContext, null, mHandler);
|
||||
CompletableFuture<List<Notification.Action>> smartActionsFuture =
|
||||
GlobalScreenshot.getSmartActionsFuture(mContext, bitmap,
|
||||
GlobalScreenshot.getSmartActionsFuture("", bitmap,
|
||||
actionsProvider,
|
||||
mHandler, true, true);
|
||||
true, true);
|
||||
Assert.assertNotNull(smartActionsFuture);
|
||||
List<Notification.Action> smartActions = smartActionsFuture.get(5, TimeUnit.MILLISECONDS);
|
||||
Assert.assertEquals(smartActions.size(), 0);
|
||||
}
|
||||
|
||||
// Tests for notification action extras.
|
||||
@Test
|
||||
public void testNotificationActionExtras() {
|
||||
if (Looper.myLooper() == null) {
|
||||
Looper.prepare();
|
||||
}
|
||||
NotificationManager notificationManager =
|
||||
(NotificationManager) mContext.getSystemService(NOTIFICATION_SERVICE);
|
||||
GlobalScreenshot.SaveImageInBackgroundData
|
||||
data = new GlobalScreenshot.SaveImageInBackgroundData();
|
||||
data.context = mContext;
|
||||
data.image = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
|
||||
data.iconSize = 10;
|
||||
data.finisher = null;
|
||||
data.onEditReady = null;
|
||||
data.previewWidth = 10;
|
||||
data.previewheight = 10;
|
||||
SaveImageInBackgroundTask task = new SaveImageInBackgroundTask(mContext, data,
|
||||
notificationManager);
|
||||
Notification.Builder notificationBuilder = new Notification.Builder(mContext,
|
||||
NotificationChannels.SCREENSHOTS_HEADSUP);
|
||||
task.populateNotificationActions(mContext, mContext.getResources(),
|
||||
Uri.parse("Screenshot_123.png"),
|
||||
CompletableFuture.completedFuture(Collections.emptyList()), notificationBuilder);
|
||||
|
||||
Notification notification = notificationBuilder.build();
|
||||
Assert.assertEquals(notification.actions.length, 3);
|
||||
boolean isShareFound = false;
|
||||
boolean isEditFound = false;
|
||||
boolean isDeleteFound = false;
|
||||
for (Notification.Action action : notification.actions) {
|
||||
Intent intent = action.actionIntent.getIntent();
|
||||
Assert.assertNotNull(intent);
|
||||
Bundle bundle = intent.getExtras();
|
||||
Assert.assertTrue(bundle.containsKey(GlobalScreenshot.EXTRA_ID));
|
||||
Assert.assertTrue(bundle.containsKey(GlobalScreenshot.EXTRA_SMART_ACTIONS_ENABLED));
|
||||
|
||||
if (action.title.equals(GlobalScreenshot.ACTION_TYPE_DELETE)) {
|
||||
isDeleteFound = intent.getAction() == null;
|
||||
} else if (action.title.equals(GlobalScreenshot.ACTION_TYPE_EDIT)) {
|
||||
isEditFound = Intent.ACTION_EDIT.equals(intent.getAction());
|
||||
} else if (action.title.equals(GlobalScreenshot.ACTION_TYPE_SHARE)) {
|
||||
isShareFound = Intent.ACTION_SEND.equals(intent.getAction());
|
||||
}
|
||||
}
|
||||
|
||||
Assert.assertTrue(isEditFound);
|
||||
Assert.assertTrue(isDeleteFound);
|
||||
Assert.assertTrue(isShareFound);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user