diff options
| author | 2024-06-24 12:43:48 +0000 | |
|---|---|---|
| committer | 2024-06-24 12:43:48 +0000 | |
| commit | 26c01fe00d1db55c93e3eb2ca8a06b71aa382518 (patch) | |
| tree | 4808fd361ddfd77a9787e508a42ddc8ad30b6249 | |
| parent | 454eac55e5c61ac6b68c96a8e89485363adf3631 (diff) | |
| parent | 87d79dd9fe92728188ba1c58cb543c5d7007ef71 (diff) | |
Merge changes I91953608,I09b3ee97 into main
* changes:
[RON] Inflate and bind Rich Ongoing Views
[RON] Extract a RichOngoingContentModel from timer notifications for testing.
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), + ) |