Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9ba2e648 authored by Jernej Virag's avatar Jernej Virag Committed by Android (Google) Code Review
Browse files

Merge "Don't store old pending intents in NotificationTemplateViewWrapper" into main

parents 4a22e55a 43b4d613
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -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.
+193 −60
Original line number Diff line number Diff line
@@ -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,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
@@ -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));
        }
    }
}
+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
    }
}