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 = {},
+)