summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Jeff DeCew <jeffdq@google.com> 2024-06-24 12:43:48 +0000
committer Android (Google) Code Review <android-gerrit@google.com> 2024-06-24 12:43:48 +0000
commit26c01fe00d1db55c93e3eb2ca8a06b71aa382518 (patch)
tree4808fd361ddfd77a9787e508a42ddc8ad30b6249
parent454eac55e5c61ac6b68c96a8e89485363adf3631 (diff)
parent87d79dd9fe92728188ba1c58cb543c5d7007ef71 (diff)
Merge changes I91953608,I09b3ee97 into main
* changes: [RON] Inflate and bind Rich Ongoing Views [RON] Extract a RichOngoingContentModel from timer notifications for testing.
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelTest.kt105
-rw-r--r--packages/SystemUI/res/layout/rich_ongoing_timer_notification.xml116
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java15
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java4
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImpl.kt260
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowModule.java23
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationContentExtractor.kt170
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationViewInflater.kt104
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepository.kt29
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractor.kt29
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/IconModel.kt35
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/NotificationContentModel.kt3
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingClock.kt97
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingNotificationFlag.kt53
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/ConfigurationTracker.kt56
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerButtonView.kt41
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerView.kt92
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/TimerViewBinder.kt64
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/RichOngoingViewModelComponent.kt36
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModel.kt110
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImplTest.kt138
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java2
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowBuilder.kt2
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepositoryKosmos.kt28
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractorKosmos.kt23
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelKosmos.kt28
26 files changed, 1574 insertions, 89 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelTest.kt
new file mode 100644
index 000000000000..5e87f4663d76
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelTest.kt
@@ -0,0 +1,105 @@
+/*
+ * 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,
+ )
+ )
+}
diff --git a/packages/SystemUI/res/layout/rich_ongoing_timer_notification.xml b/packages/SystemUI/res/layout/rich_ongoing_timer_notification.xml
new file mode 100644
index 000000000000..f2bfbe5c960d
--- /dev/null
+++ b/packages/SystemUI/res/layout/rich_ongoing_timer_notification.xml
@@ -0,0 +1,116 @@
+<?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
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
index 1adfef061235..f98a88f5328b 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
@@ -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. */
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java
index 6f00d96b6312..7fc331d6adab 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java
@@ -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;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImpl.kt
index 5e08b0bc0c99..492d802c7cb2 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImpl.kt
@@ -66,6 +66,7 @@ import com.android.systemui.statusbar.notification.row.shared.HeadsUpStatusBarMo
import com.android.systemui.statusbar.notification.row.shared.NewRemoteViews
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.row.ui.viewbinder.SingleLineConversationViewBinder
import com.android.systemui.statusbar.notification.row.ui.viewbinder.SingleLineViewBinder
import com.android.systemui.statusbar.notification.row.wrapper.NotificationViewWrapper
@@ -90,6 +91,8 @@ constructor(
private val remoteViewCache: NotifRemoteViewCache,
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,
@@ -137,6 +140,8 @@ constructor(
remoteViewCache,
entry,
conversationProcessor,
+ ronExtractor,
+ ronInflater,
row,
bindParams.isMinimized,
bindParams.usesIncreasedHeight,
@@ -181,6 +186,7 @@ constructor(
notifLayoutInflaterFactoryProvider = notifLayoutInflaterFactoryProvider,
headsUpStyleProvider = headsUpStyleProvider,
conversationProcessor = conversationProcessor,
+ ronExtractor = ronExtractor,
logger = logger,
)
inflateSmartReplyViews(
@@ -260,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)
}
@@ -337,6 +345,8 @@ constructor(
private val remoteViewCache: NotifRemoteViewCache,
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,
@@ -416,6 +426,7 @@ constructor(
notifLayoutInflaterFactoryProvider = notifLayoutInflaterFactoryProvider,
headsUpStyleProvider = headsUpStyleProvider,
conversationProcessor = conversationProcessor,
+ ronExtractor = ronExtractor,
logger = logger
)
logger.logAsyncTaskProgress(
@@ -447,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
@@ -552,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
@@ -586,6 +613,7 @@ constructor(
val inflateHeadsUp =
(reInflateFlags and FLAG_CONTENT_VIEW_HEADS_UP != 0 &&
result.remoteViews.headsUp != null)
+
if (inflateContracted || inflateExpanded || inflateHeadsUp) {
logger.logAsyncTaskProgress(entry, "inflating contracted smart reply state")
result.inflatedSmartReplyState = inflater.inflateSmartReplyState(entry)
@@ -627,6 +655,7 @@ constructor(
notifLayoutInflaterFactoryProvider: NotifLayoutInflaterFactory.Provider,
headsUpStyleProvider: HeadsUpStyleProvider,
conversationProcessor: ConversationNotificationProcessor,
+ ronExtractor: RichOngoingNotificationContentExtractor,
logger: NotificationRowContentBinderLogger
): InflationProgress {
// process conversations and extract the messaging style
@@ -635,9 +664,24 @@ constructor(
conversationProcessor.processNotification(entry, builder, logger)
} else null
+ val richOngoingContentModel =
+ if (reInflateFlags and CONTENT_VIEWS_TO_CREATE_RICH_ONGOING != 0) {
+ ronExtractor.extractContentModel(
+ entry = entry,
+ builder = builder,
+ systemUIContext = systemUIContext,
+ packageContext = packageContext
+ )
+ } 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)
+
val remoteViews =
createRemoteViews(
- reInflateFlags = reInflateFlags,
+ reInflateFlags = remoteViewsFlags,
builder = builder,
isMinimized = isMinimized,
usesIncreasedHeight = usesIncreasedHeight,
@@ -672,6 +716,7 @@ constructor(
NotificationContentModel(
headsUpStatusBarModel = headsUpStatusBarModel,
singleLineViewModel = singleLineViewModel,
+ richOngoingContentModel = richOngoingContentModel,
)
return InflationProgress(
@@ -799,7 +844,7 @@ constructor(
val publicLayout = row.publicLayout
val runningInflations = HashMap<Int, CancellationSignal>()
var flag = FLAG_CONTENT_VIEW_CONTRACTED
- if (reInflateFlags and flag != 0) {
+ if (reInflateFlags and flag != 0 && result.remoteViews.contracted != null) {
val isNewView =
!canReapplyRemoteView(
newView = result.remoteViews.contracted,
@@ -813,7 +858,7 @@ constructor(
}
override val remoteView: RemoteViews
- get() = result.remoteViews.contracted!!
+ get() = result.remoteViews.contracted
}
logger.logAsyncTaskProgress(entry, "applying contracted view")
applyRemoteView(
@@ -838,88 +883,82 @@ constructor(
)
}
flag = FLAG_CONTENT_VIEW_EXPANDED
- if (reInflateFlags and flag != 0) {
- if (result.remoteViews.expanded != null) {
- val isNewView =
- !canReapplyRemoteView(
- newView = result.remoteViews.expanded,
- oldView =
- remoteViewCache.getCachedView(entry, FLAG_CONTENT_VIEW_EXPANDED)
- )
- val applyCallback: ApplyCallback =
- object : ApplyCallback() {
- override fun setResultView(v: View) {
- logger.logAsyncTaskProgress(entry, "expanded view applied")
- result.inflatedExpandedView = v
- }
-
- override val remoteView: RemoteViews
- get() = result.remoteViews.expanded
- }
- logger.logAsyncTaskProgress(entry, "applying expanded view")
- applyRemoteView(
- inflationExecutor = inflationExecutor,
- inflateSynchronously = inflateSynchronously,
- isMinimized = isMinimized,
- result = result,
- reInflateFlags = reInflateFlags,
- inflationId = flag,
- remoteViewCache = remoteViewCache,
- entry = entry,
- row = row,
- isNewView = isNewView,
- remoteViewClickHandler = remoteViewClickHandler,
- callback = callback,
- parentLayout = privateLayout,
- existingView = privateLayout.expandedChild,
- existingWrapper = privateLayout.getVisibleWrapper(VISIBLE_TYPE_EXPANDED),
- runningInflations = runningInflations,
- applyCallback = applyCallback,
- logger = logger
+ if (reInflateFlags and flag != 0 && result.remoteViews.expanded != null) {
+ val isNewView =
+ !canReapplyRemoteView(
+ newView = result.remoteViews.expanded,
+ oldView = remoteViewCache.getCachedView(entry, FLAG_CONTENT_VIEW_EXPANDED)
)
- }
+ val applyCallback: ApplyCallback =
+ object : ApplyCallback() {
+ override fun setResultView(v: View) {
+ logger.logAsyncTaskProgress(entry, "expanded view applied")
+ result.inflatedExpandedView = v
+ }
+
+ override val remoteView: RemoteViews
+ get() = result.remoteViews.expanded
+ }
+ logger.logAsyncTaskProgress(entry, "applying expanded view")
+ applyRemoteView(
+ inflationExecutor = inflationExecutor,
+ inflateSynchronously = inflateSynchronously,
+ isMinimized = isMinimized,
+ result = result,
+ reInflateFlags = reInflateFlags,
+ inflationId = flag,
+ remoteViewCache = remoteViewCache,
+ entry = entry,
+ row = row,
+ isNewView = isNewView,
+ remoteViewClickHandler = remoteViewClickHandler,
+ callback = callback,
+ parentLayout = privateLayout,
+ existingView = privateLayout.expandedChild,
+ existingWrapper = privateLayout.getVisibleWrapper(VISIBLE_TYPE_EXPANDED),
+ runningInflations = runningInflations,
+ applyCallback = applyCallback,
+ logger = logger
+ )
}
flag = FLAG_CONTENT_VIEW_HEADS_UP
- if (reInflateFlags and flag != 0) {
- if (result.remoteViews.headsUp != null) {
- val isNewView =
- !canReapplyRemoteView(
- newView = result.remoteViews.headsUp,
- oldView =
- remoteViewCache.getCachedView(entry, FLAG_CONTENT_VIEW_HEADS_UP)
- )
- val applyCallback: ApplyCallback =
- object : ApplyCallback() {
- override fun setResultView(v: View) {
- logger.logAsyncTaskProgress(entry, "heads up view applied")
- result.inflatedHeadsUpView = v
- }
-
- override val remoteView: RemoteViews
- get() = result.remoteViews.headsUp
- }
- logger.logAsyncTaskProgress(entry, "applying heads up view")
- applyRemoteView(
- inflationExecutor = inflationExecutor,
- inflateSynchronously = inflateSynchronously,
- isMinimized = isMinimized,
- result = result,
- reInflateFlags = reInflateFlags,
- inflationId = flag,
- remoteViewCache = remoteViewCache,
- entry = entry,
- row = row,
- isNewView = isNewView,
- remoteViewClickHandler = remoteViewClickHandler,
- callback = callback,
- parentLayout = privateLayout,
- existingView = privateLayout.headsUpChild,
- existingWrapper = privateLayout.getVisibleWrapper(VISIBLE_TYPE_HEADSUP),
- runningInflations = runningInflations,
- applyCallback = applyCallback,
- logger = logger
+ if (reInflateFlags and flag != 0 && result.remoteViews.headsUp != null) {
+ val isNewView =
+ !canReapplyRemoteView(
+ newView = result.remoteViews.headsUp,
+ oldView = remoteViewCache.getCachedView(entry, FLAG_CONTENT_VIEW_HEADS_UP)
)
- }
+ val applyCallback: ApplyCallback =
+ object : ApplyCallback() {
+ override fun setResultView(v: View) {
+ logger.logAsyncTaskProgress(entry, "heads up view applied")
+ result.inflatedHeadsUpView = v
+ }
+
+ override val remoteView: RemoteViews
+ get() = result.remoteViews.headsUp
+ }
+ logger.logAsyncTaskProgress(entry, "applying heads up view")
+ applyRemoteView(
+ inflationExecutor = inflationExecutor,
+ inflateSynchronously = inflateSynchronously,
+ isMinimized = isMinimized,
+ result = result,
+ reInflateFlags = reInflateFlags,
+ inflationId = flag,
+ remoteViewCache = remoteViewCache,
+ entry = entry,
+ row = row,
+ isNewView = isNewView,
+ remoteViewClickHandler = remoteViewClickHandler,
+ callback = callback,
+ parentLayout = privateLayout,
+ existingView = privateLayout.headsUpChild,
+ existingWrapper = privateLayout.getVisibleWrapper(VISIBLE_TYPE_HEADSUP),
+ runningInflations = runningInflations,
+ applyCallback = applyCallback,
+ logger = logger
+ )
}
flag = FLAG_CONTENT_VIEW_PUBLIC
if (reInflateFlags and flag != 0) {
@@ -1332,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
@@ -1357,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,
@@ -1521,6 +1592,21 @@ constructor(
!oldView.hasFlags(RemoteViews.FLAG_REAPPLY_DISALLOWED)
}
+ @InflationFlag
+ private fun getRemoteViewsFlags(
+ @InflationFlag reInflateFlags: Int,
+ richOngoingContentModel: RichOngoingContentModel?
+ ): Int =
+ if (richOngoingContentModel != null) {
+ reInflateFlags and CONTENT_VIEWS_TO_CREATE_RICH_ONGOING.inv()
+ } else {
+ reInflateFlags
+ }
+
+ @InflationFlag
+ private const val CONTENT_VIEWS_TO_CREATE_RICH_ONGOING =
+ FLAG_CONTENT_VIEW_CONTRACTED or FLAG_CONTENT_VIEW_EXPANDED or FLAG_CONTENT_VIEW_HEADS_UP
+
private const val ASYNC_TASK_TRACE_METHOD =
"NotificationRowContentBinderImpl.AsyncInflationTask"
private const val APPLY_TRACE_METHOD = "NotificationRowContentBinderImpl#apply"
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowModule.java
index 84f2f6670839..c630c4d43fba 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowModule.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowModule.java
@@ -18,6 +18,8 @@ package com.android.systemui.statusbar.notification.row;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.statusbar.notification.row.shared.NotificationRowContentBinderRefactor;
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingNotificationFlag;
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.RichOngoingViewModelComponent;
import dagger.Binds;
import dagger.Module;
@@ -28,7 +30,7 @@ import javax.inject.Provider;
/**
* Dagger Module containing notification row and view inflation implementations.
*/
-@Module
+@Module(subcomponents = {RichOngoingViewModelComponent.class})
public abstract class NotificationRowModule {
/**
@@ -47,6 +49,25 @@ public abstract class NotificationRowModule {
}
}
+ /** Provides ron content model extractor. */
+ @Provides
+ @SysUISingleton
+ public static RichOngoingNotificationContentExtractor provideRonContentExtractor(
+ Provider<RichOngoingNotificationContentExtractorImpl> realImpl
+ ) {
+ if (RichOngoingNotificationFlag.isEnabled()) {
+ return realImpl.get();
+ } else {
+ return new NoOpRichOngoingNotificationContentExtractor();
+ }
+ }
+
+ /** Provides ron view inflater. */
+ @Binds
+ @SysUISingleton
+ public abstract RichOngoingNotificationViewInflater provideRonViewInflater(
+ RichOngoingNotificationViewInflaterImpl impl);
+
/**
* Provides notification remote view cache instance.
*/
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationContentExtractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationContentExtractor.kt
new file mode 100644
index 000000000000..b5ea861c19a6
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationContentExtractor.kt
@@ -0,0 +1,170 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Context
+import android.util.Log
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.statusbar.notification.collection.NotificationEntry
+import com.android.systemui.statusbar.notification.row.shared.IconModel
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingNotificationFlag
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel
+import java.time.Duration
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.ZoneId
+import javax.inject.Inject
+
+/**
+ * Interface which provides a [RichOngoingContentModel] for a given [Notification] when one is
+ * applicable to the given style.
+ */
+interface RichOngoingNotificationContentExtractor {
+ fun extractContentModel(
+ entry: NotificationEntry,
+ builder: Notification.Builder,
+ systemUIContext: Context,
+ packageContext: Context
+ ): RichOngoingContentModel?
+}
+
+class NoOpRichOngoingNotificationContentExtractor : RichOngoingNotificationContentExtractor {
+ override fun extractContentModel(
+ entry: NotificationEntry,
+ builder: Notification.Builder,
+ systemUIContext: Context,
+ packageContext: Context
+ ): RichOngoingContentModel? = null
+}
+
+@SysUISingleton
+class RichOngoingNotificationContentExtractorImpl @Inject constructor() :
+ RichOngoingNotificationContentExtractor {
+
+ init {
+ /* check if */ RichOngoingNotificationFlag.isUnexpectedlyInLegacyMode()
+ }
+
+ override fun extractContentModel(
+ entry: NotificationEntry,
+ builder: Notification.Builder,
+ systemUIContext: Context,
+ packageContext: Context
+ ): RichOngoingContentModel? =
+ try {
+ val sbn = entry.sbn
+ val notification = sbn.notification
+ val icon = IconModel(notification.smallIcon)
+ if (sbn.packageName == "com.google.android.deskclock") {
+ when (notification.channelId) {
+ "Timers v2" -> {
+ parseTimerNotification(notification, icon)
+ }
+ "Stopwatch v2" -> {
+ Log.i("RONs", "Can't process stopwatch yet")
+ null
+ }
+ else -> {
+ Log.i("RONs", "Can't process channel '${notification.channelId}'")
+ null
+ }
+ }
+ } else null
+ } catch (e: Exception) {
+ Log.e("RONs", "Error parsing RON", e)
+ null
+ }
+
+ /**
+ * FOR PROTOTYPING ONLY: create a RON TimerContentModel using the time information available
+ * inside the sortKey of the clock app's timer notifications.
+ */
+ private fun parseTimerNotification(
+ notification: Notification,
+ icon: IconModel
+ ): TimerContentModel {
+ // sortKey=1 0|↺7|RUNNING|▶16:21:58.523|Σ0:05:00|Δ0:00:03|⏳0:04:57
+ // sortKey=1 0|↺7|PAUSED|Σ0:05:00|Δ0:04:54|⏳0:00:06
+ // sortKey=1 1|↺7|RUNNING|▶16:30:28.433|Σ0:04:05|Δ0:00:06|⏳0:03:59
+ // sortKey=1 0|↺7|RUNNING|▶16:36:18.350|Σ0:05:00|Δ0:01:42|⏳0:03:18
+ // sortKey=1 2|↺7|RUNNING|▶16:38:37.816|Σ0:02:00|Δ0:01:09|⏳0:00:51
+ // ▶ = "current" time (when updated)
+ // Σ = total time
+ // Δ = time elapsed
+ // ⏳ = time remaining
+ val sortKey = notification.sortKey
+ val (_, _, state, extra) = sortKey.split("|", limit = 4)
+ return when (state) {
+ "PAUSED" -> {
+ val (total, _, remaining) = extra.split("|")
+ val timeRemaining = parseTimeDelta(remaining)
+ TimerContentModel(
+ icon = icon,
+ name = total,
+ state =
+ TimerContentModel.TimerState.Paused(
+ timeRemaining = timeRemaining,
+ resumeIntent = notification.findActionWithName("Resume"),
+ resetIntent = notification.findActionWithName("Reset"),
+ )
+ )
+ }
+ "RUNNING" -> {
+ val (current, total, _, remaining) = extra.split("|")
+ val finishTime = parseCurrentTime(current) + parseTimeDelta(remaining).toMillis()
+ TimerContentModel(
+ icon = icon,
+ name = total,
+ state =
+ TimerContentModel.TimerState.Running(
+ finishTime = finishTime,
+ pauseIntent = notification.findActionWithName("Pause"),
+ addOneMinuteIntent = notification.findActionWithName("Add 1 min"),
+ )
+ )
+ }
+ else -> error("unknown state ($state) in sortKey=$sortKey")
+ }
+ }
+
+ private fun Notification.findActionWithName(name: String): PendingIntent? {
+ return actions.firstOrNull { name == it.title?.toString() }?.actionIntent
+ }
+
+ private fun parseCurrentTime(current: String): Long {
+ val (hour, minute, second, millis) = current.replace("▶", "").split(":", ".")
+ // NOTE: this won't work correctly at/around midnight. It's just for prototyping.
+ val localDateTime =
+ LocalDateTime.of(
+ LocalDate.now(),
+ LocalTime.of(hour.toInt(), minute.toInt(), second.toInt(), millis.toInt() * 1000000)
+ )
+ val offset = ZoneId.systemDefault().rules.getOffset(localDateTime)
+ return localDateTime.toInstant(offset).toEpochMilli()
+ }
+
+ private fun parseTimeDelta(delta: String): Duration {
+ val (hour, minute, second) = delta.replace("Σ", "").replace("⏳", "").split(":")
+ return Duration.ofHours(hour.toLong())
+ .plusMinutes(minute.toLong())
+ .plusSeconds(second.toLong())
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationViewInflater.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationViewInflater.kt
new file mode 100644
index 000000000000..e9c4960a4011
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RichOngoingNotificationViewInflater.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row
+
+import android.app.Notification
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.notification.collection.NotificationEntry
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingNotificationFlag
+import com.android.systemui.statusbar.notification.row.shared.StopwatchContentModel
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel
+import com.android.systemui.statusbar.notification.row.ui.view.TimerView
+import com.android.systemui.statusbar.notification.row.ui.viewbinder.TimerViewBinder
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.RichOngoingViewModelComponent
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.TimerViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.DisposableHandle
+
+fun interface DeferredContentViewBinder {
+ fun setupContentViewBinder(): DisposableHandle
+}
+
+class InflatedContentViewHolder(val view: View, val binder: DeferredContentViewBinder)
+
+/**
+ * Interface which provides a [RichOngoingContentModel] for a given [Notification] when one is
+ * applicable to the given style.
+ */
+interface RichOngoingNotificationViewInflater {
+ fun inflateView(
+ contentModel: RichOngoingContentModel,
+ existingView: View?,
+ entry: NotificationEntry,
+ systemUiContext: Context,
+ parentView: ViewGroup,
+ ): InflatedContentViewHolder?
+}
+
+@SysUISingleton
+class RichOngoingNotificationViewInflaterImpl
+@Inject
+constructor(
+ private val viewModelComponentFactory: RichOngoingViewModelComponent.Factory,
+) : RichOngoingNotificationViewInflater {
+
+ override fun inflateView(
+ contentModel: RichOngoingContentModel,
+ existingView: View?,
+ entry: NotificationEntry,
+ systemUiContext: Context,
+ parentView: ViewGroup,
+ ): InflatedContentViewHolder? {
+ if (RichOngoingNotificationFlag.isUnexpectedlyInLegacyMode()) return null
+ val component = viewModelComponentFactory.create(entry)
+ return when (contentModel) {
+ is TimerContentModel ->
+ inflateTimerView(
+ existingView,
+ component::createTimerViewModel,
+ systemUiContext,
+ parentView
+ )
+ is StopwatchContentModel -> TODO("Not yet implemented")
+ }
+ }
+
+ private fun inflateTimerView(
+ existingView: View?,
+ createViewModel: () -> TimerViewModel,
+ systemUiContext: Context,
+ parentView: ViewGroup,
+ ): InflatedContentViewHolder? {
+ if (existingView is TimerView && !existingView.isReinflateNeeded()) return null
+ val newView =
+ LayoutInflater.from(systemUiContext)
+ .inflate(
+ R.layout.rich_ongoing_timer_notification,
+ parentView,
+ /* attachToRoot= */ false
+ ) as TimerView
+ return InflatedContentViewHolder(newView) {
+ TimerViewBinder.bindWhileAttached(newView, createViewModel())
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepository.kt
new file mode 100644
index 000000000000..bac887ba7f14
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepository.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.data.repository
+
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel
+import kotlinx.coroutines.flow.StateFlow
+
+/** A repository of states relating to a specific notification row. */
+interface NotificationRowRepository {
+ /**
+ * A flow of an immutable data class with the current state of the Rich Ongoing Notification
+ * content, if applicable.
+ */
+ val richOngoingContentModel: StateFlow<RichOngoingContentModel?>
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractor.kt
new file mode 100644
index 000000000000..4705ace7fac6
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractor.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ *
+ */
+
+package com.android.systemui.statusbar.notification.row.domain.interactor
+
+import com.android.systemui.statusbar.notification.row.data.repository.NotificationRowRepository
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filterIsInstance
+
+/** Interactor specific to a particular notification row. */
+class NotificationRowInteractor @Inject constructor(repository: NotificationRowRepository) {
+ /** Content of a rich ongoing timer notification. */
+ val timerContentModel: Flow<TimerContentModel> =
+ repository.richOngoingContentModel.filterIsInstance<TimerContentModel>()
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/IconModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/IconModel.kt
new file mode 100644
index 000000000000..e61193892d10
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/IconModel.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.shared
+
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.Icon
+
+// TODO: figure out how to support lazy resolution of the drawable, e.g. on unrelated text change
+class IconModel(val icon: Icon) {
+ var drawable: Drawable? = null
+
+ override fun equals(other: Any?): Boolean =
+ when (other) {
+ null -> false
+ (other === this) -> true
+ !is IconModel -> false
+ else -> other.icon.sameAs(icon)
+ }
+
+ override fun toString(): String = "IconModel(icon=$icon, drawable=$drawable)"
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/NotificationContentModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/NotificationContentModel.kt
index b2421bc72d00..46010a1ab43d 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/NotificationContentModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/NotificationContentModel.kt
@@ -21,4 +21,7 @@ import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleLineVi
data class NotificationContentModel(
val headsUpStatusBarModel: HeadsUpStatusBarModel,
val singleLineViewModel: SingleLineViewModel? = null,
+ val richOngoingContentModel: RichOngoingContentModel? = null,
)
+
+sealed interface RichOngoingContentModel
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingClock.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingClock.kt
new file mode 100644
index 000000000000..558470175e8d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingClock.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.shared
+
+import android.app.PendingIntent
+import java.time.Duration
+
+/**
+ * Represents a simple timer that counts down to a time.
+ *
+ * @param name the label for the timer
+ * @param state state of the timer, including time and whether it is paused or running
+ */
+data class TimerContentModel(
+ val icon: IconModel,
+ val name: String,
+ val state: TimerState,
+) : RichOngoingContentModel {
+ /** The state (paused or running) of the timer, and relevant time */
+ sealed interface TimerState {
+ /**
+ * Indicates a running timer
+ *
+ * @param finishTime the time in ms since epoch that the timer will finish
+ * @param pauseIntent the action for pausing the timer
+ */
+ data class Running(
+ val finishTime: Long,
+ val pauseIntent: PendingIntent?,
+ val addOneMinuteIntent: PendingIntent?,
+ ) : TimerState
+
+ /**
+ * Indicates a paused timer
+ *
+ * @param timeRemaining the time in ms remaining on the paused timer
+ * @param resumeIntent the action for resuming the timer
+ */
+ data class Paused(
+ val timeRemaining: Duration,
+ val resumeIntent: PendingIntent?,
+ val resetIntent: PendingIntent?,
+ ) : TimerState
+ }
+}
+
+/**
+ * Represents a simple stopwatch that counts up and allows tracking laps.
+ *
+ * @param state state of the stopwatch, including time and whether it is paused or running
+ * @param lapDurations a list of durations of each completed lap
+ */
+data class StopwatchContentModel(
+ val icon: IconModel,
+ val state: StopwatchState,
+ val lapDurations: List<Long>,
+) : RichOngoingContentModel {
+ /** The state (paused or running) of the stopwatch, and relevant time */
+ sealed interface StopwatchState {
+ /**
+ * Indicates a running stopwatch
+ *
+ * @param startTime the time in ms since epoch that the stopwatch started, plus any
+ * accumulated pause time
+ * @param pauseIntent the action for pausing the stopwatch
+ */
+ data class Running(
+ val startTime: Long,
+ val pauseIntent: PendingIntent,
+ ) : StopwatchState
+
+ /**
+ * Indicates a paused stopwatch
+ *
+ * @param timeElapsed the time in ms elapsed on the stopwatch
+ * @param resumeIntent the action for resuming the stopwatch
+ */
+ data class Paused(
+ val timeElapsed: Duration,
+ val resumeIntent: PendingIntent,
+ ) : StopwatchState
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingNotificationFlag.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingNotificationFlag.kt
new file mode 100644
index 000000000000..4a7f7cd46ad1
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/shared/RichOngoingNotificationFlag.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.shared
+
+import android.app.Flags
+import com.android.systemui.flags.FlagToken
+import com.android.systemui.flags.RefactorFlagUtils
+
+/** Helper for reading or using the api rich ongoing flag state. */
+@Suppress("NOTHING_TO_INLINE")
+object RichOngoingNotificationFlag {
+ /** The aconfig flag name */
+ const val FLAG_NAME = Flags.FLAG_API_RICH_ONGOING
+
+ /** A token used for dependency declaration */
+ val token: FlagToken
+ get() = FlagToken(FLAG_NAME, isEnabled)
+
+ /** Is the refactor enabled */
+ @JvmStatic
+ inline val isEnabled
+ get() = Flags.apiRichOngoing()
+
+ /**
+ * Called to ensure code is only run when the flag is enabled. This protects users from the
+ * unintended behaviors caused by accidentally running new logic, while also crashing on an eng
+ * build to ensure that the refactor author catches issues in testing.
+ */
+ @JvmStatic
+ inline fun isUnexpectedlyInLegacyMode() =
+ RefactorFlagUtils.isUnexpectedlyInLegacyMode(isEnabled, FLAG_NAME)
+
+ /**
+ * Called to ensure code is only run when the flag is disabled. This will throw an exception if
+ * the flag is enabled to ensure that the refactor author catches issues in testing.
+ */
+ @JvmStatic
+ inline fun assertInLegacyMode() = RefactorFlagUtils.assertInLegacyMode(isEnabled, FLAG_NAME)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/ConfigurationTracker.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/ConfigurationTracker.kt
new file mode 100644
index 000000000000..95c507cb72a2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/ConfigurationTracker.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.ui.view
+
+import android.content.pm.ActivityInfo.CONFIG_ASSETS_PATHS
+import android.content.pm.ActivityInfo.CONFIG_DENSITY
+import android.content.pm.ActivityInfo.CONFIG_FONT_SCALE
+import android.content.pm.ActivityInfo.CONFIG_LAYOUT_DIRECTION
+import android.content.pm.ActivityInfo.CONFIG_LOCALE
+import android.content.pm.ActivityInfo.CONFIG_UI_MODE
+import android.content.res.Configuration
+import android.content.res.Resources
+
+/**
+ * Tracks the active configuration when constructed and returns (when queried) whether the
+ * configuration has unhandled changes.
+ */
+class ConfigurationTracker(
+ private val resources: Resources,
+ private val unhandledConfigChanges: Int
+) {
+ private val initialConfig = Configuration(resources.configuration)
+
+ constructor(
+ resources: Resources,
+ handlesDensityFontScale: Boolean = false,
+ handlesTheme: Boolean = false,
+ handlesLocaleAndLayout: Boolean = true,
+ ) : this(
+ resources,
+ unhandledConfigChanges =
+ (if (handlesDensityFontScale) 0 else CONFIG_DENSITY or CONFIG_FONT_SCALE) or
+ (if (handlesTheme) 0 else CONFIG_ASSETS_PATHS or CONFIG_UI_MODE) or
+ (if (handlesLocaleAndLayout) 0 else CONFIG_LOCALE or CONFIG_LAYOUT_DIRECTION)
+ )
+
+ /**
+ * Whether the current configuration has unhandled changes relative to the initial configuration
+ */
+ fun hasUnhandledConfigChange(): Boolean =
+ initialConfig.diff(resources.configuration) and unhandledConfigChanges != 0
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerButtonView.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerButtonView.kt
new file mode 100644
index 000000000000..0d83aced6d07
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerButtonView.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.ui.view
+
+import android.annotation.DrawableRes
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.Button
+
+class TimerButtonView
+@JvmOverloads
+constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ defStyleRes: Int = 0,
+) : Button(context, attrs, defStyleAttr, defStyleRes) {
+
+ private val Int.dp: Int
+ get() = (this * context.resources.displayMetrics.density).toInt()
+
+ fun setIcon(@DrawableRes icon: Int) {
+ val drawable = context.getDrawable(icon)
+ drawable?.setBounds(0, 0, 24.dp, 24.dp)
+ setCompoundDrawablesRelative(drawable, null, null, null)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerView.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerView.kt
new file mode 100644
index 000000000000..2e164d60431d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/view/TimerView.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.ui.view
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.os.SystemClock
+import android.util.AttributeSet
+import android.widget.Chronometer
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.view.isVisible
+import com.android.systemui.res.R
+
+class TimerView
+@JvmOverloads
+constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ defStyleRes: Int = 0,
+) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
+
+ private val configTracker = ConfigurationTracker(resources)
+
+ private lateinit var icon: ImageView
+ private lateinit var label: TextView
+ private lateinit var chronometer: Chronometer
+ private lateinit var pausedTimeRemaining: TextView
+ lateinit var mainButton: TimerButtonView
+ private set
+
+ lateinit var altButton: TimerButtonView
+ private set
+
+ override fun onFinishInflate() {
+ super.onFinishInflate()
+ icon = requireViewById(R.id.icon)
+ label = requireViewById(R.id.label)
+ chronometer = requireViewById(R.id.chronoRemaining)
+ pausedTimeRemaining = requireViewById(R.id.pausedTimeRemaining)
+ mainButton = requireViewById(R.id.mainButton)
+ altButton = requireViewById(R.id.altButton)
+ }
+
+ /** the resources configuration has changed such that the view needs to be reinflated */
+ fun isReinflateNeeded(): Boolean = configTracker.hasUnhandledConfigChange()
+
+ fun setIcon(iconDrawable: Drawable?) {
+ this.icon.setImageDrawable(iconDrawable)
+ }
+
+ fun setLabel(label: String) {
+ this.label.text = label
+ }
+
+ fun setPausedTime(pausedTime: String?) {
+ if (pausedTime != null) {
+ pausedTimeRemaining.text = pausedTime
+ pausedTimeRemaining.isVisible = true
+ } else {
+ pausedTimeRemaining.isVisible = false
+ }
+ }
+
+ fun setCountdownTime(countdownTimeMs: Long?) {
+ if (countdownTimeMs != null) {
+ chronometer.base =
+ countdownTimeMs - System.currentTimeMillis() + SystemClock.elapsedRealtime()
+ chronometer.isVisible = true
+ chronometer.start()
+ } else {
+ chronometer.isVisible = false
+ chronometer.stop()
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/TimerViewBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/TimerViewBinder.kt
new file mode 100644
index 000000000000..c9ff58961582
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/TimerViewBinder.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.ui.viewbinder
+
+import android.view.View
+import androidx.core.view.isGone
+import androidx.lifecycle.lifecycleScope
+import com.android.systemui.lifecycle.repeatWhenAttached
+import com.android.systemui.statusbar.notification.row.ui.view.TimerButtonView
+import com.android.systemui.statusbar.notification.row.ui.view.TimerView
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.TimerViewModel
+import kotlinx.coroutines.DisposableHandle
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+/** Binds a [TimerView] to its [view model][TimerViewModel]. */
+object TimerViewBinder {
+ fun bindWhileAttached(
+ view: TimerView,
+ viewModel: TimerViewModel,
+ ): DisposableHandle {
+ return view.repeatWhenAttached { lifecycleScope.launch { bind(view, viewModel) } }
+ }
+
+ suspend fun bind(
+ view: TimerView,
+ viewModel: TimerViewModel,
+ ) = coroutineScope {
+ launch { viewModel.icon.collect { view.setIcon(it) } }
+ launch { viewModel.label.collect { view.setLabel(it) } }
+ launch { viewModel.pausedTime.collect { view.setPausedTime(it) } }
+ launch { viewModel.countdownTime.collect { view.setCountdownTime(it) } }
+ launch { viewModel.mainButtonModel.collect { bind(view.mainButton, it) } }
+ launch { viewModel.altButtonModel.collect { bind(view.altButton, it) } }
+ }
+
+ fun bind(buttonView: TimerButtonView, model: TimerViewModel.ButtonViewModel?) {
+ if (model != null) {
+ buttonView.setIcon(model.iconRes)
+ buttonView.setText(model.labelRes)
+ buttonView.setOnClickListener(
+ model.pendingIntent?.let { pendingIntent ->
+ View.OnClickListener { pendingIntent.send() }
+ }
+ )
+ buttonView.isEnabled = model.pendingIntent != null
+ }
+ buttonView.isGone = model == null
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/RichOngoingViewModelComponent.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/RichOngoingViewModelComponent.kt
new file mode 100644
index 000000000000..dad52a3b2c45
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/RichOngoingViewModelComponent.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.ui.viewmodel
+
+// noinspection CleanArchitectureDependencyViolation
+import com.android.systemui.statusbar.notification.row.data.repository.NotificationRowRepository
+import dagger.BindsInstance
+import dagger.Subcomponent
+
+@Subcomponent
+interface RichOngoingViewModelComponent {
+
+ @Subcomponent.Factory
+ interface Factory {
+ /** Creates an instance of [RichOngoingViewModelComponent]. */
+ fun create(
+ @BindsInstance repository: NotificationRowRepository
+ ): RichOngoingViewModelComponent
+ }
+
+ fun createTimerViewModel(): TimerViewModel
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModel.kt
new file mode 100644
index 000000000000..a85c87f288d3
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModel.kt
@@ -0,0 +1,110 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.ui.viewmodel
+
+import android.annotation.DrawableRes
+import android.annotation.StringRes
+import android.app.PendingIntent
+import android.graphics.drawable.Drawable
+import com.android.systemui.dump.DumpManager
+import com.android.systemui.statusbar.notification.row.domain.interactor.NotificationRowInteractor
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingNotificationFlag
+import com.android.systemui.statusbar.notification.row.shared.TimerContentModel.TimerState
+import com.android.systemui.util.kotlin.FlowDumperImpl
+import java.time.Duration
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
+
+/** A view model for Timer notifications. */
+class TimerViewModel
+@Inject
+constructor(
+ dumpManager: DumpManager,
+ rowInteractor: NotificationRowInteractor,
+) : FlowDumperImpl(dumpManager) {
+ init {
+ /* check if */ RichOngoingNotificationFlag.isUnexpectedlyInLegacyMode()
+ }
+
+ private val state: Flow<TimerState> = rowInteractor.timerContentModel.mapNotNull { it.state }
+
+ val icon: Flow<Drawable?> = rowInteractor.timerContentModel.mapNotNull { it.icon.drawable }
+
+ val label: Flow<String> = rowInteractor.timerContentModel.mapNotNull { it.name }
+
+ val countdownTime: Flow<Long?> = state.map { (it as? TimerState.Running)?.finishTime }
+
+ val pausedTime: Flow<String?> =
+ state.map { (it as? TimerState.Paused)?.timeRemaining?.format() }
+
+ val mainButtonModel: Flow<ButtonViewModel> =
+ state.map {
+ when (it) {
+ is TimerState.Paused ->
+ ButtonViewModel(
+ it.resumeIntent,
+ com.android.systemui.res.R.string.controls_media_resume, // "Resume",
+ com.android.systemui.res.R.drawable.ic_media_play
+ )
+ is TimerState.Running ->
+ ButtonViewModel(
+ it.pauseIntent,
+ com.android.systemui.res.R.string.controls_media_button_pause, // "Pause",
+ com.android.systemui.res.R.drawable.ic_media_pause
+ )
+ }
+ }
+
+ val altButtonModel: Flow<ButtonViewModel?> =
+ state.map {
+ when (it) {
+ is TimerState.Paused ->
+ it.resetIntent?.let { resetIntent ->
+ ButtonViewModel(
+ resetIntent,
+ com.android.systemui.res.R.string.reset, // "Reset",
+ com.android.systemui.res.R.drawable.ic_close_white_rounded
+ )
+ }
+ is TimerState.Running ->
+ it.addOneMinuteIntent?.let { addOneMinuteIntent ->
+ ButtonViewModel(
+ addOneMinuteIntent,
+ com.android.systemui.res.R.string.add, // "Add 1 minute",
+ com.android.systemui.res.R.drawable.ic_add
+ )
+ }
+ }
+ }
+
+ data class ButtonViewModel(
+ val pendingIntent: PendingIntent?,
+ @StringRes val labelRes: Int,
+ @DrawableRes val iconRes: Int,
+ )
+}
+
+private fun Duration.format(): String {
+ val hours = this.toHours()
+ return if (hours > 0) {
+ String.format("%d:%02d:%02d", hours, toMinutesPart(), toSecondsPart())
+ } else {
+ String.format("%d:%02d", toMinutes(), toSecondsPart())
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImplTest.kt
index ff0abf651895..54a26f722d7f 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImplTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinderImplTest.kt
@@ -45,20 +45,29 @@ import com.android.systemui.statusbar.notification.row.shared.HeadsUpStatusBarMo
import com.android.systemui.statusbar.notification.row.shared.NewRemoteViews
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.row.shared.TimerContentModel
import com.android.systemui.statusbar.policy.InflatedSmartReplyState
import com.android.systemui.statusbar.policy.InflatedSmartReplyViewHolder
import com.android.systemui.statusbar.policy.SmartReplyStateInflater
+import com.google.common.truth.Truth.assertThat
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.DisposableHandle
import org.junit.Assert
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
+import org.mockito.kotlin.argThat
+import org.mockito.kotlin.clearInvocations
+import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
+import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
import org.mockito.kotlin.spy
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
@@ -102,6 +111,31 @@ class NotificationRowContentBinderImplTest : SysuiTestCase() {
}
}
+ private var fakeRonContentModel: RichOngoingContentModel? = null
+ private val fakeRonExtractor =
+ object : RichOngoingNotificationContentExtractor {
+ override fun extractContentModel(
+ entry: NotificationEntry,
+ builder: Notification.Builder,
+ systemUIContext: Context,
+ packageContext: Context
+ ): RichOngoingContentModel? = fakeRonContentModel
+ }
+
+ private var fakeRonViewHolder: InflatedContentViewHolder? = null
+ private val fakeRonViewInflater =
+ spy(
+ object : RichOngoingNotificationViewInflater {
+ override fun inflateView(
+ contentModel: RichOngoingContentModel,
+ existingView: View?,
+ entry: NotificationEntry,
+ systemUiContext: Context,
+ parentView: ViewGroup
+ ): InflatedContentViewHolder? = fakeRonViewHolder
+ }
+ )
+
@Before
fun setUp() {
allowTestableLooperAsMainThread()
@@ -118,6 +152,8 @@ class NotificationRowContentBinderImplTest : SysuiTestCase() {
cache,
mock(),
mock<ConversationNotificationProcessor>(),
+ fakeRonExtractor,
+ fakeRonViewInflater,
mock(),
smartReplyStateInflater,
layoutInflaterFactoryProvider,
@@ -347,6 +383,108 @@ class NotificationRowContentBinderImplTest : SysuiTestCase() {
}
@Test
+ fun testRonModelRequiredForRonView() {
+ fakeRonContentModel = null
+ val ronView = View(context)
+ fakeRonViewHolder = InflatedContentViewHolder(view = ronView, binder = mock())
+ // WHEN inflater inflates
+ inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
+ verify(fakeRonViewInflater, never()).inflateView(any(), any(), any(), any(), any())
+ }
+
+ @Test
+ fun testRonModelTriggersInflationOfRonView() {
+ val mockRonModel = mock<TimerContentModel>()
+ val ronView = View(context)
+ val mockBinder = mock<DeferredContentViewBinder>()
+
+ val entry = row.entry
+ val privateLayout = row.privateLayout
+
+ fakeRonContentModel = mockRonModel
+ fakeRonViewHolder = InflatedContentViewHolder(view = ronView, binder = mockBinder)
+ // WHEN inflater inflates
+ inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
+ // VERIFY that the inflater is invoked
+ verify(fakeRonViewInflater)
+ .inflateView(eq(mockRonModel), any(), eq(entry), any(), eq(privateLayout))
+ assertThat(row.privateLayout.contractedChild).isSameInstanceAs(ronView)
+ verify(mockBinder).setupContentViewBinder()
+ }
+
+ @Test
+ fun ronViewAppliesElementsInOrder() {
+ val oldHandle = mock<DisposableHandle>()
+ val mockRonModel = mock<TimerContentModel>()
+ val ronView = View(context)
+ val mockBinder = mock<DeferredContentViewBinder>()
+
+ row.privateLayout.mContractedBinderHandle = oldHandle
+ val entry = spy(row.entry)
+ row.entry = entry
+ val privateLayout = spy(row.privateLayout)
+ row.privateLayout = privateLayout
+
+ fakeRonContentModel = mockRonModel
+ fakeRonViewHolder = InflatedContentViewHolder(view = ronView, binder = mockBinder)
+ // WHEN inflater inflates
+ inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
+
+ // Validate that these 4 steps happen in this precise order
+ inOrder(oldHandle, entry, privateLayout, mockBinder) {
+ verify(oldHandle).dispose()
+ verify(entry).setContentModel(argThat { richOngoingContentModel === mockRonModel })
+ verify(privateLayout).setContractedChild(eq(ronView))
+ verify(mockBinder).setupContentViewBinder()
+ }
+ }
+
+ @Test
+ fun testRonNotReinflating() {
+ val handle0 = mock<DisposableHandle>()
+ val handle1 = mock<DisposableHandle>()
+ val ronView = View(context)
+ val mockRonModel1 = mock<TimerContentModel>()
+ val mockRonModel2 = mock<TimerContentModel>()
+ val mockBinder1 = mock<DeferredContentViewBinder>()
+ doReturn(handle1).whenever(mockBinder1).setupContentViewBinder()
+
+ row.privateLayout.mContractedBinderHandle = handle0
+ val entry = spy(row.entry)
+ row.entry = entry
+ val privateLayout = spy(row.privateLayout)
+ row.privateLayout = privateLayout
+
+ // WHEN inflater inflates both a model and a view
+ fakeRonContentModel = mockRonModel1
+ fakeRonViewHolder = InflatedContentViewHolder(view = ronView, binder = mockBinder1)
+ inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
+
+ // Validate that these 4 steps happen in this precise order
+ inOrder(handle0, entry, privateLayout, mockBinder1, handle1) {
+ verify(handle0).dispose()
+ verify(entry).setContentModel(argThat { richOngoingContentModel === mockRonModel1 })
+ verify(privateLayout).setContractedChild(eq(ronView))
+ verify(mockBinder1).setupContentViewBinder()
+ verify(handle1, never()).dispose()
+ }
+
+ clearInvocations(handle0, entry, privateLayout, mockBinder1, handle1)
+
+ // THEN when the inflater inflates just a model
+ fakeRonContentModel = mockRonModel2
+ fakeRonViewHolder = null
+ inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
+
+ // Validate that for reinflation, the only thing we do us update the model
+ verify(handle1, never()).dispose()
+ verify(entry).setContentModel(argThat { richOngoingContentModel === mockRonModel2 })
+ verify(privateLayout, never()).setContractedChild(any())
+ verify(mockBinder1, never()).setupContentViewBinder()
+ verify(handle1, never()).dispose()
+ }
+
+ @Test
fun testNotificationViewHeightTooSmallFailsValidation() {
val validationError =
getValidationError(
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java
index 21d586b1b5fc..c74a04f1c3e0 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java
@@ -199,6 +199,8 @@ public class NotificationTestHelper {
mock(NotifRemoteViewCache.class),
mock(NotificationRemoteInputManager.class),
mock(ConversationNotificationProcessor.class),
+ mock(RichOngoingNotificationContentExtractor.class),
+ mock(RichOngoingNotificationViewInflater.class),
mock(Executor.class),
new MockSmartReplyInflater(),
mock(NotifLayoutInflaterFactory.Provider.class),
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowBuilder.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowBuilder.kt
index c72da64120af..16dc50f96676 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowBuilder.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowBuilder.kt
@@ -215,6 +215,8 @@ class ExpandableNotificationRowBuilder(
remoteInputManager,
conversationProcessor,
mock(),
+ mock(),
+ mock(),
smartReplyStateInflater,
notifLayoutInflaterFactoryProvider,
mock(),
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepositoryKosmos.kt
new file mode 100644
index 000000000000..84ef4b5c21c3
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/data/repository/NotificationRowRepositoryKosmos.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.data.repository
+
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.statusbar.notification.row.shared.RichOngoingContentModel
+import kotlinx.coroutines.flow.MutableStateFlow
+
+val Kosmos.fakeNotificationRowRepository by Fixture { FakeNotificationRowRepository() }
+
+class FakeNotificationRowRepository : NotificationRowRepository {
+ override val richOngoingContentModel = MutableStateFlow<RichOngoingContentModel?>(null)
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractorKosmos.kt
new file mode 100644
index 000000000000..3a7d7ba064d0
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/domain/interactor/NotificationRowInteractorKosmos.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.domain.interactor
+
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.statusbar.notification.row.data.repository.NotificationRowRepository
+
+fun Kosmos.getNotificationRowInteractor(repository: NotificationRowRepository) =
+ NotificationRowInteractor(repository = repository)
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelKosmos.kt
new file mode 100644
index 000000000000..00f45b220654
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/TimerViewModelKosmos.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.statusbar.notification.row.ui.viewmodel
+
+import com.android.systemui.dump.dumpManager
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.statusbar.notification.row.data.repository.NotificationRowRepository
+import com.android.systemui.statusbar.notification.row.domain.interactor.getNotificationRowInteractor
+
+fun Kosmos.getTimerViewModel(repository: NotificationRowRepository) =
+ TimerViewModel(
+ dumpManager = dumpManager,
+ rowInteractor = getNotificationRowInteractor(repository),
+ )