diff --git a/core/java/android/app/BroadcastOptions.java b/core/java/android/app/BroadcastOptions.java index c2df8022af0ff..ee6ab89b7773a 100644 --- a/core/java/android/app/BroadcastOptions.java +++ b/core/java/android/app/BroadcastOptions.java @@ -57,6 +57,7 @@ public class BroadcastOptions extends ComponentOptions { private long mRequireCompatChangeId = CHANGE_INVALID; private boolean mRequireCompatChangeEnabled = true; private boolean mIsAlarmBroadcast = false; + private boolean mIsInteractiveBroadcast = false; private long mIdForResponseEvent; private @Nullable IntentFilter mRemoveMatchingFilter; @@ -160,6 +161,13 @@ public class BroadcastOptions extends ComponentOptions { public static final String KEY_ALARM_BROADCAST = "android:broadcast.is_alarm"; + /** + * Corresponds to {@link #setInteractiveBroadcast(boolean)} + * @hide + */ + public static final String KEY_INTERACTIVE_BROADCAST = + "android:broadcast.is_interactive"; + /** * @hide * @deprecated Use {@link android.os.PowerExemptionManager# @@ -234,6 +242,7 @@ public class BroadcastOptions extends ComponentOptions { mRequireCompatChangeEnabled = opts.getBoolean(KEY_REQUIRE_COMPAT_CHANGE_ENABLED, true); mIdForResponseEvent = opts.getLong(KEY_ID_FOR_RESPONSE_EVENT); mIsAlarmBroadcast = opts.getBoolean(KEY_ALARM_BROADCAST, false); + mIsInteractiveBroadcast = opts.getBoolean(KEY_INTERACTIVE_BROADCAST, false); mRemoveMatchingFilter = opts.getParcelable(KEY_REMOVE_MATCHING_FILTER, IntentFilter.class); } @@ -548,6 +557,27 @@ public class BroadcastOptions extends ComponentOptions { return mIsAlarmBroadcast; } + /** + * When set, this broadcast will be understood as having originated from + * some direct interaction by the user such as a notification tap or button + * press. Only the OS itself may use this option. + * @hide + * @param broadcastIsInteractive + * @see #isInteractiveBroadcast() + */ + public void setInteractiveBroadcast(boolean broadcastIsInteractive) { + mIsInteractiveBroadcast = broadcastIsInteractive; + } + + /** + * Did this broadcast originate with a direct user interaction? + * @return true if this broadcast is the result of an interaction, false otherwise + * @hide + */ + public boolean isInteractiveBroadcast() { + return mIsInteractiveBroadcast; + } + /** * Did this broadcast originate from a push message from the server? * @@ -658,6 +688,9 @@ public class BroadcastOptions extends ComponentOptions { if (mIsAlarmBroadcast) { b.putBoolean(KEY_ALARM_BROADCAST, true); } + if (mIsInteractiveBroadcast) { + b.putBoolean(KEY_INTERACTIVE_BROADCAST, true); + } if (mMinManifestReceiverApiLevel != 0) { b.putInt(KEY_MIN_MANIFEST_RECEIVER_API_LEVEL, mMinManifestReceiverApiLevel); } diff --git a/services/core/java/com/android/server/am/ActivityManagerService.java b/services/core/java/com/android/server/am/ActivityManagerService.java index 1a4da7db54e93..3be91cf6ed7ad 100644 --- a/services/core/java/com/android/server/am/ActivityManagerService.java +++ b/services/core/java/com/android/server/am/ActivityManagerService.java @@ -14683,13 +14683,14 @@ public class ActivityManagerService extends IActivityManager.Stub // Non-system callers can't declare that a broadcast is alarm-related. // The PendingIntent invocation case is handled in PendingIntentRecord. if (bOptions != null && callingUid != SYSTEM_UID) { - if (bOptions.containsKey(BroadcastOptions.KEY_ALARM_BROADCAST)) { + if (bOptions.containsKey(BroadcastOptions.KEY_ALARM_BROADCAST) + || bOptions.containsKey(BroadcastOptions.KEY_INTERACTIVE_BROADCAST)) { if (DEBUG_BROADCAST) { Slog.w(TAG, "Non-system caller " + callingUid - + " may not flag broadcast as alarm-related"); + + " may not flag broadcast as alarm or interactive"); } throw new SecurityException( - "Non-system callers may not flag broadcasts as alarm-related"); + "Non-system callers may not flag broadcasts as alarm or interactive"); } } diff --git a/services/core/java/com/android/server/am/BroadcastProcessQueue.java b/services/core/java/com/android/server/am/BroadcastProcessQueue.java index 97635b53a6d41..9b74eaaa7b52c 100644 --- a/services/core/java/com/android/server/am/BroadcastProcessQueue.java +++ b/services/core/java/com/android/server/am/BroadcastProcessQueue.java @@ -102,6 +102,13 @@ class BroadcastProcessQueue { */ private final ArrayDeque mPending = new ArrayDeque<>(); + /** + * Ordered collection of "urgent" broadcasts that are waiting to be + * dispatched to this process, in the same representation as + * {@link #mPending}. + */ + private final ArrayDeque mPendingUrgent = new ArrayDeque<>(); + /** * Broadcast actively being dispatched to this process. */ @@ -164,39 +171,64 @@ class BroadcastProcessQueue { */ public void enqueueOrReplaceBroadcast(@NonNull BroadcastRecord record, int recordIndex, int blockedUntilTerminalCount) { - // If caller wants to replace, walk backwards looking for any matches if (record.isReplacePending()) { - final Iterator it = mPending.descendingIterator(); - final Object receiver = record.receivers.get(recordIndex); - while (it.hasNext()) { - final SomeArgs args = it.next(); - final BroadcastRecord testRecord = (BroadcastRecord) args.arg1; - final Object testReceiver = testRecord.receivers.get(args.argi1); - if ((record.callingUid == testRecord.callingUid) - && (record.userId == testRecord.userId) - && record.intent.filterEquals(testRecord.intent) - && isReceiverEquals(receiver, testReceiver)) { - // Exact match found; perform in-place swap - args.arg1 = record; - args.argi1 = recordIndex; - args.argi2 = blockedUntilTerminalCount; - onBroadcastDequeued(testRecord); - onBroadcastEnqueued(record); - return; - } + boolean didReplace = replaceBroadcastInQueue(mPending, + record, recordIndex, blockedUntilTerminalCount) + || replaceBroadcastInQueue(mPendingUrgent, + record, recordIndex, blockedUntilTerminalCount); + if (didReplace) { + return; } } // Caller isn't interested in replacing, or we didn't find any pending // item to replace above, so enqueue as a new broadcast - SomeArgs args = SomeArgs.obtain(); - args.arg1 = record; - args.argi1 = recordIndex; - args.argi2 = blockedUntilTerminalCount; - mPending.addLast(args); + SomeArgs newBroadcastArgs = SomeArgs.obtain(); + newBroadcastArgs.arg1 = record; + newBroadcastArgs.argi1 = recordIndex; + newBroadcastArgs.argi2 = blockedUntilTerminalCount; + + // Cross-broadcast prioritization policy: some broadcasts might warrant being + // issued ahead of others that are already pending, for example if this new + // broadcast is in a different delivery class or is tied to a direct user interaction + // with implicit responsiveness expectations. + final ArrayDeque queue = record.isUrgent() ? mPendingUrgent : mPending; + queue.addLast(newBroadcastArgs); onBroadcastEnqueued(record); } + /** + * Searches from newest to oldest, and at the first matching pending broadcast + * it finds, replaces it in-place and returns -- does not attempt to handle + * "duplicate" broadcasts in the queue. + *

+ * @return {@code true} if it found and replaced an existing record in the queue; + * {@code false} otherwise. + */ + private boolean replaceBroadcastInQueue(@NonNull ArrayDeque queue, + @NonNull BroadcastRecord record, int recordIndex, int blockedUntilTerminalCount) { + final Iterator it = queue.descendingIterator(); + final Object receiver = record.receivers.get(recordIndex); + while (it.hasNext()) { + final SomeArgs args = it.next(); + final BroadcastRecord testRecord = (BroadcastRecord) args.arg1; + final Object testReceiver = testRecord.receivers.get(args.argi1); + if ((record.callingUid == testRecord.callingUid) + && (record.userId == testRecord.userId) + && record.intent.filterEquals(testRecord.intent) + && isReceiverEquals(receiver, testReceiver)) { + // Exact match found; perform in-place swap + args.arg1 = record; + args.argi1 = recordIndex; + args.argi2 = blockedUntilTerminalCount; + onBroadcastDequeued(testRecord); + onBroadcastEnqueued(record); + return true; + } + } + return false; + } + /** * Functional interface that tests a {@link BroadcastRecord} that has been * previously enqueued in {@link BroadcastProcessQueue}. @@ -225,8 +257,18 @@ class BroadcastProcessQueue { */ public boolean forEachMatchingBroadcast(@NonNull BroadcastPredicate predicate, @NonNull BroadcastConsumer consumer, boolean andRemove) { + boolean didSomething = forEachMatchingBroadcastInQueue(mPending, + predicate, consumer, andRemove); + didSomething |= forEachMatchingBroadcastInQueue(mPendingUrgent, + predicate, consumer, andRemove); + return didSomething; + } + + private boolean forEachMatchingBroadcastInQueue(@NonNull ArrayDeque queue, + @NonNull BroadcastPredicate predicate, @NonNull BroadcastConsumer consumer, + boolean andRemove) { boolean didSomething = false; - final Iterator it = mPending.iterator(); + final Iterator it = queue.iterator(); while (it.hasNext()) { final SomeArgs args = it.next(); final BroadcastRecord record = (BroadcastRecord) args.arg1; @@ -301,7 +343,7 @@ class BroadcastProcessQueue { */ public void makeActiveNextPending() { // TODO: what if the next broadcast isn't runnable yet? - final SomeArgs next = mPending.removeFirst(); + final SomeArgs next = removeNextBroadcast(); mActive = (BroadcastRecord) next.arg1; mActiveIndex = next.argi1; mActiveCountSinceIdle++; @@ -403,13 +445,45 @@ class BroadcastProcessQueue { } public boolean isEmpty() { - return mPending.isEmpty(); + return mPending.isEmpty() && mPendingUrgent.isEmpty(); } public boolean isActive() { return mActive != null; } + /** + * Will thrown an exception if there are no pending broadcasts; relies on + * {@link #isEmpty()} being false. + */ + SomeArgs removeNextBroadcast() { + ArrayDeque queue = queueForNextBroadcast(); + return queue.removeFirst(); + } + + @Nullable ArrayDeque queueForNextBroadcast() { + if (!mPendingUrgent.isEmpty()) { + return mPendingUrgent; + } else if (!mPending.isEmpty()) { + return mPending; + } + return null; + } + + /** + * Returns null if there are no pending broadcasts + */ + @Nullable SomeArgs peekNextBroadcast() { + ArrayDeque queue = queueForNextBroadcast(); + return (queue != null) ? queue.peekFirst() : null; + } + + @VisibleForTesting + @Nullable BroadcastRecord peekNextBroadcastRecord() { + ArrayDeque queue = queueForNextBroadcast(); + return (queue != null) ? (BroadcastRecord) queue.peekFirst().arg1 : null; + } + /** * Quickly determine if this queue has broadcasts that are still waiting to * be delivered at some point in the future. @@ -427,11 +501,13 @@ class BroadcastProcessQueue { return mActive.enqueueTime > barrierTime; } final SomeArgs next = mPending.peekFirst(); - if (next != null) { - return ((BroadcastRecord) next.arg1).enqueueTime > barrierTime; - } - // Nothing running or runnable means we're past the barrier - return true; + final SomeArgs nextUrgent = mPendingUrgent.peekFirst(); + // Empty queue is past any barrier + final boolean nextLater = next == null + || ((BroadcastRecord) next.arg1).enqueueTime > barrierTime; + final boolean nextUrgentLater = nextUrgent == null + || ((BroadcastRecord) nextUrgent.arg1).enqueueTime > barrierTime; + return nextLater && nextUrgentLater; } public boolean isRunnable() { @@ -509,7 +585,7 @@ class BroadcastProcessQueue { * Update {@link #getRunnableAt()} if it's currently invalidated. */ private void updateRunnableAt() { - final SomeArgs next = mPending.peekFirst(); + final SomeArgs next = peekNextBroadcast(); if (next != null) { final BroadcastRecord r = (BroadcastRecord) next.arg1; final int index = next.argi1; @@ -527,7 +603,7 @@ class BroadcastProcessQueue { // If we have too many broadcasts pending, bypass any delays that // might have been applied above to aid draining - if (mPending.size() >= constants.MAX_PENDING_BROADCASTS) { + if (mPending.size() + mPendingUrgent.size() >= constants.MAX_PENDING_BROADCASTS) { mRunnableAt = runnableAt; mRunnableAtReason = REASON_MAX_PENDING; return; @@ -564,8 +640,8 @@ class BroadcastProcessQueue { */ public void checkHealthLocked() { if (mRunnableAtReason == REASON_BLOCKED) { - final SomeArgs next = mPending.peekFirst(); - Objects.requireNonNull(next, "peekFirst"); + final SomeArgs next = peekNextBroadcast(); + Objects.requireNonNull(next, "peekNextBroadcast"); // If blocked more than 10 minutes, we're likely wedged final BroadcastRecord r = (BroadcastRecord) next.arg1; diff --git a/services/core/java/com/android/server/am/BroadcastRecord.java b/services/core/java/com/android/server/am/BroadcastRecord.java index bcc76e949e5f2..ff09272869cf6 100644 --- a/services/core/java/com/android/server/am/BroadcastRecord.java +++ b/services/core/java/com/android/server/am/BroadcastRecord.java @@ -83,6 +83,7 @@ final class BroadcastRecord extends Binder { final boolean alarm; // originated from an alarm triggering? final boolean pushMessage; // originated from a push message? final boolean pushMessageOverQuota; // originated from a push message which was over quota? + final boolean interactive; // originated from user interaction? final boolean initialSticky; // initial broadcast from register to sticky? final boolean prioritized; // contains more than one priority tranche final int userId; // user id this broadcast was for @@ -389,6 +390,7 @@ final class BroadcastRecord extends Binder { alarm = options != null && options.isAlarmBroadcast(); pushMessage = options != null && options.isPushMessagingBroadcast(); pushMessageOverQuota = options != null && options.isPushMessagingOverQuotaBroadcast(); + interactive = options != null && options.isInteractiveBroadcast(); this.filterExtrasForReceiver = filterExtrasForReceiver; } @@ -446,6 +448,7 @@ final class BroadcastRecord extends Binder { alarm = from.alarm; pushMessage = from.pushMessage; pushMessageOverQuota = from.pushMessageOverQuota; + interactive = from.interactive; filterExtrasForReceiver = from.filterExtrasForReceiver; } @@ -607,6 +610,18 @@ final class BroadcastRecord extends Binder { return (intent.getFlags() & Intent.FLAG_RECEIVER_NO_ABORT) != 0; } + /** + * Core policy determination about this broadcast's delivery prioritization + */ + boolean isUrgent() { + // TODO: flags for controlling policy + // TODO: migrate alarm-prioritization flag to BroadcastConstants + return (isForeground() + || interactive + || alarm) + && receivers.size() == 1; + } + @NonNull String getHostingRecordTriggerType() { if (alarm) { return HostingRecord.TRIGGER_TYPE_ALARM; diff --git a/services/core/java/com/android/server/am/PendingIntentRecord.java b/services/core/java/com/android/server/am/PendingIntentRecord.java index bda60ff2172b3..69fe4162acc5b 100644 --- a/services/core/java/com/android/server/am/PendingIntentRecord.java +++ b/services/core/java/com/android/server/am/PendingIntentRecord.java @@ -424,13 +424,14 @@ public final class PendingIntentRecord extends IIntentSender.Stub { // invocation side effects such as allowlisting. if (options != null && callingUid != Process.SYSTEM_UID && key.type == ActivityManager.INTENT_SENDER_BROADCAST) { - if (options.containsKey(BroadcastOptions.KEY_ALARM_BROADCAST)) { + if (options.containsKey(BroadcastOptions.KEY_ALARM_BROADCAST) + || options.containsKey(BroadcastOptions.KEY_INTERACTIVE_BROADCAST)) { if (DEBUG_BROADCAST_LIGHT) { Slog.w(TAG, "Non-system caller " + callingUid - + " may not flag broadcast as alarm-related"); + + " may not flag broadcast as alarm or interactive"); } throw new SecurityException( - "Non-system callers may not flag broadcasts as alarm-related"); + "Non-system callers may not flag broadcasts as alarm or interactive"); } } diff --git a/services/tests/mockingservicestests/src/com/android/server/am/BroadcastQueueModernImplTest.java b/services/tests/mockingservicestests/src/com/android/server/am/BroadcastQueueModernImplTest.java index 86915da75467e..5a69057d2aff2 100644 --- a/services/tests/mockingservicestests/src/com/android/server/am/BroadcastQueueModernImplTest.java +++ b/services/tests/mockingservicestests/src/com/android/server/am/BroadcastQueueModernImplTest.java @@ -289,20 +289,30 @@ public class BroadcastQueueModernImplTest { final BroadcastProcessQueue queue = new BroadcastProcessQueue(mConstants, PACKAGE_GREEN, getUidForPackage(PACKAGE_GREEN)); + // enqueue a bg-priority broadcast then a fg-priority one + final Intent timezone = new Intent(Intent.ACTION_TIMEZONE_CHANGED); + final BroadcastRecord timezoneRecord = makeBroadcastRecord(timezone); + queue.enqueueOrReplaceBroadcast(timezoneRecord, 0, 0); + final Intent airplane = new Intent(Intent.ACTION_AIRPLANE_MODE_CHANGED); airplane.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); final BroadcastRecord airplaneRecord = makeBroadcastRecord(airplane); queue.enqueueOrReplaceBroadcast(airplaneRecord, 0, 0); + // verify that: + // (a) the queue is immediately runnable by existence of a fg-priority broadcast + // (b) the next one up is the fg-priority broadcast despite its later enqueue time queue.setProcessCached(false); assertTrue(queue.isRunnable()); assertEquals(airplaneRecord.enqueueTime, queue.getRunnableAt()); assertEquals(ProcessList.SCHED_GROUP_DEFAULT, queue.getPreferredSchedulingGroupLocked()); + assertEquals(queue.peekNextBroadcastRecord(), airplaneRecord); queue.setProcessCached(true); assertTrue(queue.isRunnable()); assertEquals(airplaneRecord.enqueueTime, queue.getRunnableAt()); assertEquals(ProcessList.SCHED_GROUP_DEFAULT, queue.getPreferredSchedulingGroupLocked()); + assertEquals(queue.peekNextBroadcastRecord(), airplaneRecord); } /**