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..9806649823971 100644 --- a/core/java/android/ext/settings/ExtSettings.java +++ b/core/java/android/ext/settings/ExtSettings.java @@ -100,6 +100,14 @@ 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)); + 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/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/android/provider/Settings.java b/core/java/android/provider/Settings.java index bcf423537bb66..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"; @@ -14523,6 +14528,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/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/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/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..0e5b3b50eda58 100644 --- a/core/res/res/values/public-ext.xml +++ b/core/res/res/values/public-ext.xml @@ -38,6 +38,8 @@ + + @@ -137,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/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. 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/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); + } +} 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..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; @@ -206,6 +212,8 @@ public class ClipboardService extends SystemService { private final Object mLock = new Object(); + private final ClipboardAccess mAccess; + /** * Instantiates the clipboard. */ @@ -253,6 +261,8 @@ public ClipboardService(Context context) { HandlerThread workerThread = new HandlerThread(TAG); workerThread.start(); mWorkerHandler = workerThread.getThreadHandler(); + + mAccess = new ClipboardAccess(getContext(), this, mLock); } @Override @@ -313,10 +323,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(); @@ -330,6 +342,9 @@ private 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}. @@ -431,7 +446,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 +693,36 @@ 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); + 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; + } + try { addActiveOwnerLocked(intendingUid, intendingDeviceId, pkg); } catch (SecurityException e) { @@ -702,13 +736,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 +765,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 +773,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 +867,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 +875,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 +896,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 +904,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 +979,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,7 +1142,9 @@ private void setPrimaryClipInternalNoClassifyLocked(Clipboard clipboard, return; } clipboard.primaryClip = clip; + clipboard.primaryClipGeneration++; clipboard.mNotifiedUids.clear(); + clipboard.mAccessDeniedNotifiedUids.clear(); clipboard.mNotifiedTextClassifierUids.clear(); if (clip != null) { clipboard.primaryClipUid = uid; @@ -1376,6 +1441,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 +1472,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,17 +1565,59 @@ 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; } + @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 @@ -1508,12 +1628,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 +1647,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 +1672,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 +1704,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 +1861,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/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; + }; + } +} 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. * 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) { 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); } } diff --git a/tests/Input/src/com/android/server/input/KeyGestureControllerTests.kt b/tests/Input/src/com/android/server/input/KeyGestureControllerTests.kt index 5bbc1d3ef909e..79da86e0c437a 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 @@ -104,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 { @@ -150,6 +153,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 +187,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 @@ -349,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) @@ -368,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()) { @@ -451,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() @@ -466,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() @@ -480,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() @@ -1485,6 +1510,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 +1857,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()); + } + } +}