From 2b699058172eaea27dd4df25368f04060a89248e Mon Sep 17 00:00:00 2001 From: Ned Burns Date: Mon, 10 Aug 2020 19:59:56 -0400 Subject: [PATCH] Revamp view manager logic to support any kind of view Everything in the notif shade must now be represented by a controler (specifically a NodeController). ExpandableNotificationRows are NodeControllers, the NSSL is a NodeController (well, we wrap it in something that is one), and, in the future, headers will have their own controllers. Split NotifViewManager into two pieces: - ShadeViewManager, which consumes the shade list generated by the ShadeListBuilder and generates a "node list" -- instead of NotificationEntries, a tree of controllers (and their associated views). Plus, in the future, any header views. - ShadeViewDiffer, which consumes a node list and applies any changes that need to be made to the view tree. Test: atest Change-Id: I016ff8c454ef7d18d4ac927c45ad132a3e5d4b13 --- .../coordinator/PreparationCoordinator.java | 2 +- .../inflation/NotificationRowBinderImpl.java | 1 + .../init/NotifPipelineInitializer.java | 12 +- .../collection/render/NodeController.kt | 90 ++++++ .../collection/render/NotifViewBarn.kt | 13 +- .../collection/render/NotifViewManager.kt | 240 -------------- .../collection/render/RootNodeController.kt | 58 ++++ .../collection/render/ShadeViewDiffer.kt | 221 +++++++++++++ ...agerLogger.kt => ShadeViewDifferLogger.kt} | 2 +- .../collection/render/ShadeViewManager.kt | 88 +++++ .../ExpandableNotificationRowController.java | 59 +++- .../ExpandableNotificationRowComponent.java | 3 + .../systemui/util/kotlin/nullability.kt | 22 ++ .../render/ShadeViewDifferTest.java | 304 ++++++++++++++++++ ...NotificationEntryManagerInflationTest.java | 1 + 15 files changed, 860 insertions(+), 256 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NodeController.kt delete mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewManager.kt create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/RootNodeController.kt create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDiffer.kt rename packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/{NotifViewManagerLogger.kt => ShadeViewDifferLogger.kt} (95%) create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewManager.kt create mode 100644 packages/SystemUI/src/com/android/systemui/util/kotlin/nullability.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDifferTest.java diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java index ee2bb6b1d190a..3e110671d174e 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java @@ -307,7 +307,7 @@ public class PreparationCoordinator implements Coordinator { private void onInflationFinished(NotificationEntry entry) { mLogger.logNotifInflated(entry.getKey()); mInflatingNotifs.remove(entry); - mViewBarn.registerViewForEntry(entry, entry.getRow()); + mViewBarn.registerViewForEntry(entry, entry.getRowController()); mInflationStates.put(entry, STATE_INFLATED); mNotifInflatingFilter.invalidateList(); } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java index 8849824380d38..f90ec0b4f42ad 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java @@ -136,6 +136,7 @@ public class NotificationRowBinderImpl implements NotificationRowBinder { .expandableNotificationRow(row) .notificationEntry(entry) .onExpandClickListener(mPresenter) + .listContainer(mListContainer) .build(); ExpandableNotificationRowController rowController = component.getExpandableNotificationRowController(); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/init/NotifPipelineInitializer.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/init/NotifPipelineInitializer.java index 9782c3ef55fe6..1c02c62602cff 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/init/NotifPipelineInitializer.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/init/NotifPipelineInitializer.java @@ -29,8 +29,7 @@ import com.android.systemui.statusbar.notification.collection.ShadeListBuilder; import com.android.systemui.statusbar.notification.collection.coalescer.GroupCoalescer; import com.android.systemui.statusbar.notification.collection.coordinator.NotifCoordinators; import com.android.systemui.statusbar.notification.collection.inflation.NotificationRowBinderImpl; -import com.android.systemui.statusbar.notification.collection.render.NotifViewManager; -import com.android.systemui.statusbar.notification.collection.render.NotifViewManagerBuilder; +import com.android.systemui.statusbar.notification.collection.render.ShadeViewManagerFactory; import com.android.systemui.statusbar.notification.stack.NotificationListContainer; import java.io.FileDescriptor; @@ -51,7 +50,7 @@ public class NotifPipelineInitializer implements Dumpable { private final NotifCoordinators mNotifPluggableCoordinators; private final NotifInflaterImpl mNotifInflater; private final DumpManager mDumpManager; - private final NotifViewManagerBuilder mNotifViewManagerBuilder; + private final ShadeViewManagerFactory mShadeViewManagerFactory; private final FeatureFlags mFeatureFlags; @@ -64,7 +63,7 @@ public class NotifPipelineInitializer implements Dumpable { NotifCoordinators notifCoordinators, NotifInflaterImpl notifInflater, DumpManager dumpManager, - NotifViewManagerBuilder notifViewManagerBuilder, + ShadeViewManagerFactory shadeViewManagerFactory, FeatureFlags featureFlags) { mPipelineWrapper = pipelineWrapper; mGroupCoalescer = groupCoalescer; @@ -73,8 +72,8 @@ public class NotifPipelineInitializer implements Dumpable { mNotifPluggableCoordinators = notifCoordinators; mDumpManager = dumpManager; mNotifInflater = notifInflater; + mShadeViewManagerFactory = shadeViewManagerFactory; mFeatureFlags = featureFlags; - mNotifViewManagerBuilder = notifViewManagerBuilder; } /** Hooks the new pipeline up to NotificationManager */ @@ -95,8 +94,7 @@ public class NotifPipelineInitializer implements Dumpable { // Wire up pipeline if (mFeatureFlags.isNewNotifPipelineRenderingEnabled()) { - NotifViewManager notifViewManager = mNotifViewManagerBuilder.build(listContainer); - notifViewManager.attach(mListBuilder); + mShadeViewManagerFactory.create(listContainer).attach(mListBuilder); } mListBuilder.attach(mNotifCollection); mNotifCollection.attach(mGroupCoalescer); 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 new file mode 100644 index 0000000000000..67f7b1c09df38 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NodeController.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2020 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 android.view.View +import java.lang.RuntimeException +import java.lang.StringBuilder + +/** + * A controller that represents a single unit of addable/removable view(s) in the notification + * shade. Some nodes are just a single view (such as a header), while some might involve many views + * (such as a notification row). + * + * It's possible for nodes to support having child nodes (for example, some notification rows + * contain other notification rows). If so, they must implement all of the child-related methods + * below. + */ +interface NodeController { + /** A string that uniquely(ish) represents the node in the tree. Used for debugging. */ + val nodeLabel: String + + val view: View + + fun getChildAt(index: Int): View? { + throw RuntimeException("Not supported") + } + + fun getChildCount(): Int { + throw RuntimeException("Not supported") + } + + fun addChildAt(child: NodeController, index: Int) { + throw RuntimeException("Not supported") + } + + fun moveChildTo(child: NodeController, index: Int) { + throw RuntimeException("Not supported") + } + + fun removeChild(child: NodeController, isTransfer: Boolean) { + throw RuntimeException("Not supported") + } +} + +/** + * Used to specify the tree of [NodeController]s that currently make up the shade. + */ +interface NodeSpec { + val parent: NodeSpec? + val controller: NodeController + val children: List +} + +class NodeSpecImpl( + override val parent: NodeSpec?, + override val controller: NodeController +) : NodeSpec { + override val children = mutableListOf() +} + +/** + * Converts a tree spec to human-readable string, for dumping purposes. + */ +fun treeSpecToStr(tree: NodeSpec): String { + return StringBuilder().also { treeSpecToStrHelper(tree, it, "") }.toString() +} + +private fun treeSpecToStrHelper(tree: NodeSpec, sb: StringBuilder, indent: String) { + sb.append("${indent}ns{${tree.controller.nodeLabel}") + if (tree.children.isNotEmpty()) { + val childIndent = "$indent " + for (child in tree.children) { + treeSpecToStrHelper(child, sb, childIndent) + } + } +} 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 54000950e3ef3..00fd09dd662e2 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 @@ -18,18 +18,19 @@ package com.android.systemui.statusbar.notification.collection.render import android.view.textclassifier.Log import com.android.systemui.statusbar.notification.collection.ListEntry -import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow +import com.android.systemui.statusbar.notification.row.ExpandableNotificationRowController import javax.inject.Inject import javax.inject.Singleton /** - * The ViewBarn is just a map from [ListEntry] to an instance of an [ExpandableNotificationRow]. + * The ViewBarn is just a map from [ListEntry] to an instance of an + * [ExpandableNotificationRowController]. */ @Singleton class NotifViewBarn @Inject constructor() { - private val rowMap = mutableMapOf() + private val rowMap = mutableMapOf() - fun requireView(forEntry: ListEntry): ExpandableNotificationRow { + fun requireView(forEntry: ListEntry): ExpandableNotificationRowController { if (DEBUG) { Log.d(TAG, "requireView: $forEntry.key") } @@ -41,11 +42,11 @@ class NotifViewBarn @Inject constructor() { return li } - fun registerViewForEntry(entry: ListEntry, view: ExpandableNotificationRow) { + fun registerViewForEntry(entry: ListEntry, controller: ExpandableNotificationRowController) { if (DEBUG) { Log.d(TAG, "registerViewForEntry: $entry.key") } - rowMap[entry.key] = view + rowMap[entry.key] = controller } fun removeViewForEntry(entry: ListEntry) { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewManager.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewManager.kt deleted file mode 100644 index f2e2c39ab612b..0000000000000 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewManager.kt +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright (C) 2020 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 android.annotation.MainThread -import android.view.View -import com.android.systemui.statusbar.notification.collection.GroupEntry -import com.android.systemui.statusbar.notification.collection.GroupEntry.ROOT_ENTRY -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.row.ExpandableNotificationRow -import com.android.systemui.statusbar.notification.stack.NotificationListContainer -import javax.inject.Inject - -/** - * A consumer of a Notification tree built by [ShadeListBuilder] which will update the notification - * presenter with the minimum operations required to make the old tree match the new one - */ -@MainThread -class NotifViewManager constructor( - private val listContainer: NotificationListContainer, - private val viewBarn: NotifViewBarn, - private val logger: NotifViewManagerLogger -) { - private val rootNode = RootWrapper(listContainer) - private val rows = mutableMapOf() - - fun attach(listBuilder: ShadeListBuilder) { - listBuilder.setOnRenderListListener(::onNewNotifTree) - } - - private fun onNewNotifTree(tree: List) { - // Step 1: Detach all views whose parents have changed - detachRowsWithModifiedParents() - - // Step 2: Attach all new views and reattach all views whose parents changed. - // Also reorder existing children to match the spec we've received - val orderChanged = addAndReorderChildren(rootNode, tree) - if (orderChanged) { - listContainer.generateChildOrderChangedEvent() - } - } - - private fun detachRowsWithModifiedParents() { - val toRemove = mutableListOf() - for (row in rows.values) { - val oldParentEntry = row.nodeParent?.entry - val newParentEntry = row.entry.parent - - if (newParentEntry != oldParentEntry) { - // If the parent is null, then we should remove the child completely. If not, then - // the parent merely changed: we'll detach it for now and then attach it to the - // new parent in step 2. - val isTransfer = newParentEntry != null - if (!isTransfer) { - toRemove.add(row.entry) - } - - if (!isTransfer && !isAttachedToRootEntry(oldParentEntry)) { - // If our view parent has also been removed (i.e. is no longer attached to the - // root entry) then we skip removing the child here - logger.logSkippingDetach(row.entry.key, row.nodeParent?.entry?.key) - } else { - logger.logDetachingChild( - row.entry.key, - isTransfer, - oldParentEntry?.key, - newParentEntry?.key) - row.nodeParent?.removeChild(row, isTransfer) - row.nodeParent = null - } - } - } - rows.keys.removeAll(toRemove) - } - - private fun addAndReorderChildren(parent: ParentNode, childEntries: List): Boolean { - var orderChanged = false - for ((index, entry) in childEntries.withIndex()) { - val row = getRowNode(entry) - val currView = parent.getChildViewAt(index) - if (currView != row.view) { - when (row.nodeParent) { - null -> { - logger.logAttachingChild(row.entry.key, parent.entry.key) - parent.addChildAt(row, index) - row.nodeParent = parent - } - parent -> { - logger.logMovingChild(row.entry.key, parent.entry.key, index) - parent.moveChild(row, index) - orderChanged = true - } - else -> { - throw IllegalStateException("Child ${row.entry.key} should have parent " + - "${parent.entry.key} but is actually " + - "${row.nodeParent?.entry?.key}") - } - } - } - if (row is GroupWrapper) { - val childOrderChanged = addAndReorderChildren(row, row.entry.children) - orderChanged = orderChanged || childOrderChanged - } - } - // TODO: setUntruncatedChildCount - - return orderChanged - } - - private fun getRowNode(entry: ListEntry): RowNode { - return rows.getOrPut(entry) { - when (entry) { - is NotificationEntry -> RowWrapper(entry, viewBarn.requireView(entry)) - is GroupEntry -> - GroupWrapper( - entry, - viewBarn.requireView(checkNotNull(entry.summary)), - listContainer) - else -> throw RuntimeException( - "Unexpected entry type for ${entry.key}: ${entry.javaClass}") - } - } - } -} - -class NotifViewManagerBuilder @Inject constructor( - private val viewBarn: NotifViewBarn, - private val logger: NotifViewManagerLogger -) { - fun build(listContainer: NotificationListContainer): NotifViewManager { - return NotifViewManager(listContainer, viewBarn, logger) - } -} - -private fun isAttachedToRootEntry(entry: ListEntry?): Boolean { - return when (entry) { - null -> false - ROOT_ENTRY -> true - else -> isAttachedToRootEntry(entry.parent) - } -} - -private interface Node { - val entry: ListEntry - val nodeParent: ParentNode? -} - -private interface ParentNode : Node { - fun getChildViewAt(index: Int): View? - fun addChildAt(child: RowNode, index: Int) - fun moveChild(child: RowNode, index: Int) - fun removeChild(child: RowNode, isTransfer: Boolean) -} - -private interface RowNode : Node { - val view: ExpandableNotificationRow - override var nodeParent: ParentNode? -} - -private class RootWrapper( - private val listContainer: NotificationListContainer -) : ParentNode { - override val entry: ListEntry = ROOT_ENTRY - override val nodeParent: ParentNode? = null - - override fun getChildViewAt(index: Int): View? { - return listContainer.getContainerChildAt(index) - } - - override fun addChildAt(child: RowNode, index: Int) { - listContainer.addContainerViewAt(child.view, index) - } - - override fun moveChild(child: RowNode, index: Int) { - listContainer.changeViewPosition(child.view, index) - } - - override fun removeChild(child: RowNode, isTransfer: Boolean) { - if (isTransfer) { - listContainer.setChildTransferInProgress(true) - } - listContainer.removeContainerView(child.view) - if (isTransfer) { - listContainer.setChildTransferInProgress(false) - } - } -} - -private class GroupWrapper( - override val entry: GroupEntry, - override val view: ExpandableNotificationRow, - val listContainer: NotificationListContainer -) : RowNode, ParentNode { - - override var nodeParent: ParentNode? = null - - override fun getChildViewAt(index: Int): View? { - return view.getChildNotificationAt(index) - } - - override fun addChildAt(child: RowNode, index: Int) { - view.addChildNotification(child.view, index) - listContainer.notifyGroupChildAdded(child.view) - } - - override fun moveChild(child: RowNode, index: Int) { - view.removeChildNotification(child.view) - view.addChildNotification(child.view, index) - } - - override fun removeChild(child: RowNode, isTransfer: Boolean) { - view.removeChildNotification(child.view) - if (isTransfer) { - listContainer.notifyGroupChildRemoved(child.view, view) - } - } -} - -private class RowWrapper( - override val entry: NotificationEntry, - override val view: ExpandableNotificationRow -) : RowNode { - override var nodeParent: ParentNode? = null -} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/RootNodeController.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/RootNodeController.kt new file mode 100644 index 0000000000000..e8124944bcb05 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/RootNodeController.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2020 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 android.view.View +import com.android.systemui.statusbar.notification.row.ExpandableView +import com.android.systemui.statusbar.notification.stack.NotificationListContainer + +/** + * Temporary wrapper around [NotificationListContainer], for use by [ShadeViewDiffer]. Long term, + * we should just modify NLC to implement the NodeController interface. + */ +class RootNodeController( + private val listContainer: NotificationListContainer +) : NodeController { + override val nodeLabel: String = "" + override val view: View = listContainer as View + + override fun getChildAt(index: Int): View? { + return listContainer.getContainerChildAt(index) + } + + override fun getChildCount(): Int { + return listContainer.containerChildCount + } + + override fun addChildAt(child: NodeController, index: Int) { + listContainer.addContainerViewAt(child.view, index) + } + + override fun moveChildTo(child: NodeController, index: Int) { + listContainer.changeViewPosition(child.view as ExpandableView, index) + } + + override fun removeChild(child: NodeController, isTransfer: Boolean) { + if (isTransfer) { + listContainer.setChildTransferInProgress(true) + } + listContainer.removeContainerView(child.view) + if (isTransfer) { + listContainer.setChildTransferInProgress(false) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDiffer.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDiffer.kt new file mode 100644 index 0000000000000..019520f18982a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDiffer.kt @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2020 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 android.annotation.MainThread +import android.view.View +import com.android.systemui.util.kotlin.transform + +/** + * Given a "spec" that describes a "tree" of views, adds and removes views from the + * [rootController] and its children until the actual tree matches the spec. + * + * Every node in the spec tree must specify both a view and its associated [NodeController]. + * Commands to add/remove/reorder children are sent to the controller. How the controller + * interprets these commands is left to its own discretion -- it might add them directly to its + * associated view or to some subview container. + * + * It's possible for nodes to mix "unmanaged" views in alongside managed ones within the same + * container. In this case, whenever the differ runs it will move all unmanaged views to the end + * of the node's child list. + */ +@MainThread +class ShadeViewDiffer( + rootController: NodeController, + private val logger: ShadeViewDifferLogger +) { + private val rootNode = ShadeNode(rootController) + private val nodes = mutableMapOf(rootController to rootNode) + private val views = mutableMapOf() + + /** + * Adds and removes views from the root (and its children) until their structure matches the + * provided [spec]. The root node of the spec must match the root controller passed to the + * differ's constructor. + */ + fun applySpec(spec: NodeSpec) { + val specMap = treeToMap(spec) + + if (spec.controller != rootNode.controller) { + throw IllegalArgumentException("Tree root ${spec.controller.nodeLabel} does not " + + "match own root at ${rootNode.label}") + } + + detachChildren(rootNode, specMap) + attachChildren(rootNode, specMap) + } + + /** + * If [view] is managed by this differ, then returns the label of the view's controller. + * Otherwise returns View.toString(). + * + * For debugging purposes. + */ + fun getViewLabel(view: View): String { + return views[view]?.label ?: view.toString() + } + + private fun detachChildren( + parentNode: ShadeNode, + specMap: Map + ) { + val parentSpec = specMap[parentNode.controller] + + for (i in parentNode.getChildCount() - 1 downTo 0) { + val childView = parentNode.getChildAt(i) + views[childView]?.let { childNode -> + val childSpec = specMap[childNode.controller] + + maybeDetachChild(parentNode, parentSpec, childNode, childSpec) + + if (childNode.controller.getChildCount() > 0) { + detachChildren(childNode, specMap) + } + } + } + } + + private fun maybeDetachChild( + parentNode: ShadeNode, + parentSpec: NodeSpec?, + childNode: ShadeNode, + childSpec: NodeSpec? + ) { + val newParentNode = transform(childSpec?.parent) { getNode(it) } + + if (newParentNode != parentNode) { + val childCompletelyRemoved = newParentNode == null + + if (childCompletelyRemoved) { + nodes.remove(childNode.controller) + views.remove(childNode.controller.view) + } + + if (childCompletelyRemoved && parentSpec == null) { + // If both the child and the parent are being removed at the same time, then + // keep the child attached to the parent for animation purposes + logger.logSkippingDetach(childNode.label, parentNode.label) + } else { + logger.logDetachingChild( + childNode.label, + !childCompletelyRemoved, + parentNode.label, + newParentNode?.label) + parentNode.removeChild(childNode, !childCompletelyRemoved) + childNode.parent = null + } + } + } + + private fun attachChildren( + parentNode: ShadeNode, + specMap: Map + ) { + val parentSpec = checkNotNull(specMap[parentNode.controller]) + + for ((index, childSpec) in parentSpec.children.withIndex()) { + val currView = parentNode.getChildAt(index) + val childNode = getNode(childSpec) + + if (childNode.view != currView) { + + when (childNode.parent) { + null -> { + // A new child (either newly created or coming from some other parent) + logger.logAttachingChild(childNode.label, parentNode.label) + parentNode.addChildAt(childNode, index) + childNode.parent = parentNode + } + parentNode -> { + // A pre-existing child, just in the wrong position. Move it into place + logger.logMovingChild(childNode.label, parentNode.label, index) + parentNode.moveChildTo(childNode, index) + } + else -> { + // Error: child still has a parent. We should have detached it in the + // previous step. + throw IllegalStateException("Child ${childNode.label} should have " + + "parent ${parentNode.label} but is actually " + + "${childNode.parent?.label}") + } + } + } + + if (childSpec.children.isNotEmpty()) { + attachChildren(childNode, specMap) + } + } + } + + private fun getNode(spec: NodeSpec): ShadeNode { + var node = nodes[spec.controller] + if (node == null) { + node = ShadeNode(spec.controller) + nodes[node.controller] = node + views[node.view] = node + } + return node + } + + private fun treeToMap(tree: NodeSpec): Map { + val map = mutableMapOf() + + registerNodes(tree, map) + + return map + } + + private fun registerNodes(node: NodeSpec, map: MutableMap) { + if (map.containsKey(node.controller)) { + throw RuntimeException("Node ${node.controller.nodeLabel} appears more than once") + } + map[node.controller] = node + + if (node.children.isNotEmpty()) { + for (child in node.children) { + registerNodes(child, map) + } + } + } +} + +private class ShadeNode( + val controller: NodeController +) { + val view = controller.view + + var parent: ShadeNode? = null + + val label: String + get() = controller.nodeLabel + + fun getChildAt(index: Int): View? = controller.getChildAt(index) + + fun getChildCount(): Int = controller.getChildCount() + + fun addChildAt(child: ShadeNode, index: Int) { + controller.addChildAt(child.controller, index) + } + + fun moveChildTo(child: ShadeNode, index: Int) { + controller.moveChildTo(child.controller, index) + } + + fun removeChild(child: ShadeNode, isTransfer: Boolean) { + controller.removeChild(child.controller, isTransfer) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewManagerLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDifferLogger.kt similarity index 95% rename from packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewManagerLogger.kt rename to packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDifferLogger.kt index 3d561264d367b..19e156f572d44 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/NotifViewManagerLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDifferLogger.kt @@ -21,7 +21,7 @@ import com.android.systemui.log.LogLevel import com.android.systemui.log.dagger.NotificationLog import javax.inject.Inject -class NotifViewManagerLogger @Inject constructor( +class ShadeViewDifferLogger @Inject constructor( @NotificationLog private val buffer: LogBuffer ) { fun logDetachingChild( 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 new file mode 100644 index 0000000000000..201be59d80b4f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewManager.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2020 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.ShadeListBuilder +import com.android.systemui.statusbar.notification.stack.NotificationListContainer +import java.lang.RuntimeException +import javax.inject.Inject + +/** + * Responsible for building and applying the "shade node spec": the list (tree) of things that + * currently populate the notification shade. + */ +class ShadeViewManager constructor( + listContainer: NotificationListContainer, + logger: ShadeViewDifferLogger, + private val viewBarn: NotifViewBarn +) { + private val rootController = RootNodeController(listContainer) + 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) + + for (entry in notifList) { + // TODO: Add section header logic here + root.children.add(buildNotifNode(entry, root)) + } + + return root + } + + private fun buildNotifNode(entry: ListEntry, parent: NodeSpec): NodeSpec { + return when (entry) { + is NotificationEntry -> { + NodeSpecImpl(parent, viewBarn.requireView(entry)) + } + is GroupEntry -> { + val groupNode = NodeSpecImpl( + parent, + viewBarn.requireView(checkNotNull(entry.summary))) + + for (childEntry in entry.children) { + groupNode.children.add(buildNotifNode(childEntry, groupNode)) + } + + groupNode + } + else -> { + throw RuntimeException("Unexpected entry: $entry") + } + } + } +} + +class ShadeViewManagerFactory @Inject constructor( + private val logger: ShadeViewDifferLogger, + private val viewBarn: NotifViewBarn +) { + fun create(listContainer: NotificationListContainer): ShadeViewManager { + return ShadeViewManager(listContainer, logger, viewBarn) + } +} 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 86a3271c4eac1..1dbec6603f161 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 @@ -22,21 +22,27 @@ import static com.android.systemui.statusbar.NotificationRemoteInputManager.ENAB import android.view.View; import android.view.ViewGroup; +import androidx.annotation.NonNull; + import com.android.systemui.plugins.FalsingManager; import com.android.systemui.plugins.statusbar.NotificationMenuRowPlugin; import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.shared.plugins.PluginManager; import com.android.systemui.statusbar.NotificationMediaManager; +import com.android.systemui.statusbar.notification.collection.render.NodeController; import com.android.systemui.statusbar.notification.logging.NotificationLogger; import com.android.systemui.statusbar.notification.people.PeopleNotificationIdentifier; import com.android.systemui.statusbar.notification.row.dagger.AppName; import com.android.systemui.statusbar.notification.row.dagger.NotificationKey; import com.android.systemui.statusbar.notification.row.dagger.NotificationRowScope; +import com.android.systemui.statusbar.notification.stack.NotificationListContainer; import com.android.systemui.statusbar.phone.KeyguardBypassController; import com.android.systemui.statusbar.phone.NotificationGroupManager; import com.android.systemui.statusbar.policy.HeadsUpManager; import com.android.systemui.util.time.SystemClock; +import java.util.List; + import javax.inject.Inject; import javax.inject.Named; @@ -44,8 +50,9 @@ import javax.inject.Named; * Controller for {@link ExpandableNotificationRow}. */ @NotificationRowScope -public class ExpandableNotificationRowController { +public class ExpandableNotificationRowController implements NodeController { private final ExpandableNotificationRow mView; + private final NotificationListContainer mListContainer; private final ActivatableNotificationViewController mActivatableNotificationViewController; private final NotificationMediaManager mMediaManager; private final PluginManager mPluginManager; @@ -72,6 +79,7 @@ public class ExpandableNotificationRowController { @Inject public ExpandableNotificationRowController(ExpandableNotificationRow view, + NotificationListContainer listContainer, ActivatableNotificationViewController activatableNotificationViewController, NotificationMediaManager mediaManager, PluginManager pluginManager, SystemClock clock, @AppName String appName, @NotificationKey String notificationKey, @@ -86,6 +94,7 @@ public class ExpandableNotificationRowController { OnDismissCallback onDismissCallback, FalsingManager falsingManager, PeopleNotificationIdentifier peopleNotificationIdentifier) { mView = view; + mListContainer = listContainer; mActivatableNotificationViewController = activatableNotificationViewController; mMediaManager = mediaManager; mPluginManager = pluginManager; @@ -162,4 +171,52 @@ public class ExpandableNotificationRowController { private void logNotificationExpansion(String key, boolean userAction, boolean expanded) { mNotificationLogger.onExpansionChanged(key, userAction, expanded); } + + @Override + @NonNull + public String getNodeLabel() { + return mView.getEntry().getKey(); + } + + @Override + @NonNull + public View getView() { + return mView; + } + + @Override + public View getChildAt(int index) { + return mView.getChildNotificationAt(index); + } + + @Override + public void addChildAt(NodeController child, int index) { + ExpandableNotificationRow childView = (ExpandableNotificationRow) child.getView(); + + mView.addChildNotification((ExpandableNotificationRow) child.getView()); + mListContainer.notifyGroupChildAdded(childView); + } + + @Override + public void moveChildTo(NodeController child, int index) { + ExpandableNotificationRow childView = (ExpandableNotificationRow) child.getView(); + mView.removeChildNotification(childView); + mView.addChildNotification(childView, index); + } + + @Override + public void removeChild(NodeController child, boolean isTransfer) { + ExpandableNotificationRow childView = (ExpandableNotificationRow) child.getView(); + + mView.removeChildNotification(childView); + if (!isTransfer) { + mListContainer.notifyGroupChildRemoved(childView, mView); + } + } + + @Override + public int getChildCount() { + final List mChildren = mView.getAttachedChildren(); + return mChildren != null ? mChildren.size() : 0; + } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/dagger/ExpandableNotificationRowComponent.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/dagger/ExpandableNotificationRowComponent.java index 28ddf5971bcba..becc9a772b28b 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/dagger/ExpandableNotificationRowComponent.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/dagger/ExpandableNotificationRowComponent.java @@ -25,6 +25,7 @@ import com.android.systemui.statusbar.notification.collection.NotificationEntry; import com.android.systemui.statusbar.notification.row.ActivatableNotificationView; import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow; import com.android.systemui.statusbar.notification.row.ExpandableNotificationRowController; +import com.android.systemui.statusbar.notification.stack.NotificationListContainer; import com.android.systemui.statusbar.phone.StatusBar; import dagger.Binds; @@ -55,6 +56,8 @@ public interface ExpandableNotificationRowComponent { Builder notificationEntry(NotificationEntry entry); @BindsInstance Builder onExpandClickListener(ExpandableNotificationRow.OnExpandClickListener presenter); + @BindsInstance + Builder listContainer(NotificationListContainer listContainer); ExpandableNotificationRowComponent build(); } diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/nullability.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/nullability.kt new file mode 100644 index 0000000000000..92c73a4125771 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/nullability.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2020 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.util.kotlin + +/** + * If [value] is not null, then returns block(value). Otherwise returns null. + */ +inline fun transform(value: T?, block: (T) -> R): R? = value?.let(block) diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDifferTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDifferTest.java new file mode 100644 index 0000000000000..bbe92f67ca2e9 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDifferTest.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2020 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 static org.junit.Assert.assertEquals; + +import android.content.Context; +import android.testing.AndroidTestingRunner; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.filters.SmallTest; + +import com.android.systemui.SysuiTestCase; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.List; + +@SmallTest +@RunWith(AndroidTestingRunner.class) +public class ShadeViewDifferTest extends SysuiTestCase { + private ShadeViewDiffer mDiffer; + + private FakeController mRootController = new FakeController(mContext, "RootController"); + private FakeController mController1 = new FakeController(mContext, "Controller1"); + private FakeController mController2 = new FakeController(mContext, "Controller2"); + private FakeController mController3 = new FakeController(mContext, "Controller3"); + private FakeController mController4 = new FakeController(mContext, "Controller4"); + private FakeController mController5 = new FakeController(mContext, "Controller5"); + private FakeController mController6 = new FakeController(mContext, "Controller6"); + private FakeController mController7 = new FakeController(mContext, "Controller7"); + + @Mock + ShadeViewDifferLogger mLogger; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + mDiffer = new ShadeViewDiffer(mRootController, mLogger); + } + + @Test + public void testAddInitialViews() { + // WHEN a spec is applied to an empty root + // THEN the final tree matches the spec + applySpecAndCheck( + node(mController1), + node(mController2, + node(mController3), + node(mController4) + ), + node(mController5) + ); + } + + @Test + public void testDetachViews() { + // GIVEN a preexisting tree of controllers + applySpecAndCheck( + node(mController1), + node(mController2, + node(mController3), + node(mController4) + ), + node(mController5) + ); + + // WHEN the new spec removes nodes + // THEN the final tree matches the spec + applySpecAndCheck( + node(mController5) + ); + } + + @Test + public void testReparentChildren() { + // GIVEN a preexisting tree of controllers + applySpecAndCheck( + node(mController1), + node(mController2, + node(mController3), + node(mController4) + ), + node(mController5) + ); + + // WHEN the parents of the controllers are all shuffled around + // THEN the final tree matches the spec + applySpecAndCheck( + node(mController1), + node(mController4), + node(mController3, + node(mController2) + ) + ); + } + + @Test + public void testReorderChildren() { + // GIVEN a preexisting tree of controllers + applySpecAndCheck( + node(mController1), + node(mController2), + node(mController3), + node(mController4) + ); + + // WHEN the children change order + // THEN the final tree matches the spec + applySpecAndCheck( + node(mController3), + node(mController2), + node(mController4), + node(mController1) + ); + } + + @Test + public void testRemovedGroupsAreKeptTogether() { + // GIVEN a preexisting tree with a group + applySpecAndCheck( + node(mController1), + node(mController2, + node(mController3), + node(mController4), + node(mController5) + ) + ); + + // WHEN the new spec removes the entire group + applySpecAndCheck( + node(mController1) + ); + + // THEN the group children are still attached to their parent + assertEquals(mController2.getView(), mController3.getView().getParent()); + assertEquals(mController2.getView(), mController4.getView().getParent()); + assertEquals(mController2.getView(), mController5.getView().getParent()); + } + + @Test + public void testUnmanagedViews() { + // GIVEN a preexisting tree of controllers + applySpecAndCheck( + node(mController1), + node(mController2, + node(mController3), + node(mController4) + ), + node(mController5) + ); + + // GIVEN some additional unmanaged views attached to the tree + View unmanagedView1 = new View(mContext); + View unmanagedView2 = new View(mContext); + + mRootController.getView().addView(unmanagedView1, 1); + mController2.getView().addView(unmanagedView2, 0); + + // WHEN a new spec is applied with additional nodes + // THEN the final tree matches the spec + applySpecAndCheck( + node(mController1), + node(mController2, + node(mController3), + node(mController4), + node(mController6) + ), + node(mController5), + node(mController7) + ); + + // THEN the unmanaged views have been pushed to the end of their parents + assertEquals(unmanagedView1, mRootController.view.getChildAt(4)); + assertEquals(unmanagedView2, mController2.view.getChildAt(3)); + } + + private void applySpecAndCheck(NodeSpec spec) { + mDiffer.applySpec(spec); + checkMatchesSpec(spec); + } + + private void applySpecAndCheck(SpecBuilder... children) { + applySpecAndCheck(node(mRootController, children).build()); + } + + private void checkMatchesSpec(NodeSpec spec) { + final NodeController parent = spec.getController(); + final List children = spec.getChildren(); + + for (int i = 0; i < children.size(); i++) { + NodeSpec childSpec = children.get(i); + View view = parent.getChildAt(i); + + assertEquals( + "Child " + i + " of parent " + parent.getNodeLabel() + " should be " + + childSpec.getController().getNodeLabel() + " but is instead " + + (view != null ? mDiffer.getViewLabel(view) : "null"), + view, + childSpec.getController().getView()); + + if (!childSpec.getChildren().isEmpty()) { + checkMatchesSpec(childSpec); + } + } + } + + private static class FakeController implements NodeController { + + public final FrameLayout view; + private final String mLabel; + + FakeController(Context context, String label) { + view = new FrameLayout(context); + mLabel = label; + } + + @NonNull + @Override + public String getNodeLabel() { + return mLabel; + } + + @NonNull + @Override + public FrameLayout getView() { + return view; + } + + @Override + public int getChildCount() { + return view.getChildCount(); + } + + @Override + public View getChildAt(int index) { + return view.getChildAt(index); + } + + @Override + public void addChildAt(@NonNull NodeController child, int index) { + view.addView(child.getView(), index); + } + + @Override + public void moveChildTo(@NonNull NodeController child, int index) { + view.removeView(child.getView()); + view.addView(child.getView(), index); + } + + @Override + public void removeChild(@NonNull NodeController child, boolean isTransfer) { + view.removeView(child.getView()); + } + } + + private static class SpecBuilder { + private final NodeController mController; + private final SpecBuilder[] mChildren; + + SpecBuilder(NodeController controller, SpecBuilder... children) { + mController = controller; + mChildren = children; + } + + public NodeSpec build() { + return build(null); + } + + public NodeSpec build(@Nullable NodeSpec parent) { + final NodeSpecImpl spec = new NodeSpecImpl(parent, mController); + for (SpecBuilder childBuilder : mChildren) { + spec.getChildren().add(childBuilder.build(spec)); + } + return spec; + } + } + + private static SpecBuilder node(NodeController controller, SpecBuilder... children) { + return new SpecBuilder(controller, children); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java index a90af87064b5e..7a0a19bd5424e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java @@ -221,6 +221,7 @@ public class NotificationEntryManagerInflationTest extends SysuiTestCase { .thenAnswer((Answer) invocation -> new ExpandableNotificationRowController( viewCaptor.getValue(), + mListContainer, mock(ActivatableNotificationViewController.class), mNotificationMediaManager, mock(PluginManager.class),