Loading packages/SystemUI/res/values/ids.xml +5 −0 Original line number Diff line number Diff line Loading @@ -232,6 +232,11 @@ <!-- Values assigned to the views in Biometrics Prompt --> <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. Loading packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java +193 −60 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading @@ -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. */ Loading @@ -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; Loading Loading @@ -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) { Loading Loading @@ -195,76 +204,63 @@ 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); } } private int blendColorWithBackground(int color, float alpha) { // alpha doesn't go well for color filters, so let's blend it manually return ContrastColorUtil.compositeColors(Color.argb((int) (alpha * 255), Color.red(color), Color.green(color), Color.blue(color)), resolveBackgroundColor()); updatePendingIntentCancellationListener(action, pendingIntent); } private void performOnPendingIntentCancellation(View view, Runnable cancellationRunnable) { PendingIntent pendingIntent = (PendingIntent) view.getTag( com.android.internal.R.id.pending_intent_tag); if (pendingIntent == null) { return; // 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); } 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)); @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); } 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)); // 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) { // alpha doesn't go well for color filters, so let's blend it manually return ContrastColorUtil.compositeColors(Color.argb((int) (alpha * 255), Color.red(color), Color.green(color), Color.blue(color)), resolveBackgroundColor()); } @Override Loading Loading @@ -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)); } } } packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapperTest.kt 0 → 100644 +254 −0 Original line number Diff line number Diff line /* * 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 } } Loading
packages/SystemUI/res/values/ids.xml +5 −0 Original line number Diff line number Diff line Loading @@ -232,6 +232,11 @@ <!-- Values assigned to the views in Biometrics Prompt --> <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. Loading
packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java +193 −60 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading @@ -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. */ Loading @@ -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; Loading Loading @@ -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) { Loading Loading @@ -195,76 +204,63 @@ 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); } } private int blendColorWithBackground(int color, float alpha) { // alpha doesn't go well for color filters, so let's blend it manually return ContrastColorUtil.compositeColors(Color.argb((int) (alpha * 255), Color.red(color), Color.green(color), Color.blue(color)), resolveBackgroundColor()); updatePendingIntentCancellationListener(action, pendingIntent); } private void performOnPendingIntentCancellation(View view, Runnable cancellationRunnable) { PendingIntent pendingIntent = (PendingIntent) view.getTag( com.android.internal.R.id.pending_intent_tag); if (pendingIntent == null) { return; // 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); } 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)); @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); } 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)); // 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) { // alpha doesn't go well for color filters, so let's blend it manually return ContrastColorUtil.compositeColors(Color.argb((int) (alpha * 255), Color.red(color), Color.green(color), Color.blue(color)), resolveBackgroundColor()); } @Override Loading Loading @@ -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)); } } }
packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapperTest.kt 0 → 100644 +254 −0 Original line number Diff line number Diff line /* * 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 } }