From c943808917340779c23bc8a4f7380e07d05f1f5e Mon Sep 17 00:00:00 2001 From: octocorvus Date: Fri, 17 Apr 2026 16:54:59 +0000 Subject: [PATCH 1/9] secure paste: add clipboard access policy and grants Secure paste lets users prevent third-party apps from reading clipboard contents on their own while keeping Paste working when they choose it. Add a device-wide default and per-app controls, keep access allowed by default for compatibility, and enforce the policy centrally in the clipboard service. Apps can still read the current clipboard item when it was copied by their own app identity. This preserves in-app copy workflows without allowing access to items copied by a different app identity. Apps set to Paste only can still tell that a clipboard item is available and inspect its MIME types, copy timestamp, and styled text state. TextView.canPasteAsPlainText() uses the MIME types and styled text state to offer Paste as plain text without reading the content. Keep the timestamp available for compatibility. Hide the label, extras, semantic classification, and ClipData payload. Represent an explicit Paste with a short-lived grant tied to the destination app, the specific clipboard, and its current item. Existing package identity, focus, AppOps, and device lock checks continue to apply. Later commits add authorization paths for the selection toolbar, input methods, accessibility services, and keyboard shortcuts. Co-authored-by: inthewaves --- .../java/android/content/ClipDescription.java | 4 +- .../content/pm/GosPackageStateFlag.java | 4 + core/java/android/ext/SettingsIntents.java | 1 + .../android/ext/settings/ExtSettings.java | 4 + .../android/ext/settings/app/AppSwitch.java | 13 +- .../settings/app/AswAllowClipboardRead.java | 76 +++++ core/java/android/provider/Settings.java | 4 + core/res/res/values/config_ext.xml | 3 + core/res/res/values/public-ext.xml | 2 + .../server/clipboard/ClipboardAccess.java | 281 ++++++++++++++++++ .../clipboard/ClipboardManagerInternal.java | 19 +- .../server/clipboard/ClipboardService.java | 144 ++++++--- .../server/pm/GosPackageStatePermissions.java | 4 + .../server/wm/WindowManagerInternal.java | 5 + .../server/wm/WindowManagerService.java | 10 + 15 files changed, 528 insertions(+), 46 deletions(-) create mode 100644 core/java/android/ext/settings/app/AswAllowClipboardRead.java create mode 100644 services/core/java/com/android/server/clipboard/ClipboardAccess.java diff --git a/core/java/android/content/ClipDescription.java b/core/java/android/content/ClipDescription.java index 93724bb4949d7..d1b62fc16cd73 100644 --- a/core/java/android/content/ClipDescription.java +++ b/core/java/android/content/ClipDescription.java @@ -423,8 +423,10 @@ public boolean isStyledText() { * Sets whether the associated {@link ClipData} contains styled text in its first item. This * should be called when this description is associated with clip data or when the first item * is added to the associated clip data. + * + * @hide */ - void setIsStyledText(boolean isStyledText) { + public void setIsStyledText(boolean isStyledText) { mIsStyledText = isStyledText; } diff --git a/core/java/android/content/pm/GosPackageStateFlag.java b/core/java/android/content/pm/GosPackageStateFlag.java index 79c05d16711b3..d9ff409e027ef 100644 --- a/core/java/android/content/pm/GosPackageStateFlag.java +++ b/core/java/android/content/pm/GosPackageStateFlag.java @@ -38,6 +38,8 @@ public interface GosPackageStateFlag { /** @hide */ int BLOCK_PLAY_INTEGRITY_API = 28; /** @hide */ int USE_EXEC_SPAWNING_NON_DEFAULT = 29; /** @hide */ int USE_EXEC_SPAWNING = 30; + /** @hide */ int ALLOW_CLIPBOARD_READ_NON_DEFAULT = 31; + /** @hide */ int ALLOW_CLIPBOARD_READ = 32; /** @hide */ @IntDef(value = { @@ -68,6 +70,8 @@ public interface GosPackageStateFlag { BLOCK_PLAY_INTEGRITY_API, USE_EXEC_SPAWNING_NON_DEFAULT, USE_EXEC_SPAWNING, + ALLOW_CLIPBOARD_READ_NON_DEFAULT, + ALLOW_CLIPBOARD_READ, }) @Retention(RetentionPolicy.SOURCE) @interface Enum {} diff --git a/core/java/android/ext/SettingsIntents.java b/core/java/android/ext/SettingsIntents.java index 956b1144b0e4d..ca271c78d4857 100644 --- a/core/java/android/ext/SettingsIntents.java +++ b/core/java/android/ext/SettingsIntents.java @@ -13,6 +13,7 @@ public class SettingsIntents { public static final String APP_MEMORY_DYN_CODE_LOADING = "android.settings.OPEN_APP_MEMORY_DYN_CODE_LOADING_SETTINGS"; public static final String APP_STORAGE_DYN_CODE_LOADING = "android.settings.OPEN_APP_STORAGE_DYN_CODE_LOADING_SETTINGS"; public static final String APP_MANAGE_PLAY_INTEGRITY_API = "android.settings.OPEN_APP_MANAGE_PLAY_INTEGRITY_API_SETTINGS"; + public static final String APP_CLIPBOARD_READ = "android.settings.OPEN_APP_CLIPBOARD_READ_SETTINGS"; public static Intent getAppIntent(Context ctx, String action, String pkgName) { var i = new Intent(action); diff --git a/core/java/android/ext/settings/ExtSettings.java b/core/java/android/ext/settings/ExtSettings.java index 821f3d2384ce2..b0eb63aa3b321 100644 --- a/core/java/android/ext/settings/ExtSettings.java +++ b/core/java/android/ext/settings/ExtSettings.java @@ -100,6 +100,10 @@ public class ExtSettings { public static final BoolSetting DISALLOW_DELAYED_LOCKING_ON_USER_STOP = new BoolSetting( Setting.Scope.PER_USER, Settings.Secure.DISALLOW_DELAYED_LOCKING_ON_USER_STOP, false); + public static final BoolSetting ALLOW_CLIPBOARD_READ_BY_DEFAULT = new BoolSetting( + Setting.Scope.GLOBAL, Settings.Global.ALLOW_CLIPBOARD_READ_BY_DEFAULT, + defaultBool(R.bool.setting_default_allow_clipboard_read)); + private ExtSettings() {} public static Function defaultBool(@BoolRes int res) { diff --git a/core/java/android/ext/settings/app/AppSwitch.java b/core/java/android/ext/settings/app/AppSwitch.java index 989c268bf4c43..59bda5ebfed1f 100644 --- a/core/java/android/ext/settings/app/AppSwitch.java +++ b/core/java/android/ext/settings/app/AppSwitch.java @@ -32,6 +32,7 @@ public abstract class AppSwitch { public static final int IR_EXPLOIT_PROTECTION_COMPAT_MODE = 6; public static final int IR_REQUIRED_BY_HARDENED_MALLOC = 7; public static final int IR_REQUIRED_BY_ZYGOTE_SPAWNING = 8; + public static final int IR_IS_DEFAULT_IME = 9; // default value reasons public static final int DVR_UNKNOWN = 0; @@ -127,10 +128,7 @@ public final boolean get(Context ctx, int userId, ApplicationInfo appInfo, si.isUsingDefaultValue = true; res = getDefaultValue(ctx, userId, appInfo, ps, si); } else { - res = ps.hasFlag(gosPsFlag); - if (gosPsFlagInverted) { - res = !res; - } + res = getNonDefaultValue(ps); } return res; @@ -148,10 +146,15 @@ public final void set(GosPackageState.Editor ed, boolean on) { } } - private boolean isUsingDefaultValue(GosPackageState ps) { + protected final boolean isUsingDefaultValue(GosPackageState ps) { return gosPsFlagNonDefault != 0 && !ps.hasFlag(gosPsFlagNonDefault); } + protected final boolean getNonDefaultValue(GosPackageState ps) { + final boolean value = ps.hasFlag(gosPsFlag); + return gosPsFlagInverted ? !value : value; + } + public final void setUseDefaultValue(GosPackageState.Editor ed) { ed.clearFlag(gosPsFlagNonDefault); ed.clearFlag(gosPsFlag); diff --git a/core/java/android/ext/settings/app/AswAllowClipboardRead.java b/core/java/android/ext/settings/app/AswAllowClipboardRead.java new file mode 100644 index 0000000000000..e5b8572e0aa91 --- /dev/null +++ b/core/java/android/ext/settings/app/AswAllowClipboardRead.java @@ -0,0 +1,76 @@ +package android.ext.settings.app; + +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.GosPackageState; +import android.content.pm.GosPackageStateFlag; +import android.ext.settings.ExtSettings; +import android.provider.Settings; +import android.text.TextUtils; + +/** @hide */ +public class AswAllowClipboardRead extends AppSwitch { + public static final AswAllowClipboardRead I = new AswAllowClipboardRead(); + + private AswAllowClipboardRead() { + gosPsFlag = GosPackageStateFlag.ALLOW_CLIPBOARD_READ; + gosPsFlagNonDefault = GosPackageStateFlag.ALLOW_CLIPBOARD_READ_NON_DEFAULT; + } + + @Override + public Boolean getImmutableValue(Context ctx, int userId, ApplicationInfo appInfo, + GosPackageState ps, StateInfo si) { + final int reason = getImmutabilityReason(appInfo.isSystemApp(), + isDefaultIme(ctx, userId, appInfo.packageName)); + if (reason != IR_UNKNOWN) { + si.immutabilityReason = reason; + return true; + } + + return null; + } + + @Override + protected boolean getDefaultValueInner(Context ctx, int userId, ApplicationInfo appInfo, + GosPackageState ps, StateInfo si) { + si.defaultValueReason = DVR_DEFAULT_SETTING; + return getDefaultValue(ctx, userId); + } + + public boolean get(Context ctx, int userId, boolean isSystemApp, boolean isDefaultIme, + GosPackageState ps) { + if (getImmutabilityReason(isSystemApp, isDefaultIme) != IR_UNKNOWN) { + return true; + } + return isUsingDefaultValue(ps) + ? getDefaultValue(ctx, userId) + : getNonDefaultValue(ps); + } + + private static int getImmutabilityReason(boolean isSystemApp, boolean isDefaultIme) { + if (isSystemApp) { + return IR_IS_SYSTEM_APP; + } + return isDefaultIme ? IR_IS_DEFAULT_IME : IR_UNKNOWN; + } + + private static boolean getDefaultValue(Context ctx, int userId) { + return ExtSettings.ALLOW_CLIPBOARD_READ_BY_DEFAULT.get(ctx, userId); + } + + // based on ClipboardService#isDefaultIme + private boolean isDefaultIme(Context ctx, int userId, String packageName) { + String defaultIme = Settings.Secure.getStringForUser(ctx.getContentResolver(), + Settings.Secure.DEFAULT_INPUT_METHOD, userId); + if (!TextUtils.isEmpty(defaultIme)) { + final ComponentName imeComponent = ComponentName.unflattenFromString(defaultIme); + if (imeComponent == null) { + return false; + } + final String imPkg = imeComponent.getPackageName(); + return imPkg.equals(packageName); + } + return false; + } +} diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java index bcf423537bb66..4c940ca0334bd 100644 --- a/core/java/android/provider/Settings.java +++ b/core/java/android/provider/Settings.java @@ -14523,6 +14523,10 @@ public static final class Global extends NameValueTable { @Protected(restrictReads = false, readWrite = KnownSystemPackage.SETTINGS) public static final String CERT_TRANSPARENCY_DOWNLOADER = "cert_transparency_downloader"; + /** @hide */ + @Protected(readWrite = KnownSystemPackage.SETTINGS) + public static final String ALLOW_CLIPBOARD_READ_BY_DEFAULT = "allow_clipboard_read"; + // ExtSettings END // NOTE: If you add new settings here, be sure to add them to diff --git a/core/res/res/values/config_ext.xml b/core/res/res/values/config_ext.xml index 8bdb4d16d93b8..bd323af7a0176 100644 --- a/core/res/res/values/config_ext.xml +++ b/core/res/res/values/config_ext.xml @@ -55,4 +55,7 @@ -1 + true + + diff --git a/core/res/res/values/public-ext.xml b/core/res/res/values/public-ext.xml index 2c6cfb1a63657..b03cf9cff6e72 100644 --- a/core/res/res/values/public-ext.xml +++ b/core/res/res/values/public-ext.xml @@ -38,6 +38,8 @@ + + diff --git a/services/core/java/com/android/server/clipboard/ClipboardAccess.java b/services/core/java/com/android/server/clipboard/ClipboardAccess.java new file mode 100644 index 0000000000000..eac56717e8bf7 --- /dev/null +++ b/services/core/java/com/android/server/clipboard/ClipboardAccess.java @@ -0,0 +1,281 @@ +package com.android.server.clipboard; + +import static android.content.Context.DEVICE_ID_INVALID; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.ActivityManager; +import android.app.UidObserver; +import android.content.ClipDescription; +import android.content.Context; +import android.content.pm.GosPackageState; +import android.content.pm.PackageManagerInternal; +import android.ext.settings.app.AswAllowClipboardRead; +import android.os.RemoteException; +import android.os.SystemClock; +import android.os.UserHandle; +import android.util.Slog; +import android.util.SparseArray; + +import com.android.internal.annotations.GuardedBy; +import com.android.server.LocalServices; +import com.android.server.clipboard.ClipboardService.Clipboard; +import com.android.server.pm.pkg.PackageStateInternal; +import com.android.server.pm.pkg.PackageUserStateInternal; + +/** + * Applies the per-app clipboard read setting and tracks trusted paste authorization grants. + * + *

Each grant is bound to a UID, resolved clipboard device, and primary clip generation. A grant + * initially allows time for asynchronous paste dispatch. Its first non-null primary clip read + * starts a fixed read window which later reads do not extend. A new grant replaces the previous + * grant for the UID. Grants are invalidated by expiry, a clip change, or UID exit.

+ * + *

Grant records share {@link ClipboardService}'s lock so checking a grant and selecting the + * corresponding clip are atomic. Trusted routes validate the exact current target before creating + * a grant, while the resulting authorization is deliberately UID scoped. Every read still passes + * the clipboard API's package identity, focus, AppOps, and device lock checks.

+ */ +final class ClipboardAccess { + private static final String TAG = "ClipboardAccess"; + + // Callback return does not mean the toolkit has read the clipboard. Allow one normal + // unmultiplied input dispatch timeout for asynchronous delivery while bounding abandoned work. + private static final long PASTE_GRANT_DISPATCH_TIMEOUT_MILLIS = 5000L; + + // Clipboard clients such as Chromium-based browsers may read several representations. The + // first payload read starts this fixed window; later reads must not renew it. + private static final long PASTE_GRANT_READ_WINDOW_MILLIS = 1000L; + + private final Context mContext; + private final ClipboardService mService; + private final PackageManagerInternal mPmi; + + // Lock must be the one used by ClipboardService + private final Object mLock; + + @GuardedBy("mLock") + // One record per UID. Matching reads remove expired entries, while replacement and UID exit + // bound retention without per-grant delayed cleanup tasks. + private final SparseArray mPasteGrantsByUid = new SparseArray<>(); + + ClipboardAccess(@NonNull Context context, @NonNull ClipboardService service, + @NonNull Object lock) { + mContext = context; + mService = service; + mLock = lock; + mPmi = LocalServices.getService(PackageManagerInternal.class); + registerUidObserver(); + } + + void createPasteGrantForDevice(int intendingUid, int requestedDeviceId) { + final int intendingUserId = UserHandle.getUserId(intendingUid); + final int intendingDeviceId = mService.getIntendingDeviceId( + requestedDeviceId, intendingUid); + if (intendingDeviceId == DEVICE_ID_INVALID) { + Slog.i(TAG, "createPasteGrantForDevice: invalid deviceId for uid:" + intendingUid + + " deviceId:" + requestedDeviceId); + return; + } + + synchronized (mLock) { + final Clipboard clipboard = mService.getClipboardLocked(intendingUserId, + intendingDeviceId); + if (clipboard == null) { + return; + } + + final long elapsedRealtime = SystemClock.elapsedRealtime(); + final PasteGrantRecord record = new PasteGrantRecord(intendingDeviceId, + clipboard.primaryClipGeneration, + elapsedRealtime + PASTE_GRANT_DISPATCH_TIMEOUT_MILLIS); + mPasteGrantsByUid.put(intendingUid, record); + } + } + + private void removePasteGrantForUid(int intendingUid) { + synchronized (mLock) { + mPasteGrantsByUid.remove(intendingUid); + } + } + + /** + * Source of authorization for a payload read. The service distinguishes {@link #PASTE_GRANT} + * so only a non-null payload returned through a temporary grant starts its fixed read window. + */ + enum PayloadReadAccess { + /** No clipboard ownership, persistent setting, or temporary paste grant allows the read. */ + DENIED, + + /** The requesting UID set the current primary clip. */ + CLIP_OWNER, + + /** The persistent per-app clipboard read setting allows the read. */ + PERSISTENT_APP_SETTING, + + /** A temporary grant created for a trusted user paste action allows the read. */ + PASTE_GRANT, + } + + @GuardedBy("mLock") + PayloadReadAccess getPayloadReadAccessLocked(boolean readAllowedForPackage, int intendingUid, + int intendingUserId, int intendingDeviceId) { + final Clipboard clipboard = mService.getClipboardLocked(intendingUserId, + intendingDeviceId); + if (clipboard != null && clipboard.primaryClip != null + && clipboard.primaryClipUid == intendingUid) { + return PayloadReadAccess.CLIP_OWNER; + } + if (clipboardReadAllowedByPasteGrantLocked( + intendingUid, intendingUserId, intendingDeviceId)) { + return PayloadReadAccess.PASTE_GRANT; + } + return readAllowedForPackage + ? PayloadReadAccess.PERSISTENT_APP_SETTING + : PayloadReadAccess.DENIED; + } + + @GuardedBy("mLock") + private boolean clipboardReadAllowedByPasteGrantLocked(int intendingUid, int intendingUserId, + int intendingDeviceId) { + final PasteGrantRecord record = mPasteGrantsByUid.get(intendingUid); + // A UID can access clipboard silos on different virtual devices. Looking at another silo + // must not consume the grant for the device on which Paste was authorized. + if (record == null || !record.isForDevice(intendingDeviceId)) { + return false; + } + + final Clipboard clipboard = mService.getClipboardLocked(intendingUserId, + intendingDeviceId); + if (clipboard == null + || !record.isValidFor(clipboard, SystemClock.elapsedRealtime())) { + mPasteGrantsByUid.remove(intendingUid); + return false; + } + return true; + } + + /** + * Starts the fixed read window after a non-null payload read accepted earlier under + * {@code mLock}. Synchronous work may cross the pending deadline, so the record and clip + * identity are rechecked, but the deadline is not. Metadata-only reads do not start the window. + */ + @GuardedBy("mLock") + void activatePasteGrantOnPrimaryClipReadLocked(int intendingUid, @NonNull Clipboard clipboard) { + final long elapsedRealtime = SystemClock.elapsedRealtime(); + final PasteGrantRecord record = mPasteGrantsByUid.get(intendingUid); + if (record != null && record.isForClipboard(clipboard)) { + record.startReadWindow(elapsedRealtime); + } + } + + @GuardedBy("mLock") + @Nullable + ClipDescription getPrimaryClipDescriptionLocked(boolean readAllowedForPackage, + int intendingUid, int intendingUserId, int intendingDeviceId) { + final PayloadReadAccess access = getPayloadReadAccessLocked(readAllowedForPackage, + intendingUid, intendingUserId, intendingDeviceId); + final Clipboard clipboard = mService.getClipboardLocked(intendingUserId, + intendingDeviceId); + final ClipDescription description = clipboard != null && clipboard.primaryClip != null + ? clipboard.primaryClip.getDescription() : null; + if (description == null || access != PayloadReadAccess.DENIED) { + return description; + } + + final String[] mimeTypes = new String[description.getMimeTypeCount()]; + for (int i = 0; i < mimeTypes.length; i++) { + mimeTypes[i] = description.getMimeType(i); + } + final ClipDescription descriptionWithoutLabel = new ClipDescription(null, mimeTypes); + descriptionWithoutLabel.setTimestamp(description.getTimestamp()); + // TextView.canPasteAsPlainText() uses this bit to offer Paste as plain text. + descriptionWithoutLabel.setIsStyledText(description.isStyledText()); + return descriptionWithoutLabel; + } + + @GuardedBy("mLock") + boolean hasClipboardTextLocked(boolean readAllowedForPackage, int intendingUid, + int intendingUserId, int intendingDeviceId) { + final PayloadReadAccess access = getPayloadReadAccessLocked(readAllowedForPackage, + intendingUid, intendingUserId, intendingDeviceId); + final Clipboard clipboard = mService.getClipboardLocked(intendingUserId, + intendingDeviceId); + if (clipboard == null || clipboard.primaryClip == null) { + return false; + } + if (access == PayloadReadAccess.DENIED) { + // Some apps use this query to decide whether the system toolbar should offer Paste. + // Preserve clip presence without inspecting or exposing its content. + return true; + } + final CharSequence text = clipboard.primaryClip.getItemAt(0).getText(); + return text != null && text.length() > 0; + } + + boolean clipboardReadAllowedForPackage(String packageName, int intendingUid, + int intendingUserId, boolean isDefaultIme) { + final PackageStateInternal packageState = mPmi.getPackageStateInternal(packageName); + if (packageState == null + || packageState.getAppId() != UserHandle.getAppId(intendingUid)) { + return false; + } + final PackageUserStateInternal userState = + packageState.getUserStateOrDefault(intendingUserId); + if (!userState.isInstalled() || userState.isHidden()) { + return false; + } + final GosPackageState gosPackageState = userState.getGosPackageState(); + return AswAllowClipboardRead.I.get(mContext, intendingUserId, packageState.isSystem(), + isDefaultIme, gosPackageState); + } + + private void registerUidObserver() { + try { + ActivityManager.getService().registerUidObserver(new UidObserver() { + @Override + public void onUidGone(int uid, boolean disabled) { + removePasteGrantForUid(uid); + } + }, ActivityManager.UID_OBSERVER_GONE, ActivityManager.PROCESS_STATE_UNKNOWN, null); + } catch (RemoteException e) { + // ignored; both services live in system_server + } + } + + private static final class PasteGrantRecord { + private final int mDeviceId; + private final int mClipGeneration; + + private long mExpiryElapsedRealtime; + + private boolean mReadWindowStarted; + + private PasteGrantRecord(int deviceId, int clipGeneration, long expiryElapsedRealtime) { + mDeviceId = deviceId; + mClipGeneration = clipGeneration; + mExpiryElapsedRealtime = expiryElapsedRealtime; + } + + private boolean isValidFor(@NonNull Clipboard clipboard, long elapsedRealtime) { + return isForClipboard(clipboard) + && mExpiryElapsedRealtime >= elapsedRealtime; + } + + private boolean isForClipboard(@NonNull Clipboard clipboard) { + return isForDevice(clipboard.deviceId) + && mClipGeneration == clipboard.primaryClipGeneration; + } + + private boolean isForDevice(int deviceId) { + return mDeviceId == deviceId; + } + + private void startReadWindow(long elapsedRealtime) { + if (!mReadWindowStarted) { + mReadWindowStarted = true; + mExpiryElapsedRealtime = elapsedRealtime + PASTE_GRANT_READ_WINDOW_MILLIS; + } + } + } +} diff --git a/services/core/java/com/android/server/clipboard/ClipboardManagerInternal.java b/services/core/java/com/android/server/clipboard/ClipboardManagerInternal.java index 32da7033a95b5..d27971272f158 100644 --- a/services/core/java/com/android/server/clipboard/ClipboardManagerInternal.java +++ b/services/core/java/com/android/server/clipboard/ClipboardManagerInternal.java @@ -22,10 +22,25 @@ public interface ClipboardManagerInternal { /** - * Notify that there was a recent action taken by the user that the system trusts was an - * intentional authorization of clip data. + * Records a trusted user action for clipboard access notification bookkeeping. + * + *

This suppresses a redundant user-visible access notification and marks subsequent access + * logging as user initiated. It does not grant permission to read clipboard data.

* * @param uid The uid expected to access clip data. */ void notifyUserAuthorizedClipAccess(int uid); + + /** + * Creates a short-lived clipboard read grant for the UID targeted on {@code displayId}. + * + *

The caller must validate both an approved trusted paste trigger and its exact target, and + * must create the grant synchronously before dispatching the paste action to that target. The + * grant is bound to the resolved clipboard device and current primary clip generation. It does + * not bypass the normal clipboard package identity, focus, AppOps, or device lock checks. The + * resulting authorization is UID scoped rather than tied to the route-specific input + * connection or window used to validate the action.

+ * + */ + void createPasteGrantForDisplay(int uid, int displayId); } diff --git a/services/core/java/com/android/server/clipboard/ClipboardService.java b/services/core/java/com/android/server/clipboard/ClipboardService.java index e6903628418d8..ec9a6b653efaf 100644 --- a/services/core/java/com/android/server/clipboard/ClipboardService.java +++ b/services/core/java/com/android/server/clipboard/ClipboardService.java @@ -206,6 +206,8 @@ public class ClipboardService extends SystemService { private final Object mLock = new Object(); + private final ClipboardAccess mAccess; + /** * Instantiates the clipboard. */ @@ -253,6 +255,8 @@ public ClipboardService(Context context) { HandlerThread workerThread = new HandlerThread(TAG); workerThread.start(); mWorkerHandler = workerThread.getThreadHandler(); + + mAccess = new ClipboardAccess(getContext(), this, mLock); } @Override @@ -313,10 +317,12 @@ private class ListenerInfo { } } - private static class Clipboard { + static class Clipboard { public final int userId; public final int deviceId; + int primaryClipGeneration = 0; + final RemoteCallbackList primaryClipListeners = new RemoteCallbackList(); @@ -431,7 +437,7 @@ private int getIntendingUid(String packageName, @UserIdInt int userId) { * DEVICE_ID_INVALID if this uid should not be allowed access. A value of DEVICE_ID_DEFAULT * means just use the "regular" clipboard. */ - private int getIntendingDeviceId(int requestedDeviceId, int uid) { + int getIntendingDeviceId(int requestedDeviceId, int uid) { if (mVdmInternal == null) { return DEVICE_ID_DEFAULT; } @@ -678,17 +684,29 @@ public ClipData getPrimaryClip( final int intendingUid = getIntendingUid(pkg, userId); final int intendingUserId = UserHandle.getUserId(intendingUid); final int intendingDeviceId = getIntendingDeviceId(deviceId, intendingUid); + final boolean isDefaultIme = isDefaultIme(intendingUserId, pkg); if (!clipboardAccessAllowed( AppOpsManager.OP_READ_CLIPBOARD, pkg, attributionTag, intendingUid, intendingUserId, - intendingDeviceId) + intendingDeviceId, + true /* shouldNoteOp */, + isDefaultIme) || isDeviceLocked(intendingUserId, deviceId)) { return null; } + final boolean readAllowedForPackage = mAccess.clipboardReadAllowedForPackage( + pkg, intendingUid, intendingUserId, isDefaultIme); synchronized (mLock) { + final ClipboardAccess.PayloadReadAccess readAccess = + mAccess.getPayloadReadAccessLocked(readAllowedForPackage, + intendingUid, intendingUserId, intendingDeviceId); + if (readAccess == ClipboardAccess.PayloadReadAccess.DENIED) { + return null; + } + try { addActiveOwnerLocked(intendingUid, intendingDeviceId, pkg); } catch (SecurityException e) { @@ -702,13 +720,24 @@ public ClipData getPrimaryClip( if (clipboard == null) { return null; } + // Paste grants are checked at the actual read, so they remain authoritative when + // dispatch takes longer than the legacy notification suppression timeout. + final boolean isUserInitiated = + readAccess == ClipboardAccess.PayloadReadAccess.PASTE_GRANT + || shouldSuppressAccessNotificationForUidLocked(intendingUid); boolean wasAccessShown = showAccessNotificationLocked( - pkg, intendingUid, intendingUserId, clipboard, deviceId); + pkg, intendingUid, intendingUserId, clipboard, deviceId, isUserInitiated, + isDefaultIme); notifyTextClassifierLocked(clipboard, pkg, intendingUid); if (clipboard.primaryClip != null) { scheduleWriteClipDataStatsLocked(clipboard.primaryClip, - clipboard.primaryClipUid, intendingUid, wasAccessShown); + clipboard.primaryClipUid, intendingUid, wasAccessShown, + isUserInitiated); scheduleAutoClear(userId, intendingUid, intendingDeviceId); + if (readAccess == ClipboardAccess.PayloadReadAccess.PASTE_GRANT) { + mAccess.activatePasteGrantOnPrimaryClipReadLocked( + intendingUid, clipboard); + } } return clipboard.primaryClip; } @@ -720,6 +749,7 @@ public ClipDescription getPrimaryClipDescription( final int intendingUid = getIntendingUid(callingPackage, userId); final int intendingUserId = UserHandle.getUserId(intendingUid); final int intendingDeviceId = getIntendingDeviceId(deviceId, intendingUid); + final boolean isDefaultIme = isDefaultIme(intendingUserId, callingPackage); if (!clipboardAccessAllowed( AppOpsManager.OP_READ_CLIPBOARD, callingPackage, @@ -727,14 +757,16 @@ public ClipDescription getPrimaryClipDescription( intendingUid, intendingUserId, intendingDeviceId, - false) + false /* shouldNoteOp */, + isDefaultIme) || isDeviceLocked(intendingUserId, deviceId)) { return null; } + final boolean readAllowedForPackage = mAccess.clipboardReadAllowedForPackage( + callingPackage, intendingUid, intendingUserId, isDefaultIme); synchronized (mLock) { - Clipboard clipboard = getClipboardLocked(intendingUserId, intendingDeviceId); - return (clipboard != null && clipboard.primaryClip != null) - ? clipboard.primaryClip.getDescription() : null; + return mAccess.getPrimaryClipDescriptionLocked(readAllowedForPackage, + intendingUid, intendingUserId, intendingDeviceId); } } @@ -819,6 +851,7 @@ public boolean hasClipboardText( final int intendingUid = getIntendingUid(callingPackage, userId); final int intendingUserId = UserHandle.getUserId(intendingUid); final int intendingDeviceId = getIntendingDeviceId(deviceId, intendingUid); + final boolean isDefaultIme = isDefaultIme(intendingUserId, callingPackage); if (!clipboardAccessAllowed( AppOpsManager.OP_READ_CLIPBOARD, callingPackage, @@ -826,17 +859,16 @@ public boolean hasClipboardText( intendingUid, intendingUserId, intendingDeviceId, - false) + false /* shouldNoteOp */, + isDefaultIme) || isDeviceLocked(intendingUserId, deviceId)) { return false; } + final boolean readAllowedForPackage = mAccess.clipboardReadAllowedForPackage( + callingPackage, intendingUid, intendingUserId, isDefaultIme); synchronized (mLock) { - Clipboard clipboard = getClipboardLocked(intendingUserId, intendingDeviceId); - if (clipboard != null && clipboard.primaryClip != null) { - CharSequence text = clipboard.primaryClip.getItemAt(0).getText(); - return text != null && text.length() > 0; - } - return false; + return mAccess.hasClipboardTextLocked(readAllowedForPackage, + intendingUid, intendingUserId, intendingDeviceId); } } @@ -848,6 +880,7 @@ public String getPrimaryClipSource( final int intendingUid = getIntendingUid(callingPackage, userId); final int intendingUserId = UserHandle.getUserId(intendingUid); final int intendingDeviceId = getIntendingDeviceId(deviceId, intendingUid); + final boolean isDefaultIme = isDefaultIme(intendingUserId, callingPackage); if (!clipboardAccessAllowed( AppOpsManager.OP_READ_CLIPBOARD, callingPackage, @@ -855,8 +888,14 @@ public String getPrimaryClipSource( intendingUid, intendingUserId, intendingDeviceId, - false) - || isDeviceLocked(intendingUserId, deviceId)) { + false /* shouldNoteOp */, + isDefaultIme) + || isDeviceLocked(intendingUserId, deviceId) + || !mAccess.clipboardReadAllowedForPackage( + callingPackage, + intendingUid, + intendingUserId, + isDefaultIme)) { return null; } synchronized (mLock) { @@ -924,10 +963,18 @@ private void pruneUserAuthorizedClipAccesses() { } } } + + @Override + public void createPasteGrantForDisplay(int uid, int displayId) { + final int deviceId = mVdmInternal == null + ? DEVICE_ID_DEFAULT + : mVdmInternal.getDeviceIdForDisplayId(displayId); + mAccess.createPasteGrantForDevice(uid, deviceId); + } } @GuardedBy("mLock") - private @Nullable Clipboard getClipboardLocked(@UserIdInt int userId, int deviceId) { + @Nullable Clipboard getClipboardLocked(@UserIdInt int userId, int deviceId) { Clipboard clipboard = mClipboards.get(userId, deviceId); if (clipboard == null) { try { @@ -1079,6 +1126,7 @@ private void setPrimaryClipInternalNoClassifyLocked(Clipboard clipboard, return; } clipboard.primaryClip = clip; + clipboard.primaryClipGeneration++; clipboard.mNotifiedUids.clear(); clipboard.mNotifiedTextClassifierUids.clear(); if (clip != null) { @@ -1376,6 +1424,19 @@ private boolean clipboardAccessAllowed( @UserIdInt int userId, int intendingDeviceId, boolean shouldNoteOp) { + return clipboardAccessAllowed(op, callingPackage, attributionTag, uid, userId, + intendingDeviceId, shouldNoteOp, isDefaultIme(userId, callingPackage)); + } + + private boolean clipboardAccessAllowed( + int op, + String callingPackage, + String attributionTag, + int uid, + @UserIdInt int userId, + int intendingDeviceId, + boolean shouldNoteOp, + boolean isDefaultIme) { boolean allowed; @@ -1394,7 +1455,7 @@ private boolean clipboardAccessAllowed( allowed = true; } else { // The default IME is always allowed to access the clipboard. - allowed = isDefaultIme(userId, callingPackage); + allowed = isDefaultIme; } switch (op) { @@ -1487,14 +1548,15 @@ private boolean isDefaultIme(int userId, String packageName) { @GuardedBy("mLock") private boolean shouldSuppressAccessNotificationForUidLocked(int uid) { - long elapsedRealtime = SystemClock.elapsedRealtime(); - long expiration = mUserAuthorizedClipAccesses.get(uid, elapsedRealtime); - - if (expiration > elapsedRealtime) { + final long elapsedRealtime = SystemClock.elapsedRealtime(); + final int index = mUserAuthorizedClipAccesses.indexOfKey(uid); + if (index < 0) { + return false; + } + if (mUserAuthorizedClipAccesses.valueAt(index) > elapsedRealtime) { return true; } - - mUserAuthorizedClipAccesses.delete(uid); + mUserAuthorizedClipAccesses.removeAt(index); return false; } @@ -1508,12 +1570,13 @@ private boolean shouldSuppressAccessNotificationForUidLocked(int uid) { */ @GuardedBy("mLock") private boolean showAccessNotificationLocked(String callingPackage, int uid, - @UserIdInt int userId, Clipboard clipboard, int accessDeviceId) { + @UserIdInt int userId, Clipboard clipboard, int accessDeviceId, + boolean isUserInitiated, boolean isDefaultIme) { if (clipboard.primaryClip == null) { return false; } // Don't notify if a trusted component has confirmed the user decided on clip access. - if (shouldSuppressAccessNotificationForUidLocked(uid)) { + if (isUserInitiated) { return false; } if (Settings.Secure.getInt(getContext().getContentResolver(), @@ -1526,7 +1589,7 @@ private boolean showAccessNotificationLocked(String callingPackage, int uid, return false; } // Exclude special cases: IME, ContentCapture, Autofill. - if (isDefaultIme(userId, callingPackage)) { + if (isDefaultIme) { return false; } if (mContentCaptureInternal != null @@ -1551,14 +1614,22 @@ private boolean showAccessNotificationLocked(String callingPackage, int uid, return false; } + final boolean wasAccessShown = showClipboardToastLocked(callingPackage, userId, clipboard, + accessDeviceId, R.string.pasted_from_clipboard); + clipboard.mNotifiedUids.put(uid, true); + return wasAccessShown; + } + + @GuardedBy("mLock") + private boolean showClipboardToastLocked(String callingPackage, @UserIdInt int userId, + Clipboard clipboard, int accessDeviceId, int messageResId) { final ArraySet toastContexts = getToastContexts(clipboard, accessDeviceId); - boolean[] wasAccessShown = {false}; + final boolean[] wasShown = {false}; Binder.withCleanCallingIdentity(() -> { try { CharSequence callingAppLabel = mPm.getApplicationLabel( mPm.getApplicationInfoAsUser(callingPackage, 0, userId)); - String message = - getContext().getString(R.string.pasted_from_clipboard, callingAppLabel); + String message = getContext().getString(messageResId, callingAppLabel); Slog.i(TAG, message); for (int i = 0; i < toastContexts.size(); i++) { Context toastContext = toastContexts.valueAt(i); @@ -1575,15 +1646,14 @@ private boolean showAccessNotificationLocked(String callingPackage, int uid, Toast.LENGTH_LONG); } toastToShow.show(); - wasAccessShown[0] = true; + wasShown[0] = true; } } catch (PackageManager.NameNotFoundException e) { // do nothing } }); - clipboard.mNotifiedUids.put(uid, true); - return wasAccessShown[0]; + return wasShown[0]; } /** @@ -1733,12 +1803,10 @@ private static int mimeTypeToClipDataType(@NonNull String mimeType) { @GuardedBy("mLock") private void scheduleWriteClipDataStatsLocked(@NonNull ClipData clipData, int sourceUid, - int intendingUid, boolean wasAccessShown) { + int intendingUid, boolean wasAccessShown, boolean isUserInitiated) { if (!clipboardGetEventLogging()) { return; } - final boolean isUserInitiated = - mUserAuthorizedClipAccesses.indexOfKey(intendingUid) >= 0; final ClipDescription description = clipData.getDescription(); if (description != null) { final IntArray mimeTypes = new IntArray(); diff --git a/services/core/java/com/android/server/pm/GosPackageStatePermissions.java b/services/core/java/com/android/server/pm/GosPackageStatePermissions.java index 8dc9b77d2f85d..10dc42d6e8db9 100644 --- a/services/core/java/com/android/server/pm/GosPackageStatePermissions.java +++ b/services/core/java/com/android/server/pm/GosPackageStatePermissions.java @@ -22,6 +22,8 @@ import java.util.Objects; import static android.content.pm.GosPackageStateFlag.ALLOW_ACCESS_TO_OBB_DIRECTORY; +import static android.content.pm.GosPackageStateFlag.ALLOW_CLIPBOARD_READ; +import static android.content.pm.GosPackageStateFlag.ALLOW_CLIPBOARD_READ_NON_DEFAULT; import static android.content.pm.GosPackageStateFlag.BLOCK_NATIVE_DEBUGGING; import static android.content.pm.GosPackageStateFlag.BLOCK_NATIVE_DEBUGGING_NON_DEFAULT; import static android.content.pm.GosPackageStateFlag.BLOCK_NATIVE_DEBUGGING_SUPPRESS_NOTIF; @@ -145,6 +147,8 @@ static void init(PackageManagerService pm) { USE_EXEC_SPAWNING_NON_DEFAULT, USE_EXEC_SPAWNING, ENABLE_EXPLOIT_PROTECTION_COMPAT_MODE, + ALLOW_CLIPBOARD_READ_NON_DEFAULT, + ALLOW_CLIPBOARD_READ, }; builder() .readWriteFlags(settingsReadWriteFlags) diff --git a/services/core/java/com/android/server/wm/WindowManagerInternal.java b/services/core/java/com/android/server/wm/WindowManagerInternal.java index 4fdf64379f216..e12ad800ab474 100644 --- a/services/core/java/com/android/server/wm/WindowManagerInternal.java +++ b/services/core/java/com/android/server/wm/WindowManagerInternal.java @@ -1023,6 +1023,11 @@ public abstract ImeTargetInfo onToggleImeRequested(boolean show, */ public abstract @Nullable IBinder getTargetWindowTokenFromInputToken(IBinder inputToken); + /** Returns the owner and display for a window or embedded input target. */ + public abstract @Nullable InputTargetInfo getInputTargetInfo(IBinder inputToken); + + public record InputTargetInfo(int ownerUid, int displayId) {} + /** The information of input method target when IME is requested to show or hide. */ public static class ImeTargetInfo { diff --git a/services/core/java/com/android/server/wm/WindowManagerService.java b/services/core/java/com/android/server/wm/WindowManagerService.java index b50c541b83298..13fc8538688af 100644 --- a/services/core/java/com/android/server/wm/WindowManagerService.java +++ b/services/core/java/com/android/server/wm/WindowManagerService.java @@ -9455,6 +9455,16 @@ public void setOrientationRequestPolicy(boolean respected, } } + @Override + public @Nullable InputTargetInfo getInputTargetInfo(IBinder inputToken) { + synchronized (mGlobalLock) { + final InputTarget inputTarget = + WindowManagerService.this.getInputTargetFromToken(inputToken); + return inputTarget == null ? null : new InputTargetInfo( + inputTarget.getUid(), inputTarget.getDisplayId()); + } + } + @Override public void setBlockScreenCaptureForAppsSessionId(long sessionId) { synchronized (mGlobalLock) { From 9a0026745d2d672d6a4d4ec7408041e6a81a4cd1 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Fri, 17 Apr 2026 16:54:59 +0000 Subject: [PATCH 2/9] secure paste: support selection toolbar paste actions Users who choose Paste from the selection toolbar should not need to give the focused app ongoing clipboard access. Authorize that app when the toolbar dispatches Paste. SystemUI renders the remote toolbar outside the destination app. Derive the destination from the toolbar host and current input target, and verify their identity before granting access instead of trusting an app identity reported through the renderer callback. Co-authored-by: octocorvus --- ...SelectionToolbarRenderServiceCallback.aidl | 2 +- .../SelectionToolbarRenderService.java | 6 ++-- .../app/ui/RemoteSelectionToolbar.kt | 30 +++++++++------- .../app/ui/SecurePasteToolbar.kt | 27 +++++++++++++++ .../SecurePasteSelectionToolbar.java | 34 +++++++++++++++++++ .../SelectionToolbarManagerService.java | 5 +-- 6 files changed, 85 insertions(+), 19 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/selectiontoolbar/app/ui/SecurePasteToolbar.kt create mode 100644 services/selectiontoolbar/java/com/android/server/selectiontoolbar/SecurePasteSelectionToolbar.java diff --git a/core/java/android/service/selectiontoolbar/ISelectionToolbarRenderServiceCallback.aidl b/core/java/android/service/selectiontoolbar/ISelectionToolbarRenderServiceCallback.aidl index 7cc0f250cbe51..2b6c0d4f96c73 100644 --- a/core/java/android/service/selectiontoolbar/ISelectionToolbarRenderServiceCallback.aidl +++ b/core/java/android/service/selectiontoolbar/ISelectionToolbarRenderServiceCallback.aidl @@ -26,5 +26,5 @@ import android.os.IBinder; interface ISelectionToolbarRenderServiceCallback { oneway void transferTouch(in IBinder source, in IBinder target); - void onPasteAction(int uid); + void onPasteAction(int uid, in IBinder hostInputToken); } diff --git a/core/java/android/service/selectiontoolbar/SelectionToolbarRenderService.java b/core/java/android/service/selectiontoolbar/SelectionToolbarRenderService.java index e9ee2023aa271..f4b6516a2e5dc 100644 --- a/core/java/android/service/selectiontoolbar/SelectionToolbarRenderService.java +++ b/core/java/android/service/selectiontoolbar/SelectionToolbarRenderService.java @@ -156,14 +156,14 @@ protected void transferTouch(@NonNull IBinder source, @NonNull IBinder target) { } } - protected void onPasteAction(int uid) { + protected void onPasteAction(int uid, IBinder hostInputToken) { final ISelectionToolbarRenderServiceCallback callback = mServiceCallback; if (callback == null) { Log.e(TAG, "onPasteAction(): no server callback"); return; } try { - callback.onPasteAction(uid); + callback.onPasteAction(uid, hostInputToken); } catch (RemoteException e) { Log.e(TAG, "Failed to notify onPasteAction", e); } @@ -264,6 +264,6 @@ public interface OnPasteActionCallback { /** * Notify the service to the paste action. */ - void onPasteAction(int uid); + void onPasteAction(int uid, IBinder hostInputToken); } } diff --git a/packages/SystemUI/src/com/android/systemui/selectiontoolbar/app/ui/RemoteSelectionToolbar.kt b/packages/SystemUI/src/com/android/systemui/selectiontoolbar/app/ui/RemoteSelectionToolbar.kt index 0074cb322ff99..4d8d25192dd46 100644 --- a/packages/SystemUI/src/com/android/systemui/selectiontoolbar/app/ui/RemoteSelectionToolbar.kt +++ b/packages/SystemUI/src/com/android/systemui/selectiontoolbar/app/ui/RemoteSelectionToolbar.kt @@ -84,7 +84,7 @@ class RemoteSelectionToolbar( showInfo: ShowInfo, private val callbackWrapper: RemoteCallbackWrapper, transferTouchListener: TransferTouchListener, - onPasteActionCallback: OnPasteActionCallback, + private val onPasteActionCallback: OnPasteActionCallback, ) { private val context = wrapContext(baseContext, showInfo) @@ -234,18 +234,22 @@ class RemoteSelectionToolbar( /* Menu items and click listeners */ private val menuItemButtonOnClickListener = View.OnClickListener { v: View -> - // Post the callback to fg thread because the onPasteAction() callback - // needs to be synchronous but it shouldn't block the main thread. - handler.post { - val tag = v.tag - if (tag is ToolbarMenuItem) { - if (tag.itemId == R.id.paste || tag.itemId == R.id.pasteAsPlainText) { - onPasteActionCallback.onPasteAction(hostUid) - } - callbackWrapper.onMenuItemClicked(tag.itemIndex) - } + val tag = v.tag + if (tag is ToolbarMenuItem) { + dispatchMenuItemClick(tag) + } + } + + private fun dispatchMenuItemClick(menuItem: ToolbarMenuItem) { + // Authorization is synchronous. Keep it on the renderer thread and ordered before the app + // callback because the app may read the clipboard as soon as it handles the click. + handler.post { + if (SecurePasteToolbar.isPasteAction(menuItem)) { + onPasteActionCallback.onPasteAction(hostUid, hostInputToken.token) } + callbackWrapper.onMenuItemClicked(menuItem.itemIndex) } + } private val viewPortOnScreen = Rect() // portion of screen we can draw in. @@ -1182,7 +1186,7 @@ class RemoteSelectionToolbar( overflowPanel.adapter = adapter overflowPanel.setOnItemClickListener { _, _, position: Int, _ -> val menuItem = overflowPanel.adapter.getItem(position) as ToolbarMenuItem - callbackWrapper.onMenuItemClicked(menuItem.itemIndex) + dispatchMenuItemClick(menuItem) } return overflowPanel } @@ -1415,7 +1419,7 @@ class RemoteSelectionToolbar( val menuItem = menuItems[i] transformedMenuItems.add( - when (menuItem.itemId) { + when (SecurePasteToolbar.resolvePasteActionId(context, menuItem)) { android.R.id.paste -> { val newMenuItem = ToolbarMenuItem() diff --git a/packages/SystemUI/src/com/android/systemui/selectiontoolbar/app/ui/SecurePasteToolbar.kt b/packages/SystemUI/src/com/android/systemui/selectiontoolbar/app/ui/SecurePasteToolbar.kt new file mode 100644 index 0000000000000..80140da0eae01 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/selectiontoolbar/app/ui/SecurePasteToolbar.kt @@ -0,0 +1,27 @@ +package com.android.systemui.selectiontoolbar.app.ui + +import android.content.Context +import android.text.TextUtils +import android.view.selectiontoolbar.ToolbarMenuItem + +internal object SecurePasteToolbar { + // Older toolkits use custom IDs. Match only exact localized titles, then canonicalize the + // visible action before it can authorize clipboard access. + fun resolvePasteActionId(context: Context, item: ToolbarMenuItem): Int? { + return when { + item.itemId == android.R.id.paste || + TextUtils.equals(context.getText(android.R.string.paste), item.title) -> + android.R.id.paste + item.itemId == android.R.id.pasteAsPlainText || + TextUtils.equals( + context.getText(android.R.string.paste_as_plain_text), + item.title, + ) -> android.R.id.pasteAsPlainText + else -> null + } + } + + fun isPasteAction(item: ToolbarMenuItem): Boolean { + return item.itemId == android.R.id.paste || item.itemId == android.R.id.pasteAsPlainText + } +} diff --git a/services/selectiontoolbar/java/com/android/server/selectiontoolbar/SecurePasteSelectionToolbar.java b/services/selectiontoolbar/java/com/android/server/selectiontoolbar/SecurePasteSelectionToolbar.java new file mode 100644 index 0000000000000..d857cdea0d6c8 --- /dev/null +++ b/services/selectiontoolbar/java/com/android/server/selectiontoolbar/SecurePasteSelectionToolbar.java @@ -0,0 +1,34 @@ +package com.android.server.selectiontoolbar; + +import static android.view.Display.INVALID_DISPLAY; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.os.IBinder; + +import com.android.server.LocalServices; +import com.android.server.clipboard.ClipboardManagerInternal; +import com.android.server.wm.WindowManagerInternal; +import com.android.server.wm.WindowManagerInternal.InputTargetInfo; + +final class SecurePasteSelectionToolbar { + private SecurePasteSelectionToolbar() {} + + static void onPasteAction(@NonNull ClipboardManagerInternal cmi, int uid, + @Nullable IBinder hostInputToken) { + if (hostInputToken == null) { + return; + } + final WindowManagerInternal wmi = LocalServices.getService(WindowManagerInternal.class); + if (wmi == null) { + return; + } + + final InputTargetInfo target = wmi.getInputTargetInfo(hostInputToken); + if (target == null || target.ownerUid() != uid || target.displayId() == INVALID_DISPLAY) { + return; + } + + cmi.createPasteGrantForDisplay(uid, target.displayId()); + } +} diff --git a/services/selectiontoolbar/java/com/android/server/selectiontoolbar/SelectionToolbarManagerService.java b/services/selectiontoolbar/java/com/android/server/selectiontoolbar/SelectionToolbarManagerService.java index ce3d72d59f95f..e34d1b301218c 100644 --- a/services/selectiontoolbar/java/com/android/server/selectiontoolbar/SelectionToolbarManagerService.java +++ b/services/selectiontoolbar/java/com/android/server/selectiontoolbar/SelectionToolbarManagerService.java @@ -106,8 +106,9 @@ public void transferTouch(IBinder source, IBinder target) { } @Override - public void onPasteAction(int uid) { - mClipboardManagerInternal.notifyUserAuthorizedClipAccess(uid); + public void onPasteAction(int uid, IBinder hostInputToken) { + SecurePasteSelectionToolbar.onPasteAction( + mClipboardManagerInternal, uid, hostInputToken); } } From 8e5b018391252be977b43196529fbf93132a9fc7 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Fri, 17 Apr 2026 16:54:59 +0000 Subject: [PATCH 3/9] secure paste: support input method paste actions Users should receive the same secure paste behavior when their keyboard offers Paste. Authorize the focused app before the current input method forwards the standard Paste action to it. Accept authorization only from the active input method and only for the input connection currently served by system_server. Neither an app nor an input method can use this path to choose a different destination. Co-authored-by: octocorvus --- .../IRemoteInputConnectionInvoker.java | 5 +++ .../InputMethodService.java | 5 +++ .../InputMethodServiceInternal.java | 4 ++ .../RemoteInputConnection.java | 7 ++++ .../IInputMethodPrivilegedOperations.aidl | 1 + .../InputMethodPrivilegedOperations.java | 20 ++++++++++ .../InputMethodManagerService.java | 39 +++++++++++++++++++ 7 files changed, 81 insertions(+) diff --git a/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java b/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java index 47b8550612b93..92f42074ca17d 100644 --- a/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java +++ b/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java @@ -177,6 +177,11 @@ public boolean isSameConnection(@NonNull IRemoteInputConnection connection) { return mConnection.asBinder() == connection.asBinder(); } + @NonNull + IBinder getConnectionToken() { + return mConnection.asBinder(); + } + @NonNull InputConnectionCommandHeader createHeader() { return new InputConnectionCommandHeader(mSessionId); diff --git a/core/java/android/inputmethodservice/InputMethodService.java b/core/java/android/inputmethodservice/InputMethodService.java index f0a4412ef4c3b..516424b6ece3a 100644 --- a/core/java/android/inputmethodservice/InputMethodService.java +++ b/core/java/android/inputmethodservice/InputMethodService.java @@ -4482,6 +4482,11 @@ private void exposeContentInternal(@NonNull InputContentInfo inputContentInfo, inputContentInfo.setUriToken(uriToken); } + @Override + public void onPasteAction(@NonNull IBinder inputConnectionToken) { + mPrivOps.onPasteAction(inputConnectionToken); + } + /** * {@inheritDoc} */ diff --git a/core/java/android/inputmethodservice/InputMethodServiceInternal.java b/core/java/android/inputmethodservice/InputMethodServiceInternal.java index c6612f6e54c5e..b6fee9e7553da 100644 --- a/core/java/android/inputmethodservice/InputMethodServiceInternal.java +++ b/core/java/android/inputmethodservice/InputMethodServiceInternal.java @@ -21,6 +21,7 @@ import android.annotation.SuppressLint; import android.content.Context; import android.os.Bundle; +import android.os.IBinder; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputContentInfo; @@ -61,6 +62,9 @@ default void exposeContent(@NonNull InputContentInfo inputContentInfo, default void notifyUserActionIfNecessary() { } + default void onPasteAction(@NonNull IBinder inputConnectionToken) { + } + /** * Called when the system is asking the IME to dump its information for debugging. * diff --git a/core/java/android/inputmethodservice/RemoteInputConnection.java b/core/java/android/inputmethodservice/RemoteInputConnection.java index 56e69bf4170c5..40972fc2ab9cd 100644 --- a/core/java/android/inputmethodservice/RemoteInputConnection.java +++ b/core/java/android/inputmethodservice/RemoteInputConnection.java @@ -332,6 +332,13 @@ public boolean performEditorAction(int actionCode) { @AnyThread public boolean performContextMenuAction(int id) { + if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) { + final InputMethodServiceInternal imsInternal = mImsInternal.getAndWarnIfNull(); + if (imsInternal != null) { + // Process authorization before dispatch because the app may read immediately. + imsInternal.onPasteAction(mInvoker.getConnectionToken()); + } + } return mInvoker.performContextMenuAction(id); } diff --git a/core/java/com/android/internal/inputmethod/IInputMethodPrivilegedOperations.aidl b/core/java/com/android/internal/inputmethod/IInputMethodPrivilegedOperations.aidl index b29ed44c563a4..ece74331ba4a1 100644 --- a/core/java/com/android/internal/inputmethod/IInputMethodPrivilegedOperations.aidl +++ b/core/java/com/android/internal/inputmethod/IInputMethodPrivilegedOperations.aidl @@ -49,4 +49,5 @@ oneway interface IInputMethodPrivilegedOperations { void switchKeyboardLayoutAsync(int direction); void setHandwritingSurfaceNotTouchable(boolean notTouchable); void setHandwritingTouchableRegion(in Region region); + void onPasteAction(in IBinder inputConnectionToken, in AndroidFuture future /* T=Void */); } diff --git a/core/java/com/android/internal/inputmethod/InputMethodPrivilegedOperations.java b/core/java/com/android/internal/inputmethod/InputMethodPrivilegedOperations.java index 274ac66c79736..09b4d23e9c082 100644 --- a/core/java/com/android/internal/inputmethod/InputMethodPrivilegedOperations.java +++ b/core/java/com/android/internal/inputmethod/InputMethodPrivilegedOperations.java @@ -474,4 +474,24 @@ public void switchKeyboardLayoutAsync(int direction) { throw e.rethrowFromSystemServer(); } } + + /** + * Calls {@link IInputMethodPrivilegedOperations#onPasteAction(IBinder, AndroidFuture)} and + * waits for system server to process authorization before the caller dispatches the paste + * action. + */ + @AnyThread + public void onPasteAction(@NonNull IBinder inputConnectionToken) { + final IInputMethodPrivilegedOperations ops = mOps.getAndWarnIfNull(); + if (ops == null) { + return; + } + try { + final AndroidFuture future = new AndroidFuture<>(); + ops.onPasteAction(inputConnectionToken, future); + CompletableFutureUtil.getResult(future); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } } diff --git a/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java b/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java index f314a8856eeb6..76bbdd3060723 100644 --- a/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java +++ b/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java @@ -183,6 +183,7 @@ import com.android.server.LocalServices; import com.android.server.ServiceThread; import com.android.server.SystemService; +import com.android.server.clipboard.ClipboardManagerInternal; import com.android.server.companion.virtual.VirtualDeviceManagerInternal; import com.android.server.input.InputManagerInternal; import com.android.server.inputmethod.InputMethodManagerInternal.InputMethodListListener; @@ -7089,6 +7090,44 @@ public void switchKeyboardLayoutAsync(int direction) { } } + @BinderThread + @Override + public void onPasteAction(IBinder inputConnectionToken, AndroidFuture future /* T=Void */) { + @SuppressWarnings("unchecked") final AndroidFuture typedFuture = future; + // The caller waits for completion before dispatching Paste. Bind authorization to both + // the active selected IME and its exact current editor connection. + try { + synchronized (ImfLock.class) { + if (inputConnectionToken == null + || !calledWithValidTokenLocked(mToken, mUserData)) { + typedFuture.complete(null); + return; + } + final var bindingController = mUserData.mBindingController; + final String selectedImeId = bindingController.getSelectedImeId(); + final ClientState currentClient = mUserData.mCurClient; + final IRemoteInputConnection currentConnection = + mUserData.mCurInputConnection; + if (currentClient != null + && currentConnection != null + && Binder.getCallingUid() == bindingController.getCurImeUid() + && currentConnection.asBinder() == inputConnectionToken + && selectedImeId != null + && selectedImeId.equals(bindingController.getCurImeId())) { + final ClipboardManagerInternal cmi = + LocalServices.getService(ClipboardManagerInternal.class); + if (cmi != null) { + cmi.createPasteGrantForDisplay(currentClient.mUid, + currentClient.mSelfReportedDisplayId); + } + } + typedFuture.complete(null); + } + } catch (Throwable e) { + typedFuture.completeExceptionally(e); + } + } + /** * Returns true iff the caller is identified to be the current input method with the token. * From c8db53aa6e170e3adba3bd66071cd4bb81003cbf Mon Sep 17 00:00:00 2001 From: inthewaves Date: Fri, 17 Apr 2026 16:54:59 +0000 Subject: [PATCH 4/9] secure paste: support accessibility paste actions Users can invoke an editor's Paste action through an accessibility service. Give the focused app the same temporary clipboard access as other user-initiated Paste actions. Honor the action only from a currently bound service whose caller identity matches its connection, and only when it targets the input-focused window on that display. Resolve the destination from the registered accessibility connection rather than allowing the service to name an app. Co-authored-by: octocorvus --- ...bstractAccessibilityServiceConnection.java | 8 +++ .../AccessibilityManagerService.java | 9 +++ .../AccessibilityServiceConnection.java | 2 +- .../SecurePasteAccessibility.java | 61 +++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 services/accessibility/java/com/android/server/accessibility/SecurePasteAccessibility.java diff --git a/services/accessibility/java/com/android/server/accessibility/AbstractAccessibilityServiceConnection.java b/services/accessibility/java/com/android/server/accessibility/AbstractAccessibilityServiceConnection.java index b488eecdefc96..b5cb564e19986 100644 --- a/services/accessibility/java/com/android/server/accessibility/AbstractAccessibilityServiceConnection.java +++ b/services/accessibility/java/com/android/server/accessibility/AbstractAccessibilityServiceConnection.java @@ -34,6 +34,7 @@ import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS; import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK; import static android.view.accessibility.AccessibilityNodeInfo.ACTION_LONG_CLICK; +import static android.view.accessibility.AccessibilityNodeInfo.ACTION_PASTE; import static com.android.server.accessibility.Flags.keyEventDispatcherFixFlushRaceCondition; import static com.android.server.pm.UserManagerService.enforceCurrentUserIfVisibleBackgroundEnabled; @@ -353,6 +354,9 @@ void attachAccessibilityOverlayToDisplay( int performScreenCapture( ScreenCaptureInternal.LayerCaptureArgs captureArgs, ScreenCaptureInternal.ScreenCaptureListener captureListener); + + void onPasteAction(AbstractAccessibilityServiceConnection connection, int callingUid, + int userId, int windowId); } public AbstractAccessibilityServiceConnection(Context context, ComponentName componentName, @@ -2321,6 +2325,7 @@ private boolean performAccessibilityActionInternal(int userId, int resolvedWindo connection = mA11yWindowManager.getPictureInPictureActionReplacingConnection(); } } + final int callingUid = Binder.getCallingUid(); final int interrogatingPid = Binder.getCallingPid(); final long identityToken = Binder.clearCallingIdentity(); try { @@ -2335,6 +2340,9 @@ private boolean performAccessibilityActionInternal(int userId, int resolvedWindo if (windowToken != null) { mWindowManagerService.requestWindowFocus(windowToken); } + if (action == ACTION_PASTE) { + mSystemSupport.onPasteAction(this, callingUid, userId, resolvedWindowId); + } if (intConnTracingEnabled()) { logTraceIntConn("performAccessibilityAction", accessibilityNodeId + ";" + action + ";" + arguments + ";" + interactionId diff --git a/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java b/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java index ca079f8b1d8c9..f23ae4b57975e 100644 --- a/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java +++ b/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java @@ -6846,6 +6846,15 @@ public void onDoubleTapAndHold(int displayId) { this, displayId)); } + @Override + public void onPasteAction(AbstractAccessibilityServiceConnection connection, int callingUid, + int userId, int windowId) { + synchronized (mLock) { + SecurePasteAccessibility.onPasteActionLocked(connection, callingUid, userId, windowId, + getCurrentUserStateLocked(), mA11yWindowManager); + } + } + @Override public void requestImeLocked(AbstractAccessibilityServiceConnection connection) { if (!(connection instanceof AccessibilityServiceConnection) diff --git a/services/accessibility/java/com/android/server/accessibility/AccessibilityServiceConnection.java b/services/accessibility/java/com/android/server/accessibility/AccessibilityServiceConnection.java index dad897984c58c..69b2795c70674 100644 --- a/services/accessibility/java/com/android/server/accessibility/AccessibilityServiceConnection.java +++ b/services/accessibility/java/com/android/server/accessibility/AccessibilityServiceConnection.java @@ -880,7 +880,7 @@ private void onDeviceAttached(UsbDevice device) { return ""; } - private int getClientUid() { + int getClientUid() { ResolveInfo resolveInfo = mAccessibilityServiceInfo.getResolveInfo(); if (resolveInfo != null && resolveInfo.serviceInfo != null && resolveInfo.serviceInfo.applicationInfo != null) { diff --git a/services/accessibility/java/com/android/server/accessibility/SecurePasteAccessibility.java b/services/accessibility/java/com/android/server/accessibility/SecurePasteAccessibility.java new file mode 100644 index 0000000000000..8e3422a6b19c2 --- /dev/null +++ b/services/accessibility/java/com/android/server/accessibility/SecurePasteAccessibility.java @@ -0,0 +1,61 @@ +package com.android.server.accessibility; + +import static android.view.Display.INVALID_DISPLAY; + +import android.text.TextUtils; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; + +import com.android.internal.annotations.GuardedBy; +import com.android.server.LocalServices; +import com.android.server.clipboard.ClipboardManagerInternal; + +/** + * Validates that an accessibility paste action targets the exact focused input window. + */ +final class SecurePasteAccessibility { + private SecurePasteAccessibility() {} + + @GuardedBy("AccessibilityManagerService.mLock") + static void onPasteActionLocked(AbstractAccessibilityServiceConnection connection, + int callingUid, int userId, int windowId, AccessibilityUserState userState, + AccessibilityWindowManager windowManager) { + if (!(connection instanceof AccessibilityServiceConnection realConnection) + || connection instanceof ProxyAccessibilityServiceConnection + || realConnection.mUserId != userId + || realConnection.getClientUid() != callingUid + || !userState.mBoundServices.contains(realConnection)) { + return; + } + + final AccessibilityWindowManager.RemoteAccessibilityConnection targetConnection = + windowManager.getConnectionLocked(userId, windowId); + if (targetConnection == null) { + return; + } + + final AccessibilityWindowInfo targetWindowInfo = + windowManager.findA11yWindowInfoByIdLocked(windowId); + final int targetUid = targetConnection.getUid(); + if (targetWindowInfo == null + || TextUtils.isEmpty(targetConnection.getPackageName()) + || targetUid < 0) { + return; + } + + final int targetDisplayId = targetWindowInfo.getDisplayId(); + if (targetDisplayId == INVALID_DISPLAY + || windowManager.getFocusedWindowId( + AccessibilityNodeInfo.FOCUS_INPUT, targetDisplayId) != windowId) { + return; + } + + final ClipboardManagerInternal cmi = + LocalServices.getService(ClipboardManagerInternal.class); + if (cmi == null) { + return; + } + + cmi.createPasteGrantForDisplay(targetUid, targetDisplayId); + } +} From 68dbeae94d8323a57af2fcd49fdb510c1b5706a0 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Fri, 17 Apr 2026 16:54:59 +0000 Subject: [PATCH 5/9] secure paste: support paste keyboard shortcuts Keep the hardware Paste key and the standard Ctrl-V and Shift-Insert shortcuts working for apps set to Paste only. Authorize the focused app before delivering the initial, uncancelled key-down event. Derive the destination from the focused input target. Reserve paste chords from custom gesture assignment so the same key event cannot both authorize Paste and invoke an unrelated global action. The current SystemUI customizer requires the Meta key (for example, the Windows logo key) and does not offer Paste as a customizable action, so it cannot create or remap these chords. This still changes the privileged custom gesture API: Ctrl-Shift-V, Shift-Insert, and the hardware Paste key were previously accepted, and existing mappings using them will be rejected when gestures are reloaded. Ctrl-V was already reserved. Co-authored-by: octocorvus --- .../server/input/InputGestureManager.java | 6 +++ .../server/input/KeyGestureController.java | 16 ++++++ .../input/SecurePasteKeyEventHandler.java | 51 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 services/core/java/com/android/server/input/SecurePasteKeyEventHandler.java diff --git a/services/core/java/com/android/server/input/InputGestureManager.java b/services/core/java/com/android/server/input/InputGestureManager.java index 2ba274515a8e6..7170e45ada671 100644 --- a/services/core/java/com/android/server/input/InputGestureManager.java +++ b/services/core/java/com/android/server/input/InputGestureManager.java @@ -87,7 +87,13 @@ final class InputGestureManager { KeyEvent.META_CTRL_ON | KeyEvent.META_ALT_ON), createKeyTrigger(KeyEvent.KEYCODE_A, KeyEvent.META_CTRL_ON), createKeyTrigger(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON), + // Keep paste gestures unavailable for reassignment and in sync with + // SecurePasteKeyEventHandler.isPasteKeyEvent(KeyEvent). createKeyTrigger(KeyEvent.KEYCODE_V, KeyEvent.META_CTRL_ON), + createKeyTrigger(KeyEvent.KEYCODE_V, + KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON), + createKeyTrigger(KeyEvent.KEYCODE_INSERT, KeyEvent.META_SHIFT_ON), + createKeyTrigger(KeyEvent.KEYCODE_PASTE, 0), createKeyTrigger(KeyEvent.KEYCODE_X, KeyEvent.META_CTRL_ON), createKeyTrigger(KeyEvent.KEYCODE_Z, KeyEvent.META_CTRL_ON), createKeyTrigger(KeyEvent.KEYCODE_Y, KeyEvent.META_CTRL_ON), diff --git a/services/core/java/com/android/server/input/KeyGestureController.java b/services/core/java/com/android/server/input/KeyGestureController.java index c332f36e9af4b..457ff9071ea3d 100644 --- a/services/core/java/com/android/server/input/KeyGestureController.java +++ b/services/core/java/com/android/server/input/KeyGestureController.java @@ -27,6 +27,7 @@ import static android.view.WindowManager.ScreenshotSource.SCREENSHOT_KEY_CHORD; import static android.view.WindowManager.ScreenshotSource.SCREENSHOT_KEY_OTHER; import static android.view.WindowManagerPolicyConstants.FLAG_INTERACTIVE; +import static android.view.WindowManagerPolicyConstants.FLAG_TRUSTED; import static com.android.hardware.input.Flags.enablePartialScreenshotKeyboardShortcut; import static com.android.hardware.input.Flags.enableNew26q2Keycodes; @@ -609,6 +610,17 @@ public long interceptKeyBeforeDispatching(IBinder focus, KeyEvent event, int pol return result; } + // Paste chords are ordinary app key events. Apply policy interception first; on initial + // key down, create the grant immediately before returning the event to the focused app. + if ((policyFlags & FLAG_TRUSTED) != 0 + && SecurePasteKeyEventHandler.isPasteKeyEvent(event)) { + if (mWindowManagerCallbacks.interceptKeyBeforeDispatching(focus, event)) { + return KEY_INTERCEPT_RESULT_CONSUMED; + } + SecurePasteKeyEventHandler.maybeGrantAccess(mWindowManagerInternal, focus, event); + return KEY_INTERCEPT_RESULT_NOT_CONSUMED; + } + // TODO(b/358569822) Remove below once we have nicer API for listening to shortcuts if ((event.isMetaPressed() || KeyEvent.isMetaKey(event.getKeyCode())) && shouldInterceptShortcuts(focus)) { @@ -1168,6 +1180,10 @@ private boolean interceptCapturableShortcuts(@Nullable IBinder focusedToken, } boolean interceptUnhandledKey(@NonNull KeyEvent event, @Nullable IBinder focus) { + // A paste chord must not become a global or custom shortcut after the app declines it. + if (SecurePasteKeyEventHandler.isPasteKeyEvent(event)) { + return false; + } return mInterceptStages.get(INTERCEPT_STAGE_UNHANDLED_SHORTCUTS).interceptKey(focus, event); } diff --git a/services/core/java/com/android/server/input/SecurePasteKeyEventHandler.java b/services/core/java/com/android/server/input/SecurePasteKeyEventHandler.java new file mode 100644 index 0000000000000..e047e149347f4 --- /dev/null +++ b/services/core/java/com/android/server/input/SecurePasteKeyEventHandler.java @@ -0,0 +1,51 @@ +package com.android.server.input; + +import static android.view.Display.INVALID_DISPLAY; + +import android.annotation.Nullable; +import android.os.IBinder; +import android.view.KeyEvent; + +import com.android.server.LocalServices; +import com.android.server.clipboard.ClipboardManagerInternal; +import com.android.server.wm.WindowManagerInternal; +import com.android.server.wm.WindowManagerInternal.InputTargetInfo; + +final class SecurePasteKeyEventHandler { + private SecurePasteKeyEventHandler() {} + + static void maybeGrantAccess(WindowManagerInternal wmi, @Nullable IBinder focusedToken, + KeyEvent event) { + if (focusedToken == null + || !isPasteKeyEvent(event) + || event.getAction() != KeyEvent.ACTION_DOWN + || event.getRepeatCount() != 0 + || event.isCanceled()) { + return; + } + + final InputTargetInfo target = wmi.getInputTargetInfo(focusedToken); + if (target == null || target.ownerUid() < 0 || target.displayId() == INVALID_DISPLAY) { + return; + } + + final ClipboardManagerInternal cmi = + LocalServices.getService(ClipboardManagerInternal.class); + if (cmi == null) { + return; + } + + cmi.createPasteGrantForDisplay(target.ownerUid(), target.displayId()); + } + + static boolean isPasteKeyEvent(KeyEvent event) { + // Keep in sync with the paste entries in InputGestureManager.mBlockListedTriggers. + return switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_V -> event.hasModifiers(KeyEvent.META_CTRL_ON) + || event.hasModifiers(KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON); + case KeyEvent.KEYCODE_INSERT -> event.hasModifiers(KeyEvent.META_SHIFT_ON); + case KeyEvent.KEYCODE_PASTE -> event.hasNoModifiers(); + default -> false; + }; + } +} From 6ef7b1353275da04682b7fe00e1a781ad4b29268 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Mon, 4 May 2026 17:15:13 -0700 Subject: [PATCH 6/9] secure paste: add integration tests Verify that users can restrict direct clipboard reads without breaking explicit Paste actions. Use separate apps, apps sharing an identity, and privileged apps so the suite covers the same package and process boundaries as real callers, including access to an app identity's own clipboard contents, compatible metadata access, and the global and per-app policy. Exercise Paste through framework widgets, Compose, the remote toolbar, an input method, accessibility, and hardware key gestures. Virtual device coverage uses a separate writer and checks that authorization stays with the clipboard and display where the user initiated Paste. The privileged coverage uses the SecurePasteTestSystemApp module. Add it to PRODUCT_PACKAGES_DEBUG in build/make so userdebug test images install the system app before the suite runs. The secure-paste-compat-default-allow group checks that secure paste preserves existing AOSP clipboard behavior when global clipboard access is allowed by default. It runs the relevant platform and CTS coverage to catch compatibility regressions for devices that retain that policy. Test: atest --test-mapping frameworks/base/tests/SecurePasteTests:gos-postsubmit Test: adb shell settings put global allow_clipboard_read 1 && atest --test-mapping \ frameworks/base/tests/SecurePasteTests:secure-paste-compat-default-allow --- .../server/input/KeyGestureControllerTests.kt | 181 ++++ tests/SecurePasteTests/Android.bp | 130 +++ tests/SecurePasteTests/AndroidManifest.xml | 62 ++ tests/SecurePasteTests/AndroidTest.xml | 31 + tests/SecurePasteTests/TEST_MAPPING | 179 ++++ .../apps/cacheclient/AndroidManifest.xml | 16 + .../apps/customtoolbar/AndroidManifest.xml | 16 + .../apps/edittext/AndroidManifest.xml | 16 + .../apps/ime/AndroidManifest.xml | 24 + .../apps/ime/res/xml/method.xml | 4 + .../ime/SecurePasteImeService.java | 83 ++ .../apps/jetpackcompose/AndroidManifest.xml | 16 + .../SecurePasteJetpackComposeActivity.kt | 169 ++++ .../apps/reader/AndroidManifest.xml | 19 + .../apps/shareduid_a/AndroidManifest.xml | 17 + .../apps/shareduid_b/AndroidManifest.xml | 17 + .../apps/system/AndroidManifest.xml | 22 + .../apps/writer/AndroidManifest.xml | 24 + .../helper/common/AndroidManifest.xml | 3 + .../SecurePasteAccessibilityServiceBase.java | 67 ++ .../helper/SecurePasteActivity.java | 356 ++++++++ .../helper/SecurePasteCommandProvider.java | 545 ++++++++++++ .../helper/SecurePasteKeyEventActivity.java | 220 +++++ .../helper/SecurePasteUriProvider.java | 89 ++ .../res/xml/accessibility_service.xml | 8 + .../SecurePasteAccessibilityService.java | 6 + .../SecurePasteAccessibilityTest.java | 64 ++ .../SecurePasteCompatibilityTest.java | 205 +++++ .../securepaste/SecurePasteDeviceTest.java | 361 ++++++++ .../SecurePasteJetpackComposeTest.java | 80 ++ .../securepaste/SecurePasteKeyboardTest.java | 142 +++ .../securepaste/SecurePasteTestBase.java | 813 ++++++++++++++++++ .../SecurePasteVirtualDeviceTest.java | 216 +++++ 33 files changed, 4201 insertions(+) create mode 100644 tests/SecurePasteTests/Android.bp create mode 100644 tests/SecurePasteTests/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/AndroidTest.xml create mode 100644 tests/SecurePasteTests/TEST_MAPPING create mode 100644 tests/SecurePasteTests/apps/cacheclient/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/apps/customtoolbar/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/apps/edittext/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/apps/ime/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/apps/ime/res/xml/method.xml create mode 100644 tests/SecurePasteTests/apps/ime/src/grapheneos/securepaste/ime/SecurePasteImeService.java create mode 100644 tests/SecurePasteTests/apps/jetpackcompose/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/apps/jetpackcompose/src/grapheneos/securepaste/jetpackcompose/SecurePasteJetpackComposeActivity.kt create mode 100644 tests/SecurePasteTests/apps/reader/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/apps/shareduid_a/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/apps/shareduid_b/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/apps/system/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/apps/writer/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/helper/common/AndroidManifest.xml create mode 100644 tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteAccessibilityServiceBase.java create mode 100644 tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteActivity.java create mode 100644 tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteCommandProvider.java create mode 100644 tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteKeyEventActivity.java create mode 100644 tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteUriProvider.java create mode 100644 tests/SecurePasteTests/res/xml/accessibility_service.xml create mode 100644 tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteAccessibilityService.java create mode 100644 tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteAccessibilityTest.java create mode 100644 tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteCompatibilityTest.java create mode 100644 tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteDeviceTest.java create mode 100644 tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteJetpackComposeTest.java create mode 100644 tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteKeyboardTest.java create mode 100644 tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteTestBase.java create mode 100644 tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteVirtualDeviceTest.java diff --git a/tests/Input/src/com/android/server/input/KeyGestureControllerTests.kt b/tests/Input/src/com/android/server/input/KeyGestureControllerTests.kt index 5bbc1d3ef909e..7a99d66b0507b 100644 --- a/tests/Input/src/com/android/server/input/KeyGestureControllerTests.kt +++ b/tests/Input/src/com/android/server/input/KeyGestureControllerTests.kt @@ -29,6 +29,7 @@ import android.hardware.input.IKeyGestureHandler import android.hardware.input.InputGestureData import android.hardware.input.InputManager import android.hardware.input.KeyGestureEvent +import android.os.Binder import android.os.Handler import android.os.IBinder import android.os.Process @@ -45,27 +46,32 @@ import android.provider.DeviceConfig import android.testing.TestableContext import android.testing.TestableResources import android.view.Display.DEFAULT_DISPLAY +import android.view.Display.INVALID_DISPLAY import android.view.InputDevice import android.view.KeyCharacterMap import android.view.KeyEvent import android.view.WindowManager import android.view.WindowManagerPolicyConstants.FLAG_INTERACTIVE +import android.view.WindowManagerPolicyConstants.FLAG_TRUSTED import androidx.test.core.app.ApplicationProvider import com.android.dx.mockito.inline.extended.ExtendedMockito import com.android.internal.R import com.android.internal.accessibility.AccessibilityShortcutController import com.android.internal.annotations.Keep import com.android.internal.config.sysui.SystemUiDeviceConfigFlags +import com.android.internal.policy.IShortcutService import com.android.internal.policy.KeyInterceptionInfo import com.android.internal.util.FrameworkStatsLog import com.android.internal.util.ScreenshotHelper import com.android.internal.util.ScreenshotRequest import com.android.modules.utils.testing.ExtendedMockitoRule import com.android.server.LocalServices +import com.android.server.clipboard.ClipboardManagerInternal import com.android.server.input.InputManagerService.WindowManagerCallbacks import com.android.server.input.InputManagerServiceTests.Companion.ACTION_KEY_EVENTS import com.android.server.input.data.TestDataStore import com.android.server.wm.WindowManagerInternal +import com.android.server.wm.WindowManagerInternal.InputTargetInfo import junitparams.JUnitParamsRunner import junitparams.Parameters import org.junit.Assert.assertArrayEquals @@ -150,6 +156,7 @@ class KeyGestureControllerTests { const val RANDOM_PID1 = 11 const val RANDOM_PID2 = 12 const val RANDOM_DISPLAY_ID = 123 + const val TEST_UID = 12345 const val SCREENSHOT_CHORD_DELAY: Long = 1000 // Current default multi-key press timeout used in KeyCombinationManager const val COMBINE_KEY_DELAY_MILLIS: Long = 150 @@ -183,6 +190,7 @@ class KeyGestureControllerTests { @Mock private lateinit var accessibilityShortcutController: AccessibilityShortcutController @Mock private lateinit var screenshotHelper: ScreenshotHelper @Mock private lateinit var windowManagerInternal: WindowManagerInternal + @Mock private lateinit var clipboardManagerInternal: ClipboardManagerInternal @Mock private lateinit var userManager: UserManager @Mock private lateinit var roleManager: RoleManager @@ -1485,6 +1493,122 @@ class KeyGestureControllerTests { assertEquals(-1, keyGestureController.interceptKeyBeforeDispatching(null, event, 0)) } + @Test + fun testSecurePasteShortcuts_passThroughBeforeDispatch() { + setupKeyGestureController() + val focus = Binder() + configureSecurePasteGrant(focus) + Mockito.`when`(wmCallbacks.interceptKeyBeforeDispatching(any(), any())).thenReturn(false) + + var shortcutCallCount = 0 + val shortcutService = + object : IShortcutService.Stub() { + override fun notifyShortcutKeyPressed(shortcutCode: Long) { + shortcutCallCount++ + } + } + registerPasteShortcutServices(shortcutService) + + for (event in createPasteShortcutEvents()) { + assertEquals( + 0, + keyGestureController.interceptKeyBeforeDispatching(focus, event, FLAG_TRUSTED), + ) + Mockito.verify(wmCallbacks).interceptKeyBeforeDispatching(focus, event) + } + assertEquals(0, shortcutCallCount) + } + + @Test + fun testSecurePasteShortcuts_notInterceptedWhenUnhandled() { + setupKeyGestureController() + val focus = Binder() + + var shortcutCallCount = 0 + val shortcutService = + object : IShortcutService.Stub() { + override fun notifyShortcutKeyPressed(shortcutCode: Long) { + shortcutCallCount++ + } + } + registerPasteShortcutServices(shortcutService) + + for (event in createPasteShortcutEvents()) { + assertFalse(keyGestureController.interceptUnhandledKey(event, focus)) + } + assertEquals(0, shortcutCallCount) + } + + @Test + fun testSecurePasteGrant_onlyForTrustedInitialDown() { + setupKeyGestureController() + val focus = Binder() + configureSecurePasteGrant(focus) + Mockito.`when`(wmCallbacks.interceptKeyBeforeDispatching(any(), any())).thenReturn(false) + + val initialDown = createPasteKeyEvent(KeyEvent.ACTION_DOWN) + val repeatDown = createPasteKeyEvent(KeyEvent.ACTION_DOWN, repeatCount = 1) + val up = createPasteKeyEvent(KeyEvent.ACTION_UP) + val canceledDown = + createPasteKeyEvent(KeyEvent.ACTION_DOWN, eventFlags = KeyEvent.FLAG_CANCELED) + + assertEquals( + 0, + keyGestureController.interceptKeyBeforeDispatching(focus, initialDown, FLAG_TRUSTED), + ) + assertEquals( + 0, + keyGestureController.interceptKeyBeforeDispatching(focus, repeatDown, FLAG_TRUSTED), + ) + assertEquals( + 0, + keyGestureController.interceptKeyBeforeDispatching(focus, up, FLAG_TRUSTED), + ) + assertEquals( + 0, + keyGestureController.interceptKeyBeforeDispatching(focus, canceledDown, FLAG_TRUSTED), + ) + assertEquals( + 0, + keyGestureController.interceptKeyBeforeDispatching( + focus, + createPasteKeyEvent(KeyEvent.ACTION_DOWN), + /* policyFlags = */ 0, + ), + ) + Mockito.`when`(windowManagerInternal.getInputTargetInfo(focus)) + .thenReturn(InputTargetInfo(TEST_UID, INVALID_DISPLAY)) + assertEquals( + 0, + keyGestureController.interceptKeyBeforeDispatching( + focus, + createPasteKeyEvent(KeyEvent.ACTION_DOWN), + FLAG_TRUSTED, + ), + ) + + Mockito.verify(clipboardManagerInternal, times(1)) + .createPasteGrantForDisplay(TEST_UID, DEFAULT_DISPLAY) + Mockito.verifyNoMoreInteractions(clipboardManagerInternal) + } + + @Test + fun testSecurePasteGrant_notCreatedWhenWmConsumes() { + setupKeyGestureController() + val focus = Binder() + configureSecurePasteGrant(focus) + Mockito.`when`(wmCallbacks.interceptKeyBeforeDispatching(Mockito.eq(focus), any())) + .thenReturn(true) + val event = createPasteKeyEvent(KeyEvent.ACTION_DOWN) + + assertEquals( + -1, + keyGestureController.interceptKeyBeforeDispatching(focus, event, FLAG_TRUSTED), + ) + Mockito.verify(clipboardManagerInternal, never()) + .createPasteGrantForDisplay(anyInt(), anyInt()) + } + @Test fun testLongPressEscape_withKeyCapture_exitGestureCompleted() { setupKeyGestureController() @@ -1716,6 +1840,63 @@ class KeyGestureControllerTests { return false } + private fun configureSecurePasteGrant(focus: IBinder) { + Mockito.`when`(windowManagerInternal.getInputTargetInfo(focus)) + .thenReturn(InputTargetInfo(TEST_UID, DEFAULT_DISPLAY)) + ExtendedMockito.doReturn(clipboardManagerInternal).`when` { + LocalServices.getService( + ArgumentMatchers.eq(ClipboardManagerInternal::class.java) + ) + } + } + + private fun registerPasteShortcutServices(shortcutService: IShortcutService) { + for (event in createPasteShortcutEvents()) { + val shortcutCode = + event.keyCode.toLong() or (event.metaState.toLong() shl Integer.SIZE) + keyGestureController.registerShortcutKey(shortcutCode, shortcutService) + } + } + + private fun createPasteShortcutEvents(): List = + listOf( + createPasteKeyEvent(KeyEvent.ACTION_DOWN), + createPasteKeyEvent( + KeyEvent.ACTION_DOWN, + metaState = KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON, + ), + createPasteKeyEvent( + KeyEvent.ACTION_DOWN, + keyCode = KeyEvent.KEYCODE_INSERT, + metaState = KeyEvent.META_SHIFT_ON, + ), + createPasteKeyEvent( + KeyEvent.ACTION_DOWN, + keyCode = KeyEvent.KEYCODE_PASTE, + metaState = 0, + ), + ) + + private fun createPasteKeyEvent( + action: Int, + keyCode: Int = KeyEvent.KEYCODE_V, + metaState: Int = KeyEvent.META_CTRL_ON, + repeatCount: Int = 0, + eventFlags: Int = 0, + ): KeyEvent = + KeyEvent( + /* downTime = */ 0, + /* eventTime = */ 0, + action, + keyCode, + repeatCount, + metaState, + DEVICE_ID, + /* scanCode = */ 0, + eventFlags, + InputDevice.SOURCE_KEYBOARD, + ) + fun overrideSendActionKeyEventsToFocusedWindow( hasPermission: Boolean, hasPrivateFlag: Boolean, diff --git a/tests/SecurePasteTests/Android.bp b/tests/SecurePasteTests/Android.bp new file mode 100644 index 0000000000000..adcefdb17db44 --- /dev/null +++ b/tests/SecurePasteTests/Android.bp @@ -0,0 +1,130 @@ +package { + default_applicable_licenses: ["frameworks_base_license"], +} + +android_library { + name: "SecurePasteHelperCommon", + srcs: ["helper/common/src/**/*.java"], + manifest: "helper/common/AndroidManifest.xml", + sdk_version: "system_current", +} + +android_test { + name: "SecurePasteTests", + srcs: ["src/**/*.java"], + manifest: "AndroidManifest.xml", + test_config: "AndroidTest.xml", + resource_dirs: ["res"], + static_libs: [ + "SecurePasteHelperCommon", + "CtsVirtualDeviceCommonLib", + "androidx.test.ext.junit", + "androidx.test.runner", + "androidx.test.rules", + "androidx.test.uiautomator_uiautomator", + "compatibility-device-util-axt", + "truth", + ], + libs: ["android.test.runner.stubs"], + data: [ + ":SecurePasteWriterApp", + ":SecurePasteReaderApp", + ":SecurePasteEditTextApp", + ":SecurePasteJetpackComposeApp", + ":SecurePasteImeApp", + ":SecurePasteCustomToolbarApp", + ":SecurePasteCacheClientApp", + ":SecurePasteSharedUidA", + ":SecurePasteSharedUidB", + ], + platform_apis: true, + certificate: "platform", + test_suites: ["device-tests"], +} + +android_test_helper_app { + name: "SecurePasteWriterApp", + manifest: "apps/writer/AndroidManifest.xml", + static_libs: ["SecurePasteHelperCommon"], + sdk_version: "system_current", +} + +android_test_helper_app { + name: "SecurePasteReaderApp", + manifest: "apps/reader/AndroidManifest.xml", + static_libs: ["SecurePasteHelperCommon"], + sdk_version: "system_current", +} + +android_test_helper_app { + name: "SecurePasteEditTextApp", + manifest: "apps/edittext/AndroidManifest.xml", + static_libs: ["SecurePasteHelperCommon"], + sdk_version: "system_current", +} + +android_test_helper_app { + name: "SecurePasteJetpackComposeApp", + srcs: ["apps/jetpackcompose/src/**/*.kt"], + manifest: "apps/jetpackcompose/AndroidManifest.xml", + static_libs: [ + "SecurePasteHelperCommon", + "androidx.activity_activity-compose", + "androidx.compose.foundation_foundation", + "androidx.compose.foundation_foundation-layout", + "androidx.compose.runtime_runtime", + "androidx.compose.ui_ui", + "androidx.compose.ui_ui-text", + "androidx.compose.ui_ui-unit", + ], + kotlincflags: [ + "-Xjvm-default=all", + "-P plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true", + ], + sdk_version: "system_current", +} + +android_test_helper_app { + name: "SecurePasteCustomToolbarApp", + manifest: "apps/customtoolbar/AndroidManifest.xml", + static_libs: ["SecurePasteHelperCommon"], + sdk_version: "system_current", +} + +android_test_helper_app { + name: "SecurePasteCacheClientApp", + manifest: "apps/cacheclient/AndroidManifest.xml", + static_libs: ["SecurePasteHelperCommon"], + sdk_version: "system_current", +} + +android_test_helper_app { + name: "SecurePasteSharedUidA", + manifest: "apps/shareduid_a/AndroidManifest.xml", + static_libs: ["SecurePasteHelperCommon"], + sdk_version: "system_current", +} + +android_test_helper_app { + name: "SecurePasteSharedUidB", + manifest: "apps/shareduid_b/AndroidManifest.xml", + static_libs: ["SecurePasteHelperCommon"], + sdk_version: "system_current", +} + +android_app { + name: "SecurePasteTestSystemApp", + manifest: "apps/system/AndroidManifest.xml", + static_libs: ["SecurePasteHelperCommon"], + sdk_version: "system_current", + certificate: "platform", +} + +android_test_helper_app { + name: "SecurePasteImeApp", + srcs: ["apps/ime/src/**/*.java"], + manifest: "apps/ime/AndroidManifest.xml", + resource_dirs: ["apps/ime/res"], + static_libs: ["SecurePasteHelperCommon"], + sdk_version: "system_current", +} diff --git a/tests/SecurePasteTests/AndroidManifest.xml b/tests/SecurePasteTests/AndroidManifest.xml new file mode 100644 index 0000000000000..02c150b28e836 --- /dev/null +++ b/tests/SecurePasteTests/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/SecurePasteTests/AndroidTest.xml b/tests/SecurePasteTests/AndroidTest.xml new file mode 100644 index 0000000000000..40e1ab44c80ac --- /dev/null +++ b/tests/SecurePasteTests/AndroidTest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/tests/SecurePasteTests/TEST_MAPPING b/tests/SecurePasteTests/TEST_MAPPING new file mode 100644 index 0000000000000..ebb4c208c45f4 --- /dev/null +++ b/tests/SecurePasteTests/TEST_MAPPING @@ -0,0 +1,179 @@ +{ + "gos-postsubmit": [ + { + "name": "SecurePasteTests" + }, + { + "name": "InputTests", + "options": [ + { + "include-filter": "com.android.server.input.KeyGestureControllerTests#testSecurePasteShortcuts_passThroughBeforeDispatch" + }, + { + "include-filter": "com.android.server.input.KeyGestureControllerTests#testSecurePasteShortcuts_notInterceptedWhenUnhandled" + }, + { + "include-filter": "com.android.server.input.KeyGestureControllerTests#testSecurePasteGrant_onlyForTrustedInitialDown" + }, + { + "include-filter": "com.android.server.input.KeyGestureControllerTests#testSecurePasteGrant_notCreatedWhenWmConsumes" + } + ] + } + ], + "secure-paste-compat-default-allow": [ + { + "name": "FrameworksCoreTests", + "options": [ + { + "include-filter": "android.widget.TextViewActivityTest#testPastePlainText_menuAction" + }, + { + "include-filter": "android.widget.TextViewActivityTest#testPastePlainText_noMenuItemForPlainText" + }, + { + "include-filter": "com.android.internal.app.ChooserActivityTest#copyTextToClipboard" + }, + { + "include-filter": "android.widget.TextViewContextMenuTest#testContextMenuPasteAddedWhenAvailable" + }, + { + "include-filter": "android.widget.TextViewContextMenuTest#testContextMenuPasteAsPlaintextAddedWhenAvailable" + }, + { + "include-filter": "android.widget.TextViewReceiveContentTest" + } + ] + }, + { + "name": "IntentResolver-tests-activity", + "options": [ + { + "include-filter": "com.android.intentresolver.ChooserActivityTest#copyTextToClipboard" + } + ] + }, + { + "name": "TextClassifierNotificationTests", + "options": [ + { + "include-filter": "com.android.textclassifier.notification.CopyCodeActivityTest" + } + ] + }, + { + "name": "FrameworksServicesTests_accessibility", + "options": [ + { + "include-filter": "com.android.server.accessibility.AbstractAccessibilityServiceConnectionTest#performAccessibilityAction_withPipWindow_invokeGetPipReplacingConnection" + }, + { + "include-filter": "com.android.server.accessibility.AbstractAccessibilityServiceConnectionTest#performAccessibilityAction_withClick_shouldNotifyOutsideTouch" + } + ] + }, + { + "name": "FrameworksServicesTests_android_server_uri", + "options": [ + { + "include-filter": "com.android.server.uri.UriGrantsManagerServiceTest" + } + ] + }, + { + "name": "CtsContentTestCases", + "options": [ + { + "include-filter": "android.content.cts.ClipboardManagerTest" + }, + { + "include-filter": "android.content.cts.ClipDescriptionTest" + }, + { + "include-filter": "android.content.cts.ClipboardManagerListenerTest" + }, + { + "include-filter": "android.content.cts.ClipboardAutoClearTest" + } + ] + }, + { + "name": "CtsTextTestCases", + "options": [ + { + "include-filter": "android.text.cts.ClipboardManagerTest" + } + ] + }, + { + "name": "CtsWidgetTestCases", + "options": [ + { + "include-filter": "android.widget.cts.TextViewTest" + }, + { + "include-filter": "android.widget.cts.TextViewReceiveContentTest" + }, + { + "include-filter": "android.widget.cts.TextViewMouseInteractionTest" + } + ] + }, + { + "name": "CtsAccessibilityServiceTestCases", + "options": [ + { + "include-filter": "android.accessibilityservice.cts.AccessibilityTextTraversalTest" + } + ] + }, + { + "name": "CtsInputMethodTestCases", + "options": [ + { + "include-filter": "android.view.inputmethod.cts.InputConnectionEndToEndTest" + } + ] + }, + { + "name": "CtsVirtualDevicesAppLaunchTestCases", + "options": [ + { + "include-filter": "android.virtualdevice.cts.applaunch.StreamedAppClipboardTest" + } + ] + }, + { + "name": "CtsContentCaptureServiceTestCases", + "options": [ + { + "include-filter": "android.contentcaptureservice.cts.ClipboardAccessTest" + } + ] + }, + { + "name": "CtsAutoFillServiceTestCases", + "options": [ + { + "include-filter": "android.autofillservice.cts.augmented.ClipboardAccessTest" + } + ] + }, + { + "name": "CtsDevicePolicyManagerTestCases", + "options": [ + { + "include-filter": "com.android.cts.devicepolicy.ManagedProfileCrossProfileTest#testCrossProfileCopyPaste" + } + ] + }, + { + "name": "CtsAppSecurityHostTestCases", + "options": [ + { + "include-filter": "android.appsecurity.cts.AppSecurityTests#testPermissionDiffCert" + } + ] + } + ] +} diff --git a/tests/SecurePasteTests/apps/cacheclient/AndroidManifest.xml b/tests/SecurePasteTests/apps/cacheclient/AndroidManifest.xml new file mode 100644 index 0000000000000..5f6ef4f0c6cb1 --- /dev/null +++ b/tests/SecurePasteTests/apps/cacheclient/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/tests/SecurePasteTests/apps/customtoolbar/AndroidManifest.xml b/tests/SecurePasteTests/apps/customtoolbar/AndroidManifest.xml new file mode 100644 index 0000000000000..38725e98ea2df --- /dev/null +++ b/tests/SecurePasteTests/apps/customtoolbar/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/tests/SecurePasteTests/apps/edittext/AndroidManifest.xml b/tests/SecurePasteTests/apps/edittext/AndroidManifest.xml new file mode 100644 index 0000000000000..4f3a7e232fc13 --- /dev/null +++ b/tests/SecurePasteTests/apps/edittext/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/tests/SecurePasteTests/apps/ime/AndroidManifest.xml b/tests/SecurePasteTests/apps/ime/AndroidManifest.xml new file mode 100644 index 0000000000000..c36964bb7a144 --- /dev/null +++ b/tests/SecurePasteTests/apps/ime/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + diff --git a/tests/SecurePasteTests/apps/ime/res/xml/method.xml b/tests/SecurePasteTests/apps/ime/res/xml/method.xml new file mode 100644 index 0000000000000..8306c7bb03798 --- /dev/null +++ b/tests/SecurePasteTests/apps/ime/res/xml/method.xml @@ -0,0 +1,4 @@ + + diff --git a/tests/SecurePasteTests/apps/ime/src/grapheneos/securepaste/ime/SecurePasteImeService.java b/tests/SecurePasteTests/apps/ime/src/grapheneos/securepaste/ime/SecurePasteImeService.java new file mode 100644 index 0000000000000..7af997dda584f --- /dev/null +++ b/tests/SecurePasteTests/apps/ime/src/grapheneos/securepaste/ime/SecurePasteImeService.java @@ -0,0 +1,83 @@ +package grapheneos.securepaste.ime; + +import android.inputmethodservice.InputMethodService; +import android.view.Gravity; +import android.view.View; +import android.view.inputmethod.InputConnection; +import android.widget.Button; +import android.widget.LinearLayout; + +// Test-only IME used to exercise current and deliberately retained InputConnection paste calls. +// The harness verifies both the normal default-IME paste path and binding to the current target. +public class SecurePasteImeService extends InputMethodService { + private static volatile SecurePasteImeService sInstance; + private static volatile InputConnection sRetainedInputConnection; + + @Override + public void onCreate() { + super.onCreate(); + sInstance = this; + } + + @Override + public void onDestroy() { + if (sInstance == this) { + sInstance = null; + sRetainedInputConnection = null; + } + super.onDestroy(); + } + + @Override + public View onCreateInputView() { + final LinearLayout root = new LinearLayout(this); + root.setGravity(Gravity.CENTER); + final Button button = new Button(this); + button.setText("IME Paste"); + button.setOnClickListener(v -> requestPaste()); + root.addView(button); + return root; + } + + public static boolean isReady() { + return sInstance != null; + } + + public static boolean requestPaste() { + final SecurePasteImeService service = sInstance; + if (service == null) { + return false; + } + final InputConnection inputConnection = service.getCurrentInputConnection(); + if (inputConnection == null) { + return false; + } + inputConnection.performContextMenuAction(android.R.id.paste); + // Compose handles this action while returning false. Report whether dispatch was possible, + // then let the test verify whether the focused editor consumed it. + return true; + } + + public static boolean retainCurrentInputConnection() { + final SecurePasteImeService service = sInstance; + if (service == null) { + return false; + } + sRetainedInputConnection = service.getCurrentInputConnection(); + return sRetainedInputConnection != null; + } + + public static boolean requestPasteFromRetainedInputConnection() { + final SecurePasteImeService service = sInstance; + final InputConnection retained = sRetainedInputConnection; + if (service == null || retained == null) { + return false; + } + final InputConnection current = service.getCurrentInputConnection(); + if (current == null || current == retained) { + return false; + } + retained.performContextMenuAction(android.R.id.paste); + return true; + } +} diff --git a/tests/SecurePasteTests/apps/jetpackcompose/AndroidManifest.xml b/tests/SecurePasteTests/apps/jetpackcompose/AndroidManifest.xml new file mode 100644 index 0000000000000..0abb3f7ef66b2 --- /dev/null +++ b/tests/SecurePasteTests/apps/jetpackcompose/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/tests/SecurePasteTests/apps/jetpackcompose/src/grapheneos/securepaste/jetpackcompose/SecurePasteJetpackComposeActivity.kt b/tests/SecurePasteTests/apps/jetpackcompose/src/grapheneos/securepaste/jetpackcompose/SecurePasteJetpackComposeActivity.kt new file mode 100644 index 0000000000000..c910227fdac63 --- /dev/null +++ b/tests/SecurePasteTests/apps/jetpackcompose/src/grapheneos/securepaste/jetpackcompose/SecurePasteJetpackComposeActivity.kt @@ -0,0 +1,169 @@ +package grapheneos.securepaste.jetpackcompose + +import android.os.Bundle +import android.view.Window +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import grapheneos.securepaste.helper.SecurePasteActivity +import java.lang.ref.WeakReference +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class SecurePasteJetpackComposeActivity : ComponentActivity() { + private val editorState = mutableStateOf(TextFieldValue("")) + private val fieldMode = mutableStateOf(FIELD_MODE_VALUE) + @Volatile private var editorText = "" + @Volatile private var ready = false + private var focusRequester: FocusRequester? = null + private var focusManager: FocusManager? = null + private var stateEditorState: TextFieldState? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestWindowFeature(Window.FEATURE_NO_TITLE) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + activity = WeakReference(this) + + setContent { + val requester = remember { FocusRequester() } + val manager = LocalFocusManager.current + val state = rememberTextFieldState() + val stateText = state.text.toString() + val valueText = editorState.value.text + val modifier = Modifier + .padding(24.dp) + .fillMaxWidth() + .heightIn(min = 128.dp) + .focusRequester(requester) + .semantics { + contentDescription = SecurePasteActivity.EDITOR_DESCRIPTION + } + + SideEffect { + focusRequester = requester + focusManager = manager + stateEditorState = state + editorText = if (fieldMode.value == FIELD_MODE_STATE) stateText else valueText + ready = true + } + + LaunchedEffect(Unit) { + requester.requestFocus() + } + + if (fieldMode.value == FIELD_MODE_STATE) { + BasicTextField( + state = state, + modifier = modifier, + textStyle = TextStyle(fontSize = 18.sp), + ) + } else { + BasicTextField( + value = editorState.value, + onValueChange = { value -> + editorState.value = value + editorText = value.text + }, + modifier = modifier, + textStyle = TextStyle(fontSize = 18.sp), + ) + } + } + } + + override fun onDestroy() { + if (activity.get() === this) { + activity = WeakReference(null) + } + super.onDestroy() + } + + private fun setFieldModeOnUiThread(mode: String) { + val text = editorText + fieldMode.value = mode + if (mode == FIELD_MODE_STATE) { + stateEditorState?.setTextAndPlaceCursorAtEnd(text) + } else { + editorState.value = TextFieldValue(text, selection = TextRange(text.length)) + } + } + + private fun requestFocusOnUiThread() { + focusRequester?.requestFocus() + } + + private fun clearFocusOnUiThread() { + focusManager?.clearFocus(force = true) + } + + companion object { + private const val FIELD_MODE_VALUE = "value" + private const val FIELD_MODE_STATE = "state" + + @Volatile + private var activity = WeakReference(null) + + @JvmStatic + fun isReady(): Boolean = activity.get()?.ready == true + + @JvmStatic + fun getEditorText(): String? = activity.get()?.editorText + + @JvmStatic + fun setFieldMode(mode: String): Boolean { + if (mode != FIELD_MODE_VALUE && mode != FIELD_MODE_STATE) { + return false + } + val current = activity.get() ?: return false + val latch = CountDownLatch(1) + current.runOnUiThread { + current.setFieldModeOnUiThread(mode) + latch.countDown() + } + return try { + latch.await(5, TimeUnit.SECONDS) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + false + } + } + + @JvmStatic + fun requestEditorFocus(): Boolean { + val current = activity.get() ?: return false + current.runOnUiThread { current.requestFocusOnUiThread() } + return true + } + + @JvmStatic + fun clearEditorFocus(): Boolean { + val current = activity.get() ?: return false + current.runOnUiThread { current.clearFocusOnUiThread() } + return true + } + } +} diff --git a/tests/SecurePasteTests/apps/reader/AndroidManifest.xml b/tests/SecurePasteTests/apps/reader/AndroidManifest.xml new file mode 100644 index 0000000000000..3d7ceee4d6ba8 --- /dev/null +++ b/tests/SecurePasteTests/apps/reader/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/tests/SecurePasteTests/apps/shareduid_a/AndroidManifest.xml b/tests/SecurePasteTests/apps/shareduid_a/AndroidManifest.xml new file mode 100644 index 0000000000000..c00c13f32de75 --- /dev/null +++ b/tests/SecurePasteTests/apps/shareduid_a/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/tests/SecurePasteTests/apps/shareduid_b/AndroidManifest.xml b/tests/SecurePasteTests/apps/shareduid_b/AndroidManifest.xml new file mode 100644 index 0000000000000..e708ff7329f87 --- /dev/null +++ b/tests/SecurePasteTests/apps/shareduid_b/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/tests/SecurePasteTests/apps/system/AndroidManifest.xml b/tests/SecurePasteTests/apps/system/AndroidManifest.xml new file mode 100644 index 0000000000000..499cda0de67ac --- /dev/null +++ b/tests/SecurePasteTests/apps/system/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/tests/SecurePasteTests/apps/writer/AndroidManifest.xml b/tests/SecurePasteTests/apps/writer/AndroidManifest.xml new file mode 100644 index 0000000000000..828a6d7dae11b --- /dev/null +++ b/tests/SecurePasteTests/apps/writer/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/tests/SecurePasteTests/helper/common/AndroidManifest.xml b/tests/SecurePasteTests/helper/common/AndroidManifest.xml new file mode 100644 index 0000000000000..ed267a0a38438 --- /dev/null +++ b/tests/SecurePasteTests/helper/common/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteAccessibilityServiceBase.java b/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteAccessibilityServiceBase.java new file mode 100644 index 0000000000000..23acb9dd6b8f0 --- /dev/null +++ b/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteAccessibilityServiceBase.java @@ -0,0 +1,67 @@ +package grapheneos.securepaste.helper; + +import android.accessibilityservice.AccessibilityService; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; + +import java.lang.ref.WeakReference; +public class SecurePasteAccessibilityServiceBase extends AccessibilityService { + private static WeakReference sInstance = + new WeakReference<>(null); + + @Override + protected void onServiceConnected() { + sInstance = new WeakReference<>(this); + SecurePasteCommandProvider.setAccessibilityConnected(true); + } + + @Override + public void onAccessibilityEvent(AccessibilityEvent event) { + } + + @Override + public void onInterrupt() { + } + + @Override + public void onDestroy() { + if (sInstance.get() == this) { + sInstance = new WeakReference<>(null); + SecurePasteCommandProvider.setAccessibilityConnected(false); + } + super.onDestroy(); + } + + public static boolean performPasteOnFocusedNode() { + final SecurePasteAccessibilityServiceBase service = sInstance.get(); + if (service == null) { + return false; + } + AccessibilityNodeInfo target = null; + final AccessibilityNodeInfo root = service.getRootInActiveWindow(); + if (root != null) { + target = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT); + if (target == null) { + target = findEditable(root); + } + } + return target != null && target.performAction(AccessibilityNodeInfo.ACTION_PASTE); + } + + private static AccessibilityNodeInfo findEditable(AccessibilityNodeInfo node) { + if (node == null) { + return null; + } + if (node.isEditable() || node.isFocused()) { + return node; + } + for (int i = 0; i < node.getChildCount(); i++) { + final AccessibilityNodeInfo child = node.getChild(i); + final AccessibilityNodeInfo result = findEditable(child); + if (result != null) { + return result; + } + } + return null; + } +} diff --git a/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteActivity.java b/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteActivity.java new file mode 100644 index 0000000000000..70e9501c7b7ba --- /dev/null +++ b/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteActivity.java @@ -0,0 +1,356 @@ +package grapheneos.securepaste.helper; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipDescription; +import android.os.Bundle; +import android.os.PersistableBundle; +import android.text.style.StrikethroughSpan; +import android.view.ActionMode; +import android.view.ContentInfo; +import android.view.DragEvent; +import android.view.Gravity; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; + +import java.lang.ref.WeakReference; + +public class SecurePasteActivity extends Activity { + public static final String EDITOR_DESCRIPTION = "Secure Paste Editor"; + public static final String DRAG_SOURCE_DESCRIPTION = "Secure Paste Drag Source"; + public static final String DRAG_TARGET_DESCRIPTION = "Secure Paste Drag Target"; + public static final String DRAG_LABEL = "secure-paste-drag-label"; + public static final String DRAG_TEXT = "secure-paste-drag-text"; + public static final String DRAG_EXTRA_KEY = "secure-paste-drag-extra-key"; + public static final String DRAG_EXTRA_VALUE = "secure-paste-drag-extra-value"; + public static final String CUSTOM_PASTE_DESCRIPTION = "Custom Paste"; + private static final int MENU_FRAMEWORK_TITLE_PASTE = 0x53500001; + private static final int MENU_CUSTOM_PASTE = 0x53500002; + + private static WeakReference sActivity = + new WeakReference<>(null); + private static volatile DragResult sDragResult = new DragResult(); + private static volatile ContentInfo sReceivedContent; + + private EditText mEditor; + private boolean mReady; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); + + final LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(24, 24, 24, 24); + + if (isCustomToolbarApp()) { + root.addView(newPasteButton()); + } + mEditor = new EditText(this); + mEditor.setContentDescription(EDITOR_DESCRIPTION); + mEditor.setMinLines(4); + mEditor.setSingleLine(false); + mEditor.setTextIsSelectable(true); + mEditor.setText(""); + mEditor.setSelectAllOnFocus(false); + mEditor.setCustomSelectionActionModeCallback(newPasteActionModeCallback()); + mEditor.setCustomInsertionActionModeCallback(newPasteActionModeCallback()); + root.addView(mEditor, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + if (isWriterApp()) { + root.addView(newDragRow()); + } + + setContentView(root); + sActivity = new WeakReference<>(this); + mReady = true; + mEditor.requestFocus(); + } + + @Override + protected void onDestroy() { + if (sActivity.get() == this) { + sActivity = new WeakReference<>(null); + } + super.onDestroy(); + } + + public static boolean isReady() { + final SecurePasteActivity activity = sActivity.get(); + return activity != null && activity.mReady; + } + + public static String getEditorText() { + final SecurePasteActivity activity = sActivity.get(); + if (activity == null || activity.mEditor == null) { + return null; + } + return activity.mEditor.getText().toString(); + } + + static boolean editorHasStrikethroughSpan() { + final SecurePasteActivity activity = sActivity.get(); + return activity != null && activity.mEditor != null + && activity.mEditor.getText().getSpans( + 0, activity.mEditor.length(), StrikethroughSpan.class).length > 0; + } + + static boolean enableRecordingContentReceiver() { + final SecurePasteActivity activity = sActivity.get(); + if (activity == null || activity.mEditor == null) { + return false; + } + sReceivedContent = null; + activity.runOnUiThread(() -> activity.mEditor.setOnReceiveContentListener( + new String[] {"text/plain", "video/avi"}, (view, content) -> { + sReceivedContent = content; + return null; + })); + return true; + } + + static ContentInfo getReceivedContent() { + return sReceivedContent; + } + + public static void resetDragResult() { + sDragResult = new DragResult(); + } + + public static DragResult getDragResult() { + return sDragResult; + } + + public static boolean requestEditorFocus() { + final SecurePasteActivity activity = sActivity.get(); + if (activity == null || activity.mEditor == null) { + return false; + } + activity.runOnUiThread(() -> { + activity.mEditor.requestFocus(); + activity.mEditor.setSelection(activity.mEditor.getText().length()); + }); + return true; + } + + public static boolean clearEditorFocus() { + final SecurePasteActivity activity = sActivity.get(); + if (activity == null || activity.mEditor == null) { + return false; + } + activity.runOnUiThread(() -> activity.mEditor.clearFocus()); + return true; + } + + public static boolean directPasteFromClipboard() { + final SecurePasteActivity activity = sActivity.get(); + if (activity == null) { + return false; + } + final String text = SecurePasteCommandProvider.readClipboardText(activity); + return pasteText(text); + } + + public static boolean pasteText(String text) { + final SecurePasteActivity activity = sActivity.get(); + if (activity == null || activity.mEditor == null || text == null) { + return false; + } + activity.runOnUiThread(() -> { + final int start = Math.max(0, activity.mEditor.getSelectionStart()); + final int end = Math.max(0, activity.mEditor.getSelectionEnd()); + activity.mEditor.getText().replace(Math.min(start, end), Math.max(start, end), text); + }); + return true; + } + + private static ClipData newDragClipData() { + final ClipDescription description = new ClipDescription(DRAG_LABEL, + new String[] {ClipDescription.MIMETYPE_TEXT_PLAIN}); + final PersistableBundle extras = new PersistableBundle(); + extras.putString(DRAG_EXTRA_KEY, DRAG_EXTRA_VALUE); + description.setExtras(extras); + return new ClipData(description, new ClipData.Item(DRAG_TEXT)); + } + + private Button newPasteButton() { + final Button button = new Button(this); + button.setText(CUSTOM_PASTE_DESCRIPTION); + button.setContentDescription(CUSTOM_PASTE_DESCRIPTION); + button.setOnClickListener(v -> directPasteFromClipboard()); + return button; + } + + private LinearLayout newDragRow() { + final LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + // Keep the UiAutomator drag low and horizontal so it does not pull down the SystemUI + // notification shade from the status bar region. + row.addView(newDragSourceView(), new LinearLayout.LayoutParams( + 0, 220, 1)); + row.addView(newDragTargetView(), new LinearLayout.LayoutParams( + 0, 220, 1)); + return row; + } + + private TextView newDragSourceView() { + final TextView view = new TextView(this); + view.setText(DRAG_SOURCE_DESCRIPTION); + view.setContentDescription(DRAG_SOURCE_DESCRIPTION); + view.setGravity(Gravity.CENTER); + view.setMinHeight(160); + view.setOnTouchListener((v, event) -> { + if (event.getAction() != MotionEvent.ACTION_DOWN) { + return false; + } + final DragResult result = new DragResult(); + sDragResult = result; + result.startDragResult = v.startDragAndDrop( + newDragClipData(), new View.DragShadowBuilder(v), null, + View.DRAG_FLAG_GLOBAL); + return true; + }); + return view; + } + + private TextView newDragTargetView() { + final TextView view = new TextView(this); + view.setText(DRAG_TARGET_DESCRIPTION); + view.setContentDescription(DRAG_TARGET_DESCRIPTION); + view.setGravity(Gravity.CENTER); + view.setMinHeight(160); + view.setOnDragListener((v, event) -> { + final DragResult result = sDragResult; + switch (event.getAction()) { + case DragEvent.ACTION_DRAG_STARTED: + recordDragStarted(result, event); + return true; + case DragEvent.ACTION_DRAG_ENTERED: + case DragEvent.ACTION_DRAG_LOCATION: + case DragEvent.ACTION_DRAG_EXITED: + return true; + case DragEvent.ACTION_DROP: + recordDrop(result, event); + return true; + case DragEvent.ACTION_DRAG_ENDED: + result.ended = true; + result.dropResult = event.getResult(); + return true; + default: + return false; + } + }); + return view; + } + + private static void recordDragStarted(DragResult result, DragEvent event) { + result.started = true; + result.startedHasClipData = event.getClipData() != null; + result.startedText = textFromClipData(event.getClipData()); + final ClipDescription description = event.getClipDescription(); + if (description != null) { + result.startedLabel = String.valueOf(description.getLabel()); + result.startedMimeTypes = mimeTypes(description); + final PersistableBundle extras = description.getExtras(); + result.startedExtraValue = extras == null ? null : extras.getString(DRAG_EXTRA_KEY); + } + } + + private static void recordDrop(DragResult result, DragEvent event) { + result.dropped = true; + result.dropHasClipData = event.getClipData() != null; + result.dropText = textFromClipData(event.getClipData()); + } + + private static String textFromClipData(ClipData clipData) { + if (clipData == null || clipData.getItemCount() == 0) { + return null; + } + final CharSequence text = clipData.getItemAt(0).getText(); + return text == null ? null : text.toString(); + } + + private static String[] mimeTypes(ClipDescription description) { + final String[] mimeTypes = new String[description.getMimeTypeCount()]; + for (int i = 0; i < description.getMimeTypeCount(); i++) { + mimeTypes[i] = description.getMimeType(i); + } + return mimeTypes; + } + + private ActionMode.Callback newPasteActionModeCallback() { + return new ActionMode.Callback() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + addCustomPasteItems(menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + addCustomPasteItems(menu); + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + if (item.getItemId() == MENU_FRAMEWORK_TITLE_PASTE + || item.getItemId() == MENU_CUSTOM_PASTE) { + directPasteFromClipboard(); + mode.finish(); + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) {} + }; + } + + private void addCustomPasteItems(Menu menu) { + if (!isCustomToolbarApp()) { + return; + } + menu.removeItem(android.R.id.paste); + menu.removeItem(MENU_FRAMEWORK_TITLE_PASTE); + menu.removeItem(MENU_CUSTOM_PASTE); + menu.add(0, MENU_FRAMEWORK_TITLE_PASTE, 0, getString(android.R.string.paste)) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); + menu.add(0, MENU_CUSTOM_PASTE, 1, "Non-framework paste") + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + } + + private boolean isCustomToolbarApp() { + return getPackageName().contains("customtoolbar"); + } + + private boolean isWriterApp() { + return getPackageName().endsWith(".writer"); + } + + public static final class DragResult { + public volatile boolean startDragResult; + public volatile boolean started; + public volatile boolean startedHasClipData; + public volatile String startedText; + public volatile String startedLabel; + public volatile String[] startedMimeTypes; + public volatile String startedExtraValue; + public volatile boolean dropped; + public volatile boolean dropHasClipData; + public volatile String dropText; + public volatile boolean ended; + public volatile boolean dropResult; + } +} diff --git a/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteCommandProvider.java b/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteCommandProvider.java new file mode 100644 index 0000000000000..de7dd2b1c456a --- /dev/null +++ b/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteCommandProvider.java @@ -0,0 +1,545 @@ +package grapheneos.securepaste.helper; + +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.StrikethroughSpan; +import android.view.ContentInfo; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +public class SecurePasteCommandProvider extends ContentProvider { + private static final String JETPACK_COMPOSE_PACKAGE = + "grapheneos.securepaste.jetpackcompose"; + private static final String JETPACK_COMPOSE_ACTIVITY = + "grapheneos.securepaste.jetpackcompose.SecurePasteJetpackComposeActivity"; + private static final String WRITER_PACKAGE = "grapheneos.securepaste.writer"; + + public static final String METHOD_SET_CLIP_TEXT = "set_clip_text"; + public static final String METHOD_SET_CLIP_HTML = "set_clip_html"; + public static final String METHOD_SET_CLIP_STYLED_TEXT = "set_clip_styled_text"; + public static final String METHOD_SET_CLIP_INTENT = "set_clip_intent"; + public static final String METHOD_SET_CLIP_URI = "set_clip_uri"; + public static final String METHOD_SET_CLIP_URI_ONLY = "set_clip_uri_only"; + public static final String METHOD_SET_CLIP_COMPLEX_ITEM = "set_clip_complex_item"; + public static final String METHOD_SET_CLIP_MULTIPLE_TEXT_ITEMS = + "set_clip_multiple_text_items"; + public static final String METHOD_SET_CLIP_UNSUPPORTED_MIME_TYPE = + "set_clip_unsupported_mime_type"; + public static final String METHOD_CLEAR_CLIP = "clear_clip"; + public static final String METHOD_READ_CLIP = "read_clip"; + public static final String METHOD_READ_OWN_CLIP = "read_own_clip"; + public static final String METHOD_REGISTER_LISTENER = "register_listener"; + public static final String METHOD_RESET_LISTENER = "reset_listener"; + public static final String METHOD_GET_LISTENER_COUNT = "get_listener_count"; + public static final String METHOD_CACHE_CLIP = "cache_clip"; + public static final String METHOD_ACTIVITY_READY = "activity_ready"; + public static final String METHOD_GET_EDITOR_TEXT = "get_editor_text"; + public static final String METHOD_ENABLE_RECORDING_CONTENT_RECEIVER = + "enable_recording_content_receiver"; + public static final String METHOD_GET_RECEIVED_CONTENT = "get_received_content"; + public static final String METHOD_REQUEST_FOCUS = "request_focus"; + public static final String METHOD_CLEAR_FOCUS = "clear_focus"; + public static final String METHOD_CACHED_PASTE = "cached_paste"; + public static final String METHOD_READ_WRITER_URI = "read_writer_uri"; + public static final String METHOD_READ_WRITER_INTENT_URI = "read_writer_intent_uri"; + public static final String METHOD_RESET_DRAG_RESULT = "reset_drag_result"; + public static final String METHOD_GET_DRAG_RESULT = "get_drag_result"; + public static final String METHOD_ACCESSIBILITY_CONNECTED = "accessibility_connected"; + public static final String METHOD_ACCESSIBILITY_PASTE = "accessibility_paste"; + public static final String METHOD_IME_READY = "ime_ready"; + public static final String METHOD_IME_PASTE = "ime_paste"; + public static final String METHOD_IME_RETAIN_INPUT_CONNECTION = + "ime_retain_input_connection"; + public static final String METHOD_IME_PASTE_RETAINED = "ime_paste_retained"; + public static final String METHOD_SET_JETPACK_COMPOSE_FIELD_MODE = + "set_jetpack_compose_field_mode"; + + public static final String EXTRA_TEXT = "text"; + public static final String EXTRA_HTML = "html"; + public static final String EXTRA_MODE = "mode"; + public static final String TEXT_CLIP_LABEL = "secure-paste-label"; + public static final String INTENT_CLIP_ACTION = "grapheneos.securepaste.intent.CLIP"; + public static final String RESULT_OK = "ok"; + public static final String RESULT_TEXT = "text"; + public static final String RESULT_EXCEPTION = "exception"; + public static final String RESULT_HAS_CLIP = "hasClip"; + public static final String RESULT_HAS_TEXT = "hasText"; + public static final String RESULT_DESCRIPTION = "description"; + public static final String RESULT_MIME_TYPES = "mimeTypes"; + public static final String RESULT_TIMESTAMP = "timestamp"; + public static final String RESULT_COUNT = "count"; + public static final String RESULT_HTML = "html"; + public static final String RESULT_URI = "uri"; + public static final String RESULT_SOURCE = "source"; + public static final String RESULT_IS_STYLED_TEXT = "isStyledText"; + public static final String RESULT_HAS_STRIKETHROUGH_SPAN = "hasStrikethroughSpan"; + public static final String RESULT_DRAG_START_RESULT = "dragStartResult"; + public static final String RESULT_DRAG_STARTED_HAS_CLIP_DATA = "dragStartedHasClipData"; + public static final String RESULT_DRAG_STARTED_TEXT = "dragStartedText"; + public static final String RESULT_DRAG_STARTED_EXTRA_VALUE = "dragStartedExtraValue"; + public static final String RESULT_DRAG_DROPPED = "dragDropped"; + public static final String RESULT_DRAG_DROP_HAS_CLIP_DATA = "dragDropHasClipData"; + public static final String RESULT_DRAG_DROP_TEXT = "dragDropText"; + public static final String RESULT_DRAG_ENDED = "dragEnded"; + public static final String RESULT_DRAG_DROP_RESULT = "dragDropResult"; + + private static final AtomicInteger sListenerCount = new AtomicInteger(); + private static ClipboardManager.OnPrimaryClipChangedListener sListener; + private static volatile String sCachedText; + private static volatile boolean sAccessibilityConnected; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Bundle call(String method, String arg, Bundle extras) { + final Bundle result = new Bundle(); + final Context context = requireContext(); + try { + switch (method) { + case METHOD_SET_CLIP_TEXT: + setTextClip(context, getString(extras, EXTRA_TEXT, "secure-paste-text")); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_SET_CLIP_HTML: + setHtmlClip(context, getString(extras, EXTRA_TEXT, "secure-paste-text"), + getString(extras, EXTRA_HTML, "secure-paste-text")); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_SET_CLIP_STYLED_TEXT: + setStyledTextClip(context, + getString(extras, EXTRA_TEXT, "secure-paste-styled-text")); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_SET_CLIP_INTENT: + setIntentClip(context); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_SET_CLIP_URI: + setUriClip(context, getString(extras, EXTRA_TEXT, "secure-paste-uri-text")); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_SET_CLIP_URI_ONLY: + setUriOnlyClip(context); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_SET_CLIP_COMPLEX_ITEM: + setComplexItemClip(context, + getString(extras, EXTRA_TEXT, "secure-paste-complex-text")); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_SET_CLIP_MULTIPLE_TEXT_ITEMS: + setMultipleTextItemsClip(context); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_SET_CLIP_UNSUPPORTED_MIME_TYPE: + setUnsupportedMimeTypeClip(context); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_CLEAR_CLIP: + clipboard(context).clearPrimaryClip(); + sCachedText = null; + result.putBoolean(RESULT_OK, true); + break; + case METHOD_READ_CLIP: + readClip(context, result); + break; + case METHOD_READ_OWN_CLIP: + setTextClip(context, getString(extras, EXTRA_TEXT, "secure-paste-own-text")); + readClip(context, result); + break; + case METHOD_REGISTER_LISTENER: + registerListener(context); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_RESET_LISTENER: + sListenerCount.set(0); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_GET_LISTENER_COUNT: + result.putInt(RESULT_COUNT, sListenerCount.get()); + break; + case METHOD_CACHE_CLIP: + sCachedText = readClipboardText(context); + result.putString(RESULT_TEXT, sCachedText); + result.putBoolean(RESULT_OK, sCachedText != null); + break; + case METHOD_ACTIVITY_READY: + result.putBoolean(RESULT_OK, isActivityReady(context)); + break; + case METHOD_GET_EDITOR_TEXT: + result.putString(RESULT_TEXT, getEditorText(context)); + result.putBoolean(RESULT_HAS_STRIKETHROUGH_SPAN, + SecurePasteActivity.editorHasStrikethroughSpan()); + result.putBoolean(RESULT_OK, isActivityReady(context)); + break; + case METHOD_ENABLE_RECORDING_CONTENT_RECEIVER: + result.putBoolean(RESULT_OK, + SecurePasteActivity.enableRecordingContentReceiver()); + break; + case METHOD_GET_RECEIVED_CONTENT: + readReceivedContent(result); + break; + case METHOD_REQUEST_FOCUS: + result.putBoolean(RESULT_OK, requestEditorFocus(context)); + break; + case METHOD_CLEAR_FOCUS: + result.putBoolean(RESULT_OK, clearEditorFocus(context)); + break; + case METHOD_CACHED_PASTE: + result.putBoolean(RESULT_OK, SecurePasteActivity.pasteText(sCachedText)); + result.putString(RESULT_TEXT, SecurePasteActivity.getEditorText()); + break; + case METHOD_READ_WRITER_URI: + readUri(context, SecurePasteUriProvider.getUri(WRITER_PACKAGE), result); + break; + case METHOD_READ_WRITER_INTENT_URI: + readUri(context, SecurePasteUriProvider.getIntentUri(WRITER_PACKAGE), result); + break; + case METHOD_RESET_DRAG_RESULT: + SecurePasteActivity.resetDragResult(); + result.putBoolean(RESULT_OK, true); + break; + case METHOD_GET_DRAG_RESULT: + readDragResult(result); + break; + case METHOD_SET_JETPACK_COMPOSE_FIELD_MODE: + result.putBoolean(RESULT_OK, setJetpackComposeFieldMode(context, + getString(extras, EXTRA_MODE, "value"))); + break; + case METHOD_ACCESSIBILITY_CONNECTED: + result.putBoolean(RESULT_OK, sAccessibilityConnected); + break; + case METHOD_ACCESSIBILITY_PASTE: + result.putBoolean(RESULT_OK, + SecurePasteAccessibilityServiceBase.performPasteOnFocusedNode()); + break; + case METHOD_IME_READY: + result.putBoolean(RESULT_OK, invokeImeBoolean("isReady")); + break; + case METHOD_IME_PASTE: + result.putBoolean(RESULT_OK, invokeImeBoolean("requestPaste")); + break; + case METHOD_IME_RETAIN_INPUT_CONNECTION: + result.putBoolean(RESULT_OK, + invokeImeBoolean("retainCurrentInputConnection")); + break; + case METHOD_IME_PASTE_RETAINED: + result.putBoolean(RESULT_OK, + invokeImeBoolean("requestPasteFromRetainedInputConnection")); + break; + default: + result.putBoolean(RESULT_OK, false); + result.putString(RESULT_EXCEPTION, "Unknown method " + method); + } + } catch (Throwable t) { + result.putBoolean(RESULT_OK, false); + result.putString(RESULT_EXCEPTION, t.toString()); + } + return result; + } + + public static void setAccessibilityConnected(boolean connected) { + sAccessibilityConnected = connected; + } + + public static String readClipboardText(Context context) { + final ClipData clip = clipboard(context).getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) { + return null; + } + final CharSequence text = clip.getItemAt(0).coerceToText(context); + return text == null ? null : text.toString(); + } + + private static boolean isActivityReady(Context context) throws Exception { + if (isJetpackComposeApp(context)) { + return (Boolean) invokeJetpackCompose("isReady"); + } + return SecurePasteActivity.isReady(); + } + + private static String getEditorText(Context context) throws Exception { + if (isJetpackComposeApp(context)) { + return (String) invokeJetpackCompose("getEditorText"); + } + return SecurePasteActivity.getEditorText(); + } + + private static boolean requestEditorFocus(Context context) throws Exception { + if (isJetpackComposeApp(context)) { + return (Boolean) invokeJetpackCompose("requestEditorFocus"); + } + return SecurePasteActivity.requestEditorFocus(); + } + + private static boolean clearEditorFocus(Context context) throws Exception { + if (isJetpackComposeApp(context)) { + return (Boolean) invokeJetpackCompose("clearEditorFocus"); + } + return SecurePasteActivity.clearEditorFocus(); + } + + private static boolean setJetpackComposeFieldMode(Context context, String mode) + throws Exception { + if (!isJetpackComposeApp(context)) { + return false; + } + return (Boolean) invokeJetpackCompose("setFieldMode", + new Class[] {String.class}, mode); + } + + private static boolean isJetpackComposeApp(Context context) { + return JETPACK_COMPOSE_PACKAGE.equals(context.getPackageName()); + } + + private static Object invokeJetpackCompose(String methodName) throws Exception { + return invokeJetpackCompose(methodName, new Class[0]); + } + + private static Object invokeJetpackCompose(String methodName, Class[] parameterTypes, + Object... args) throws Exception { + final Class cls = Class.forName(JETPACK_COMPOSE_ACTIVITY); + final Method method = cls.getDeclaredMethod(methodName, parameterTypes); + return method.invoke(null, args); + } + + private static void setTextClip(Context context, String text) { + clipboard(context).setPrimaryClip(ClipData.newPlainText(TEXT_CLIP_LABEL, text)); + } + + private static void setHtmlClip(Context context, String text, String html) { + clipboard(context).setPrimaryClip(ClipData.newHtmlText( + "secure-paste-html-label", text, html)); + } + + private static void setStyledTextClip(Context context, String text) { + // Shape from cts/tests/tests/widget/src/android/widget/cts/TextViewReceiveContentTest.java: + // CtsWidgetTestCases:TextViewReceiveContentTest#testDefaultReceiver_onReceive_styledText. + // Use StrikethroughSpan instead of CTS's UnderlineSpan because an active input method can + // add UnderlineSpan to composing text after paste. + final SpannableString styledText = new SpannableString(text); + styledText.setSpan(new StrikethroughSpan(), 0, styledText.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + clipboard(context).setPrimaryClip(ClipData.newPlainText( + "secure-paste-styled-label", styledText)); + } + + private static void setIntentClip(Context context) { + // Shape from cts/tests/tests/content/src/android/content/cts/ClipboardManagerTest.java: + // CtsContentTestCases:ClipboardManagerTest#testSetPrimaryClip_intent. + clipboard(context).setPrimaryClip(ClipData.newIntent( + "secure-paste-intent-label", new Intent(INTENT_CLIP_ACTION))); + } + + private static void setUriClip(Context context, String text) { + final Uri uri = SecurePasteUriProvider.getUri(context.getPackageName()); + final ClipData clip = new ClipData("secure-paste-uri-label", + new String[] {ClipDescription.MIMETYPE_TEXT_PLAIN, "text/uri-list"}, + new ClipData.Item(text, null, uri)); + clipboard(context).setPrimaryClip(clip); + } + + private static void setUriOnlyClip(Context context) { + // Shape from cts/tests/tests/content/src/android/content/cts/ClipboardManagerTest.java: + // CtsContentTestCases:ClipboardManagerTest#testSetPrimaryClip_rawUri. A content URI also + // exercises the + // temporary URI permission needed to coerce the item to text during Paste. + final Uri uri = SecurePasteUriProvider.getUri(context.getPackageName()); + clipboard(context).setPrimaryClip(ClipData.newRawUri( + "secure-paste-uri-only-label", uri)); + } + + private static void setComplexItemClip(Context context, String text) { + // Shape from cts/tests/tests/content/src/android/content/cts/ClipboardManagerTest.java: + // CtsContentTestCases:ClipboardManagerTest#testSetPrimaryClip_complexItem. + // The Intent data uses a second content URI to cover both clipboard URI grant branches. + final Uri uri = SecurePasteUriProvider.getUri(context.getPackageName()); + final Intent intent = new Intent(INTENT_CLIP_ACTION) + .setData(SecurePasteUriProvider.getIntentUri(context.getPackageName())); + final ClipDescription description = new ClipDescription("secure-paste-complex-label", + new String[] { + ClipDescription.MIMETYPE_TEXT_PLAIN, + ClipDescription.MIMETYPE_TEXT_INTENT, + ClipDescription.MIMETYPE_TEXT_URILIST, + }); + clipboard(context).setPrimaryClip(new ClipData( + description, new ClipData.Item(text, intent, uri))); + } + + private static void setMultipleTextItemsClip(Context context) { + // Shape from cts/tests/tests/widget/src/android/widget/cts/TextViewReceiveContentTest.java: + // CtsWidgetTestCases:TextViewReceiveContentTest#testDefaultReceiver_onReceive_multipleItemsInClipData. + final ClipData clip = ClipData.newPlainText("secure-paste-multiple-label", "ONE"); + clip.addItem(new ClipData.Item("TWO")); + clip.addItem(new ClipData.Item("THREE")); + clipboard(context).setPrimaryClip(clip); + } + + private static void setUnsupportedMimeTypeClip(Context context) { + // Shape from cts/tests/tests/widget/src/android/widget/cts/TextViewReceiveContentTest.java: + // CtsWidgetTestCases:TextViewReceiveContentTest#testPaste_customReceiver_unsupportedMimeType. + final ClipData clip = new ClipData("secure-paste-unsupported-label", + new String[] {"video/mp4"}, + new ClipData.Item("text", "html", null, + SecurePasteUriProvider.getUri(context.getPackageName()))); + clipboard(context).setPrimaryClip(clip); + } + + private static void readClip(Context context, Bundle result) { + try { + final ClipboardManager clipboard = clipboard(context); + final ClipData clip = clipboard.getPrimaryClip(); + result.putBoolean(RESULT_OK, clip != null); + result.putBoolean(RESULT_HAS_CLIP, clipboard.hasPrimaryClip()); + result.putBoolean(RESULT_HAS_TEXT, clipboard.hasText()); + final ClipDescription description = clipboard.getPrimaryClipDescription(); + if (description != null) { + final CharSequence label = description.getLabel(); + result.putString(RESULT_DESCRIPTION, label == null ? null : label.toString()); + result.putStringArray(RESULT_MIME_TYPES, mimeTypes(description)); + result.putLong(RESULT_TIMESTAMP, description.getTimestamp()); + result.putBoolean(RESULT_IS_STYLED_TEXT, description.isStyledText()); + } + if (clip != null && clip.getItemCount() > 0) { + final CharSequence text = clip.getItemAt(0).coerceToText(context); + result.putString(RESULT_TEXT, text == null ? null : text.toString()); + result.putInt(RESULT_COUNT, clip.getItemCount()); + } + } catch (Throwable t) { + result.putBoolean(RESULT_OK, false); + result.putString(RESULT_EXCEPTION, t.toString()); + } + } + + private static void readUri(Context context, Uri uri, Bundle result) throws IOException { + try (InputStream in = context.getContentResolver().openInputStream(uri)) { + if (in == null) { + result.putBoolean(RESULT_OK, false); + return; + } + result.putString(RESULT_TEXT, + new String(in.readAllBytes(), StandardCharsets.UTF_8)); + result.putBoolean(RESULT_OK, true); + } + } + + private static void readReceivedContent(Bundle result) { + final ContentInfo content = SecurePasteActivity.getReceivedContent(); + if (content == null) { + result.putBoolean(RESULT_OK, false); + return; + } + + final ClipData clip = content.getClip(); + final ClipDescription description = clip.getDescription(); + result.putBoolean(RESULT_OK, true); + result.putInt(RESULT_SOURCE, content.getSource()); + result.putInt(RESULT_COUNT, clip.getItemCount()); + result.putStringArray(RESULT_MIME_TYPES, mimeTypes(description)); + if (clip.getItemCount() > 0) { + final ClipData.Item item = clip.getItemAt(0); + final CharSequence text = item.getText(); + result.putString(RESULT_TEXT, text == null ? null : text.toString()); + result.putString(RESULT_HTML, item.getHtmlText()); + final Uri uri = item.getUri(); + result.putString(RESULT_URI, uri == null ? null : uri.toString()); + } + } + + private static String[] mimeTypes(ClipDescription description) { + final String[] mimeTypes = new String[description.getMimeTypeCount()]; + for (int i = 0; i < description.getMimeTypeCount(); i++) { + mimeTypes[i] = description.getMimeType(i); + } + return mimeTypes; + } + + private static void readDragResult(Bundle result) { + final SecurePasteActivity.DragResult dragResult = SecurePasteActivity.getDragResult(); + result.putBoolean(RESULT_OK, dragResult.started); + result.putBoolean(RESULT_DRAG_START_RESULT, dragResult.startDragResult); + result.putBoolean(RESULT_DRAG_STARTED_HAS_CLIP_DATA, + dragResult.startedHasClipData); + result.putString(RESULT_DRAG_STARTED_TEXT, dragResult.startedText); + result.putString(RESULT_DESCRIPTION, dragResult.startedLabel); + result.putStringArray(RESULT_MIME_TYPES, dragResult.startedMimeTypes); + result.putString(RESULT_DRAG_STARTED_EXTRA_VALUE, dragResult.startedExtraValue); + result.putBoolean(RESULT_DRAG_DROPPED, dragResult.dropped); + result.putBoolean(RESULT_DRAG_DROP_HAS_CLIP_DATA, dragResult.dropHasClipData); + result.putString(RESULT_DRAG_DROP_TEXT, dragResult.dropText); + result.putBoolean(RESULT_DRAG_ENDED, dragResult.ended); + result.putBoolean(RESULT_DRAG_DROP_RESULT, dragResult.dropResult); + } + + private static void registerListener(Context context) { + if (sListener != null) { + return; + } + sListener = () -> { + sCachedText = null; + sListenerCount.incrementAndGet(); + }; + clipboard(context).addPrimaryClipChangedListener(sListener); + } + + private static ClipboardManager clipboard(Context context) { + return context.getSystemService(ClipboardManager.class); + } + + private static String getString(Bundle extras, String key, String fallback) { + if (extras == null) { + return fallback; + } + final String value = extras.getString(key); + return value == null ? fallback : value; + } + + private static boolean invokeImeBoolean(String methodName) throws Exception { + final Class cls = Class.forName("grapheneos.securepaste.ime.SecurePasteImeService"); + final Method method = cls.getDeclaredMethod(methodName); + return (Boolean) method.invoke(null); + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + return null; + } + + @Override + public String getType(Uri uri) { + return "text/plain"; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } +} diff --git a/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteKeyEventActivity.java b/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteKeyEventActivity.java new file mode 100644 index 0000000000000..0b5ab257634a1 --- /dev/null +++ b/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteKeyEventActivity.java @@ -0,0 +1,220 @@ +package grapheneos.securepaste.helper; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.graphics.Insets; +import android.os.Bundle; +import android.os.RemoteCallback; +import android.view.KeyEvent; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputConnectionWrapper; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; + +public class SecurePasteKeyEventActivity extends Activity { + public static final String NON_EDITOR_DESCRIPTION = "Secure Paste Key Target"; + public static final String EDITOR_DESCRIPTION = "Secure Paste Key Editor"; + public static final String RESULT_DESCRIPTION = "Secure Paste Key Result"; + public static final String EXTRA_READ_CLIPBOARD_ON_SHORTCUT = "readClipboardOnShortcut"; + public static final String EXTRA_CLIPBOARD_READ_DELAY_MILLIS = "clipboardReadDelayMillis"; + public static final String EXTRA_CLIPBOARD_READ_DEVICE_IDS = "clipboardReadDeviceIds"; + public static final String EXTRA_CLIP_TEXT_TO_SET = "clipTextToSet"; + public static final String EXTRA_RESULT_RECEIVER = "resultReceiver"; + public static final String RESULT_CLIP_TEXTS = "clipTexts"; + public static final String RESULT_DEVICE_ID = "deviceId"; + public static final String RESULT_HAS_WINDOW_FOCUS = "hasWindowFocus"; + + private TextView mResult; + private String mLastKey = "none"; + private String mLastClipText; + private boolean mReadClipboardOnShortcut; + private long mClipboardReadDelayMillis; + private int[] mClipboardReadDeviceIds; + private RemoteCallback mResultReceiver; + private String mClipTextToSet; + private boolean mInputConnectionCreated; + private int mContextMenuActionCount; + private int mLastContextMenuAction; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); + mReadClipboardOnShortcut = getIntent().getBooleanExtra( + EXTRA_READ_CLIPBOARD_ON_SHORTCUT, true); + mClipboardReadDelayMillis = getIntent().getLongExtra( + EXTRA_CLIPBOARD_READ_DELAY_MILLIS, 0); + mClipboardReadDeviceIds = getIntent().getIntArrayExtra(EXTRA_CLIPBOARD_READ_DEVICE_IDS); + mResultReceiver = getIntent().getParcelableExtra( + EXTRA_RESULT_RECEIVER, RemoteCallback.class); + mClipTextToSet = getIntent().getStringExtra(EXTRA_CLIP_TEXT_TO_SET); + + final LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + final int contentPadding = 24; + root.setPadding(contentPadding, contentPadding, contentPadding, contentPadding); + // UiAutomator ignores nodes outside the interactive region, so inset the test controls + // from system bars and display cutouts when edge-to-edge is enforced. + root.setOnApplyWindowInsetsListener((v, windowInsets) -> { + final Insets insets = windowInsets.getInsets( + WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()); + v.setPadding(contentPadding + insets.left, contentPadding + insets.top, + contentPadding + insets.right, contentPadding + insets.bottom); + return windowInsets; + }); + + mResult = new TextView(this); + mResult.setContentDescription(RESULT_DESCRIPTION); + root.addView(mResult); + + final Button nonEditor = new Button(this); + nonEditor.setText(NON_EDITOR_DESCRIPTION); + nonEditor.setContentDescription(NON_EDITOR_DESCRIPTION); + nonEditor.setFocusableInTouchMode(true); + nonEditor.setOnClickListener(v -> v.requestFocus()); + root.addView(nonEditor); + + final EditText editor = new RecordingEditText(); + editor.setContentDescription(EDITOR_DESCRIPTION); + editor.setMinLines(4); + root.addView(editor); + + setContentView(root); + updateResult(); + nonEditor.requestFocus(); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + // Wait until this app is associated with the virtual device before selecting its clipboard. + if (hasFocus && mClipTextToSet != null) { + getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText( + SecurePasteCommandProvider.TEXT_CLIP_LABEL, mClipTextToSet)); + mClipTextToSet = null; + } + sendResult(null); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + final String key = getTestShortcut(event); + if (key == null) { + return super.dispatchKeyEvent(event); + } + if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { + mLastKey = key; + if (!mReadClipboardOnShortcut) { + updateResult(); + } else if (mClipboardReadDelayMillis > 0) { + // Model a paste handler which probes availability before posting its payload work. + probeClipboardMetadata(); + updateResult(); + mResult.postDelayed(this::readClipboardAndUpdateResult, + mClipboardReadDelayMillis); + } else { + readClipboardAndUpdateResult(); + } + } + return true; + } + + private void probeClipboardMetadata() { + final ClipboardManager clipboard = getSystemService(ClipboardManager.class); + clipboard.hasPrimaryClip(); + clipboard.hasText(); + clipboard.getPrimaryClipDescription(); + } + + private void readClipboardAndUpdateResult() { + final int[] deviceIds = mClipboardReadDeviceIds == null + ? new int[] {getDeviceId()} : mClipboardReadDeviceIds; + final String[] clipTexts = new String[deviceIds.length]; + for (int i = 0; i < deviceIds.length; i++) { + clipTexts[i] = SecurePasteCommandProvider.readClipboardText( + createDeviceContext(deviceIds[i])); + } + mLastClipText = clipTexts[0]; + updateResult(); + sendResult(clipTexts); + } + + private String getTestShortcut(KeyEvent event) { + return switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_V -> { + if (event.hasModifiers(KeyEvent.META_CTRL_ON)) { + yield "CTRL_V"; + } + if (event.hasModifiers(KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON)) { + yield "CTRL_SHIFT_V"; + } + if (event.hasModifiers(KeyEvent.META_CTRL_ON | KeyEvent.META_ALT_ON)) { + yield "CTRL_ALT_V"; + } + yield null; + } + case KeyEvent.KEYCODE_INSERT -> event.hasModifiers(KeyEvent.META_SHIFT_ON) + ? "SHIFT_INSERT" : null; + case KeyEvent.KEYCODE_PASTE -> event.hasNoModifiers() ? "PASTE" : null; + default -> null; + }; + } + + private void updateResult() { + mResult.setText("key=" + mLastKey + + ";clip=" + mLastClipText + + ";inputConnectionCreated=" + mInputConnectionCreated + + ";contextActions=" + mContextMenuActionCount + + ";lastContextAction=" + mLastContextMenuAction); + } + + private void sendResult(String[] clipTexts) { + if (mResultReceiver != null) { + final Bundle result = new Bundle(); + result.putInt(RESULT_DEVICE_ID, getDeviceId()); + result.putBoolean(RESULT_HAS_WINDOW_FOCUS, hasWindowFocus()); + if (clipTexts != null) { + result.putStringArray(RESULT_CLIP_TEXTS, clipTexts); + } + mResultReceiver.sendResult(result); + } + } + + private final class RecordingEditText extends EditText { + RecordingEditText() { + super(SecurePasteKeyEventActivity.this); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + final InputConnection target = super.onCreateInputConnection(outAttrs); + if (target == null) { + return null; + } + mInputConnectionCreated = true; + updateResult(); + return new InputConnectionWrapper(target, false) { + @Override + public boolean performContextMenuAction(int id) { + if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) { + mContextMenuActionCount++; + mLastContextMenuAction = id; + updateResult(); + } + if (id == android.R.id.pasteAsPlainText) { + return false; + } + return super.performContextMenuAction(id); + } + }; + } + } +} diff --git a/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteUriProvider.java b/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteUriProvider.java new file mode 100644 index 0000000000000..c47566fadf2ed --- /dev/null +++ b/tests/SecurePasteTests/helper/common/src/grapheneos/securepaste/helper/SecurePasteUriProvider.java @@ -0,0 +1,89 @@ +package grapheneos.securepaste.helper; + +import android.content.ClipDescription; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public final class SecurePasteUriProvider extends ContentProvider { + public static final String DATA = "secure-paste-uri-provider-data"; + + public static Uri getUri(String packageName) { + return getUri(packageName, "clip"); + } + + static Uri getIntentUri(String packageName) { + return getUri(packageName, "intent-clip"); + } + + private static Uri getUri(String packageName, String path) { + return new Uri.Builder() + .scheme("content") + .authority(packageName + ".uri") + .path(path) + .build(); + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + if (!"r".equals(mode)) { + throw new FileNotFoundException("Unsupported mode " + mode); + } + try { + final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + new Thread(() -> { + try (ParcelFileDescriptor.AutoCloseOutputStream out = + new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])) { + out.write(DATA.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ignored) { + } + }, "SecurePasteUriPipe").start(); + return pipe[0]; + } catch (IOException e) { + throw new FileNotFoundException(e.toString()); + } + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + return null; + } + + @Override + public String getType(Uri uri) { + return "text/plain"; + } + + @Override + public String[] getStreamTypes(Uri uri, String mimeTypeFilter) { + return ClipDescription.compareMimeTypes("text/plain", mimeTypeFilter) + ? new String[] {"text/plain"} : null; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } +} diff --git a/tests/SecurePasteTests/res/xml/accessibility_service.xml b/tests/SecurePasteTests/res/xml/accessibility_service.xml new file mode 100644 index 0000000000000..1b365ef2538d0 --- /dev/null +++ b/tests/SecurePasteTests/res/xml/accessibility_service.xml @@ -0,0 +1,8 @@ + + diff --git a/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteAccessibilityService.java b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteAccessibilityService.java new file mode 100644 index 0000000000000..224a96b6d6b31 --- /dev/null +++ b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteAccessibilityService.java @@ -0,0 +1,6 @@ +package grapheneos.securepaste; + +import grapheneos.securepaste.helper.SecurePasteAccessibilityServiceBase; + +public class SecurePasteAccessibilityService extends SecurePasteAccessibilityServiceBase { +} diff --git a/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteAccessibilityTest.java b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteAccessibilityTest.java new file mode 100644 index 0000000000000..3c31f7cd95a44 --- /dev/null +++ b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteAccessibilityTest.java @@ -0,0 +1,64 @@ +package grapheneos.securepaste; + +import static com.google.common.truth.Truth.assertThat; + +import grapheneos.securepaste.helper.SecurePasteCommandProvider; + +import org.junit.Test; + +public class SecurePasteAccessibilityTest extends SecurePasteTestBase { + @Test + public void blockedPackage_accessibilityActionPasteSucceeds() { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + enableAccessibilityService(); + + assertThat(call(TEST_PKG, SecurePasteCommandProvider.METHOD_ACCESSIBILITY_PASTE) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + + waitForEditorText(READER, TEXT_A); + } + + @Test + public void blockedPackage_accessibilityPasteDoesNotEnableDirectRead() { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + enableAccessibilityService(); + + assertThat(call(TEST_PKG, SecurePasteCommandProvider.METHOD_ACCESSIBILITY_PASTE) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + waitForEditorText(READER, TEXT_A); + sleep(PASTE_GRANT_EXPIRY_WAIT_MILLIS); + + assertDirectReadDenied(READER); + } + + @Test + public void accessibilityPaste_withoutFocusedEditorDenied() { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + call(READER, SecurePasteCommandProvider.METHOD_CLEAR_FOCUS); + mDevice.pressHome(); + enableAccessibilityService(); + + assertThat(call(TEST_PKG, SecurePasteCommandProvider.METHOD_ACCESSIBILITY_PASTE) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isFalse(); + } + + @Test + public void accessibilityPaste_usesFocusedWindowInsteadOfStaleEditorInfo() { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + blockPackageClipboardRead(EDIT_TEXT); + launchHelperActivity(READER); + enableAccessibilityService(); + launchHelperActivity(EDIT_TEXT); + + assertThat(call(TEST_PKG, SecurePasteCommandProvider.METHOD_ACCESSIBILITY_PASTE) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + waitForEditorText(EDIT_TEXT, TEXT_A); + } +} diff --git a/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteCompatibilityTest.java b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteCompatibilityTest.java new file mode 100644 index 0000000000000..8e5c28ff075c6 --- /dev/null +++ b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteCompatibilityTest.java @@ -0,0 +1,205 @@ +package grapheneos.securepaste; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.ClipDescription; +import android.os.Bundle; + +import grapheneos.securepaste.helper.SecurePasteActivity; +import grapheneos.securepaste.helper.SecurePasteCommandProvider; + +import org.junit.Test; + +import java.util.Arrays; + +public class SecurePasteCompatibilityTest extends SecurePasteTestBase { + @Test + public void blockedPackage_imePastePolicy() { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + enableSecurePasteIme(); + + assertThat(call(IME, SecurePasteCommandProvider.METHOD_IME_PASTE) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + + waitForEditorText(READER, TEXT_A); + } + + @Test + public void blockedPackage_defaultImeDoesNotBecomeBroadBypass() { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + enableSecurePasteIme(); + + assertThat(call(IME, SecurePasteCommandProvider.METHOD_IME_PASTE) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + waitForEditorText(READER, TEXT_A); + sleep(PASTE_GRANT_EXPIRY_WAIT_MILLIS); + + assertDirectReadDenied(READER); + } + + @Test + public void staleImeInputConnectionDoesNotGrantCurrentApp() { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + blockPackageClipboardRead(EDIT_TEXT); + enableSecurePasteIme(); + waitForProviderResult(IME, + SecurePasteCommandProvider.METHOD_IME_RETAIN_INPUT_CONNECTION); + + launchHelperActivity(EDIT_TEXT); + waitForProviderResult(IME, + SecurePasteCommandProvider.METHOD_IME_PASTE_RETAINED); + + assertDirectReadDenied(EDIT_TEXT); + } + + @Test + public void blockedPackage_customToolbarNonFrameworkPasteLabelDenied() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(CUSTOM_TOOLBAR); + launchHelperActivity(CUSTOM_TOOLBAR); + focusEditor(); + longClickEditor(); + + clickFloatingToolbarItemOrFail("Non-framework paste"); + + assertThat(getEditorText(CUSTOM_TOOLBAR)).doesNotContain(TEXT_A); + assertDirectReadDenied(CUSTOM_TOOLBAR); + } + + @Test + public void systemSelectionToolbarFeatureFlagEnabled() { + assertThat(systemSelectionToolbarFlagEnabled()).isTrue(); + } + + @Test + public void blockedPackage_customToolbarOverflowPasteTitleGrants() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(CUSTOM_TOOLBAR); + launchHelperActivity(CUSTOM_TOOLBAR); + focusEditor(); + longClickEditor(); + + clickFloatingToolbarOverflowItemOrFail(getPasteLabel()); + + waitForEditorText(CUSTOM_TOOLBAR, TEXT_A); + sleep(PASTE_GRANT_EXPIRY_WAIT_MILLIS); + assertDirectReadDenied(CUSTOM_TOOLBAR); + } + + @Test + public void sharedUidPendingGrantScope() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(SHARED_A); + blockPackageClipboardRead(SHARED_B); + + launchHelperActivity(SHARED_B); + launchHelperActivity(SHARED_A); + focusEditor(); + longClickEditor(); + + assertThat(clickFloatingToolbarItem(getPasteLabel())).isTrue(); + + assertDirectReadAllowed(SHARED_B, TEXT_A); + waitForEditorText(SHARED_A, TEXT_A); + } + + @Test + public void defaultIme_immutableClipboardAllowIsExplicit() { + writerSetsText(TEXT_A); + blockPackageClipboardRead(IME); + enableSecurePasteIme(); + + assertDirectReadAllowed(IME, TEXT_A); + } + + @Test + public void systemAppImmutableAllowIsExplicit() { + assertSystemAppPreinstalled(SYSTEM); + writerSetsText(TEXT_A); + blockPackageClipboardRead(SYSTEM); + launchHelperActivity(SYSTEM); + + assertDirectReadAllowed(SYSTEM, TEXT_A); + } + + @Test + public void dragStartedMetadataPolicy() throws Exception { + blockPackageClipboardRead(WRITER); + launchHelperActivity(WRITER); + assertThat(call(WRITER, SecurePasteCommandProvider.METHOD_RESET_DRAG_RESULT) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + + dragByDescription(SecurePasteActivity.DRAG_SOURCE_DESCRIPTION, + SecurePasteActivity.DRAG_TARGET_DESCRIPTION); + + final Bundle result = waitForDragResult(); + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_DRAG_START_RESULT)) + .isTrue(); + + // ACTION_DRAG_STARTED carries ClipDescription metadata for compatibility, but not the + // ClipData payload. The payload is only delivered to the user-selected ACTION_DROP target. + assertThat(result.getString(SecurePasteCommandProvider.RESULT_DESCRIPTION)) + .isEqualTo(SecurePasteActivity.DRAG_LABEL); + final String[] mimeTypes = result.getStringArray( + SecurePasteCommandProvider.RESULT_MIME_TYPES); + assertThat(mimeTypes).isNotNull(); + assertThat(Arrays.asList(mimeTypes)).contains(ClipDescription.MIMETYPE_TEXT_PLAIN); + assertThat(result.getString( + SecurePasteCommandProvider.RESULT_DRAG_STARTED_EXTRA_VALUE)) + .isEqualTo(SecurePasteActivity.DRAG_EXTRA_VALUE); + assertThat(result.getBoolean( + SecurePasteCommandProvider.RESULT_DRAG_STARTED_HAS_CLIP_DATA)).isFalse(); + assertThat(result.getString( + SecurePasteCommandProvider.RESULT_DRAG_STARTED_TEXT)).isNull(); + + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_DRAG_DROPPED)).isTrue(); + assertThat(result.getBoolean( + SecurePasteCommandProvider.RESULT_DRAG_DROP_HAS_CLIP_DATA)).isTrue(); + assertThat(result.getString(SecurePasteCommandProvider.RESULT_DRAG_DROP_TEXT)) + .isEqualTo(SecurePasteActivity.DRAG_TEXT); + } + + @Test + public void blockedPackage_keycodePastePolicy() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + focusEditor(); + + sendPasteKey(); + + waitForEditorText(READER, TEXT_A); + } + + @Test + public void blockedPackage_shiftInsertPastePolicy() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + focusEditor(); + + sendShiftInsert(); + + waitForEditorText(READER, TEXT_A); + } + + private Bundle waitForDragResult() { + final long deadline = System.currentTimeMillis() + 10_000; + Bundle result = null; + while (System.currentTimeMillis() < deadline) { + result = call(WRITER, SecurePasteCommandProvider.METHOD_GET_DRAG_RESULT); + if (result.getBoolean(SecurePasteCommandProvider.RESULT_OK) + && result.getBoolean(SecurePasteCommandProvider.RESULT_DRAG_DROPPED)) { + return result; + } + sleep(100); + } + assertThat(result).isNotNull(); + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_DRAG_DROPPED)).isTrue(); + return result; + } +} diff --git a/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteDeviceTest.java b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteDeviceTest.java new file mode 100644 index 0000000000000..33024f168f5b7 --- /dev/null +++ b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteDeviceTest.java @@ -0,0 +1,361 @@ +package grapheneos.securepaste; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Intent; +import android.os.Bundle; +import android.view.ContentInfo; + +import org.junit.Test; + +import grapheneos.securepaste.helper.SecurePasteCommandProvider; +import grapheneos.securepaste.helper.SecurePasteUriProvider; + +public class SecurePasteDeviceTest extends SecurePasteTestBase { + private static final String HTML_FALLBACK_TEXT = "*secure paste html*"; + private static final String HTML_RENDERED_TEXT = "secure paste html"; + private static final String HTML_MARKUP = "secure paste html"; + private static final String STYLED_TEXT = "secure paste styled text"; + private static final String COMPLEX_ITEM_TEXT = "secure paste complex item"; + private static final String MULTIPLE_ITEMS_TEXT = "ONE\nTWO\nTHREE"; + + @Test + public void blockedPackage_cannotReadForeignTextClip() { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + + assertDirectReadDenied(READER); + } + + @Test + public void allowedPackage_canReadForeignTextClip() { + writerSetsText(TEXT_A); + allowPackageClipboardRead(READER); + launchHelperActivity(READER); + + assertDirectReadAllowed(READER, TEXT_A); + } + + @Test + public void defaultBlockedByGlobalPolicy_cannotReadForeignClip() { + writerSetsText(TEXT_A); + setGlobalClipboardDefault(false); + resetPackageClipboardPolicy(READER); + launchHelperActivity(READER); + + assertDirectReadDenied(READER); + } + + @Test + public void blockedClipOwner_canReadOwnClip() { + blockPackageClipboardRead(WRITER); + launchHelperActivity(WRITER); + final Bundle extras = new Bundle(); + extras.putString(SecurePasteCommandProvider.EXTRA_TEXT, TEXT_A); + + final Bundle result = call(WRITER, + SecurePasteCommandProvider.METHOD_READ_OWN_CLIP, extras); + assertThat(result.getString(SecurePasteCommandProvider.RESULT_EXCEPTION)).isNull(); + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + assertThat(result.getString(SecurePasteCommandProvider.RESULT_TEXT)).contains(TEXT_A); + assertThat(result.getString(SecurePasteCommandProvider.RESULT_DESCRIPTION)) + .isEqualTo(SecurePasteCommandProvider.TEXT_CLIP_LABEL); + } + + @Test + public void blockedPackage_canReadClipFromSharedUid() { + blockPackageClipboardRead(SHARED_A); + blockPackageClipboardRead(SHARED_B); + packageSetsText(SHARED_A, TEXT_A); + launchHelperActivity(SHARED_B); + + assertDirectReadAllowed(SHARED_B, TEXT_A); + } + + @Test + public void blockedPackage_clipChangeInvalidatesCachedPayload() throws Exception { + packageSetsText(WRITER, ""); + allowPackageClipboardRead(CACHE_CLIENT); + launchHelperActivity(CACHE_CLIENT); + final Bundle cached = call(CACHE_CLIENT, SecurePasteCommandProvider.METHOD_CACHE_CLIP); + assertThat(cached.getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + assertThat(cached.getString(SecurePasteCommandProvider.RESULT_TEXT)).isEmpty(); + call(CACHE_CLIENT, SecurePasteCommandProvider.METHOD_REGISTER_LISTENER); + call(CACHE_CLIENT, SecurePasteCommandProvider.METHOD_RESET_LISTENER); + + blockPackageClipboardRead(CACHE_CLIENT); + writerSetsText(TEXT_B); + final long deadline = System.currentTimeMillis() + 5_000; + int listenerCount = 0; + while (listenerCount == 0 && System.currentTimeMillis() < deadline) { + listenerCount = call(CACHE_CLIENT, + SecurePasteCommandProvider.METHOD_GET_LISTENER_COUNT) + .getInt(SecurePasteCommandProvider.RESULT_COUNT); + if (listenerCount == 0) { + sleep(100); + } + } + + assertThat(listenerCount).isGreaterThan(0); + assertDirectReadDenied(CACHE_CLIENT); + assertThat(call(CACHE_CLIENT, SecurePasteCommandProvider.METHOD_CACHED_PASTE) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isFalse(); + + systemToolbarPasteInto(CACHE_CLIENT); + + waitForEditorText(CACHE_CLIENT, TEXT_B); + assertThat(getEditorText(CACHE_CLIENT)).isEqualTo(TEXT_B); + } + + @Test + public void blockedPackage_systemToolbarPasteIntoEditTextSucceeds() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + + systemToolbarPasteInto(READER); + + waitForEditorText(READER, TEXT_A); + } + + @Test + public void blockedPackage_htmlPasteUsesStyledRepresentation() throws Exception { + // Shape from cts/tests/tests/widget/src/android/widget/cts/TextViewReceiveContentTest.java: + // CtsWidgetTestCases:TextViewReceiveContentTest#testDefaultReceiver_onReceive_html. + writerSetsHtml(HTML_FALLBACK_TEXT, HTML_MARKUP); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + assertDirectReadDenied(READER); + + systemToolbarPasteInto(READER); + + waitForEditorText(READER, HTML_RENDERED_TEXT); + } + + @Test + public void blockedPackage_htmlPasteAsPlainTextUsesFallback() throws Exception { + // Shape from cts/tests/tests/widget/src/android/widget/cts/TextViewReceiveContentTest.java: + // CtsWidgetTestCases:TextViewReceiveContentTest#testDefaultReceiver_onReceive_html_convertToPlainText. + // TextView.canPasteAsPlainText() needs its text/html MIME type to offer the action. + writerSetsHtml(HTML_FALLBACK_TEXT, HTML_MARKUP); + blockPackageClipboardRead(READER); + + systemToolbarPasteAsPlainTextInto(READER); + + waitForEditorText(READER, HTML_FALLBACK_TEXT); + } + + @Test + public void blockedPackage_styledTextPastePreservesSpan() throws Exception { + writerSetsStyledText(STYLED_TEXT); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + final Bundle deniedRead = readClipboard(READER); + assertDirectReadDenied(deniedRead); + assertThat(deniedRead.getBoolean(SecurePasteCommandProvider.RESULT_IS_STYLED_TEXT)) + .isTrue(); + + systemToolbarPasteInto(READER); + + waitForEditorText(READER, STYLED_TEXT); + assertThat(call(READER, SecurePasteCommandProvider.METHOD_GET_EDITOR_TEXT) + .getBoolean(SecurePasteCommandProvider.RESULT_HAS_STRIKETHROUGH_SPAN)).isTrue(); + } + + @Test + public void blockedPackage_styledTextPasteAsPlainTextRemovesSpan() throws Exception { + // Shape from cts/tests/tests/widget/src/android/widget/cts/TextViewReceiveContentTest.java: + // CtsWidgetTestCases:TextViewReceiveContentTest#testDefaultReceiver_onReceive_styledText_convertToPlainText. + // TextView.canPasteAsPlainText() needs its styled-text bit to offer the action. + writerSetsStyledText(STYLED_TEXT); + blockPackageClipboardRead(READER); + + systemToolbarPasteAsPlainTextInto(READER); + + waitForEditorText(READER, STYLED_TEXT); + assertThat(call(READER, SecurePasteCommandProvider.METHOD_GET_EDITOR_TEXT) + .getBoolean(SecurePasteCommandProvider.RESULT_HAS_STRIKETHROUGH_SPAN)).isFalse(); + } + + @Test + public void blockedPackage_intentClipPasteSucceeds() throws Exception { + writerSetsIntentClip(); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + assertDirectReadDenied(READER); + final String expected = new Intent(SecurePasteCommandProvider.INTENT_CLIP_ACTION) + .toUri(Intent.URI_INTENT_SCHEME); + + systemToolbarPasteInto(READER); + + waitForEditorText(READER, expected); + } + + @Test + public void blockedPackage_uriOnlyClipPasteGrantsUri() throws Exception { + writerSetsUriOnlyClip(); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + assertDirectReadDenied(READER); + assertUriReadDenied(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + + systemToolbarPasteInto(READER); + + waitForEditorText(READER, SecurePasteUriProvider.DATA); + assertUriReadAllowed(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + assertUriReadDenied(EDIT_TEXT, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + } + + @Test + public void blockedPackage_complexItemPasteGrantsBothUris() throws Exception { + writerSetsComplexItemClip(COMPLEX_ITEM_TEXT); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + assertDirectReadDenied(READER); + assertUriReadDenied(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + assertUriReadDenied(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_INTENT_URI); + + systemToolbarPasteInto(READER); + + waitForEditorText(READER, COMPLEX_ITEM_TEXT); + assertUriReadAllowed(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + assertUriReadAllowed(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_INTENT_URI); + assertUriReadDenied(EDIT_TEXT, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + assertUriReadDenied(EDIT_TEXT, + SecurePasteCommandProvider.METHOD_READ_WRITER_INTENT_URI); + + writerSetsText(TEXT_A); + + assertUriReadDenied(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + assertUriReadDenied(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_INTENT_URI); + } + + @Test + public void blockedPackage_multipleTextItemsPasteAllItems() throws Exception { + writerSetsMultipleTextItemsClip(); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + assertDirectReadDenied(READER); + + systemToolbarPasteInto(READER); + + waitForEditorText(READER, MULTIPLE_ITEMS_TEXT); + } + + @Test + public void blockedPackage_unsupportedMimeTypePasteReachesCustomReceiver() throws Exception { + writerSetsUnsupportedMimeTypeClip(); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + assertDirectReadDenied(READER); + assertThat(call(READER, + SecurePasteCommandProvider.METHOD_ENABLE_RECORDING_CONTENT_RECEIVER) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + + showSystemToolbarForFocusedEditor(READER); + clickFloatingToolbarItemOrFail(getPasteLabel()); + + final Bundle received = call( + READER, SecurePasteCommandProvider.METHOD_GET_RECEIVED_CONTENT); + assertThat(received.getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + assertThat(received.getInt(SecurePasteCommandProvider.RESULT_SOURCE)) + .isEqualTo(ContentInfo.SOURCE_CLIPBOARD); + assertThat(received.getInt(SecurePasteCommandProvider.RESULT_COUNT)).isEqualTo(1); + assertThat(received.getStringArray(SecurePasteCommandProvider.RESULT_MIME_TYPES)) + .asList().containsExactly("video/mp4"); + assertThat(received.getString(SecurePasteCommandProvider.RESULT_TEXT)).isEqualTo("text"); + assertThat(received.getString(SecurePasteCommandProvider.RESULT_HTML)).isEqualTo("html"); + assertThat(received.getString(SecurePasteCommandProvider.RESULT_URI)) + .isEqualTo(SecurePasteUriProvider.getUri(WRITER).toString()); + assertUriReadAllowed(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + assertUriReadDenied(EDIT_TEXT, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + } + + @Test + public void blockedPackage_emptyTextClipPreservesClipboardPresence() { + // Shape from cts/tests/tests/text/src/android/text/cts/ClipboardManagerTest.java: + // CtsTextTestCases:ClipboardManagerTest#testHasText. + writerSetsText(""); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + + assertDirectReadDenied(READER); + } + + @Test + public void blockedPackage_toolbarGrantExpires() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + + systemToolbarPasteInto(READER); + waitForEditorText(READER, TEXT_A); + sleep(PASTE_GRANT_EXPIRY_WAIT_MILLIS); + + assertDirectReadDenied(READER); + } + + @Test + public void blockedPackage_noToolbarAction_noTemporaryGrant() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + focusEditor(); + longClickEditor(); + + assertDirectReadDenied(READER); + } + + @Test + public void blockedPackage_keyboardPastePolicy() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchHelperActivity(READER); + focusEditor(); + + sendCtrlV(); + + waitForEditorText(READER, TEXT_A); + } + + @Test + public void blockedPackage_toolbarPasteUriClipGrantsOnlyPasteTarget() throws Exception { + writerSetsUriClip(URI_TEXT); + blockPackageClipboardRead(READER); + + systemToolbarPasteInto(READER); + + waitForEditorText(READER, URI_TEXT); + assertUriReadAllowed(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + assertUriReadDenied(EDIT_TEXT, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + } + + @Test + public void blockedPackage_pastedUriOutlivesClipboardLease() throws Exception { + writerSetsUriClip(URI_TEXT); + blockPackageClipboardRead(READER); + assertUriReadDenied(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + + systemToolbarPasteInto(READER); + waitForEditorText(READER, URI_TEXT); + sleep(PASTE_GRANT_EXPIRY_WAIT_MILLIS); + + assertDirectReadDenied(READER); + assertUriReadAllowed(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + + writerSetsText(TEXT_A); + + assertUriReadDenied(READER, SecurePasteCommandProvider.METHOD_READ_WRITER_URI); + } + + private void assertUriReadAllowed(String pkg, String method) { + final Bundle result = call(pkg, method); + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + assertThat(result.getString(SecurePasteCommandProvider.RESULT_TEXT)) + .isEqualTo(SecurePasteUriProvider.DATA); + } + + private void assertUriReadDenied(String pkg, String method) { + assertThat(call(pkg, method) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isFalse(); + } +} diff --git a/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteJetpackComposeTest.java b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteJetpackComposeTest.java new file mode 100644 index 0000000000000..f91716dc1e694 --- /dev/null +++ b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteJetpackComposeTest.java @@ -0,0 +1,80 @@ +package grapheneos.securepaste; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Bundle; + +import grapheneos.securepaste.helper.SecurePasteCommandProvider; + +import org.junit.Test; + +public class SecurePasteJetpackComposeTest extends SecurePasteTestBase { + private static final String FIELD_MODE_VALUE = "value"; + private static final String FIELD_MODE_STATE = "state"; + + @Test + public void blockedJetpackCompose_valueBasedMetadataShowsPasteAndGrants() + throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(JETPACK_COMPOSE); + + // Compose checks primaryClipDescription before showing Paste and reads the payload only + // from the Paste callback. + showSystemToolbarForEditor(JETPACK_COMPOSE); + + assertFloatingToolbarItemPresent(getPasteLabel()); + assertDirectReadDenied(JETPACK_COMPOSE); + clickFloatingToolbarItemOrFail(getPasteLabel()); + waitForEditorText(JETPACK_COMPOSE, TEXT_A); + } + + @Test + public void blockedJetpackCompose_stateBasedMetadataShowsPasteAndGrants() + throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(JETPACK_COMPOSE); + launchHelperActivity(JETPACK_COMPOSE); + setJetpackComposeFieldMode(FIELD_MODE_STATE); + + // Use the physical gesture exercised by Compose's state-based text toolbar path. + focusEditor(); + longClickEditor(); + assertFloatingToolbarItemPresent(getPasteLabel()); + assertDirectReadDenied(JETPACK_COMPOSE); + + clickFloatingToolbarItemOrFail(getPasteLabel()); + waitForEditorText(JETPACK_COMPOSE, TEXT_A); + } + + @Test + public void blockedJetpackCompose_valueBasedImePasteGrants() throws Exception { + assertImePasteGrants(FIELD_MODE_VALUE); + } + + @Test + public void blockedJetpackCompose_stateBasedImePasteGrants() throws Exception { + assertImePasteGrants(FIELD_MODE_STATE); + } + + private void assertImePasteGrants(String fieldMode) throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(JETPACK_COMPOSE); + enableSecurePasteIme(JETPACK_COMPOSE); + setJetpackComposeFieldMode(fieldMode); + focusEditor(); + assertDirectReadDenied(JETPACK_COMPOSE); + + waitForProviderResult(IME, SecurePasteCommandProvider.METHOD_IME_PASTE); + + waitForEditorText(JETPACK_COMPOSE, TEXT_A); + } + + private void setJetpackComposeFieldMode(String mode) { + final Bundle extras = new Bundle(); + extras.putString(SecurePasteCommandProvider.EXTRA_MODE, mode); + assertThat(call(JETPACK_COMPOSE, + SecurePasteCommandProvider.METHOD_SET_JETPACK_COMPOSE_FIELD_MODE, extras) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + mDevice.waitForIdle(); + } +} diff --git a/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteKeyboardTest.java b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteKeyboardTest.java new file mode 100644 index 0000000000000..f4ea4cfbd7711 --- /dev/null +++ b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteKeyboardTest.java @@ -0,0 +1,142 @@ +package grapheneos.securepaste; + +import static com.google.common.truth.Truth.assertThat; + +import android.hardware.input.InputGestureData; +import android.hardware.input.InputManager; +import android.hardware.input.KeyGestureEvent; +import android.view.KeyEvent; + +import org.junit.Test; + +public class SecurePasteKeyboardTest extends SecurePasteTestBase { + private static final long DELAYED_CLIPBOARD_READ_MILLIS = 1_500; + + @Test + public void pasteShortcutsCannotBeCustomGestures() { + final InputManager inputManager = mContext.getSystemService(InputManager.class); + assertThat(inputManager).isNotNull(); + final InputGestureData.Trigger[] triggers = { + InputGestureData.createKeyTrigger(KeyEvent.KEYCODE_V, KeyEvent.META_CTRL_ON), + InputGestureData.createKeyTrigger(KeyEvent.KEYCODE_V, + KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON), + InputGestureData.createKeyTrigger(KeyEvent.KEYCODE_INSERT, + KeyEvent.META_SHIFT_ON), + InputGestureData.createKeyTrigger(KeyEvent.KEYCODE_PASTE, 0), + }; + + for (InputGestureData.Trigger trigger : triggers) { + final InputGestureData gesture = new InputGestureData.Builder() + .setTrigger(trigger) + .setKeyGestureType(KeyGestureEvent.KEY_GESTURE_TYPE_HOME) + .build(); + final int result = inputManager.addCustomInputGesture(gesture); + if (result == InputManager.CUSTOM_INPUT_GESTURE_RESULT_SUCCESS) { + inputManager.removeCustomInputGesture(gesture); + } + assertThat(result).isEqualTo( + InputManager.CUSTOM_INPUT_GESTURE_RESULT_ERROR_RESERVED_GESTURE); + } + } + + @Test + public void blockedPackage_keyboardPasteShortcutsReachAppHandler() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchKeyEventActivity(); + focusKeyEventTarget(false); + + sendCtrlV(); + assertAppHandledShortcut("CTRL_V", TEXT_A); + + writerSetsText(TEXT_B); + sendShiftInsert(); + assertAppHandledShortcut("SHIFT_INSERT", TEXT_B); + + writerSetsText(TEXT_A); + sendPasteKey(); + assertAppHandledShortcut("PASTE", TEXT_A); + } + + @Test + public void blockedPackage_ctrlShiftVReachesAppHandlerWithUnsupportedInputConnection() + throws Exception { + writerSetsHtml(TEXT_A, "" + TEXT_A + ""); + blockPackageClipboardRead(READER); + launchKeyEventActivity(); + focusKeyEventTarget(true); + waitForKeyInputConnection(); + + sendCtrlShiftV(); + + assertAppHandledShortcut("CTRL_SHIFT_V", TEXT_A); + } + + @Test + public void blockedPackage_unrelatedShortcutDoesNotGrantClipboardAccess() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchKeyEventActivity(); + focusKeyEventTarget(false); + + sendCtrlAltV(); + + assertAppHandledShortcut("CTRL_ALT_V", "null"); + } + + @Test + public void blockedPackage_keyboardPasteGrantDoesNotSurviveClipboardChange() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchKeyEventActivity(); + focusKeyEventTarget(false); + + sendCtrlV(); + assertAppHandledShortcut("CTRL_V", TEXT_A); + writerSetsText(TEXT_B); + + assertDirectReadDenied(READER); + } + + @Test + public void blockedPackage_delayedKeyboardHandlerReadsAfterMetadataProbe() throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + launchKeyEventActivityWithDelayedClipboardRead(DELAYED_CLIPBOARD_READ_MILLIS); + focusKeyEventTarget(false); + + sendCtrlV(); + + // Some UI toolkits probe clipboard availability before posting their payload work. The + // delayed handler must still complete the Paste action without leaving broad access. + assertAppHandledShortcut("CTRL_V", TEXT_A); + sleep(PASTE_GRANT_EXPIRY_WAIT_MILLIS); + assertDirectReadDenied(READER); + } + + @Test + public void blockedPackage_clipboardChangeInvalidatesUnreadKeyboardPaste() + throws Exception { + writerSetsText(TEXT_A); + blockPackageClipboardRead(READER); + sendKeyboardPasteWithoutReadingClipboard(); + + writerSetsText(TEXT_B); + + assertDirectReadDenied(READER); + } + + private void sendKeyboardPasteWithoutReadingClipboard() throws Exception { + launchKeyEventActivityWithoutClipboardRead(); + focusKeyEventTarget(false); + + sendCtrlV(); + + waitForKeyEventResult("CTRL_V", "null"); + } + + private void assertAppHandledShortcut(String key, String clipText) { + final String result = waitForKeyEventResult(key, clipText); + assertThat(result).contains(";contextActions=0;"); + } +} diff --git a/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteTestBase.java b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteTestBase.java new file mode 100644 index 0000000000000..6a4053031ee43 --- /dev/null +++ b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteTestBase.java @@ -0,0 +1,813 @@ +package grapheneos.securepaste; + +import static android.permission.flags.Flags.systemSelectionToolbarEnabled; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import android.app.Instrumentation; +import android.app.UiAutomation; +import android.content.Context; +import android.content.res.Resources; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.view.KeyEvent; +import android.view.accessibility.AccessibilityNodeInfo; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.uiautomator.By; +import androidx.test.uiautomator.BySelector; +import androidx.test.uiautomator.Configurator; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObject; +import androidx.test.uiautomator.UiObject2; +import androidx.test.uiautomator.UiObjectNotFoundException; +import androidx.test.uiautomator.UiSelector; +import androidx.test.uiautomator.Until; + +import com.android.internal.R; + +import grapheneos.securepaste.helper.SecurePasteActivity; +import grapheneos.securepaste.helper.SecurePasteCommandProvider; +import grapheneos.securepaste.helper.SecurePasteKeyEventActivity; + +import org.junit.After; +import org.junit.Before; + +import java.util.ArrayList; +import java.util.List; + +public abstract class SecurePasteTestBase { + static final String TEST_PKG = "grapheneos.securepaste"; + static final String WRITER = "grapheneos.securepaste.writer"; + static final String READER = "grapheneos.securepaste.reader"; + static final String EDIT_TEXT = "grapheneos.securepaste.edittext"; + static final String JETPACK_COMPOSE = "grapheneos.securepaste.jetpackcompose"; + static final String IME = "grapheneos.securepaste.ime"; + static final String CUSTOM_TOOLBAR = "grapheneos.securepaste.customtoolbar"; + static final String CACHE_CLIENT = "grapheneos.securepaste.cacheclient"; + static final String SHARED_A = "grapheneos.securepaste.shareduid.a"; + static final String SHARED_B = "grapheneos.securepaste.shareduid.b"; + static final String SYSTEM = "grapheneos.securepaste.system"; + + static final String TEXT_A = "secure-paste-text-A"; + static final String TEXT_B = "secure-paste-text-B"; + static final String URI_TEXT = "secure-paste-uri-text"; + // The fixed read window lasts one second from the first payload read. Leave scheduling margin + // before checking that direct reads are denied again. + static final long PASTE_GRANT_EXPIRY_WAIT_MILLIS = 1_500; + + private static final String ACTIVITY = + "grapheneos.securepaste.helper.SecurePasteActivity"; + private static final String JETPACK_COMPOSE_ACTIVITY = + "grapheneos.securepaste.jetpackcompose.SecurePasteJetpackComposeActivity"; + private static final String KEY_EVENT_ACTIVITY = + "grapheneos.securepaste.helper.SecurePasteKeyEventActivity"; + private static final String ACCESSIBILITY_SERVICE = + TEST_PKG + "/" + TEST_PKG + ".SecurePasteAccessibilityService"; + private static final String IME_SERVICE = IME + "/.SecurePasteImeService"; + private static final long UI_OBJECT_TIMEOUT_MILLIS = 5_000; + private static final String[] KNOWN_PACKAGES = { + WRITER, + READER, + EDIT_TEXT, + JETPACK_COMPOSE, + IME, + CUSTOM_TOOLBAR, + CACHE_CLIENT, + SHARED_A, + SHARED_B, + SYSTEM, + }; + // Matches the UiAutomator pattern in: + // frameworks/base/core/tests/coretests/src/android/widget/FloatingToolbarUtils.java + private static final String TOOLBAR_CONTAINER_RES = "floating_popup_container"; + + protected Instrumentation mInstrumentation; + protected Context mContext; + protected UiDevice mDevice; + protected int mUserId; + + private String mOldGlobalClipboardDefault; + private String mOldEnabledAccessibilityServices; + private String mOldAccessibilityEnabled; + private String mOldEnabledInputMethods; + private String mOldDefaultInputMethod; + + @Before + public void setUpSecurePasteBase() throws Exception { + mInstrumentation = InstrumentationRegistry.getInstrumentation(); + mContext = mInstrumentation.getContext(); + mDevice = UiDevice.getInstance(mInstrumentation); + mUserId = mContext.getUserId(); + Configurator.getInstance().setUiAutomationFlags( + UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES); + mDevice.wakeUp(); + shell("wm dismiss-keyguard"); + call(WRITER, SecurePasteCommandProvider.METHOD_CLEAR_CLIP); + resetAllKnownPackageState(); + } + + @After + public void tearDownSecurePasteBase() throws Throwable { + Throwable failure = null; + failure = runCleanup(failure, + () -> call(WRITER, SecurePasteCommandProvider.METHOD_CLEAR_CLIP)); + for (String pkg : KNOWN_PACKAGES) { + failure = runCleanup(failure, () -> resetPackageClipboardPolicy(pkg)); + } + failure = restoreGlobalClipboardDefault(failure); + failure = restoreAccessibilityState(failure); + failure = restoreImeState(failure); + for (String pkg : KNOWN_PACKAGES) { + if (IME.equals(pkg)) { + // It may have been the active IME before the test. + continue; + } + failure = runCleanup(failure, + () -> shell("am force-stop --user " + mUserId + " " + pkg)); + } + failure = runCleanup(failure, () -> mDevice.pressHome()); + if (failure != null) { + throw failure; + } + } + + protected void writerSetsText(String text) { + packageSetsText(WRITER, text); + } + + protected void packageSetsText(String pkg, String text) { + final Bundle extras = new Bundle(); + extras.putString(SecurePasteCommandProvider.EXTRA_TEXT, text); + assertThat(call(pkg, SecurePasteCommandProvider.METHOD_SET_CLIP_TEXT, extras) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + } + + protected void writerSetsHtml(String text, String html) { + final Bundle extras = new Bundle(); + extras.putString(SecurePasteCommandProvider.EXTRA_TEXT, text); + extras.putString(SecurePasteCommandProvider.EXTRA_HTML, html); + assertThat(call(WRITER, SecurePasteCommandProvider.METHOD_SET_CLIP_HTML, extras) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + } + + protected void writerSetsStyledText(String text) { + final Bundle extras = new Bundle(); + extras.putString(SecurePasteCommandProvider.EXTRA_TEXT, text); + assertThat(call(WRITER, SecurePasteCommandProvider.METHOD_SET_CLIP_STYLED_TEXT, extras) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + } + + protected void writerSetsIntentClip() { + assertWriterSetsClip(SecurePasteCommandProvider.METHOD_SET_CLIP_INTENT); + } + + protected void writerSetsUriClip(String text) { + final Bundle extras = new Bundle(); + extras.putString(SecurePasteCommandProvider.EXTRA_TEXT, text); + assertThat(call(WRITER, SecurePasteCommandProvider.METHOD_SET_CLIP_URI, extras) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + } + + protected void writerSetsUriOnlyClip() { + assertWriterSetsClip(SecurePasteCommandProvider.METHOD_SET_CLIP_URI_ONLY); + } + + protected void writerSetsComplexItemClip(String text) { + final Bundle extras = new Bundle(); + extras.putString(SecurePasteCommandProvider.EXTRA_TEXT, text); + assertThat(call(WRITER, SecurePasteCommandProvider.METHOD_SET_CLIP_COMPLEX_ITEM, extras) + .getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + } + + protected void writerSetsMultipleTextItemsClip() { + assertWriterSetsClip(SecurePasteCommandProvider.METHOD_SET_CLIP_MULTIPLE_TEXT_ITEMS); + } + + protected void writerSetsUnsupportedMimeTypeClip() { + assertWriterSetsClip(SecurePasteCommandProvider.METHOD_SET_CLIP_UNSUPPORTED_MIME_TYPE); + } + + private void assertWriterSetsClip(String method) { + assertThat(call(WRITER, method).getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + } + + protected Bundle readClipboard(String pkg) { + return call(pkg, SecurePasteCommandProvider.METHOD_READ_CLIP); + } + + protected void assertDirectReadDenied(String pkg) { + assertDirectReadDenied(readClipboard(pkg)); + } + + protected void assertDirectReadDenied(Bundle result) { + assertThat(result.getString(SecurePasteCommandProvider.RESULT_TEXT)).isNull(); + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_OK)).isFalse(); + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_HAS_CLIP)).isTrue(); + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_HAS_TEXT)).isTrue(); + assertThat(result.containsKey(SecurePasteCommandProvider.RESULT_DESCRIPTION)).isTrue(); + assertThat(result.getString(SecurePasteCommandProvider.RESULT_DESCRIPTION)).isNull(); + assertThat(result.getStringArray(SecurePasteCommandProvider.RESULT_MIME_TYPES)) + .isNotEmpty(); + assertThat(result.getLong(SecurePasteCommandProvider.RESULT_TIMESTAMP)).isGreaterThan(0); + } + + protected void assertDirectReadAllowed(String pkg, String expected) { + final Bundle result = readClipboard(pkg); + assertThat(result.getString(SecurePasteCommandProvider.RESULT_EXCEPTION)).isNull(); + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + assertThat(result.getString(SecurePasteCommandProvider.RESULT_TEXT)).contains(expected); + assertThat(result.getString(SecurePasteCommandProvider.RESULT_DESCRIPTION)) + .isEqualTo(SecurePasteCommandProvider.TEXT_CLIP_LABEL); + } + + protected void blockPackageClipboardRead(String pkg) { + shell("cmd package edit-gos-package-state " + pkg + " " + mUserId + + " add-flag ALLOW_CLIPBOARD_READ_NON_DEFAULT" + + " clear-flag ALLOW_CLIPBOARD_READ"); + } + + protected void allowPackageClipboardRead(String pkg) { + shell("cmd package edit-gos-package-state " + pkg + " " + mUserId + + " add-flag ALLOW_CLIPBOARD_READ_NON_DEFAULT" + + " add-flag ALLOW_CLIPBOARD_READ"); + } + + protected void resetPackageClipboardPolicy(String pkg) { + shell("cmd package edit-gos-package-state " + pkg + " " + mUserId + + " clear-flag ALLOW_CLIPBOARD_READ_NON_DEFAULT" + + " clear-flag ALLOW_CLIPBOARD_READ"); + } + + protected void setGlobalClipboardDefault(boolean allowed) { + if (mOldGlobalClipboardDefault == null) { + mOldGlobalClipboardDefault = shell("settings get global " + + Settings.Global.ALLOW_CLIPBOARD_READ_BY_DEFAULT).trim(); + } + shell("settings put global " + Settings.Global.ALLOW_CLIPBOARD_READ_BY_DEFAULT + " " + + (allowed ? "1" : "0")); + } + + protected void assertSystemAppPreinstalled(String pkg) { + final String path = shell("pm path --user " + mUserId + " " + pkg).trim(); + assertThat(path).startsWith("package:/system/"); + } + + protected void launchHelperActivity(String pkg) { + shell("am start -W --user " + mUserId + " -n " + pkg + "/" + activityName(pkg)); + waitForProviderResult(pkg, SecurePasteCommandProvider.METHOD_ACTIVITY_READY); + call(pkg, SecurePasteCommandProvider.METHOD_REQUEST_FOCUS); + mDevice.waitForIdle(); + } + + protected void launchKeyEventActivity() { + launchKeyEventActivityWithArgs(""); + } + + protected void launchKeyEventActivityWithDelayedClipboardRead(long delayMillis) { + launchKeyEventActivityWithArgs(" --el " + + SecurePasteKeyEventActivity.EXTRA_CLIPBOARD_READ_DELAY_MILLIS + " " + + delayMillis); + } + + protected void launchKeyEventActivityWithoutClipboardRead() { + launchKeyEventActivityWithArgs(" --ez " + + SecurePasteKeyEventActivity.EXTRA_READ_CLIPBOARD_ON_SHORTCUT + " false"); + } + + private void launchKeyEventActivityWithArgs(String extraArgs) { + shell("am start -W --user " + mUserId + " -n " + READER + "/" + KEY_EVENT_ACTIVITY + + extraArgs); + final UiObject2 result = mDevice.wait(Until.findObject( + By.desc(SecurePasteKeyEventActivity.RESULT_DESCRIPTION)), + UI_OBJECT_TIMEOUT_MILLIS); + assertThat(result).isNotNull(); + mDevice.waitForIdle(); + } + + protected void focusKeyEventTarget(boolean editor) throws UiObjectNotFoundException { + clickByDescription(editor + ? SecurePasteKeyEventActivity.EDITOR_DESCRIPTION + : SecurePasteKeyEventActivity.NON_EDITOR_DESCRIPTION); + } + + protected String waitForKeyEventResult(String key, String clipText) { + final String expectedPrefix = "key=" + key + ";"; + final String expectedClip = ";clip=" + clipText + ";"; + final long deadline = System.currentTimeMillis() + UI_OBJECT_TIMEOUT_MILLIS; + String text = null; + while (System.currentTimeMillis() < deadline) { + final UiObject2 result = mDevice.findObject( + By.desc(SecurePasteKeyEventActivity.RESULT_DESCRIPTION)); + text = result == null ? null : result.getText(); + if (text != null && text.startsWith(expectedPrefix) && text.contains(expectedClip)) { + return text; + } + sleep(100); + } + assertThat(text).startsWith(expectedPrefix); + assertThat(text).contains(expectedClip); + return text; + } + + protected void waitForKeyInputConnection() { + final String expected = ";inputConnectionCreated=true;"; + final long deadline = System.currentTimeMillis() + UI_OBJECT_TIMEOUT_MILLIS; + String text = null; + while (System.currentTimeMillis() < deadline) { + final UiObject2 result = mDevice.findObject( + By.desc(SecurePasteKeyEventActivity.RESULT_DESCRIPTION)); + text = result == null ? null : result.getText(); + if (text != null && text.contains(expected)) { + return; + } + sleep(100); + } + assertThat(text).contains(expected); + } + + protected void focusEditor() throws UiObjectNotFoundException { + final UiObject editor = mDevice.findObject( + new UiSelector().description(SecurePasteActivity.EDITOR_DESCRIPTION)); + assertThat(editor.exists()).isTrue(); + editor.click(); + mDevice.waitForIdle(); + } + + protected void longClickEditor() throws UiObjectNotFoundException { + final UiObject editor = mDevice.findObject( + new UiSelector().description(SecurePasteActivity.EDITOR_DESCRIPTION)); + assertThat(editor.exists()).isTrue(); + editor.longClick(); + mDevice.waitForIdle(); + } + + protected void systemToolbarPasteInto(String pkg) throws Exception { + showSystemToolbarForEditor(pkg); + clickFloatingToolbarItemOrFail(getPasteLabel()); + } + + protected void showSystemToolbarForEditor(String pkg) throws Exception { + launchHelperActivity(pkg); + showSystemToolbarForFocusedEditor(pkg); + } + + protected void showSystemToolbarForFocusedEditor(String pkg) throws Exception { + focusEditor(); + if (JETPACK_COMPOSE.equals(pkg)) { + accessibilityLongClickEditor(); + } else { + longClickEditor(); + } + } + + protected void systemToolbarPasteAsPlainTextInto(String pkg) throws Exception { + showSystemToolbarForEditor(pkg); + final String label = getPasteAsPlainTextLabel(); + if (!clickFloatingToolbarItem(label)) { + clickFloatingToolbarOverflowItemOrFail(label); + } + } + + protected void clickByDescription(String description) throws UiObjectNotFoundException { + final UiObject object = waitForObjectByDescriptionOrText(description); + assertThat(object.exists()).isTrue(); + object.click(); + mDevice.waitForIdle(); + } + + protected void dragByDescription(String sourceDescription, String targetDescription) + throws UiObjectNotFoundException { + final UiObject source = waitForObjectByDescriptionOrText(sourceDescription); + final UiObject target = waitForObjectByDescriptionOrText(targetDescription); + assertThat(source.exists()).isTrue(); + assertThat(target.exists()).isTrue(); + // startDragAndDrop requires an active pointer down; CTS drives it with injected input too: + // cts/tests/framework/base/windowmanager/src/android/server/wm/draganddrop/DragDropTest.java + assertThat(source.dragTo(target, 80)).isTrue(); + mDevice.waitForIdle(); + } + + protected boolean clickFloatingToolbarItem(String text) { + final UiObject2 item = findFloatingToolbarItem(text); + if (item == null) { + return false; + } + item.click(); + mDevice.waitForIdle(); + return true; + } + + protected boolean hasFloatingToolbarItem(String text) { + return findFloatingToolbarItem(text) != null; + } + + protected void assertFloatingToolbarItemPresent(String text) { + if (hasFloatingToolbarItem(text)) { + return; + } + fail("Expected floating toolbar item \"" + text + "\" but visible items were " + + floatingToolbarItemTexts()); + } + + protected void clickFloatingToolbarItemOrFail(String text) { + if (clickFloatingToolbarItem(text)) { + return; + } + fail("Expected to click floating toolbar item \"" + text + "\" but visible items were " + + floatingToolbarItemTexts()); + } + + protected void clickFloatingToolbarOverflowItemOrFail(String text) { + final UiObject2 toolbar = mDevice.wait(Until.findObject(floatingToolbarSelector()), + UI_OBJECT_TIMEOUT_MILLIS); + assertThat(toolbar).isNotNull(); + final UiObject2 overflowButton = toolbar.findObject(By.desc(Resources.getSystem() + .getString(R.string.floating_toolbar_open_overflow_description))); + assertThat(overflowButton).isNotNull(); + overflowButton.click(); + + final UiObject2 item = mDevice.wait(Until.findObject( + floatingToolbarSelector().hasDescendant(By.text(text))), + UI_OBJECT_TIMEOUT_MILLIS); + assertThat(item).isNotNull(); + final UiObject2 itemText = item.findObject(By.text(text)); + assertThat(itemText).isNotNull(); + final UiObject2 clickableItem = findClickableAncestor(itemText); + assertThat(clickableItem).isNotNull(); + clickableItem.click(); + mDevice.waitForIdle(); + } + + private void accessibilityLongClickEditor() { + // Compose maps framework ACTION_LONG_CLICK to its OnLongClick semantics action. + final long deadline = System.currentTimeMillis() + UI_OBJECT_TIMEOUT_MILLIS; + while (System.currentTimeMillis() < deadline) { + final AccessibilityNodeInfo root = + mInstrumentation.getUiAutomation().getRootInActiveWindow(); + final AccessibilityNodeInfo editor = findAccessibilityNodeByDescription( + root, SecurePasteActivity.EDITOR_DESCRIPTION); + if (editor != null && performAccessibilityActionOnNodeSubtreeOrParent( + editor, AccessibilityNodeInfo.ACTION_LONG_CLICK)) { + mDevice.waitForIdle(); + return; + } + sleep(100); + } + fail("Unable to perform accessibility long-click on editor"); + } + + private boolean performAccessibilityActionOnNodeSubtreeOrParent( + AccessibilityNodeInfo node, int action) { + if (performAccessibilityActionInSubtree(node, action)) { + return true; + } + AccessibilityNodeInfo current = node.getParent(); + while (current != null) { + if (current.performAction(action)) { + return true; + } + current = current.getParent(); + } + return false; + } + + private boolean performAccessibilityActionInSubtree(AccessibilityNodeInfo node, int action) { + if (node == null) { + return false; + } + if (node.performAction(action)) { + return true; + } + for (int i = 0; i < node.getChildCount(); i++) { + if (performAccessibilityActionInSubtree(node.getChild(i), action)) { + return true; + } + } + return false; + } + + private AccessibilityNodeInfo findAccessibilityNodeByDescription( + AccessibilityNodeInfo node, String description) { + if (node == null) { + return null; + } + final CharSequence nodeDescription = node.getContentDescription(); + if (nodeDescription != null && nodeDescription.toString().contains(description)) { + return node; + } + for (int i = 0; i < node.getChildCount(); i++) { + final AccessibilityNodeInfo match = + findAccessibilityNodeByDescription(node.getChild(i), description); + if (match != null) { + return match; + } + } + return null; + } + + private UiObject2 findFloatingToolbarItem(String text) { + final UiObject2 toolbar = mDevice.wait(Until.findObject( + floatingToolbarSelector().hasDescendant(By.text(text))), + UI_OBJECT_TIMEOUT_MILLIS); + return toolbar == null ? null + : findClickableAncestor(toolbar.findObject(By.text(text))); + } + + private UiObject2 findClickableAncestor(UiObject2 object) { + while (object != null && !object.isClickable()) { + object = object.getParent(); + } + return object; + } + + private List floatingToolbarItemTexts() { + final UiObject2 toolbar = mDevice.wait(Until.findObject(floatingToolbarSelector()), + UI_OBJECT_TIMEOUT_MILLIS); + final List texts = new ArrayList<>(); + collectText(toolbar, texts); + return texts; + } + + private BySelector floatingToolbarSelector() { + return By.res("android", TOOLBAR_CONTAINER_RES); + } + + private void collectText(UiObject2 object, List texts) { + if (object == null) { + return; + } + addTextIfNotEmpty(texts, object.getText()); + addTextIfNotEmpty(texts, object.getContentDescription()); + for (UiObject2 child : object.getChildren()) { + collectText(child, texts); + } + } + + private void addTextIfNotEmpty(List texts, String text) { + if (text != null && !text.isEmpty() && !texts.contains(text)) { + texts.add(text); + } + } + + private UiObject waitForObjectByDescriptionOrText(String text) { + final long deadline = System.currentTimeMillis() + UI_OBJECT_TIMEOUT_MILLIS; + UiObject object = findObjectByDescriptionOrText(text); + while (!object.exists() && System.currentTimeMillis() < deadline) { + sleep(100); + object = findObjectByDescriptionOrText(text); + } + return object; + } + + private UiObject findObjectByDescriptionOrText(String text) { + UiObject object = mDevice.findObject(new UiSelector().description(text)); + if (!object.exists()) { + object = mDevice.findObject(new UiSelector().descriptionContains(text)); + } + if (!object.exists()) { + object = mDevice.findObject(new UiSelector().text(text)); + } + if (!object.exists()) { + object = mDevice.findObject(new UiSelector().textContains(text)); + } + return object; + } + + protected void waitForEditorText(String pkg, String expected) { + final long deadline = System.currentTimeMillis() + 10_000; + String text = null; + while (System.currentTimeMillis() < deadline) { + text = call(pkg, SecurePasteCommandProvider.METHOD_GET_EDITOR_TEXT) + .getString(SecurePasteCommandProvider.RESULT_TEXT); + if (text != null && text.contains(expected)) { + return; + } + sleep(100); + } + assertThat(text).contains(expected); + } + + protected String getEditorText(String pkg) { + return call(pkg, SecurePasteCommandProvider.METHOD_GET_EDITOR_TEXT) + .getString(SecurePasteCommandProvider.RESULT_TEXT); + } + + protected void sendCtrlV() { + mDevice.pressKeyCode(KeyEvent.KEYCODE_V, KeyEvent.META_CTRL_ON); + mDevice.waitForIdle(); + } + + protected void sendCtrlShiftV() { + mDevice.pressKeyCode(KeyEvent.KEYCODE_V, + KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON); + mDevice.waitForIdle(); + } + + protected void sendCtrlAltV() { + mDevice.pressKeyCode(KeyEvent.KEYCODE_V, + KeyEvent.META_CTRL_ON | KeyEvent.META_ALT_ON); + mDevice.waitForIdle(); + } + + protected void sendPasteKey() { + mDevice.pressKeyCode(KeyEvent.KEYCODE_PASTE); + mDevice.waitForIdle(); + } + + protected void sendShiftInsert() { + mDevice.pressKeyCode(KeyEvent.KEYCODE_INSERT, KeyEvent.META_SHIFT_ON); + mDevice.waitForIdle(); + } + + protected void enableAccessibilityService() { + backupAccessibilityState(); + putSecureSetting(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, ACCESSIBILITY_SERVICE); + putSecureSetting(Settings.Secure.ACCESSIBILITY_ENABLED, "1"); + waitForProviderResult(TEST_PKG, SecurePasteCommandProvider.METHOD_ACCESSIBILITY_CONNECTED); + } + + protected void enableSecurePasteIme() { + enableSecurePasteIme(READER); + } + + protected void enableSecurePasteIme(String targetPackage) { + backupImeState(); + shell("ime enable --user " + mUserId + " " + IME_SERVICE); + shell("ime set --user " + mUserId + " " + IME_SERVICE); + if (!waitForDefaultIme(IME_SERVICE, 5_000)) { + putSecureSetting(Settings.Secure.DEFAULT_INPUT_METHOD, IME_SERVICE); + shell("ime set --user " + mUserId + " " + IME_SERVICE); + } + assertThat(waitForDefaultIme(IME_SERVICE, 10_000)).isTrue(); + launchHelperActivity(targetPackage); + waitForProviderResult(IME, SecurePasteCommandProvider.METHOD_IME_READY); + } + + protected boolean systemSelectionToolbarFlagEnabled() { + return systemSelectionToolbarEnabled(); + } + + protected Bundle call(String pkg, String method) { + return call(pkg, method, null); + } + + protected Bundle call(String pkg, String method, Bundle extras) { + final Bundle result = mContext.getContentResolver().call( + Uri.parse("content://" + pkg + ".provider"), method, null, extras); + if (result == null) { + fail("No provider result for " + pkg + " method " + method); + } + return result; + } + + protected String shell(String command) { + try { + return mDevice.executeShellCommand(command); + } catch (Exception e) { + throw new AssertionError("Shell command failed: " + command, e); + } + } + + protected void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + protected String getPasteLabel() { + return mContext.getString(android.R.string.paste); + } + + protected String getPasteAsPlainTextLabel() { + return mContext.getString(android.R.string.paste_as_plain_text); + } + + protected void waitForProviderResult(String pkg, String method) { + final long deadline = System.currentTimeMillis() + 10_000; + Bundle result = null; + while (System.currentTimeMillis() < deadline) { + result = call(pkg, method); + if (result.getBoolean(SecurePasteCommandProvider.RESULT_OK)) { + return; + } + sleep(100); + } + assertThat(result).isNotNull(); + assertThat(result.getBoolean(SecurePasteCommandProvider.RESULT_OK)).isTrue(); + } + + private boolean waitForDefaultIme(String ime, long timeoutMillis) { + final long deadline = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < deadline) { + final String current = getSecureSetting(Settings.Secure.DEFAULT_INPUT_METHOD).trim(); + if (current.equals(ime) || current.equals(IME + "/" + IME + ".SecurePasteImeService")) { + return true; + } + sleep(100); + } + return false; + } + + private void backupAccessibilityState() { + if (mOldEnabledAccessibilityServices == null) { + final String enabledServices = getSecureSetting( + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES); + final String accessibilityEnabled = getSecureSetting( + Settings.Secure.ACCESSIBILITY_ENABLED); + mOldEnabledAccessibilityServices = enabledServices; + mOldAccessibilityEnabled = accessibilityEnabled; + } + } + + private void backupImeState() { + if (mOldEnabledInputMethods == null) { + final String enabledInputMethods = getSecureSetting( + Settings.Secure.ENABLED_INPUT_METHODS); + final String defaultInputMethod = getSecureSetting( + Settings.Secure.DEFAULT_INPUT_METHOD); + mOldEnabledInputMethods = enabledInputMethods; + mOldDefaultInputMethod = defaultInputMethod; + } + } + + private Throwable restoreAccessibilityState(Throwable failure) { + if (mOldEnabledAccessibilityServices != null) { + failure = runCleanup(failure, () -> restoreSecureSetting( + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + mOldEnabledAccessibilityServices)); + failure = runCleanup(failure, () -> restoreSecureSetting( + Settings.Secure.ACCESSIBILITY_ENABLED, mOldAccessibilityEnabled)); + } + return failure; + } + + private Throwable restoreImeState(Throwable failure) { + if (mOldEnabledInputMethods != null) { + failure = runCleanup(failure, () -> restoreSecureSetting( + Settings.Secure.ENABLED_INPUT_METHODS, mOldEnabledInputMethods)); + failure = runCleanup(failure, () -> restoreSecureSetting( + Settings.Secure.DEFAULT_INPUT_METHOD, mOldDefaultInputMethod)); + } + return failure; + } + + private Throwable restoreGlobalClipboardDefault(Throwable failure) { + if (mOldGlobalClipboardDefault == null) { + return failure; + } + if ("null".equals(mOldGlobalClipboardDefault)) { + return runCleanup(failure, () -> shell("settings delete global " + + Settings.Global.ALLOW_CLIPBOARD_READ_BY_DEFAULT)); + } else { + return runCleanup(failure, () -> shell("settings put global " + + Settings.Global.ALLOW_CLIPBOARD_READ_BY_DEFAULT + " " + + mOldGlobalClipboardDefault)); + } + } + + private String getSecureSetting(String key) { + return shell("settings get --user " + mUserId + " secure " + key).trim(); + } + + private void putSecureSetting(String key, String value) { + shell("settings put --user " + mUserId + " secure " + key + " " + value); + } + + private void restoreSecureSetting(String key, String value) { + if (value == null || value.isEmpty() || "null".equals(value)) { + shell("settings delete --user " + mUserId + " secure " + key); + } else { + shell("settings put --user " + mUserId + " secure " + key + " " + value); + } + } + + private void resetAllKnownPackageState() { + for (String pkg : KNOWN_PACKAGES) { + resetPackageClipboardPolicy(pkg); + } + } + + private String activityName(String pkg) { + return JETPACK_COMPOSE.equals(pkg) ? JETPACK_COMPOSE_ACTIVITY : ACTIVITY; + } + + private static Throwable runCleanup(Throwable failure, CleanupAction action) { + try { + action.run(); + } catch (Throwable t) { + if (failure == null) { + return t; + } + failure.addSuppressed(t); + } + return failure; + } + + private interface CleanupAction { + void run() throws Exception; + } +} diff --git a/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteVirtualDeviceTest.java b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteVirtualDeviceTest.java new file mode 100644 index 0000000000000..198920e7bfb30 --- /dev/null +++ b/tests/SecurePasteTests/src/grapheneos/securepaste/SecurePasteVirtualDeviceTest.java @@ -0,0 +1,216 @@ +package grapheneos.securepaste; + +import static android.companion.virtual.VirtualDeviceParams.DEVICE_POLICY_DEFAULT; +import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_CLIPBOARD; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assume.assumeFalse; + +import android.app.KeyguardManager; +import android.companion.virtual.VirtualDeviceManager.VirtualDevice; +import android.companion.virtual.VirtualDeviceParams; +import android.content.ComponentName; +import android.content.Intent; +import android.hardware.display.DisplayManager; +import android.hardware.display.VirtualDisplay; +import android.hardware.input.VirtualKeyEvent; +import android.hardware.input.VirtualKeyboard; +import android.os.Bundle; +import android.os.RemoteCallback; +import android.view.KeyEvent; + +import android.virtualdevice.cts.common.VirtualDeviceRule; + +import grapheneos.securepaste.helper.SecurePasteCommandProvider; +import grapheneos.securepaste.helper.SecurePasteKeyEventActivity; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExternalResource; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +/** + * Secure Paste coverage for the virtual-device clipboard silos exercised by CTS's + * {@code android.virtualdevice.cts.applaunch.StreamedAppClipboardTest}. + * + *

The test package owns the virtual devices so reads of either silo pass the base virtual-device + * access check. A separate helper UID writes each clip so reads by the blocked test package reach + * secure paste's device-scoped grant check rather than the clip owner exemption.

+ */ +public class SecurePasteVirtualDeviceTest extends SecurePasteTestBase { + private static final long RESULT_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(5); + + // VirtualDeviceRule uses the CDM test API to skip the role grant, which requires an insecure + // keyguard. + @Rule(order = 0) + public final ExternalResource mInsecureKeyguardRule = new ExternalResource() { + @Override + protected void before() { + final KeyguardManager keyguardManager = getInstrumentation().getContext() + .getSystemService(KeyguardManager.class); + assumeFalse("VirtualDeviceRule requires an insecure keyguard", + keyguardManager != null && keyguardManager.isKeyguardSecure()); + } + }; + + @Rule(order = 1) + public final VirtualDeviceRule mVirtualDeviceRule = VirtualDeviceRule.createDefault(); + + private VirtualDeviceEnvironment mFirstDevice; + private VirtualDeviceEnvironment mSecondDevice; + + @Before + public void setUpVirtualDevices() throws InterruptedException { + mFirstDevice = new VirtualDeviceEnvironment(TEXT_A); + mSecondDevice = new VirtualDeviceEnvironment(TEXT_B); + blockPackageClipboardRead(TEST_PKG); + } + + @After + public void resetTestPackageClipboardPolicy() { + resetPackageClipboardPolicy(TEST_PKG); + } + + @Test + public void blockedPackage_virtualDisplayPasteReadsVirtualDeviceClipboard() + throws InterruptedException { + final BlockingQueue results = launchKeyEventActivity( + mFirstDevice, mFirstDevice.deviceId); + + mFirstDevice.sendCtrlV(); + + assertThat(awaitClipboardRead(results)).asList().containsExactly(TEXT_A); + } + + @Test + public void blockedPackage_virtualDisplayPasteGrantDoesNotAuthorizeAnotherVirtualDevice() + throws InterruptedException { + final BlockingQueue results = launchKeyEventActivity( + mFirstDevice, mSecondDevice.deviceId); + + mFirstDevice.sendCtrlV(); + + assertThat(awaitClipboardRead(results)).asList().containsExactly((String) null); + } + + @Test + public void blockedPackage_secondVirtualDisplayPasteGrantDoesNotAuthorizeFirstVirtualDevice() + throws InterruptedException { + final BlockingQueue results = launchKeyEventActivity( + mSecondDevice, mFirstDevice.deviceId); + + mSecondDevice.sendCtrlV(); + + assertThat(awaitClipboardRead(results)).asList().containsExactly((String) null); + } + + @Test + public void blockedPackage_otherDeviceReadDoesNotConsumePasteGrant() + throws InterruptedException { + final BlockingQueue results = launchKeyEventActivity( + mFirstDevice, mSecondDevice.deviceId, mFirstDevice.deviceId); + + mFirstDevice.sendCtrlV(); + + assertThat(awaitClipboardRead(results)).asList() + .containsExactly((String) null, TEXT_A).inOrder(); + } + + private BlockingQueue launchKeyEventActivity( + VirtualDeviceEnvironment environment, int... deviceIds) throws InterruptedException { + final BlockingQueue results = new LinkedBlockingQueue<>(); + final Intent intent = new Intent() + .setComponent(new ComponentName( + TEST_PKG, SecurePasteKeyEventActivity.class.getName())) + .putExtra(SecurePasteKeyEventActivity.EXTRA_CLIPBOARD_READ_DEVICE_IDS, deviceIds) + .putExtra(SecurePasteKeyEventActivity.EXTRA_RESULT_RECEIVER, + new RemoteCallback(results::add)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + mVirtualDeviceRule.sendIntentToDisplay(intent, environment.display); + awaitResult(results, result -> result.getBoolean( + SecurePasteKeyEventActivity.RESULT_HAS_WINDOW_FOCUS) + && result.getInt(SecurePasteKeyEventActivity.RESULT_DEVICE_ID) + == environment.deviceId); + return results; + } + + private String[] awaitClipboardRead(BlockingQueue results) + throws InterruptedException { + return awaitResult(results, result -> result.containsKey( + SecurePasteKeyEventActivity.RESULT_CLIP_TEXTS)) + .getStringArray(SecurePasteKeyEventActivity.RESULT_CLIP_TEXTS); + } + + private Bundle awaitResult(BlockingQueue results, Predicate predicate) + throws InterruptedException { + final long deadline = System.nanoTime() + RESULT_TIMEOUT_NANOS; + while (true) { + final long remainingNanos = deadline - System.nanoTime(); + assertThat(remainingNanos).isGreaterThan(0); + final Bundle result = results.poll(remainingNanos, TimeUnit.NANOSECONDS); + assertThat(result).isNotNull(); + if (predicate.test(result)) { + return result; + } + } + } + + private final class VirtualDeviceEnvironment { + final int deviceId; + final VirtualDisplay display; + final VirtualKeyboard keyboard; + + @SuppressWarnings("deprecation") + VirtualDeviceEnvironment(String clipText) throws InterruptedException { + // Match the isolated clipboard and trusted display setup used by + // android.virtualdevice.cts.applaunch.StreamedAppClipboardTest. + final VirtualDevice device = mVirtualDeviceRule.createManagedVirtualDevice( + new VirtualDeviceParams.Builder() + .setLockState(VirtualDeviceParams.LOCK_STATE_ALWAYS_UNLOCKED) + .setDevicePolicy(POLICY_TYPE_CLIPBOARD, DEVICE_POLICY_DEFAULT) + .build()); + deviceId = device.getDeviceId(); + display = mVirtualDeviceRule.createManagedVirtualDisplayWithFlags(device, + DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC + | DisplayManager.VIRTUAL_DISPLAY_FLAG_TRUSTED + | DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY); + keyboard = device.createVirtualKeyboard( + display, "secure-paste-keyboard-" + deviceId, 1, 1); + final BlockingQueue results = new LinkedBlockingQueue<>(); + final Intent intent = new Intent() + .setComponent(new ComponentName( + WRITER, SecurePasteKeyEventActivity.class.getName())) + .putExtra(SecurePasteKeyEventActivity.EXTRA_CLIP_TEXT_TO_SET, clipText) + .putExtra(SecurePasteKeyEventActivity.EXTRA_RESULT_RECEIVER, + new RemoteCallback(results::add)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + mVirtualDeviceRule.sendIntentToDisplay(intent, display); + awaitResult(results, result -> result.getBoolean( + SecurePasteKeyEventActivity.RESULT_HAS_WINDOW_FOCUS) + && result.getInt(SecurePasteKeyEventActivity.RESULT_DEVICE_ID) == deviceId); + } + + void sendCtrlV() { + sendKey(KeyEvent.KEYCODE_CTRL_LEFT, VirtualKeyEvent.ACTION_DOWN); + sendKey(KeyEvent.KEYCODE_V, VirtualKeyEvent.ACTION_DOWN); + sendKey(KeyEvent.KEYCODE_V, VirtualKeyEvent.ACTION_UP); + sendKey(KeyEvent.KEYCODE_CTRL_LEFT, VirtualKeyEvent.ACTION_UP); + } + + private void sendKey(int keyCode, int action) { + keyboard.sendKeyEvent(new VirtualKeyEvent.Builder() + .setKeyCode(keyCode) + .setAction(action) + .build()); + } + } +} From 6d20d404d461f21c72ab56e5ed4d46b511e90913 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Fri, 14 Aug 2026 21:36:38 -0700 Subject: [PATCH 7/9] secure paste: notify when clipboard reads are blocked An app set to Paste only can use clipboard metadata to decide whether to offer Paste. If it then tries to read the payload without an explicit Paste action, the read fails without telling the user why. Show an optional message when secure paste blocks an otherwise eligible read, and enable it by default. Users can turn the message off independently from successful clipboard access messages. Avoid repeated messages by allowing one notification attempt per app identity and clipboard generation, no more than once per minute. Both a new generation and the elapsed minute are required. Setting the clipboard again starts a new generation even when the contents have not changed, but it does not bypass the time limit. --- .../android/ext/settings/ExtSettings.java | 4 ++ core/java/android/provider/Settings.java | 5 ++ core/res/res/values/public-ext.xml | 2 + core/res/res/values/string_ext.xml | 3 + .../server/clipboard/ClipboardService.java | 58 +++++++++++++++++++ 5 files changed, 72 insertions(+) diff --git a/core/java/android/ext/settings/ExtSettings.java b/core/java/android/ext/settings/ExtSettings.java index b0eb63aa3b321..9806649823971 100644 --- a/core/java/android/ext/settings/ExtSettings.java +++ b/core/java/android/ext/settings/ExtSettings.java @@ -100,6 +100,10 @@ public class ExtSettings { public static final BoolSetting DISALLOW_DELAYED_LOCKING_ON_USER_STOP = new BoolSetting( Setting.Scope.PER_USER, Settings.Secure.DISALLOW_DELAYED_LOCKING_ON_USER_STOP, false); + public static final BoolSetting SHOW_CLIPBOARD_ACCESS_DENIAL_NOTIFICATIONS = new BoolSetting( + Setting.Scope.PER_USER, + Settings.Secure.CLIPBOARD_SHOW_ACCESS_DENIAL_NOTIFICATIONS, true); + public static final BoolSetting ALLOW_CLIPBOARD_READ_BY_DEFAULT = new BoolSetting( Setting.Scope.GLOBAL, Settings.Global.ALLOW_CLIPBOARD_READ_BY_DEFAULT, defaultBool(R.bool.setting_default_allow_clipboard_read)); diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java index 4c940ca0334bd..1c1a98a1e366f 100644 --- a/core/java/android/provider/Settings.java +++ b/core/java/android/provider/Settings.java @@ -7519,6 +7519,11 @@ public static final class Secure extends NameValueTable { @Protected(readWrite = KnownSystemPackage.SETTINGS) public static final String CROSS_PROFILE_CLIPBOARD_ACCESS = "cross_profile_clipboard_access"; + /** @hide */ + @Protected(readWrite = KnownSystemPackage.SETTINGS) + public static final String CLIPBOARD_SHOW_ACCESS_DENIAL_NOTIFICATIONS = + "clipboard_show_access_denial_notifications"; + /** @hide */ @Protected(readWrite = KnownSystemPackage.SETTINGS) public static final String DISALLOW_DELAYED_LOCKING_ON_USER_STOP = "disallow_delayed_locking_on_user_stop"; diff --git a/core/res/res/values/public-ext.xml b/core/res/res/values/public-ext.xml index b03cf9cff6e72..0e5b3b50eda58 100644 --- a/core/res/res/values/public-ext.xml +++ b/core/res/res/values/public-ext.xml @@ -139,5 +139,7 @@ + +
diff --git a/core/res/res/values/string_ext.xml b/core/res/res/values/string_ext.xml index 12fbc7f26e3b6..cc5fc7cf70158 100644 --- a/core/res/res/values/string_ext.xml +++ b/core/res/res/values/string_ext.xml @@ -53,4 +53,7 @@ USB-C port security feature has malfunctioned + %1$s was blocked from accessing your clipboard + + diff --git a/services/core/java/com/android/server/clipboard/ClipboardService.java b/services/core/java/com/android/server/clipboard/ClipboardService.java index ec9a6b653efaf..471b071ef58c4 100644 --- a/services/core/java/com/android/server/clipboard/ClipboardService.java +++ b/services/core/java/com/android/server/clipboard/ClipboardService.java @@ -67,6 +67,7 @@ import android.content.pm.PackageManager; import android.content.pm.PackageManagerInternal; import android.content.pm.UserInfo; +import android.ext.settings.ExtSettings; import android.graphics.drawable.Drawable; import android.hardware.display.DisplayManager; import android.net.Uri; @@ -163,6 +164,8 @@ public class ClipboardService extends SystemService { CLIPBOARD_GET_EVENT_REPORTED__CLIP_DATA_TYPE__MIMETYPE_UNKNOWN }; private static final long ACCESS_NOTIFICATION_SUPPRESSION_TIMEOUT_MILLIS = 1000L; + private static final long ACCESS_DENIED_NOTIFICATION_MIN_INTERVAL_MILLIS = + TimeUnit.MINUTES.toMillis(1); private final ActivityManagerInternal mAmInternal; private final IUriGrantsManager mUgm; @@ -194,6 +197,9 @@ public class ClipboardService extends SystemService { @GuardedBy("mLock") private final SparseLongArray mUserAuthorizedClipAccesses = new SparseLongArray(); + @GuardedBy("mLock") + private final SparseLongArray mLastAccessDeniedNotificationTimes = new SparseLongArray(); + @GuardedBy("mLock") private boolean mShowAccessNotifications = ClipboardManager.DEVICE_CONFIG_DEFAULT_SHOW_ACCESS_NOTIFICATIONS; @@ -336,6 +342,9 @@ static class Clipboard { /** Uids that have already triggered a toast notification for {@link #primaryClip} */ final SparseBooleanArray mNotifiedUids = new SparseBooleanArray(); + /** Uids that have already been notified of denied access to {@link #primaryClip}. */ + final SparseBooleanArray mAccessDeniedNotifiedUids = new SparseBooleanArray(); + /** * Uids that have already triggered a notification to text classifier for * {@link #primaryClip}. @@ -699,11 +708,18 @@ public ClipData getPrimaryClip( } final boolean readAllowedForPackage = mAccess.clipboardReadAllowedForPackage( pkg, intendingUid, intendingUserId, isDefaultIme); + final boolean showAccessDeniedNotifications = !readAllowedForPackage + && ExtSettings.SHOW_CLIPBOARD_ACCESS_DENIAL_NOTIFICATIONS.get( + getContext(), intendingUserId); synchronized (mLock) { final ClipboardAccess.PayloadReadAccess readAccess = mAccess.getPayloadReadAccessLocked(readAllowedForPackage, intendingUid, intendingUserId, intendingDeviceId); if (readAccess == ClipboardAccess.PayloadReadAccess.DENIED) { + if (showAccessDeniedNotifications) { + showAccessDeniedNotificationLocked(pkg, intendingUid, intendingUserId, + intendingDeviceId, deviceId); + } return null; } @@ -1128,6 +1144,7 @@ private void setPrimaryClipInternalNoClassifyLocked(Clipboard clipboard, clipboard.primaryClip = clip; clipboard.primaryClipGeneration++; clipboard.mNotifiedUids.clear(); + clipboard.mAccessDeniedNotifiedUids.clear(); clipboard.mNotifiedTextClassifierUids.clear(); if (clip != null) { clipboard.primaryClipUid = uid; @@ -1560,6 +1577,47 @@ private boolean shouldSuppressAccessNotificationForUidLocked(int uid) { return false; } + @GuardedBy("mLock") + private void showAccessDeniedNotificationLocked(String callingPackage, int uid, + @UserIdInt int userId, int clipboardDeviceId, int accessDeviceId) { + final Clipboard clipboard = mClipboards.get(userId, clipboardDeviceId); + if (clipboard == null + || clipboard.primaryClip == null + || clipboard.mAccessDeniedNotifiedUids.get(uid)) { + return; + } + + final long elapsedRealtime = SystemClock.elapsedRealtime(); + final int lastNotificationIndex = mLastAccessDeniedNotificationTimes.indexOfKey(uid); + // Per-clip suppression handles repeated reads of one clip. The independent interval stops + // an app from resetting that suppression by replacing the clipboard before each read. + if (lastNotificationIndex >= 0 + && elapsedRealtime - mLastAccessDeniedNotificationTimes.valueAt( + lastNotificationIndex) < ACCESS_DENIED_NOTIFICATION_MIN_INTERVAL_MILLIS) { + return; + } + + showClipboardToastLocked(callingPackage, userId, clipboard, accessDeviceId, + R.string.clipboard_access_blocked); + clipboard.mAccessDeniedNotifiedUids.put(uid, true); + mLastAccessDeniedNotificationTimes.put(uid, elapsedRealtime); + mWorkerHandler.postDelayed(PooledLambda.obtainRunnable( + ClipboardService::pruneAccessDeniedNotificationTimes, this), + ACCESS_DENIED_NOTIFICATION_MIN_INTERVAL_MILLIS + 1); + } + + private void pruneAccessDeniedNotificationTimes() { + final long elapsedRealtime = SystemClock.elapsedRealtime(); + synchronized (mLock) { + for (int i = mLastAccessDeniedNotificationTimes.size() - 1; i >= 0; i--) { + if (elapsedRealtime - mLastAccessDeniedNotificationTimes.valueAt(i) + >= ACCESS_DENIED_NOTIFICATION_MIN_INTERVAL_MILLIS) { + mLastAccessDeniedNotificationTimes.removeAt(i); + } + } + } + } + /** * Shows a toast to inform the user that an app has accessed the clipboard. This is only done if * the setting is enabled, and if the accessing app is not the source of the data and is not the From b36d7cef6ba8eb5159840a7cf31c0aca034a1649 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Sat, 15 Aug 2026 03:31:12 -0700 Subject: [PATCH 8/9] InputTests: scope optional gesture flags to relevant tests KeyGestureControllerTests enables every optional system gesture at class scope. On product builds with disabled read-only optimized flags, SetFlagsRule skips every method before its test body runs, including tests unrelated to those gestures. Scope keyboard backlight, contextual input and contextual cursor overrides to the parameterized gesture tests whose data contains them. Other tests can then run against the product defaults, while the relevant gesture tests retain their overrides on builds where the flags are mutable. --- .../server/input/KeyGestureControllerTests.kt | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/Input/src/com/android/server/input/KeyGestureControllerTests.kt b/tests/Input/src/com/android/server/input/KeyGestureControllerTests.kt index 7a99d66b0507b..79da86e0c437a 100644 --- a/tests/Input/src/com/android/server/input/KeyGestureControllerTests.kt +++ b/tests/Input/src/com/android/server/input/KeyGestureControllerTests.kt @@ -110,11 +110,8 @@ import org.mockito.kotlin.times com.android.hardware.input.Flags.FLAG_ENABLE_TALKBACK_KEY_GESTURES, com.android.hardware.input.Flags.FLAG_ENABLE_NEW_26Q2_KEYCODES, com.android.hardware.input.Flags.FLAG_ENABLE_QUICK_SETTINGS_PANEL_SHORTCUT, - com.android.hardware.input.Flags.FLAG_KEYBOARD_BACKLIGHT_SHORTCUTS, com.android.hardware.input.Flags.FLAG_ENABLE_CONTEXTUAL_SEARCH_DESKTOP_ENTRYPOINTS, com.android.hardware.input.Flags.FLAG_ENABLE_NOTE_TAKING_KEYBOARD_SHORTCUT, - com.android.hardware.input.Flags.FLAG_ENABLE_CONTEXTUAL_INPUT_TRIGGER, - com.android.hardware.input.Flags.FLAG_ENABLE_CONTEXTUAL_CURSOR_DESKTOP_ENTRYPOINTS, ) @DisabledOnRavenwood(reason = "Static mocking in bivalent tests is tricky", bug = 310268946) class KeyGestureControllerTests { @@ -357,7 +354,12 @@ class KeyGestureControllerTests { @Test @Parameters(method = "systemGesturesTestArguments") - @EnableFlags(com.android.hardware.input.Flags.FLAG_ENABLE_PARTIAL_SCREENSHOT_KEYBOARD_SHORTCUT) + @EnableFlags( + com.android.hardware.input.Flags.FLAG_ENABLE_PARTIAL_SCREENSHOT_KEYBOARD_SHORTCUT, + com.android.hardware.input.Flags.FLAG_KEYBOARD_BACKLIGHT_SHORTCUTS, + com.android.hardware.input.Flags.FLAG_ENABLE_CONTEXTUAL_INPUT_TRIGGER, + com.android.hardware.input.Flags.FLAG_ENABLE_CONTEXTUAL_CURSOR_DESKTOP_ENTRYPOINTS, + ) fun testKeyGestures(test: KeyGestureData) { setupKeyGestureController() testKeyGestureProduced(test, PASS_THROUGH_APP) @@ -376,7 +378,12 @@ class KeyGestureControllerTests { } @Test - @EnableFlags(com.android.hardware.input.Flags.FLAG_ENABLE_PARTIAL_SCREENSHOT_KEYBOARD_SHORTCUT) + @EnableFlags( + com.android.hardware.input.Flags.FLAG_ENABLE_PARTIAL_SCREENSHOT_KEYBOARD_SHORTCUT, + com.android.hardware.input.Flags.FLAG_KEYBOARD_BACKLIGHT_SHORTCUTS, + com.android.hardware.input.Flags.FLAG_ENABLE_CONTEXTUAL_INPUT_TRIGGER, + com.android.hardware.input.Flags.FLAG_ENABLE_CONTEXTUAL_CURSOR_DESKTOP_ENTRYPOINTS, + ) fun testCustomKeyGesturesNotAllowedForSystemGestures() { setupKeyGestureController() for (systemGesture in systemGesturesTestArguments()) { @@ -459,7 +466,11 @@ class KeyGestureControllerTests { @Test @Parameters(method = "nonCapturableKeyGestures") - @EnableFlags(com.android.hardware.input.Flags.FLAG_ENABLE_PARTIAL_SCREENSHOT_KEYBOARD_SHORTCUT) + @EnableFlags( + com.android.hardware.input.Flags.FLAG_ENABLE_PARTIAL_SCREENSHOT_KEYBOARD_SHORTCUT, + com.android.hardware.input.Flags.FLAG_KEYBOARD_BACKLIGHT_SHORTCUTS, + com.android.hardware.input.Flags.FLAG_ENABLE_CONTEXTUAL_INPUT_TRIGGER, + ) fun testKeyGestures_withKeyCapture_nonCapturableGestures(test: KeyGestureData) { setupKeyGestureController() enableKeyCaptureForFocussedWindow() @@ -474,7 +485,10 @@ class KeyGestureControllerTests { @Test @Parameters(method = "capturableKeyGestures") - @EnableFlags(com.android.hardware.input.Flags.FLAG_ENABLE_PARTIAL_SCREENSHOT_KEYBOARD_SHORTCUT) + @EnableFlags( + com.android.hardware.input.Flags.FLAG_ENABLE_PARTIAL_SCREENSHOT_KEYBOARD_SHORTCUT, + com.android.hardware.input.Flags.FLAG_ENABLE_CONTEXTUAL_CURSOR_DESKTOP_ENTRYPOINTS, + ) fun testKeyGestures_withKeyCapture_capturableGestures(test: KeyGestureData) { setupKeyGestureController() enableKeyCaptureForFocussedWindow() @@ -488,7 +502,10 @@ class KeyGestureControllerTests { @Test @Parameters(method = "capturableKeyGestures_handledAsFallback") - @EnableFlags(com.android.hardware.input.Flags.FLAG_ENABLE_PARTIAL_SCREENSHOT_KEYBOARD_SHORTCUT) + @EnableFlags( + com.android.hardware.input.Flags.FLAG_ENABLE_PARTIAL_SCREENSHOT_KEYBOARD_SHORTCUT, + com.android.hardware.input.Flags.FLAG_ENABLE_CONTEXTUAL_CURSOR_DESKTOP_ENTRYPOINTS, + ) fun testKeyGestures_withKeyCapture_capturableGesturesHandledAsFallback(test: KeyGestureData) { setupKeyGestureController() enableKeyCaptureForFocussedWindow() From 903af7e0212f526b32187e7070b574f25be252ea Mon Sep 17 00:00:00 2001 From: inthewaves Date: Fri, 21 Aug 2026 23:01:58 -0700 Subject: [PATCH 9/9] FrameworksCoreTests: stabilize TextView interaction tests FrameworksCoreTests targets the current platform SDK. That enables a compatibility change which disables TextView's legacy autofill fallback to InputConnection.commitContent() for apps targeting Android T and newer. Disable the change for the receive content tests so their positive and negative cases exercise the fallback instead of stopping at the target SDK gate. Keep TextView test content below the system bars so taps and long presses reach the editors. Require the floating toolbar to exist before checking that Paste as plain text is absent. Test: atest FrameworksCoreTests:android.widget.TextViewActivityTest Test: atest FrameworksCoreTests:android.widget.TextViewReceiveContentTest --- .../activity_custom_input_connection_edit_text.xml | 3 ++- .../coretests/res/layout/activity_text_view.xml | 3 ++- .../src/android/widget/TextViewActivityTest.java | 1 + .../android/widget/TextViewReceiveContentTest.java | 12 ++++++++++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/core/tests/coretests/res/layout/activity_custom_input_connection_edit_text.xml b/core/tests/coretests/res/layout/activity_custom_input_connection_edit_text.xml index c4db8becaf48a..1e12d87ce5d85 100644 --- a/core/tests/coretests/res/layout/activity_custom_input_connection_edit_text.xml +++ b/core/tests/coretests/res/layout/activity_custom_input_connection_edit_text.xml @@ -18,7 +18,8 @@ + android:layout_height="match_parent" + android:fitsSystemWindows="true"> + android:layout_height="match_parent" + android:fitsSystemWindows="true"> mActivityRule = new ActivityTestRule<>(CustomInputConnectionEditTextActivity.class); + @Rule + public TestRule compatChangeRule = new PlatformCompatChangeRule(); private Instrumentation mInstrumentation; private Activity mActivity; @@ -86,6 +93,7 @@ public void before() { } @Test + @DisableCompatChanges(AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_LISTENER) public void testGetFallbackMimeTypesForAutofill() throws Throwable { // Configure the EditText with an EditorInfo/InputConnection that supports some image MIME // types. @@ -103,6 +111,7 @@ public void testGetFallbackMimeTypesForAutofill() throws Throwable { } @Test + @DisableCompatChanges(AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_LISTENER) public void testGetFallbackMimeTypesForAutofill_noMimeTypesInEditorInfo() throws Throwable { // Configure the EditText with an EditorInfo/InputConnection that doesn't declare any MIME @@ -119,6 +128,7 @@ public void testGetFallbackMimeTypesForAutofill_noMimeTypesInEditorInfo() } @Test + @DisableCompatChanges(AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_LISTENER) public void testOnReceive_fallbackToCommitContent() throws Throwable { // Configure the EditText with an EditorInfo/InputConnection that supports some image MIME // types. @@ -142,6 +152,7 @@ public void testOnReceive_fallbackToCommitContent() throws Throwable { } @Test + @DisableCompatChanges(AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_LISTENER) public void testOnReceive_fallbackToCommitContent_noMimeTypesInEditorInfo() throws Throwable { // Configure the EditText with an EditorInfo/InputConnection that doesn't declare any MIME // types. @@ -162,6 +173,7 @@ public void testOnReceive_fallbackToCommitContent_noMimeTypesInEditorInfo() thro } @Test + @DisableCompatChanges(AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_LISTENER) public void testOnReceive_fallbackToCommitContent_sourceOtherThanAutofill() throws Throwable { // Configure the EditText with an EditorInfo/InputConnection that supports some image MIME // types.