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

Commit 87d79dd9 authored by Jeff DeCew's avatar Jeff DeCew
Browse files

[RON] Inflate and bind Rich Ongoing Views

Bug: 343942780
Flag: com.android.systemui.notification_row_content_binder_refactor
Flag: android.app.api_rich_ongoing
Test: atest NotificationRowContentBinderImplTest
Change-Id: I91953608918da98a7b57f6f47ff953294bfad6fa
parent 5a8fde05
Loading
Loading
Loading
Loading
+105 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.
 */

@file:OptIn(ExperimentalCoroutinesApi::class)

package com.android.systemui.statusbar.notification.row.ui.viewmodel

import android.app.PendingIntent
import android.platform.test.annotations.EnableFlags
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testScope
import com.android.systemui.statusbar.notification.row.data.repository.fakeNotificationRowRepository
import com.android.systemui.statusbar.notification.row.shared.IconModel
import com.android.systemui.statusbar.notification.row.shared.RichOngoingNotificationFlag
import com.android.systemui.statusbar.notification.row.shared.TimerContentModel
import com.android.systemui.statusbar.notification.row.shared.TimerContentModel.TimerState.Paused
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import java.time.Duration
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock

@RunWith(AndroidJUnit4::class)
@SmallTest
@EnableFlags(RichOngoingNotificationFlag.FLAG_NAME)
class TimerViewModelTest : SysuiTestCase() {
    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val repository = kosmos.fakeNotificationRowRepository

    private var contentModel: TimerContentModel?
        get() = repository.richOngoingContentModel.value as? TimerContentModel
        set(value) {
            repository.richOngoingContentModel.value = value
        }

    private lateinit var underTest: TimerViewModel

    @Before
    fun setup() {
        underTest = kosmos.getTimerViewModel(repository)
    }

    @Test
    fun labelShowsTheTimerName() =
        testScope.runTest {
            val label by collectLastValue(underTest.label)
            contentModel = pausedTimer(name = "Example Timer Name")
            assertThat(label).isEqualTo("Example Timer Name")
        }

    @Test
    fun pausedTimeRemainingFormatsWell() =
        testScope.runTest {
            val label by collectLastValue(underTest.pausedTime)
            contentModel = pausedTimer(timeRemaining = Duration.ofMinutes(3))
            assertThat(label).isEqualTo("3:00")
            contentModel = pausedTimer(timeRemaining = Duration.ofSeconds(119))
            assertThat(label).isEqualTo("1:59")
            contentModel = pausedTimer(timeRemaining = Duration.ofSeconds(121))
            assertThat(label).isEqualTo("2:01")
            contentModel = pausedTimer(timeRemaining = Duration.ofHours(1))
            assertThat(label).isEqualTo("1:00:00")
            contentModel = pausedTimer(timeRemaining = Duration.ofHours(24))
            assertThat(label).isEqualTo("24:00:00")
        }

    private fun pausedTimer(
        icon: IconModel = mock(),
        name: String = "example",
        timeRemaining: Duration = Duration.ofMinutes(3),
        resumeIntent: PendingIntent? = null,
        resetIntent: PendingIntent? = null
    ) =
        TimerContentModel(
            icon = icon,
            name = name,
            state =
                Paused(
                    timeRemaining = timeRemaining,
                    resumeIntent = resumeIntent,
                    resetIntent = resetIntent,
                )
        )
}
+116 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<!--
  ~ Copyright (C) 2024 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
  -->
<com.android.systemui.statusbar.notification.row.ui.view.TimerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/topBaseline"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_begin="22sp"
        />

    <ImageView
        android:id="@+id/icon"
        android:layout_width="24dp"
        android:layout_height="24dp"
        android:src="@drawable/ic_close"
        app:tint="@android:color/white"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/label"
        android:baseline="18dp"
        app:layout_constraintBaseline_toTopOf="@id/topBaseline"
        />
    <TextView
        android:id="@+id/label"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toEndOf="@id/icon"
        app:layout_constraintEnd_toStartOf="@id/chronoRemaining"
        android:singleLine="true"
        tools:text="15s Timer"
        app:layout_constraintBaseline_toTopOf="@id/topBaseline"
        android:paddingEnd="4dp"
        />
    <Chronometer
        android:id="@+id/chronoRemaining"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:singleLine="true"
        android:textSize="20sp"
        android:gravity="end"
        tools:text="0:12"
        app:layout_constraintBaseline_toTopOf="@id/topBaseline"
        app:layout_constraintEnd_toStartOf="@id/pausedTimeRemaining"
        app:layout_constraintStart_toEndOf="@id/label"
        android:countDown="true"
        android:paddingEnd="4dp"
        />
    <TextView
        android:id="@+id/pausedTimeRemaining"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:singleLine="true"
        android:textSize="20sp"
        android:gravity="end"
        tools:text="0:12"
        app:layout_constraintBaseline_toTopOf="@id/topBaseline"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/chronoRemaining"
        android:paddingEnd="4dp"
        />

    <androidx.constraintlayout.widget.Barrier
        android:id="@+id/bottomOfTop"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:barrierDirection="bottom"
        app:constraint_referenced_ids="icon,label,chronoRemaining,pausedTimeRemaining"
        />

    <com.android.systemui.statusbar.notification.row.ui.view.TimerButtonView
        android:id="@+id/mainButton"
        android:layout_width="124dp"
        android:layout_height="wrap_content"
        tools:text="Reset"
        tools:drawableStart="@android:drawable/ic_menu_add"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/altButton"
        app:layout_constraintTop_toBottomOf="@id/bottomOfTop"
        app:layout_constraintHorizontal_chainStyle="spread"
        android:paddingEnd="4dp"
        />

    <com.android.systemui.statusbar.notification.row.ui.view.TimerButtonView
        android:id="@+id/altButton"
        tools:text="Reset"
        tools:drawableStart="@android:drawable/ic_menu_add"
        android:drawablePadding="2dp"
        android:drawableTint="@android:color/white"
        android:layout_width="124dp"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/bottomOfTop"
        app:layout_constraintStart_toEndOf="@id/mainButton"
        app:layout_constraintEnd_toEndOf="parent"
        android:paddingEnd="4dp"
        />
</com.android.systemui.statusbar.notification.row.ui.view.TimerView>
 No newline at end of file
+14 −1
Original line number Diff line number Diff line
@@ -68,9 +68,11 @@ import com.android.systemui.statusbar.notification.icon.IconPack;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRowController;
import com.android.systemui.statusbar.notification.row.NotificationGuts;
import com.android.systemui.statusbar.notification.row.data.repository.NotificationRowRepository;
import com.android.systemui.statusbar.notification.row.shared.HeadsUpStatusBarModel;
import com.android.systemui.statusbar.notification.row.shared.NotificationContentModel;
import com.android.systemui.statusbar.notification.row.shared.NotificationRowContentBinderRefactor;
import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel;
import com.android.systemui.statusbar.notification.stack.PriorityBucket;
import com.android.systemui.util.ListenerSet;

@@ -97,7 +99,7 @@ import java.util.Objects;
 * At the moment, there are many things here that shouldn't be and vice-versa. Hopefully we can
 * clean this up in the future.
 */
public final class NotificationEntry extends ListEntry {
public final class NotificationEntry extends ListEntry implements NotificationRowRepository {

    private final String mKey;
    private StatusBarNotification mSbn;
@@ -159,6 +161,8 @@ public final class NotificationEntry extends ListEntry {
            StateFlowKt.MutableStateFlow(null);
    private final MutableStateFlow<CharSequence> mHeadsUpStatusBarTextPublic =
            StateFlowKt.MutableStateFlow(null);
    private final MutableStateFlow<RichOngoingContentModel> mRichOngoingContentModel =
            StateFlowKt.MutableStateFlow(null);

    // indicates when this entry's view was first attached to a window
    // this value will reset when the view is completely removed from the shade (ie: filtered out)
@@ -945,6 +949,7 @@ public final class NotificationEntry extends ListEntry {
    }

    /** @see #setHeadsUpStatusBarText(CharSequence) */
    @NonNull
    public StateFlow<CharSequence> getHeadsUpStatusBarText() {
        return mHeadsUpStatusBarText;
    }
@@ -959,10 +964,17 @@ public final class NotificationEntry extends ListEntry {
    }

    /** @see #setHeadsUpStatusBarTextPublic(CharSequence) */
    @NonNull
    public StateFlow<CharSequence> getHeadsUpStatusBarTextPublic() {
        return mHeadsUpStatusBarTextPublic;
    }

    /** Gets the current RON content model, which may be null */
    @NonNull
    public StateFlow<RichOngoingContentModel> getRichOngoingContentModel() {
        return mRichOngoingContentModel;
    }

    /**
     * Sets the text to be displayed on the StatusBar, when this notification is the top pinned
     * heads up, and its content is sensitive right now.
@@ -1047,6 +1059,7 @@ public final class NotificationEntry extends ListEntry {
        HeadsUpStatusBarModel headsUpStatusBarModel = contentModel.getHeadsUpStatusBarModel();
        this.mHeadsUpStatusBarText.setValue(headsUpStatusBarModel.getPrivateText());
        this.mHeadsUpStatusBarTextPublic.setValue(headsUpStatusBarModel.getPublicText());
        this.mRichOngoingContentModel.setValue(contentModel.getRichOngoingContentModel());
    }

    /** Information about a suggestion that is being edited. */
+4 −0
Original line number Diff line number Diff line
@@ -72,6 +72,8 @@ import com.android.systemui.statusbar.policy.dagger.RemoteInputViewSubcomponent;
import com.android.systemui.util.Compile;
import com.android.systemui.util.DumpUtilsKt;

import kotlinx.coroutines.DisposableHandle;

import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collections;
@@ -109,6 +111,8 @@ public class NotificationContentView extends FrameLayout implements Notification
    private View mHeadsUpChild;
    private HybridNotificationView mSingleLineView;

    @Nullable public DisposableHandle mContractedBinderHandle;

    private RemoteInputView mExpandedRemoteInput;
    private RemoteInputView mHeadsUpRemoteInput;

+63 −7
Original line number Diff line number Diff line
@@ -92,6 +92,7 @@ constructor(
    private val remoteInputManager: NotificationRemoteInputManager,
    private val conversationProcessor: ConversationNotificationProcessor,
    private val ronExtractor: RichOngoingNotificationContentExtractor,
    private val ronInflater: RichOngoingNotificationViewInflater,
    @NotifInflation private val inflationExecutor: Executor,
    private val smartReplyStateInflater: SmartReplyStateInflater,
    private val notifLayoutInflaterFactoryProvider: NotifLayoutInflaterFactory.Provider,
@@ -140,6 +141,7 @@ constructor(
                entry,
                conversationProcessor,
                ronExtractor,
                ronInflater,
                row,
                bindParams.isMinimized,
                bindParams.usesIncreasedHeight,
@@ -264,6 +266,8 @@ constructor(
        when (inflateFlag) {
            FLAG_CONTENT_VIEW_CONTRACTED ->
                row.privateLayout.performWhenContentInactive(VISIBLE_TYPE_CONTRACTED) {
                    row.privateLayout.mContractedBinderHandle?.dispose()
                    row.privateLayout.mContractedBinderHandle = null
                    row.privateLayout.setContractedChild(null)
                    remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_CONTRACTED)
                }
@@ -342,6 +346,7 @@ constructor(
        private val entry: NotificationEntry,
        private val conversationProcessor: ConversationNotificationProcessor,
        private val ronExtractor: RichOngoingNotificationContentExtractor,
        private val ronInflater: RichOngoingNotificationViewInflater,
        private val row: ExpandableNotificationRow,
        private val isMinimized: Boolean,
        private val usesIncreasedHeight: Boolean,
@@ -453,6 +458,21 @@ constructor(
                        )
                    }
            }

            if (reInflateFlags and CONTENT_VIEWS_TO_CREATE_RICH_ONGOING != 0) {
                logger.logAsyncTaskProgress(entry, "inflating RON view")
                inflationProgress.richOngoingNotificationViewHolder =
                    inflationProgress.contentModel.richOngoingContentModel?.let {
                        ronInflater.inflateView(
                            contentModel = it,
                            existingView = row.privateLayout.contractedChild,
                            entry = entry,
                            systemUiContext = context,
                            parentView = row.privateLayout
                        )
                    }
            }

            logger.logAsyncTaskProgress(entry, "getting row image resolver (on wrong thread!)")
            val imageResolver = row.imageResolver
            // wait for image resolver to finish preloading
@@ -558,6 +578,7 @@ constructor(
        var inflatedSmartReplyState: InflatedSmartReplyState? = null
        var expandedInflatedSmartReplies: InflatedSmartReplyViewHolder? = null
        var headsUpInflatedSmartReplies: InflatedSmartReplyViewHolder? = null
        var richOngoingNotificationViewHolder: InflatedContentViewHolder? = null

        // Inflated SingleLineView that lacks the UI State
        var inflatedSingleLineView: HybridNotificationView? = null
@@ -651,7 +672,10 @@ constructor(
                        systemUIContext = systemUIContext,
                        packageContext = packageContext
                    )
                } else null
                } else {
                    // if we're not re-inflating any RON views, make sure the model doesn't change
                    entry.richOngoingContentModel.value
                }

            val remoteViewsFlags = getRemoteViewsFlags(reInflateFlags, richOngoingContentModel)

@@ -1347,16 +1371,33 @@ constructor(
                return false
            }
            logger.logAsyncTaskProgress(entry, "finishing")
            setViewsFromRemoteViews(
                reInflateFlags,

            // before updating the content model, stop existing binding if necessary
            val hasRichOngoingContentModel = result.contentModel.richOngoingContentModel != null
            val requestedRichOngoing = reInflateFlags and CONTENT_VIEWS_TO_CREATE_RICH_ONGOING != 0
            val rejectedRichOngoing = requestedRichOngoing && !hasRichOngoingContentModel
            if (result.richOngoingNotificationViewHolder != null || rejectedRichOngoing) {
                row.privateLayout.mContractedBinderHandle?.dispose()
                row.privateLayout.mContractedBinderHandle = null
            }

            // set the content model after disposal and before setting new rich ongoing view
            entry.setContentModel(result.contentModel)
            result.inflatedSmartReplyState?.let { row.privateLayout.setInflatedSmartReplyState(it) }

            // set normal remote views (skipping rich ongoing states when that model exists)
            val remoteViewsFlags =
                getRemoteViewsFlags(reInflateFlags, result.contentModel.richOngoingContentModel)
            setContentViewsFromRemoteViews(
                remoteViewsFlags,
                entry,
                remoteViewCache,
                result,
                row,
                isMinimized,
            )
            result.inflatedSmartReplyState?.let { row.privateLayout.setInflatedSmartReplyState(it) }

            // set single line view
            if (
                AsyncHybridViewInflation.isEnabled &&
                    reInflateFlags and FLAG_CONTENT_VIEW_SINGLE_LINE != 0
@@ -1372,14 +1413,29 @@ constructor(
                    row.privateLayout.setSingleLineView(result.inflatedSingleLineView)
                }
            }
            entry.setContentModel(result.contentModel)

            // after updating the content model, set the view, then start the new binder
            result.richOngoingNotificationViewHolder?.let { viewHolder ->
                row.privateLayout.contractedChild = viewHolder.view
                row.privateLayout.expandedChild = null
                row.privateLayout.headsUpChild = null
                row.privateLayout.setExpandedInflatedSmartReplies(null)
                row.privateLayout.setHeadsUpInflatedSmartReplies(null)
                row.privateLayout.mContractedBinderHandle =
                    viewHolder.binder.setupContentViewBinder()
                row.setExpandable(false)
                remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_CONTRACTED)
                remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_EXPANDED)
                remoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_HEADS_UP)
            }

            Trace.endAsyncSection(APPLY_TRACE_METHOD, System.identityHashCode(row))
            endListener?.onAsyncInflationFinished(entry)
            return true
        }

        private fun setViewsFromRemoteViews(
            reInflateFlags: Int,
        private fun setContentViewsFromRemoteViews(
            @InflationFlag reInflateFlags: Int,
            entry: NotificationEntry,
            remoteViewCache: NotifRemoteViewCache,
            result: InflationProgress,
Loading