From 193bc0da38ff48c2949367b7224c6c46ddaf7757 Mon Sep 17 00:00:00 2001 From: octocorvus Date: Sun, 23 Jun 2024 08:27:32 +0000 Subject: [PATCH] add per-app clipboard setting --- .../ext/settings/app/AswClipboardRead.java | 48 ++++++ core/res/res/values/string_ext.xml | 7 + .../clipboard/ClipboardManagerInternal.java | 8 + .../server/clipboard/ClipboardHelper.java | 50 ++++++ .../server/clipboard/ClipboardService.java | 72 ++++++++- .../server/ext/ClipboardReadNotification.java | 143 ++++++++++++++++++ 6 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 core/java/android/ext/settings/app/AswClipboardRead.java create mode 100644 services/core/java/android/content/clipboard/ClipboardManagerInternal.java create mode 100644 services/core/java/com/android/server/clipboard/ClipboardHelper.java create mode 100644 services/core/java/com/android/server/ext/ClipboardReadNotification.java diff --git a/core/java/android/ext/settings/app/AswClipboardRead.java b/core/java/android/ext/settings/app/AswClipboardRead.java new file mode 100644 index 000000000000..f9542e8795b4 --- /dev/null +++ b/core/java/android/ext/settings/app/AswClipboardRead.java @@ -0,0 +1,48 @@ +package android.ext.settings.app; + +import static android.ext.settings.ExtSettings.CLIPBOARD_READ_ACCESS; + +import android.annotation.Nullable; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.GosPackageState; +import android.content.pm.GosPackageStateBase; +import android.ext.settings.ClipboardReadSetting; + +/** @hide */ +public class AswClipboardRead extends AppSwitch { + public static final AswClipboardRead I = new AswClipboardRead(); + + private AswClipboardRead() { + gosPsFlag = GosPackageState.FLAG_CLIPBOARD_READ; + gosPsFlagNonDefault = GosPackageState.FLAG_CLIPBOARD_READ_NON_DEFAULT; + gosPsFlagSuppressNotif = GosPackageState.FLAG_CLIPBOARD_READ_SUPPRESS_NOTIF; + } + + @Override + public Boolean getImmutableValue(Context ctx, int userId, ApplicationInfo appInfo, + @Nullable GosPackageStateBase ps, StateInfo si) { + if (appInfo.isSystemApp()) { + si.immutabilityReason = IR_IS_SYSTEM_APP; + return true; + } + + return null; + } + + @Override + protected boolean getDefaultValueInner(Context ctx, int userId, ApplicationInfo appInfo, + @Nullable GosPackageStateBase ps, StateInfo si) { + si.defaultValueReason = DVR_DEFAULT_SETTING; + return CLIPBOARD_READ_ACCESS.get(ctx, userId) == ClipboardReadSetting.ALLOWED; + } + + public boolean isNotificationSuppressed(Context ctx, int userId, + @Nullable GosPackageStateBase ps) { + if (isUsingDefaultValue(ps)) { + return CLIPBOARD_READ_ACCESS.get(ctx, userId) == ClipboardReadSetting.BLOCKED; + } + + return isNotificationSuppressed(ps); + } +} diff --git a/core/res/res/values/string_ext.xml b/core/res/res/values/string_ext.xml index 19a4e2aa1a2d..050d6249cf7d 100644 --- a/core/res/res/values/string_ext.xml +++ b/core/res/res/values/string_ext.xml @@ -27,6 +27,8 @@ More info + Allow once + %1$s tried to use native code debugging @@ -46,4 +48,9 @@ hardened_malloc detected an error in %1$s + Allow %1$s to read the clipboard? + + Clipboard holds data from %1$s. Tap to allow in settings. + + diff --git a/services/core/java/android/content/clipboard/ClipboardManagerInternal.java b/services/core/java/android/content/clipboard/ClipboardManagerInternal.java new file mode 100644 index 000000000000..801a6a26c7e3 --- /dev/null +++ b/services/core/java/android/content/clipboard/ClipboardManagerInternal.java @@ -0,0 +1,8 @@ +package android.content.clipboard; + +/** + * @hide + */ +public abstract class ClipboardManagerInternal { + public abstract void setAllowOneTimeAccess(String pkg, int userId); +} diff --git a/services/core/java/com/android/server/clipboard/ClipboardHelper.java b/services/core/java/com/android/server/clipboard/ClipboardHelper.java new file mode 100644 index 000000000000..f57ce25c8946 --- /dev/null +++ b/services/core/java/com/android/server/clipboard/ClipboardHelper.java @@ -0,0 +1,50 @@ +package com.android.server.clipboard; + +import android.content.ClipData; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManagerInternal; +import android.ext.settings.app.AswClipboardRead; +import android.os.Process; + +import com.android.server.LocalServices; +import com.android.server.ext.ClipboardReadNotification; +import com.android.server.pm.pkg.GosPackageStatePm; + +class ClipboardHelper { + static final ClipData dummyClip = ClipData.newPlainText(null, ""); + + static int getPackageUid(String pkgName, int userId) { + var pmi = LocalServices.getService(PackageManagerInternal.class); + return pmi.getPackageUid(pkgName, 0, userId); + } + + static boolean isReadAllowedForPackage(Context ctx, String pkgName, int userId) { + var pmi = LocalServices.getService(PackageManagerInternal.class); + ApplicationInfo appInfo = pmi.getApplicationInfo(pkgName, 0, Process.SYSTEM_UID, userId); + if (appInfo == null) { + return false; + } + GosPackageStatePm ps = pmi.getGosPackageState(pkgName, userId); + return AswClipboardRead.I.get(ctx, userId, appInfo, ps); + } + + static boolean isNotificationSuppressed(Context ctx, String pkgName, int userId) { + var pmi = LocalServices.getService(PackageManagerInternal.class); + ApplicationInfo appInfo = pmi.getApplicationInfo(pkgName, 0, Process.SYSTEM_UID, userId); + if (appInfo == null) { + return true; + } + GosPackageStatePm ps = pmi.getGosPackageState(pkgName, userId); + return AswClipboardRead.I.isNotificationSuppressed(ctx, userId, ps); + } + + static void maybeNotifyAccessDenied(Context ctx, int deviceId, String pkgName, int pkgUid, + String primaryClipPackage, int primaryClipUid) { + var n = ClipboardReadNotification.maybeCreate(ctx, deviceId, pkgName, pkgUid, + primaryClipPackage, primaryClipUid); + if (n != null) { + n.maybeShow(); + } + } +} diff --git a/services/core/java/com/android/server/clipboard/ClipboardService.java b/services/core/java/com/android/server/clipboard/ClipboardService.java index 49f607095b90..192757839181 100644 --- a/services/core/java/com/android/server/clipboard/ClipboardService.java +++ b/services/core/java/com/android/server/clipboard/ClipboardService.java @@ -48,6 +48,7 @@ import android.content.IOnPrimaryClipChangedListener; import android.content.Intent; import android.content.IntentFilter; +import android.content.clipboard.ClipboardManagerInternal; import android.content.pm.IPackageManager; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; @@ -98,6 +99,7 @@ import com.android.server.UiThread; import com.android.server.companion.virtual.VirtualDeviceManagerInternal; import com.android.server.contentcapture.ContentCaptureManagerInternal; +import com.android.server.ext.ClipboardReadNotification; import com.android.server.uri.UriGrantsManagerInternal; import com.android.server.wm.WindowManagerInternal; @@ -212,6 +214,8 @@ public ClipboardService(Context context) { HandlerThread workerThread = new HandlerThread(TAG); workerThread.start(); mWorkerHandler = workerThread.getThreadHandler(); + + LocalServices.addService(ClipboardManagerInternal.class, new ClipboardManagerInternalImpl()); } @Override @@ -322,6 +326,9 @@ private static class Clipboard { */ final SparseBooleanArray mNotifiedTextClassifierUids = new SparseBooleanArray(); + /** Uids that have access to current clip. */ + final SparseBooleanArray mCurrentClipAllowedUids = new SparseBooleanArray(); + final HashSet activePermissionOwners = new HashSet(); @@ -682,6 +689,10 @@ public ClipData getPrimaryClip( if (clipboard == null) { return null; } + if (!isReadAllowedForPkg(pkg, intendingUid, clipboard)) { + maybeNotifyAccessDenied(pkg, intendingUid, intendingDeviceId, clipboard); + return clipboard.primaryClip != null ? ClipboardHelper.dummyClip : null; + } showAccessNotificationLocked(pkg, intendingUid, intendingUserId, clipboard); notifyTextClassifierLocked(clipboard, pkg, intendingUid); if (clipboard.primaryClip != null) { @@ -710,8 +721,13 @@ public ClipDescription getPrimaryClipDescription( } synchronized (mLock) { Clipboard clipboard = getClipboardLocked(intendingUserId, intendingDeviceId); - return (clipboard != null && clipboard.primaryClip != null) - ? clipboard.primaryClip.getDescription() : null; + ClipDescription clipDesc = null; + if (clipboard != null && clipboard.primaryClip != null) { + clipDesc = isReadAllowedForPkg(callingPackage, intendingUid, clipboard) + ? clipboard.primaryClip.getDescription() + : ClipboardHelper.dummyClip.getDescription(); + } + return clipDesc; } } @@ -809,7 +825,8 @@ public boolean hasClipboardText( } synchronized (mLock) { Clipboard clipboard = getClipboardLocked(intendingUserId, intendingDeviceId); - if (clipboard != null && clipboard.primaryClip != null) { + if (clipboard != null && clipboard.primaryClip != null + && isReadAllowedForPkg(callingPackage, intendingUid, clipboard)) { CharSequence text = clipboard.primaryClip.getItemAt(0).getText(); return text != null && text.length() > 0; } @@ -838,7 +855,8 @@ public String getPrimaryClipSource( } synchronized (mLock) { Clipboard clipboard = getClipboardLocked(intendingUserId, intendingDeviceId); - if (clipboard != null && clipboard.primaryClip != null) { + if (clipboard != null && clipboard.primaryClip != null + && isReadAllowedForPkg(callingPackage, intendingUid, clipboard)) { return clipboard.mPrimaryClipPackage; } return null; @@ -876,6 +894,23 @@ public void handleMessage(@NonNull Message msg) { } }; + private class ClipboardManagerInternalImpl extends ClipboardManagerInternal { + @Override + public void setAllowOneTimeAccess(String pkg, int userId) { + synchronized (mLock) { + int pkgUid = ClipboardHelper.getPackageUid(pkg, userId); + if (pkgUid < 0) { + return; + } + Clipboard clipboard = getClipboardLocked(userId, DEVICE_ID_DEFAULT); + if (clipboard == null) { + return; + } + clipboard.mCurrentClipAllowedUids.put(pkgUid, true); + } + } + } + @GuardedBy("mLock") private @Nullable Clipboard getClipboardLocked(@UserIdInt int userId, int deviceId) { Clipboard clipboard = mClipboards.get(userId, deviceId); @@ -1022,6 +1057,8 @@ private void setPrimaryClipInternalNoClassifyLocked(Clipboard clipboard, clipboard.primaryClip = clip; clipboard.mNotifiedUids.clear(); clipboard.mNotifiedTextClassifierUids.clear(); + clipboard.mCurrentClipAllowedUids.clear(); + Binder.withCleanCallingIdentity(() -> ClipboardReadNotification.cancelAll(getContext())); if (clip != null) { clipboard.primaryClipUid = uid; clipboard.mPrimaryClipPackage = sourcePackage; @@ -1047,6 +1084,10 @@ private void sendClipChangedBroadcast(Clipboard clipboard) { ListenerInfo li = (ListenerInfo) clipboard.primaryClipListeners.getBroadcastCookie(i); + if (!isReadAllowedForPkg(li.mPackageName, li.mUid, clipboard)) { + continue; + } + if (clipboardAccessAllowed( AppOpsManager.OP_READ_CLIPBOARD, li.mPackageName, @@ -1619,4 +1660,27 @@ private TextClassificationManager createTextClassificationManagerAsUser(@UserIdI Context context = getContext().createContextAsUser(UserHandle.of(userId), /* flags= */ 0); return context.getSystemService(TextClassificationManager.class); } + + private boolean isReadAllowedForPkg(String pkg, int pkgUid, Clipboard clipboard) { + int userId = UserHandle.getUserId(pkgUid); + return pkgUid == clipboard.primaryClipUid + || clipboard.mCurrentClipAllowedUids.get(pkgUid) + || ClipboardHelper.isReadAllowedForPackage(getContext(), pkg, userId); + } + + private void maybeNotifyAccessDenied(String pkg, int uid, int deviceId, Clipboard clipboard) { + if (clipboard.primaryClip == null) { + return; + } + + Slog.d(TAG, "clipboard read blocked for: " + pkg); + + int userId = UserHandle.getUserId(uid); + if (ClipboardHelper.isNotificationSuppressed(getContext(), pkg, userId)) { + return; + } + Binder.withCleanCallingIdentity( + () -> ClipboardHelper.maybeNotifyAccessDenied(getContext(), deviceId, pkg, uid, + clipboard.mPrimaryClipPackage, clipboard.primaryClipUid)); + } } diff --git a/services/core/java/com/android/server/ext/ClipboardReadNotification.java b/services/core/java/com/android/server/ext/ClipboardReadNotification.java new file mode 100644 index 000000000000..c33429940a97 --- /dev/null +++ b/services/core/java/com/android/server/ext/ClipboardReadNotification.java @@ -0,0 +1,143 @@ +package com.android.server.ext; + +import static com.android.server.ext.SseUtils.addNotifAction; + +import android.annotation.Nullable; +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.clipboard.ClipboardManagerInternal; +import android.content.pm.ApplicationInfo; +import android.ext.SettingsIntents; +import android.ext.settings.app.AswClipboardRead; +import android.os.Bundle; +import android.os.UserHandle; +import android.util.ArraySet; +import android.util.Slog; + +import com.android.internal.R; +import com.android.server.LocalServices; + +import java.util.Set; + +public class ClipboardReadNotification extends AppSwitchNotificationBase { + private static final String TAG = "ClipboardAccessNotification"; + + public final int deviceId; + @Nullable public final ApplicationInfo primaryClipAppInfo; + + @Nullable + public static ClipboardReadNotification maybeCreate(Context ctx, int deviceId, + String firstPackageName, int packageUid, + @Nullable String primaryClipPackage, + int primaryClipUid) { + ApplicationInfo appInfo = getAppInfo(firstPackageName, packageUid); + if (appInfo == null) { + Slog.d(TAG, "appInfo is null"); + return null; + } + + ApplicationInfo primaryClipAppInfo = null; + if (primaryClipPackage != null) { + primaryClipAppInfo = getAppInfo(primaryClipPackage, primaryClipUid); + if (primaryClipAppInfo == null) { + Slog.d(TAG, "primaryClipAppInfo is null"); + } + } + + return new ClipboardReadNotification(ctx, deviceId, appInfo, primaryClipAppInfo); + } + + public static ClipboardReadNotification create(Context ctx, int deviceId, + ApplicationInfo appInfo, + @Nullable ApplicationInfo primaryClipAppInfo) { + return new ClipboardReadNotification(ctx, deviceId, appInfo, primaryClipAppInfo); + } + + protected ClipboardReadNotification(Context ctx, int deviceId, ApplicationInfo appInfo, + @Nullable ApplicationInfo primaryClipAppInfo) { + super(ctx, appInfo, AswClipboardRead.I); + this.deviceId = deviceId; + this.primaryClipAppInfo = primaryClipAppInfo; + } + + /** uids for which notification was shown */ + private static final Set notifiedUids = new ArraySet<>(); + + public static void cancelAll(Context ctx) { + synchronized (notifiedUids) { + for (int uid : notifiedUids) { + cancelNotif(ctx, uid, AswClipboardRead.class); + } + notifiedUids.clear(); + } + } + + @Override + protected long getSameUidNotifRateLimit() { + return 5_000; // ms + } + + @Override + protected void modifyNotification(Notification.Builder nb, int notifId) { + final Context ctx = context; + + CharSequence appLabel = getAppLabel(ctx, appInfo); + nb.setContentTitle(ctx.getString(R.string.notif_clipboard_read_title, appLabel)); + + String contentText; + if (primaryClipAppInfo == null) { + contentText = ctx.getString(R.string.notif_text_tap_to_open_settings); + } else { + contentText = ctx.getString(R.string.notif_clipboard_read_content_text, + getAppLabel(ctx, primaryClipAppInfo)); + } + nb.setContentText(contentText); + + { + Intent settingsIntent = SettingsIntents.getAppIntent(SettingsIntents.APP_CLIPBOARD, + pkgName); + PendingIntent pi = PendingIntent.getActivityAsUser(ctx, 0, settingsIntent, + PendingIntent.FLAG_IMMUTABLE, null, userHandle); + nb.setContentIntent(pi); + } + nb.setAutoCancel(true); + + { + Bundle args = getDefaultNotifArgs(notifId); + PendingIntent cancelPi = IntentReceiver.getPendingIntent(CancelActionReceiver.class, + CancelActionReceiver::new, args, ctx); + addNotifAction(ctx, cancelPi, R.string.cancel, nb); + } + + if (deviceId == Context.DEVICE_ID_DEFAULT) { + Bundle args = getDefaultNotifArgs(notifId); + PendingIntent allowOncePi = IntentReceiver.getPendingIntent( + AllowOnceActionReceiver.class, AllowOnceActionReceiver::new, args, ctx); + addNotifAction(ctx, allowOncePi, R.string.notif_action_allow_once, nb); + } + } + + @Override + protected void onNotificationShown() { + synchronized (notifiedUids) { + notifiedUids.add(appInfo.uid); + } + } + + static class CancelActionReceiver extends NotifActionReceiver { + @Override + public void onReceive(Context ctx, String packageName, UserHandle user) { + // do nothing, notification is already cancelled by super class + } + } + + static class AllowOnceActionReceiver extends NotifActionReceiver { + @Override + public void onReceive(Context ctx, String packageName, UserHandle user) { + LocalServices.getService(ClipboardManagerInternal.class) + .setAllowOneTimeAccess(packageName, user.getIdentifier()); + } + } +}