diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NodeController.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NodeController.kt index 727ce20cd72c0..289dacbca69ee 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NodeController.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NodeController.kt @@ -78,7 +78,7 @@ fun treeSpecToStr(tree: NodeSpec): String { } private fun treeSpecToStrHelper(tree: NodeSpec, sb: StringBuilder, indent: String) { - sb.append("${indent}ns{${tree.controller.nodeLabel}") + sb.append("${indent}{${tree.controller.nodeLabel}}\n") if (tree.children.isNotEmpty()) { val childIndent = "$indent " for (child in tree.children) { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NodeSpecBuilder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NodeSpecBuilder.kt new file mode 100644 index 0000000000000..9b8ac722d5c97 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NodeSpecBuilder.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2021 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.collection.render + +import com.android.systemui.statusbar.notification.collection.GroupEntry +import com.android.systemui.statusbar.notification.collection.ListEntry +import com.android.systemui.statusbar.notification.collection.NotificationEntry +import com.android.systemui.statusbar.notification.collection.listbuilder.NotifSection + +/** + * Converts a notif list (the output of the ShadeListBuilder) into a NodeSpec, an abstract + * representation of which views should be present in the shade. This spec will later be consumed + * by the ViewDiffer, which will add and remove views until the shade matches the spec. Up until + * this point, the pipeline has dealt with pure data representations of notifications (in the + * form of NotificationEntries). In this step, NotificationEntries finally become associated with + * the views that will represent them. In addition, we add in any non-notification views that also + * need to present in the shade, notably the section headers. + */ +class NodeSpecBuilder( + private val viewBarn: NotifViewBarn +) { + fun buildNodeSpec( + rootController: NodeController, + notifList: List + ): NodeSpec { + val root = NodeSpecImpl(null, rootController) + var currentSection: NotifSection? = null + val prevSections = mutableSetOf() + + for (entry in notifList) { + val section = entry.section!! + + if (prevSections.contains(section)) { + throw java.lang.RuntimeException("Section ${section.label} has been duplicated") + } + + // If this notif begins a new section, first add the section's header view + if (section != currentSection) { + section.headerController?.let { headerController -> + root.children.add(NodeSpecImpl(root, headerController)) + } + prevSections.add(currentSection) + currentSection = section + } + + // Finally, add the actual notif node! + root.children.add(buildNotifNode(root, entry)) + } + + return root + } + + private fun buildNotifNode(parent: NodeSpec, entry: ListEntry): NodeSpec = when (entry) { + is NotificationEntry -> NodeSpecImpl(parent, viewBarn.requireView(entry)) + is GroupEntry -> NodeSpecImpl(parent, viewBarn.requireView(checkNotNull(entry.summary))) + .apply { entry.children.forEach { children.add(buildNotifNode(this, it)) } } + else -> throw RuntimeException("Unexpected entry: $entry") + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewBarn.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewBarn.kt index 79bc3d757ebdd..c79f59b5c6250 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewBarn.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewBarn.kt @@ -19,18 +19,16 @@ package com.android.systemui.statusbar.notification.collection.render import android.view.textclassifier.Log import com.android.systemui.dagger.SysUISingleton import com.android.systemui.statusbar.notification.collection.ListEntry -import com.android.systemui.statusbar.notification.row.ExpandableNotificationRowController import javax.inject.Inject /** - * The ViewBarn is just a map from [ListEntry] to an instance of an - * [ExpandableNotificationRowController]. + * The ViewBarn is just a map from [ListEntry] to an instance of a [NodeController]. */ @SysUISingleton class NotifViewBarn @Inject constructor() { - private val rowMap = mutableMapOf() + private val rowMap = mutableMapOf() - fun requireView(forEntry: ListEntry): ExpandableNotificationRowController { + fun requireView(forEntry: ListEntry): NodeController { if (DEBUG) { Log.d(TAG, "requireView: $forEntry.key") } @@ -42,7 +40,7 @@ class NotifViewBarn @Inject constructor() { return li } - fun registerViewForEntry(entry: ListEntry, controller: ExpandableNotificationRowController) { + fun registerViewForEntry(entry: ListEntry, controller: NodeController) { if (DEBUG) { Log.d(TAG, "registerViewForEntry: $entry.key") } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewManager.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewManager.kt index ef1d75e9fdaa8..a2c7aa5cc8f75 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewManager.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewManager.kt @@ -18,9 +18,7 @@ package com.android.systemui.statusbar.notification.collection.render import android.content.Context import android.view.View -import com.android.systemui.statusbar.notification.collection.GroupEntry import com.android.systemui.statusbar.notification.collection.ListEntry -import com.android.systemui.statusbar.notification.collection.NotificationEntry import com.android.systemui.statusbar.notification.collection.ShadeListBuilder import com.android.systemui.statusbar.notification.stack.NotificationListContainer import com.android.systemui.statusbar.phone.NotificationIconAreaController @@ -34,45 +32,21 @@ class ShadeViewManager constructor( context: Context, listContainer: NotificationListContainer, logger: ShadeViewDifferLogger, - private val viewBarn: NotifViewBarn, + viewBarn: NotifViewBarn, private val notificationIconAreaController: NotificationIconAreaController ) { // We pass a shim view here because the listContainer may not actually have a view associated // with it and the differ never actually cares about the root node's view. private val rootController = RootNodeController(listContainer, View(context)) + private val specBuilder = NodeSpecBuilder(viewBarn) private val viewDiffer = ShadeViewDiffer(rootController, logger) fun attach(listBuilder: ShadeListBuilder) = listBuilder.setOnRenderListListener(::onNewNotifTree) - private fun onNewNotifTree(tree: List) = viewDiffer.applySpec(buildTree(tree)) - - private fun buildTree(notifList: List): NodeSpec { - val root = NodeSpecImpl(null, rootController).apply { - // Insert first section header, if present - notifList.firstOrNull()?.section?.headerController?.let { - children.add(NodeSpecImpl(this, it)) - } - notifList.firstOrNull()?.let { - children.add(buildNotifNode(it, this)) - } - notifList.asSequence().zipWithNext().forEach { (prev, entry) -> - // Insert new header if the section has changed between two entries - entry.section.takeIf { it != prev.section }?.headerController?.let { - children.add(NodeSpecImpl(this, it)) - } - children.add(buildNotifNode(entry, this)) - } - } + private fun onNewNotifTree(notifList: List) { + viewDiffer.applySpec(specBuilder.buildNodeSpec(rootController, notifList)) notificationIconAreaController.updateNotificationIcons(notifList) - return root - } - - private fun buildNotifNode(entry: ListEntry, parent: NodeSpec): NodeSpec = when (entry) { - is NotificationEntry -> NodeSpecImpl(parent, viewBarn.requireView(entry)) - is GroupEntry -> NodeSpecImpl(parent, viewBarn.requireView(checkNotNull(entry.summary))) - .apply { entry.children.forEach { children.add(buildNotifNode(it, this)) } } - else -> throw RuntimeException("Unexpected entry: $entry") } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt index 62667bc5281f4..da956ec67696e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt @@ -30,3 +30,7 @@ inline fun modifyEntry( modifier(builder) builder.apply(entry) } + +fun getAttachState(entry: ListEntry): ListAttachState { + return entry.attachState +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/render/NodeSpecBuilderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/render/NodeSpecBuilderTest.kt new file mode 100644 index 0000000000000..2e676bbe65416 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/render/NodeSpecBuilderTest.kt @@ -0,0 +1,311 @@ +/* + * Copyright (C) 2021 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.collection.render + +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.notification.collection.GroupEntry +import com.android.systemui.statusbar.notification.collection.GroupEntryBuilder +import com.android.systemui.statusbar.notification.collection.ListEntry +import com.android.systemui.statusbar.notification.collection.NotificationEntry +import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder +import com.android.systemui.statusbar.notification.collection.getAttachState +import com.android.systemui.statusbar.notification.collection.listbuilder.NotifSection +import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner +import com.android.systemui.util.mockito.any +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations + +@SmallTest +class NodeSpecBuilderTest : SysuiTestCase() { + + @Mock + private lateinit var viewBarn: NotifViewBarn + + private var rootController: NodeController = buildFakeController("rootController") + private var headerController0: NodeController = buildFakeController("header0") + private var headerController1: NodeController = buildFakeController("header1") + private var headerController2: NodeController = buildFakeController("header2") + + private val section0 = buildSection(0, headerController0) + private val section0NoHeader = buildSection(0, null) + private val section1 = buildSection(1, headerController1) + private val section1NoHeader = buildSection(1, null) + private val section2 = buildSection(2, headerController2) + + private val fakeViewBarn = FakeViewBarn() + + private lateinit var specBuilder: NodeSpecBuilder + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + `when`(viewBarn.requireView(any())).thenAnswer { + fakeViewBarn.getViewByEntry(it.getArgument(0)) + } + + specBuilder = NodeSpecBuilder(viewBarn) + } + + @Test + fun testSimpleMapping() { + checkOutput( + // GIVEN a simple flat list of notifications all in the same headerless section + listOf( + notif(0, section0NoHeader), + notif(1, section0NoHeader), + notif(2, section0NoHeader), + notif(3, section0NoHeader) + ), + + // THEN we output a similarly simple flag list of nodes + tree( + notifNode(0), + notifNode(1), + notifNode(2), + notifNode(3) + ) + ) + } + + @Test + fun testHeaderInjection() { + checkOutput( + // GIVEN a flat list of notifications, spread across three sections + listOf( + notif(0, section0), + notif(1, section0), + notif(2, section1), + notif(3, section2) + ), + + // THEN each section has its header injected + tree( + node(headerController0), + notifNode(0), + notifNode(1), + node(headerController1), + notifNode(2), + node(headerController2), + notifNode(3) + ) + ) + } + + @Test + fun testGroups() { + checkOutput( + // GIVEN a mixed list of top-level notifications and groups + listOf( + notif(0, section0), + group(1, section1, + notif(2), + notif(3), + notif(4) + ), + notif(5, section2), + group(6, section2, + notif(7), + notif(8), + notif(9) + ) + ), + + // THEN we properly construct all the nodes + tree( + node(headerController0), + notifNode(0), + node(headerController1), + notifNode(1, + notifNode(2), + notifNode(3), + notifNode(4) + ), + node(headerController2), + notifNode(5), + notifNode(6, + notifNode(7), + notifNode(8), + notifNode(9) + ) + ) + ) + } + + @Test + fun testSecondSectionWithNoHeader() { + checkOutput( + // GIVEN a middle section with no associated header view + listOf( + notif(0, section0), + notif(1, section1NoHeader), + group(2, section1NoHeader, + notif(3), + notif(4) + ), + notif(5, section2) + ), + + // THEN the header view is left out of the tree (but the notifs are still present) + tree( + node(headerController0), + notifNode(0), + notifNode(1), + notifNode(2, + notifNode(3), + notifNode(4) + ), + node(headerController2), + notifNode(5) + ) + ) + } + + @Test(expected = RuntimeException::class) + fun testRepeatedSectionsThrow() { + checkOutput( + // GIVEN a malformed list where sections are not contiguous + listOf( + notif(0, section0), + notif(1, section1), + notif(2, section0) + ), + + // THEN an exception is thrown + tree() + ) + } + + private fun checkOutput(list: List, desiredTree: NodeSpecImpl) { + checkTree(desiredTree, specBuilder.buildNodeSpec(rootController, list)) + } + + private fun checkTree(desiredTree: NodeSpec, actualTree: NodeSpec) { + try { + checkNode(desiredTree, actualTree) + } catch (e: AssertionError) { + throw AssertionError("Trees don't match: ${e.message}\nActual tree:\n" + + treeSpecToStr(actualTree)) + } + } + + private fun checkNode(desiredTree: NodeSpec, actualTree: NodeSpec) { + if (actualTree.controller != desiredTree.controller) { + throw AssertionError("Node {${actualTree.controller.nodeLabel}} should " + + "be ${desiredTree.controller.nodeLabel}") + } + for (i in 0 until desiredTree.children.size) { + if (i >= actualTree.children.size) { + throw AssertionError("Node {${actualTree.controller.nodeLabel}}" + + " is missing child ${desiredTree.children[i].controller.nodeLabel}") + } + checkNode(desiredTree.children[i], actualTree.children[i]) + } + } + + private fun notif(id: Int, section: NotifSection? = null): NotificationEntry { + val entry = NotificationEntryBuilder() + .setId(id) + .build() + if (section != null) { + getAttachState(entry).section = section + } + fakeViewBarn.buildNotifView(id, entry) + return entry + } + + private fun group( + id: Int, + section: NotifSection, + vararg children: NotificationEntry + ): GroupEntry { + val group = GroupEntryBuilder() + .setKey("group_$id") + .setSummary( + NotificationEntryBuilder() + .setId(id) + .build()) + .setChildren(children.asList()) + .build() + getAttachState(group).section = section + fakeViewBarn.buildNotifView(id, group.summary!!) + + for (child in children) { + getAttachState(child).section = section + } + return group + } + + private fun tree(vararg children: NodeSpecImpl): NodeSpecImpl { + return node(rootController, *children) + } + + private fun node(view: NodeController, vararg children: NodeSpecImpl): NodeSpecImpl { + val node = NodeSpecImpl(null, view) + node.children.addAll(children) + return node + } + + private fun notifNode(id: Int, vararg children: NodeSpecImpl): NodeSpecImpl { + return node(fakeViewBarn.getViewById(id), *children) + } +} + +private class FakeViewBarn { + private val entries = mutableMapOf() + private val views = mutableMapOf() + + fun buildNotifView(id: Int, entry: NotificationEntry) { + if (entries.contains(id)) { + throw RuntimeException("ID $id is already in use") + } + entries[id] = entry + views[entry] = buildFakeController("Entry $id") + } + + fun getViewById(id: Int): NodeController { + return views[entries[id] ?: throw RuntimeException("No view with ID $id")]!! + } + + fun getViewByEntry(entry: NotificationEntry): NodeController { + return views[entry] ?: throw RuntimeException("No view defined for key ${entry.key}") + } +} + +private fun buildFakeController(name: String): NodeController { + val controller = Mockito.mock(NodeController::class.java) + `when`(controller.nodeLabel).thenReturn(name) + return controller +} + +private fun buildSection(index: Int, nodeController: NodeController?): NotifSection { + return NotifSection(object : NotifSectioner("Section $index") { + + override fun isInSection(entry: ListEntry?): Boolean { + throw NotImplementedError("This should never be called") + } + + override fun getHeaderNodeController(): NodeController? { + return nodeController + } + }, index) +} \ No newline at end of file