summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/res/values/ids.xml5
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java253
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapperTest.kt254
3 files changed, 452 insertions, 60 deletions
diff --git a/packages/SystemUI/res/values/ids.xml b/packages/SystemUI/res/values/ids.xml
index 21696fefb80f..6cdd15e637bd 100644
--- a/packages/SystemUI/res/values/ids.xml
+++ b/packages/SystemUI/res/values/ids.xml
@@ -233,6 +233,11 @@
<item type="id" name="pin_pad"/>
<!--
+ Tag used to store pending intent registration listeners in NotificationTemplateViewWrapper
+ -->
+ <item type="id" name="pending_intent_listener_tag" />
+
+ <!--
Used to tag views programmatically added to the smartspace area so they can be more easily
removed later.
-->
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java
index 875a409c07f0..91b12ccf919a 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java
@@ -20,6 +20,9 @@ import static android.view.View.VISIBLE;
import static com.android.systemui.statusbar.notification.row.ExpandableNotificationRow.DEFAULT_HEADER_VISIBLE_AMOUNT;
+import android.annotation.MainThread;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Context;
@@ -34,8 +37,7 @@ import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
-import androidx.annotation.Nullable;
-
+import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.ContrastColorUtil;
import com.android.internal.widget.NotificationActionListLayout;
import com.android.systemui.Dependency;
@@ -49,6 +51,8 @@ import com.android.systemui.statusbar.notification.TransformState;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
import com.android.systemui.statusbar.notification.row.HybridNotificationView;
+import java.util.function.Consumer;
+
/**
* Wraps a notification view inflated from a template.
*/
@@ -66,9 +70,13 @@ public class NotificationTemplateViewWrapper extends NotificationHeaderViewWrapp
private int mContentHeight;
private int mMinHeightHint;
+ @Nullable
private NotificationActionListLayout mActions;
- private ArraySet<PendingIntent> mCancelledPendingIntents = new ArraySet<>();
- private UiOffloadThread mUiOffloadThread;
+ // Holds list of pending intents that have been cancelled by now - we only keep hash codes
+ // to avoid holding full binder proxies for intents that may have been removed by now.
+ @NonNull
+ @VisibleForTesting
+ final ArraySet<Integer> mCancelledPendingIntents = new ArraySet<>();
private View mRemoteInputHistory;
private boolean mCanHideHeader;
private float mHeaderTranslation;
@@ -147,6 +155,7 @@ public class NotificationTemplateViewWrapper extends NotificationHeaderViewWrapp
com.android.internal.R.dimen.notification_content_margin_top);
}
+ @MainThread
private void resolveTemplateViews(StatusBarNotification sbn) {
mRightIcon = mView.findViewById(com.android.internal.R.id.right_icon);
if (mRightIcon != null) {
@@ -195,34 +204,57 @@ public class NotificationTemplateViewWrapper extends NotificationHeaderViewWrapp
return getLargeIcon(n);
}
+ @MainThread
private void updatePendingIntentCancellations() {
if (mActions != null) {
int numActions = mActions.getChildCount();
+ final ArraySet<Integer> currentlyActivePendingIntents = new ArraySet<>(numActions);
for (int i = 0; i < numActions; i++) {
Button action = (Button) mActions.getChildAt(i);
- performOnPendingIntentCancellation(action, () -> {
- if (action.isEnabled()) {
- action.setEnabled(false);
- // The visual appearance doesn't look disabled enough yet, let's add the
- // alpha as well. Since Alpha doesn't play nicely right now with the
- // transformation, we rather blend it manually with the background color.
- ColorStateList textColors = action.getTextColors();
- int[] colors = textColors.getColors();
- int[] newColors = new int[colors.length];
- float disabledAlpha = mView.getResources().getFloat(
- com.android.internal.R.dimen.notification_action_disabled_alpha);
- for (int j = 0; j < colors.length; j++) {
- int color = colors[j];
- color = blendColorWithBackground(color, disabledAlpha);
- newColors[j] = color;
- }
- ColorStateList newColorStateList = new ColorStateList(
- textColors.getStates(), newColors);
- action.setTextColor(newColorStateList);
+ PendingIntent pendingIntent = getPendingIntentForAction(action);
+ // Check if passed intent has already been cancelled in this class and immediately
+ // disable the action to avoid temporary race with enable/disable.
+ if (pendingIntent != null) {
+ int pendingIntentHashCode = getHashCodeForPendingIntent(pendingIntent);
+ currentlyActivePendingIntents.add(pendingIntentHashCode);
+ if (mCancelledPendingIntents.contains(pendingIntentHashCode)) {
+ disableActionView(action);
}
- });
+ }
+ updatePendingIntentCancellationListener(action, pendingIntent);
+ }
+
+ // This cleanup ensures that the size of this set doesn't grow into unreasonable sizes.
+ // There are scenarios where applications updated notifications with different
+ // PendingIntents which could cause this Set to grow to 1000+ elements.
+ mCancelledPendingIntents.retainAll(currentlyActivePendingIntents);
+ }
+ }
+
+ @MainThread
+ private void updatePendingIntentCancellationListener(Button action,
+ @Nullable PendingIntent pendingIntent) {
+ ActionPendingIntentCancellationHandler cancellationHandler = null;
+ if (pendingIntent != null) {
+ // Attach listeners to handle intent cancellation to this view.
+ cancellationHandler = new ActionPendingIntentCancellationHandler(pendingIntent, action,
+ this::disableActionViewWithIntent);
+ action.addOnAttachStateChangeListener(cancellationHandler);
+ // Immediately fire the event if the view is already attached to register
+ // pending intent cancellation listener.
+ if (action.isAttachedToWindow()) {
+ cancellationHandler.onViewAttachedToWindow(action);
}
}
+
+ // If the view has an old attached listener, remove it to avoid leaking intents.
+ ActionPendingIntentCancellationHandler previousHandler =
+ (ActionPendingIntentCancellationHandler) action.getTag(
+ R.id.pending_intent_listener_tag);
+ if (previousHandler != null) {
+ previousHandler.remove();
+ }
+ action.setTag(R.id.pending_intent_listener_tag, cancellationHandler);
}
private int blendColorWithBackground(int color, float alpha) {
@@ -231,42 +263,6 @@ public class NotificationTemplateViewWrapper extends NotificationHeaderViewWrapp
Color.red(color), Color.green(color), Color.blue(color)), resolveBackgroundColor());
}
- private void performOnPendingIntentCancellation(View view, Runnable cancellationRunnable) {
- PendingIntent pendingIntent = (PendingIntent) view.getTag(
- com.android.internal.R.id.pending_intent_tag);
- if (pendingIntent == null) {
- return;
- }
- if (mCancelledPendingIntents.contains(pendingIntent)) {
- cancellationRunnable.run();
- } else {
- PendingIntent.CancelListener listener = (PendingIntent intent) -> {
- mView.post(() -> {
- mCancelledPendingIntents.add(pendingIntent);
- cancellationRunnable.run();
- });
- };
- if (mUiOffloadThread == null) {
- mUiOffloadThread = Dependency.get(UiOffloadThread.class);
- }
- if (view.isAttachedToWindow()) {
- mUiOffloadThread.execute(() -> pendingIntent.registerCancelListener(listener));
- }
- view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
- @Override
- public void onViewAttachedToWindow(View v) {
- mUiOffloadThread.execute(() -> pendingIntent.registerCancelListener(listener));
- }
-
- @Override
- public void onViewDetachedFromWindow(View v) {
- mUiOffloadThread.execute(
- () -> pendingIntent.unregisterCancelListener(listener));
- }
- });
- }
- }
-
@Override
public void onContentUpdated(ExpandableNotificationRow row) {
// Reinspect the notification. Before the super call, because the super call also updates
@@ -364,4 +360,141 @@ public class NotificationTemplateViewWrapper extends NotificationHeaderViewWrapp
}
return extra + super.getExtraMeasureHeight();
}
+
+ /**
+ * This finds Action view with a given intent and disables it.
+ * With maximum of 3 views, this is sufficiently fast to iterate on main thread every time.
+ */
+ @MainThread
+ private void disableActionViewWithIntent(PendingIntent intent) {
+ mCancelledPendingIntents.add(getHashCodeForPendingIntent(intent));
+ if (mActions != null) {
+ int numActions = mActions.getChildCount();
+ for (int i = 0; i < numActions; i++) {
+ Button action = (Button) mActions.getChildAt(i);
+ PendingIntent pendingIntent = getPendingIntentForAction(action);
+ if (intent.equals(pendingIntent)) {
+ disableActionView(action);
+ }
+ }
+ }
+ }
+
+ /**
+ * Disables Action view when, e.g., its PendingIntent is disabled.
+ */
+ @MainThread
+ private void disableActionView(Button action) {
+ if (action.isEnabled()) {
+ action.setEnabled(false);
+ // The visual appearance doesn't look disabled enough yet, let's add the
+ // alpha as well. Since Alpha doesn't play nicely right now with the
+ // transformation, we rather blend it manually with the background color.
+ ColorStateList textColors = action.getTextColors();
+ int[] colors = textColors.getColors();
+ int[] newColors = new int[colors.length];
+ float disabledAlpha = mView.getResources().getFloat(
+ com.android.internal.R.dimen.notification_action_disabled_alpha);
+ for (int j = 0; j < colors.length; j++) {
+ int color = colors[j];
+ color = blendColorWithBackground(color, disabledAlpha);
+ newColors[j] = color;
+ }
+ ColorStateList newColorStateList = new ColorStateList(
+ textColors.getStates(), newColors);
+ action.setTextColor(newColorStateList);
+ }
+ }
+
+ /**
+ * Returns the hashcode of underlying target of PendingIntent. We can get multiple
+ * Java PendingIntent wrapper objects pointing to the same cancelled PI in system_server.
+ * This makes sure we treat them equally.
+ */
+ private static int getHashCodeForPendingIntent(PendingIntent pendingIntent) {
+ return System.identityHashCode(pendingIntent.getTarget().asBinder());
+ }
+
+ /**
+ * Returns PendingIntent contained in the action tag. May be null.
+ */
+ @Nullable
+ private static PendingIntent getPendingIntentForAction(View action) {
+ return (PendingIntent) action.getTag(com.android.internal.R.id.pending_intent_tag);
+ }
+
+ /**
+ * Registers listeners for pending intent cancellation when Action views are attached
+ * to window.
+ * It calls onCancelPendingIntentForActionView when a PendingIntent is cancelled.
+ */
+ @VisibleForTesting
+ static final class ActionPendingIntentCancellationHandler
+ implements View.OnAttachStateChangeListener {
+
+ @Nullable
+ private static UiOffloadThread sUiOffloadThread = null;
+
+ @NonNull
+ private static UiOffloadThread getUiOffloadThread() {
+ if (sUiOffloadThread == null) {
+ sUiOffloadThread = Dependency.get(UiOffloadThread.class);
+ }
+ return sUiOffloadThread;
+ }
+
+ private final View mView;
+ private final Consumer<PendingIntent> mOnCancelledCallback;
+
+ private final PendingIntent mPendingIntent;
+
+ ActionPendingIntentCancellationHandler(PendingIntent pendingIntent, View actionView,
+ Consumer<PendingIntent> onCancelled) {
+ this.mPendingIntent = pendingIntent;
+ this.mView = actionView;
+ this.mOnCancelledCallback = onCancelled;
+ }
+
+ private final PendingIntent.CancelListener mCancelListener =
+ new PendingIntent.CancelListener() {
+ @Override
+ public void onCanceled(PendingIntent pendingIntent) {
+ mView.post(() -> {
+ mOnCancelledCallback.accept(pendingIntent);
+ // We don't need this listener anymore once the intent was cancelled.
+ remove();
+ });
+ }
+ };
+
+ @MainThread
+ @Override
+ public void onViewAttachedToWindow(View view) {
+ // This is safe to call multiple times with the same listener instance.
+ getUiOffloadThread().execute(() -> {
+ mPendingIntent.registerCancelListener(mCancelListener);
+ });
+ }
+
+ @MainThread
+ @Override
+ public void onViewDetachedFromWindow(View view) {
+ // This is safe to call multiple times with the same listener instance.
+ getUiOffloadThread().execute(() ->
+ mPendingIntent.unregisterCancelListener(mCancelListener));
+ }
+
+ /**
+ * Removes this listener from callbacks and releases the held PendingIntent.
+ */
+ @MainThread
+ public void remove() {
+ mView.removeOnAttachStateChangeListener(this);
+ if (mView.getTag(R.id.pending_intent_listener_tag) == this) {
+ mView.setTag(R.id.pending_intent_listener_tag, null);
+ }
+ getUiOffloadThread().execute(() ->
+ mPendingIntent.unregisterCancelListener(mCancelListener));
+ }
+ }
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapperTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapperTest.kt
new file mode 100644
index 000000000000..8c3bfd55ecf1
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapperTest.kt
@@ -0,0 +1,254 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.statusbar.notification.row.wrapper
+
+import android.app.PendingIntent
+import android.app.PendingIntent.CancelListener
+import android.content.Intent
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.testing.TestableLooper.RunWithLooper
+import android.testing.ViewUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.test.filters.SmallTest
+import com.android.internal.R
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
+import com.android.systemui.statusbar.notification.row.NotificationTestHelper
+import com.android.systemui.statusbar.notification.row.wrapper.NotificationTemplateViewWrapper.ActionPendingIntentCancellationHandler
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Mockito
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@RunWithLooper
+class NotificationTemplateViewWrapperTest : SysuiTestCase() {
+
+ private lateinit var helper: NotificationTestHelper
+
+ private lateinit var root: ViewGroup
+ private lateinit var view: ViewGroup
+ private lateinit var row: ExpandableNotificationRow
+ private lateinit var actions: ViewGroup
+
+ private lateinit var looper: TestableLooper
+
+ @Before
+ fun setUp() {
+ looper = TestableLooper.get(this)
+ allowTestableLooperAsMainThread()
+ helper = NotificationTestHelper(mContext, mDependency, looper)
+ row = helper.createRow()
+ // Some code in the view iterates through parents so we need some extra containers around
+ // it.
+ root = FrameLayout(mContext)
+ val root2 = FrameLayout(mContext)
+ root.addView(root2)
+ view =
+ (LayoutInflater.from(mContext)
+ .inflate(R.layout.notification_template_material_big_text, root2) as ViewGroup)
+ actions = view.findViewById(R.id.actions)!!
+ ViewUtils.attachView(root)
+ }
+
+ @Test
+ fun noActionsPresent_noCrash() {
+ view.removeView(actions)
+ val wrapper = NotificationTemplateViewWrapper(mContext, view, row)
+ wrapper.onContentUpdated(row)
+ }
+
+ @Test
+ fun actionPendingIntentCancelled_actionDisabled() {
+ val wrapper = NotificationTemplateViewWrapper(mContext, view, row)
+ val action1 = createActionWithPendingIntent()
+ val action2 = createActionWithPendingIntent()
+ val action3 = createActionWithPendingIntent()
+ wrapper.onContentUpdated(row)
+ waitForUiOffloadThread() // Wait for cancellation registration to execute.
+
+ val pi3 = getPendingIntent(action3)
+ pi3.cancel()
+ looper.processAllMessages() // Wait for listener callbacks to execute
+
+ assertThat(action1.isEnabled).isTrue()
+ assertThat(action2.isEnabled).isTrue()
+ assertThat(action3.isEnabled).isFalse()
+ assertThat(wrapper.mCancelledPendingIntents)
+ .doesNotContain(getPendingIntent(action1).hashCode())
+ assertThat(wrapper.mCancelledPendingIntents)
+ .doesNotContain(getPendingIntent(action2).hashCode())
+ assertThat(wrapper.mCancelledPendingIntents).contains(pi3.hashCode())
+ }
+
+ @Test
+ fun newActionWithSamePendingIntentPosted_actionDisabled() {
+ val wrapper = NotificationTemplateViewWrapper(mContext, view, row)
+ val action = createActionWithPendingIntent()
+ wrapper.onContentUpdated(row)
+ waitForUiOffloadThread() // Wait for cancellation registration to execute.
+
+ // Cancel the intent and check action is now false.
+ val pi = getPendingIntent(action)
+ pi.cancel()
+ looper.processAllMessages() // Wait for listener callbacks to execute
+ assertThat(action.isEnabled).isFalse()
+
+ // Create a NEW action and make sure that one will also be cancelled with same PI.
+ actions.removeView(action)
+ val newAction = createActionWithPendingIntent(pi)
+ wrapper.onContentUpdated(row)
+ looper.processAllMessages() // Wait for listener callbacks to execute
+
+ assertThat(newAction.isEnabled).isFalse()
+ assertThat(wrapper.mCancelledPendingIntents).containsExactly(pi.hashCode())
+ }
+
+ @Test
+ fun twoActionsWithSameCancelledIntent_bothActionsDisabled() {
+ val wrapper = NotificationTemplateViewWrapper(mContext, view, row)
+ val action1 = createActionWithPendingIntent()
+ val action2 = createActionWithPendingIntent()
+ val action3 = createActionWithPendingIntent(getPendingIntent(action2))
+ wrapper.onContentUpdated(row)
+ waitForUiOffloadThread() // Wait for cancellation registration to execute.
+
+ val pi = getPendingIntent(action2)
+ pi.cancel()
+ looper.processAllMessages() // Wait for listener callbacks to execute
+
+ assertThat(action1.isEnabled).isTrue()
+ assertThat(action2.isEnabled).isFalse()
+ assertThat(action3.isEnabled).isFalse()
+ }
+
+ @Test
+ fun actionPendingIntentCancelled_whileDetached_actionDisabled() {
+ ViewUtils.detachView(root)
+ val wrapper = NotificationTemplateViewWrapper(mContext, view, row)
+ val action = createActionWithPendingIntent()
+ wrapper.onContentUpdated(row)
+ getPendingIntent(action).cancel()
+ ViewUtils.attachView(root)
+ waitForUiOffloadThread()
+ looper.processAllMessages()
+
+ assertThat(action.isEnabled).isFalse()
+ }
+
+ @Test
+ fun actionViewDetached_pendingIntentListenersDeregistered() {
+ val pi =
+ PendingIntent.getActivity(
+ mContext,
+ System.currentTimeMillis().toInt(),
+ Intent(Intent.ACTION_VIEW),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ val spy = Mockito.spy(pi)
+ createActionWithPendingIntent(spy)
+ val wrapper = NotificationTemplateViewWrapper(mContext, view, row)
+ wrapper.onContentUpdated(row)
+ ViewUtils.detachView(root)
+ waitForUiOffloadThread()
+ looper.processAllMessages()
+
+ val captor = ArgumentCaptor.forClass(CancelListener::class.java)
+ verify(spy, times(1)).registerCancelListener(captor.capture())
+ verify(spy, times(1)).unregisterCancelListener(captor.value)
+ }
+
+ @Test
+ fun actionViewUpdated_oldPendingIntentListenersRemoved() {
+ val pi =
+ PendingIntent.getActivity(
+ mContext,
+ System.currentTimeMillis().toInt(),
+ Intent(Intent.ACTION_VIEW),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ val spy = Mockito.spy(pi)
+ val action = createActionWithPendingIntent(spy)
+ val wrapper = NotificationTemplateViewWrapper(mContext, view, row)
+ wrapper.onContentUpdated(row)
+ waitForUiOffloadThread()
+ looper.processAllMessages()
+
+ // Grab set attach listener
+ val attachListener =
+ Mockito.spy(action.getTag(com.android.systemui.res.R.id.pending_intent_listener_tag))
+ as ActionPendingIntentCancellationHandler
+ action.setTag(com.android.systemui.res.R.id.pending_intent_listener_tag, attachListener)
+
+ // Update pending intent in the existing action
+ val newPi =
+ PendingIntent.getActivity(
+ mContext,
+ System.currentTimeMillis().toInt(),
+ Intent(Intent.ACTION_ALARM_CHANGED),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ action.setTagInternal(R.id.pending_intent_tag, newPi)
+ wrapper.onContentUpdated(row)
+ waitForUiOffloadThread()
+ looper.processAllMessages()
+
+ // Listeners for original pending intent need to be cleaned up now.
+ val captor = ArgumentCaptor.forClass(CancelListener::class.java)
+ verify(spy, times(1)).registerCancelListener(captor.capture())
+ verify(spy, times(1)).unregisterCancelListener(captor.value)
+ // Attach listener has to be replaced with a new one.
+ assertThat(action.getTag(com.android.systemui.res.R.id.pending_intent_listener_tag))
+ .isNotEqualTo(attachListener)
+ verify(attachListener).remove()
+ }
+
+ private fun createActionWithPendingIntent(): View {
+ val pi =
+ PendingIntent.getActivity(
+ mContext,
+ System.currentTimeMillis().toInt(),
+ Intent(Intent.ACTION_VIEW),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ return createActionWithPendingIntent(pi)
+ }
+
+ private fun createActionWithPendingIntent(pi: PendingIntent): View {
+ val view =
+ LayoutInflater.from(mContext)
+ .inflate(R.layout.notification_material_action, null, false)
+ view.setTagInternal(R.id.pending_intent_tag, pi)
+ actions.addView(view)
+ return view
+ }
+
+ private fun getPendingIntent(action: View): PendingIntent {
+ val pendingIntent = action.getTag(R.id.pending_intent_tag) as PendingIntent
+ assertThat(pendingIntent).isNotNull()
+ return pendingIntent
+ }
+}