Implement BundleHeader guts in Compose
This is a conservative version without changing any guts APIs at this
point. See BundleHeaderGutsContent class documentation for additional
context. I may attempt to remove the View in a follow up CL but I
wanted to not complicate this CL which focuses on adding the Compose UI
and enabling it.
Most functionality is in TODOs for follow-up CLs so we can iterate fast
on this base setup.
Test: Manual test and tests added
Bug: 409748420
Flag: com.android.systemui.notification_bundle_ui
Change-Id: Ic6e268ea854ca265f6cb0db49bdd297514baefa1
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt
index bcb8b9e..f0c1abc 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt
@@ -151,7 +151,13 @@
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.padding(vertical = 16.dp),
) {
- BundleIcon(viewModel.bundleIcon, modifier = Modifier.padding(horizontal = 16.dp))
+ BundleIcon(
+ viewModel.bundleIcon,
+ modifier =
+ Modifier.padding(horizontal = 16.dp)
+ // Has to be a shared element because we may have a semi-transparent background
+ .element(NotificationRowPrimitives.Elements.NotificationIconBackground),
+ )
Text(
text = stringResource(viewModel.titleTextResId),
style = MaterialTheme.typography.titleMediumEmphasized,
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeaderGuts.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeaderGuts.kt
new file mode 100644
index 0000000..6ccb57e
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeaderGuts.kt
@@ -0,0 +1,230 @@
+/*
+ * Copyright (C) 2025 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(ExperimentalMaterial3ExpressiveApi::class)
+
+package com.android.systemui.notifications.ui.composable.row
+
+import android.content.Context
+import android.view.View
+import androidx.activity.OnBackPressedDispatcher
+import androidx.activity.OnBackPressedDispatcherOwner
+import androidx.activity.setViewTreeOnBackPressedDispatcherOwner
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.compose.theme.PlatformTheme
+import com.android.internal.R
+import com.android.systemui.lifecycle.repeatWhenAttached
+import com.android.systemui.scene.shared.flag.SceneContainerFlag
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.BundleHeaderGutsViewModel
+
+fun createBundleHeaderGutsComposeView(
+ context: Context,
+ viewModel: BundleHeaderGutsViewModel,
+): ComposeView {
+ return ComposeView(context).apply {
+ repeatWhenAttached {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ initOnBackPressureDispatcherOwner(this@repeatWhenAttached.lifecycle)
+ setContent {
+ // TODO(b/399588047): Check if we can init PlatformTheme once instead of once
+ // per ComposeView
+ PlatformTheme { BundleHeaderGuts(viewModel) }
+ }
+ }
+ }
+ }
+}
+
+private fun View.initOnBackPressureDispatcherOwner(lifecycle: Lifecycle) {
+ if (!SceneContainerFlag.isEnabled) {
+ setViewTreeOnBackPressedDispatcherOwner(
+ object : OnBackPressedDispatcherOwner {
+ override val onBackPressedDispatcher =
+ OnBackPressedDispatcher().apply {
+ setOnBackInvokedDispatcher(viewRootImpl.onBackInvokedDispatcher)
+ }
+
+ override val lifecycle: Lifecycle = lifecycle
+ }
+ )
+ }
+}
+
+@Composable
+fun BundleHeaderGuts(viewModel: BundleHeaderGutsViewModel, modifier: Modifier = Modifier) {
+ Column(modifier.padding(horizontal = 16.dp)) {
+ TopRow(viewModel)
+ ContentRow()
+ BottomRow(viewModel)
+ }
+}
+
+@Composable
+private fun TopRow(viewModel: BundleHeaderGutsViewModel, modifier: Modifier = Modifier) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.padding(vertical = 16.dp),
+ ) {
+ BundleIcon(viewModel.bundleIcon, modifier = Modifier.padding(end = 16.dp))
+ Text(
+ text = stringResource(viewModel.titleTextResId),
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ color = MaterialTheme.colorScheme.primary,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ modifier = Modifier.weight(1f),
+ )
+
+ Image(
+ painter = painterResource(R.drawable.ic_settings_24dp),
+ // TODO(b/409748420): Add correct CD
+ contentDescription =
+ stringResource(com.android.systemui.res.R.string.notification_more_settings),
+ modifier =
+ Modifier.size(24.dp)
+ .clickable(
+ onClick = viewModel.onSettingsClicked,
+ indication = null,
+ interactionSource = null,
+ ),
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
+ )
+ }
+}
+
+@Composable
+private fun ContentRow(modifier: Modifier = Modifier) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ modifier
+ .background(
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ shape = RoundedCornerShape(size = 20.dp),
+ )
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text =
+ stringResource(
+ com.android.systemui.res.R.string.notification_guts_bundle_title
+ ),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ Text(
+ // TODO(b/409748420): Implement string based on bundle type
+ text =
+ stringResource(
+ com.android.systemui.res.R.string.notification_guts_social_summary
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ }
+
+ var checked by remember { mutableStateOf(true) }
+
+ Switch(
+ checked = checked,
+ // TODO(b/409748420): Implement proper checked logic
+ onCheckedChange = { checked = !checked },
+ thumbContent = {
+ Icon(
+ // TODO(b/409748420): Add correct icon
+ painter = painterResource(R.drawable.ic_check_circle_24px),
+ contentDescription = null,
+ modifier = Modifier.size(SwitchDefaults.IconSize),
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ },
+ // TODO(b/409748420): Implement correct switch colors
+ )
+ }
+}
+
+@Composable
+private fun BottomRow(viewModel: BundleHeaderGutsViewModel, modifier: Modifier = Modifier) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.padding(vertical = 16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.dismiss_action),
+ style = MaterialTheme.typography.titleSmallEmphasized,
+ color = MaterialTheme.colorScheme.primary,
+ modifier =
+ modifier
+ .padding(vertical = 13.dp)
+ .clickable(
+ onClick = viewModel.onDoneClicked,
+ indication = null,
+ interactionSource = null,
+ ),
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // TODO(b/409748420): Implement done/apply switch
+ Text(
+ text = stringResource(R.string.done_label),
+ style = MaterialTheme.typography.titleSmallEmphasized,
+ color = MaterialTheme.colorScheme.primary,
+ modifier =
+ modifier
+ .padding(vertical = 13.dp)
+ .clickable(
+ onClick = viewModel.onDismissClicked,
+ indication = null,
+ interactionSource = null,
+ ),
+ )
+ }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt
index 2c520fb..836c87b 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt
@@ -72,20 +72,12 @@
/** The Icon displayed at the start of any notification row. */
@Composable
-fun ContentScope.BundleIcon(@DrawableRes drawable: Int?, modifier: Modifier = Modifier) {
+fun BundleIcon(@DrawableRes drawable: Int?, modifier: Modifier = Modifier) {
val surfaceColor = notificationElementSurfaceColor()
- Box(
- modifier =
- modifier
- // Has to be a shared element because we may have semi-transparent background color
- .element(NotificationRowPrimitives.Elements.NotificationIconBackground)
- .size(40.dp)
- .background(color = surfaceColor, shape = CircleShape)
- ) {
+ Box(modifier = modifier.size(40.dp).background(color = surfaceColor, shape = CircleShape)) {
if (drawable == null) return@Box
- val painter = painterResource(drawable)
Image(
- painter = painter,
+ painter = painterResource(drawable),
contentDescription = null,
modifier = Modifier.padding(10.dp).fillMaxSize(),
contentScale = ContentScale.Fit,
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/BundleHeaderGutsContent.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/BundleHeaderGutsContent.kt
new file mode 100644
index 0000000..52cc07e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/BundleHeaderGutsContent.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2025 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.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.widget.FrameLayout
+import androidx.compose.ui.platform.ComposeView
+import com.android.systemui.notifications.ui.composable.row.createBundleHeaderGutsComposeView
+import com.android.systemui.statusbar.notification.collection.BundleEntryAdapter
+import com.android.systemui.statusbar.notification.row.NotificationGuts.GutsContent
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.BundleHeaderGutsViewModel
+
+/**
+ * This View is a container for a ComposeView and implements GutsContent. Technically, this should
+ * not be a View as GutsContent could just return the ComposeView directly for getContentView().
+ * Unfortunately, the legacy design of `NotificationMenuRowPlugin.MenuItem.getGutsView()` forces the
+ * GutsContent to be a View itself. Therefore this class is a view that just holds the ComposeView.
+ *
+ * A redesign of `NotificationMenuRowPlugin.MenuItem.getGutsView()` to return GutsContent instead is
+ * desired but it lacks proper module dependencies. As soon as this class does not need to inherit
+ * from View it can just return the ComposeView directly instead.
+ */
+class BundleHeaderGutsContent
+@JvmOverloads
+constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
+ FrameLayout(context, attrs, defStyleAttr), GutsContent {
+
+ private var composeView: ComposeView? = null
+ private var gutsParent: NotificationGuts? = null
+
+ fun bindNotification(
+ row: ExpandableNotificationRow,
+ onSettingsClicked: () -> Unit = {},
+ onDoneClicked: () -> Unit = {},
+ onDismissClicked: () -> Unit = {},
+ ) {
+ if (composeView != null) return
+
+ val repository = (row.entryAdapter as BundleEntryAdapter).entry.bundleRepository
+ val viewModel =
+ BundleHeaderGutsViewModel(
+ titleTextResId = repository.titleTextResId,
+ bundleIcon = repository.bundleIcon,
+ onSettingsClicked = onSettingsClicked,
+ onDoneClicked = onDoneClicked,
+ onDismissClicked = onDismissClicked,
+ )
+ composeView = createBundleHeaderGutsComposeView(context, viewModel)
+ addView(composeView)
+ }
+
+ override fun setGutsParent(listener: NotificationGuts?) {
+ this.gutsParent = listener
+ }
+
+ override fun getContentView(): View {
+ return this
+ }
+
+ override fun getActualHeight(): Int {
+ return composeView?.measuredHeight ?: 0
+ }
+
+ override fun handleCloseControls(save: Boolean, force: Boolean): Boolean {
+ return false
+ }
+
+ override fun willBeRemoved(): Boolean {
+ return false
+ }
+
+ override fun shouldBeSavedOnClose(): Boolean {
+ return false
+ }
+
+ override fun needsFalsingProtection(): Boolean {
+ return true
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java
index 0ac768d..8ea5b16 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRow.java
@@ -2884,6 +2884,10 @@
}
}
+ public boolean isBundle() {
+ return mIsBundle;
+ }
+
private void updateChildrenVisibility() {
boolean hideContentWhileLaunching = mExpandAnimationRunning && mGuts != null
&& mGuts.isExposed();
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowController.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowController.java
index 25a8f83..0723acb 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowController.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ExpandableNotificationRowController.java
@@ -407,7 +407,8 @@
if (com.android.systemui.Flags.msdlFeedback()) {
mMSDLPlayer.playToken(MSDLToken.LONG_PRESS, null);
}
- if (mView.isSummaryWithChildren()) {
+ // TODO(b/409748420): The BundleHeader Guts overrides individual notification Guts
+ if (mView.isSummaryWithChildren() && !mView.isBundle()) {
mView.expandNotification();
return true;
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationGutsManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationGutsManager.java
index 2ef2219..0e2de6e 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationGutsManager.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationGutsManager.java
@@ -83,6 +83,9 @@
import com.android.systemui.util.kotlin.JavaAdapter;
import com.android.systemui.wmshell.BubblesManager;
+import kotlin.Unit;
+import kotlin.jvm.functions.Function0;
+
import java.util.Optional;
import javax.inject.Inject;
@@ -306,13 +309,15 @@
? row.getEntryAdapter().getRanking()
: row.getEntryLegacy().getRanking();
- if (sbn == null || ranking == null) {
+ if ((sbn == null || ranking == null) && !row.isBundle()) {
// only valid for notification rows
return false;
}
row.setGutsView(item);
- row.setTag(sbn.getPackageName());
+ if (sbn != null) {
+ row.setTag(sbn.getPackageName());
+ }
row.getGuts().setClosedListener((NotificationGuts g) -> {
row.onGutsClosed();
if (!g.willBeRemoved() && !row.isRemoved()) {
@@ -358,6 +363,8 @@
} else if (gutsView instanceof BundledNotificationInfo) {
initializeBundledNotificationInfo(
row, sbn, ranking, (BundledNotificationInfo) gutsView);
+ } else if (gutsView instanceof BundleHeaderGutsContent) {
+ initializeBundleHeaderGutsContent(row, (BundleHeaderGutsContent) gutsView);
}
return true;
} catch (Exception e) {
@@ -490,6 +497,40 @@
}
/**
+ * Sets up the {@link BundleHeaderGutsContent} inside the notification row's guts.
+ * @param row view to set up the guts for
+ * @param gutsView view to set up/bind within {@code row}
+ */
+ @VisibleForTesting
+ void initializeBundleHeaderGutsContent(
+ final ExpandableNotificationRow row,
+ BundleHeaderGutsContent gutsView) {
+
+ NotificationGuts guts = row.getGuts();
+
+ Function0<Unit> onSettingsClicked = () -> {
+ guts.resetFalsingCheck();
+ // TODO(b/409748420): navigate to correct settings page
+ startBundleSettingsActivity(0, row);
+ return Unit.INSTANCE;
+ };
+
+ Function0<Unit> onDismissClicked = () -> {
+ guts.resetFalsingCheck();
+ // TODO(b/409748420): Not yet implemented
+ return Unit.INSTANCE;
+ };
+
+ Function0<Unit> onDoneClicked = () -> {
+ guts.resetFalsingCheck();
+ // TODO(b/409748420): Not yet implemented
+ return Unit.INSTANCE;
+ };
+
+ gutsView.bindNotification(row, onSettingsClicked, onDismissClicked, onDoneClicked);
+ }
+
+ /**
* Sets up the {@link NotificationInfo} inside the notification row's guts.
* @param row view to set up the guts for
* @param notificationInfoView view to set up/bind within {@code row}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationMenuRow.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationMenuRow.java
index 16a9442..41334462 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationMenuRow.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationMenuRow.java
@@ -286,6 +286,8 @@
mInfoItem = createPromotedItem(mContext);
} else if (android.app.Flags.notificationClassificationUi() && isBundled) {
mInfoItem = createBundledInfoItem(mContext);
+ } else if (mParent.isBundle()) {
+ mInfoItem = createBundleHeaderInfoItem(mContext);
} else {
mInfoItem = createInfoItem(mContext);
}
@@ -771,6 +773,15 @@
NotificationMenuItem.OMIT_FROM_SWIPE_MENU);
}
+ static NotificationMenuItem createBundleHeaderInfoItem(Context context) {
+ BundleHeaderGutsContent infoContent = new BundleHeaderGutsContent(context);
+ // TODO(b/409748420): correct infoDescription?
+ String infoDescription =
+ context.getResources().getString(R.string.notification_menu_gear_description);
+ return new NotificationMenuItem(context, infoDescription, infoContent,
+ NotificationMenuItem.OMIT_FROM_SWIPE_MENU);
+ }
+
static NotificationMenuItem createInfoItem(Context context) {
Resources res = context.getResources();
String infoDescription = res.getString(R.string.notification_menu_gear_description);
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderGutsViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderGutsViewModel.kt
new file mode 100644
index 0000000..ed05900
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderGutsViewModel.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2025 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 androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+
+class BundleHeaderGutsViewModel(
+ @StringRes val titleTextResId: Int,
+ @DrawableRes val bundleIcon: Int,
+ val onSettingsClicked: () -> Unit = {},
+ val onDoneClicked: () -> Unit = {},
+ val onDismissClicked: () -> Unit = {},
+)