diff options
22 files changed, 1077 insertions, 443 deletions
diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml index f767636f7c55..133d375b8c6e 100644 --- a/packages/SystemUI/AndroidManifest.xml +++ b/packages/SystemUI/AndroidManifest.xml @@ -679,6 +679,15 @@ android:visibleToInstantApps="true"> </activity> + <activity android:name=".controls.management.ControlsEditingActivity" + android:theme="@style/Theme.ControlsManagement" + android:excludeFromRecents="true" + android:showForAllUsers="true" + android:finishOnTaskLaunch="true" + android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden" + android:visibleToInstantApps="true"> + </activity> + <activity android:name=".controls.management.ControlsFavoritingActivity" android:theme="@style/Theme.ControlsManagement" android:excludeFromRecents="true" diff --git a/packages/SystemUI/res/layout/controls_horizontal_divider_withEmpty.xml b/packages/SystemUI/res/layout/controls_horizontal_divider_withEmpty.xml new file mode 100644 index 000000000000..90b3398e3de2 --- /dev/null +++ b/packages/SystemUI/res/layout/controls_horizontal_divider_withEmpty.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content" +> + + <View + android:layout_width="match_parent" + android:layout_height="@dimen/controls_management_list_margin" + /> + + <FrameLayout + android:id="@+id/frame" + android:layout_width="match_parent" + android:layout_height="@dimen/control_height" + android:visibility="gone" + > + </FrameLayout> + <View + android:id="@+id/divider" + android:layout_width="match_parent" + android:layout_height="1dp" + android:layout_marginBottom="10dp" + android:layout_marginStart="40dp" + android:layout_marginEnd="40dp" + android:background="#4dffffff" /> +</LinearLayout> diff --git a/packages/SystemUI/res/layout/controls_management_apps.xml b/packages/SystemUI/res/layout/controls_management_apps.xml index 42d73f3cc9ce..94df9d8f4775 100644 --- a/packages/SystemUI/res/layout/controls_management_apps.xml +++ b/packages/SystemUI/res/layout/controls_management_apps.xml @@ -14,18 +14,11 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> -<androidx.core.widget.NestedScrollView +<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/list" android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1" - android:orientation="vertical" - android:layout_marginTop="@dimen/controls_management_list_margin"> + android:layout_height="match_parent" + android:layout_marginTop="@dimen/controls_management_list_margin" +/> - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/list" - android:layout_width="match_parent" - android:layout_height="match_parent" - /> - -</androidx.core.widget.NestedScrollView> diff --git a/packages/SystemUI/res/layout/controls_management_editing.xml b/packages/SystemUI/res/layout/controls_management_editing.xml new file mode 100644 index 000000000000..8a14ec3666b2 --- /dev/null +++ b/packages/SystemUI/res/layout/controls_management_editing.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<androidx.recyclerview.widget.RecyclerView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/list" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false" + android:paddingTop="@dimen/controls_management_list_margin" +/> + diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml index 566d143208fc..7c0b6054dddb 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -2670,8 +2670,11 @@ <string name="controls_favorite_default_title">Controls</string> <!-- Controls management controls screen subtitle [CHAR LIMIT=NONE] --> <string name="controls_favorite_subtitle">Choose controls to access from the power menu</string> - <!-- Controls management controls screen, user direction for rearranging controls [CHAR LIMIT=NONE] --> - <string name="controls_favorite_rearrange">Hold and drag a control to move it</string> + <!-- Controls management editing screen, user direction for rearranging controls [CHAR LIMIT=NONE] --> + <string name="controls_favorite_rearrange">Hold & drag to rearrange controls</string> + + <!-- Controls management editing screen, text to indicate that all the favorites have been removed [CHAR LIMIT=NONE] --> + <string name="controls_favorite_removed">All controls removed</string> <!-- Controls management controls screen error on load message [CHAR LIMIT=60] --> <string name="controls_favorite_load_error">The list of all controls could not be loaded.</string> diff --git a/packages/SystemUI/src/com/android/systemui/controls/ControlStatus.kt b/packages/SystemUI/src/com/android/systemui/controls/ControlStatus.kt index dec60073a55e..5891a7f705c8 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/ControlStatus.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/ControlStatus.kt @@ -18,10 +18,34 @@ package com.android.systemui.controls import android.content.ComponentName import android.service.controls.Control +import android.service.controls.DeviceTypes + +interface ControlInterface { + val favorite: Boolean + val component: ComponentName + val controlId: String + val title: CharSequence + val subtitle: CharSequence + val removed: Boolean + get() = false + @DeviceTypes.DeviceType val deviceType: Int +} data class ControlStatus( val control: Control, - val component: ComponentName, - var favorite: Boolean, - val removed: Boolean = false -) + override val component: ComponentName, + override var favorite: Boolean, + override val removed: Boolean = false +) : ControlInterface { + override val controlId: String + get() = control.controlId + + override val title: CharSequence + get() = control.title + + override val subtitle: CharSequence + get() = control.subtitle + + @DeviceTypes.DeviceType override val deviceType: Int + get() = control.deviceType +} diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlInfo.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlInfo.kt index 6e59ac162657..40606c2689e5 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlInfo.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlInfo.kt @@ -16,6 +16,7 @@ package com.android.systemui.controls.controller +import android.service.controls.Control import android.service.controls.DeviceTypes /** @@ -39,6 +40,14 @@ data class ControlInfo( companion object { private const val SEPARATOR = ":" + fun fromControl(control: Control): ControlInfo { + return ControlInfo( + control.controlId, + control.title, + control.subtitle, + control.deviceType + ) + } } /** @@ -49,13 +58,4 @@ data class ControlInfo( override fun toString(): String { return "$SEPARATOR$controlId$SEPARATOR$controlTitle$SEPARATOR$deviceType" } - - class Builder { - lateinit var controlId: String - lateinit var controlTitle: CharSequence - lateinit var controlSubtitle: CharSequence - var deviceType: Int = DeviceTypes.TYPE_UNKNOWN - - fun build() = ControlInfo(controlId, controlTitle, controlSubtitle, deviceType) - } } diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsController.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsController.kt index 568fb289027d..7cab847d52f7 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsController.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsController.kt @@ -148,6 +148,18 @@ interface ControlsController : UserAwareController { fun getFavoritesForComponent(componentName: ComponentName): List<StructureInfo> /** + * Get all the favorites for a given structure. + * + * @param componentName the name of the service that provides the [Control] + * @param structureName the name of the structure + * @return a list of the current favorites in that structure + */ + fun getFavoritesForStructure( + componentName: ComponentName, + structureName: CharSequence + ): List<ControlInfo> + + /** * Adds a single favorite to a given component and structure * @param componentName the name of the service that provides the [Control] * @param structureName the name of the structure that holds the [Control] diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsControllerImpl.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsControllerImpl.kt index 8805694616a4..6d34009169d5 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsControllerImpl.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsControllerImpl.kt @@ -544,6 +544,15 @@ class ControlsControllerImpl @Inject constructor ( override fun getFavoritesForComponent(componentName: ComponentName): List<StructureInfo> = Favorites.getStructuresForComponent(componentName) + override fun getFavoritesForStructure( + componentName: ComponentName, + structureName: CharSequence + ): List<ControlInfo> { + return Favorites.getControlsForStructure( + StructureInfo(componentName, structureName, emptyList()) + ) + } + override fun dump(fd: FileDescriptor, pw: PrintWriter, args: Array<out String>) { pw.println("ControlsController state:") pw.println(" Available: $available") diff --git a/packages/SystemUI/src/com/android/systemui/controls/dagger/ControlsModule.kt b/packages/SystemUI/src/com/android/systemui/controls/dagger/ControlsModule.kt index 946a2365585a..3bed55912332 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/dagger/ControlsModule.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/dagger/ControlsModule.kt @@ -22,6 +22,7 @@ import com.android.systemui.controls.controller.ControlsBindingControllerImpl import com.android.systemui.controls.controller.ControlsController import com.android.systemui.controls.controller.ControlsControllerImpl import com.android.systemui.controls.controller.ControlsFavoritePersistenceWrapper +import com.android.systemui.controls.management.ControlsEditingActivity import com.android.systemui.controls.management.ControlsFavoritingActivity import com.android.systemui.controls.management.ControlsListingController import com.android.systemui.controls.management.ControlsListingControllerImpl @@ -73,6 +74,13 @@ abstract class ControlsModule { @Binds @IntoMap + @ClassKey(ControlsEditingActivity::class) + abstract fun provideControlsEditingActivity( + activity: ControlsEditingActivity + ): Activity + + @Binds + @IntoMap @ClassKey(ControlsRequestDialog::class) abstract fun provideControlsRequestDialog( activity: ControlsRequestDialog diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/AllModel.kt b/packages/SystemUI/src/com/android/systemui/controls/management/AllModel.kt index 11181e56838e..175ed061c714 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/management/AllModel.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/management/AllModel.kt @@ -37,23 +37,22 @@ import com.android.systemui.controls.controller.ControlInfo * @property controls List of controls as returned by loading * @property initialFavoriteIds sorted ids of favorite controls. * @property noZoneString text to use as header for all controls that have blank or `null` zone. + * @property controlsModelCallback callback to notify that favorites have changed for the first time */ class AllModel( private val controls: List<ControlStatus>, initialFavoriteIds: List<String>, - private val emptyZoneString: CharSequence + private val emptyZoneString: CharSequence, + private val controlsModelCallback: ControlsModel.ControlsModelCallback ) : ControlsModel { - override val favorites: List<ControlInfo.Builder> + private var modified = false + + override val favorites: List<ControlInfo> get() = favoriteIds.mapNotNull { id -> val control = controls.firstOrNull { it.control.controlId == id }?.control control?.let { - ControlInfo.Builder().apply { - controlId = it.controlId - controlTitle = it.title - controlSubtitle = it.subtitle - deviceType = it.deviceType - } + ControlInfo.fromControl(it) } } @@ -66,14 +65,18 @@ class AllModel( override fun changeFavoriteStatus(controlId: String, favorite: Boolean) { val toChange = elements.firstOrNull { - it is ControlWrapper && it.controlStatus.control.controlId == controlId - } as ControlWrapper? + it is ControlStatusWrapper && it.controlStatus.control.controlId == controlId + } as ControlStatusWrapper? if (favorite == toChange?.controlStatus?.favorite) return - if (favorite) { + val changed: Boolean = if (favorite) { favoriteIds.add(controlId) } else { favoriteIds.remove(controlId) } + if (changed && !modified) { + modified = true + controlsModelCallback.onFirstChange() + } toChange?.let { it.controlStatus.favorite = favorite } @@ -84,9 +87,9 @@ class AllModel( it.control.zone ?: "" } val output = mutableListOf<ElementWrapper>() - var emptyZoneValues: Sequence<ControlWrapper>? = null + var emptyZoneValues: Sequence<ControlStatusWrapper>? = null for (zoneName in map.orderedKeys) { - val values = map.getValue(zoneName).asSequence().map { ControlWrapper(it) } + val values = map.getValue(zoneName).asSequence().map { ControlStatusWrapper(it) } if (TextUtils.isEmpty(zoneName)) { emptyZoneValues = values } else { diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/ControlAdapter.kt b/packages/SystemUI/src/com/android/systemui/controls/management/ControlAdapter.kt index 1291dd98932e..607934c3bae7 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/management/ControlAdapter.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/management/ControlAdapter.kt @@ -28,6 +28,7 @@ import android.widget.TextView import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.systemui.R +import com.android.systemui.controls.ControlInterface import com.android.systemui.controls.ui.RenderInfo private typealias ModelFavoriteChanger = (String, Boolean) -> Unit @@ -35,11 +36,10 @@ private typealias ModelFavoriteChanger = (String, Boolean) -> Unit /** * Adapter for binding [Control] information to views. * - * The model for this adapter is provided by a [FavoriteModel] that is set using + * The model for this adapter is provided by a [ControlModel] that is set using * [changeFavoritesModel]. This allows for updating the model if there's a reload. * - * @param layoutInflater an inflater for the views in the containing [RecyclerView] - * @param onlyFavorites set to true to only display favorites instead of all controls + * @property elevation elevation of each control view */ class ControlAdapter( private val elevation: Float @@ -48,11 +48,12 @@ class ControlAdapter( companion object { private const val TYPE_ZONE = 0 private const val TYPE_CONTROL = 1 + private const val TYPE_DIVIDER = 2 } val spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { - return if (getItemViewType(position) == TYPE_ZONE) 2 else 1 + return if (getItemViewType(position) != TYPE_CONTROL) 2 else 1 } } @@ -78,6 +79,10 @@ class ControlAdapter( TYPE_ZONE -> { ZoneHolder(layoutInflater.inflate(R.layout.controls_zone_header, parent, false)) } + TYPE_DIVIDER -> { + DividerHolder(layoutInflater.inflate( + R.layout.controls_horizontal_divider_withEmpty, parent, false)) + } else -> throw IllegalStateException("Wrong viewType: $viewType") } } @@ -95,11 +100,26 @@ class ControlAdapter( } } + override fun onBindViewHolder(holder: Holder, position: Int, payloads: MutableList<Any>) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + } else { + model?.let { + val el = it.elements[position] + if (el is ControlInterface) { + holder.updateFavorite(el.favorite) + } + } + } + } + override fun getItemViewType(position: Int): Int { model?.let { return when (it.elements.get(position)) { is ZoneNameWrapper -> TYPE_ZONE - is ControlWrapper -> TYPE_CONTROL + is ControlStatusWrapper -> TYPE_CONTROL + is ControlInfoWrapper -> TYPE_CONTROL + is DividerWrapper -> TYPE_DIVIDER } } ?: throw IllegalStateException("Getting item type for null model") } @@ -115,6 +135,24 @@ sealed class Holder(view: View) : RecyclerView.ViewHolder(view) { * Bind the data from the model into the view */ abstract fun bindData(wrapper: ElementWrapper) + + open fun updateFavorite(favorite: Boolean) {} +} + +/** + * Holder for using with [DividerWrapper] to display a divider between zones. + * + * The divider can be shown or hidden. It also has a frame view the height of a control, that can + * be toggled visible or gone. + */ +private class DividerHolder(view: View) : Holder(view) { + private val frame: View = itemView.requireViewById(R.id.frame) + private val divider: View = itemView.requireViewById(R.id.divider) + override fun bindData(wrapper: ElementWrapper) { + wrapper as DividerWrapper + frame.visibility = if (wrapper.showNone) View.VISIBLE else View.GONE + divider.visibility = if (wrapper.showDivider) View.VISIBLE else View.GONE + } } /** @@ -130,11 +168,14 @@ private class ZoneHolder(view: View) : Holder(view) { } /** - * Holder for using with [ControlWrapper] to display names of zones. + * Holder for using with [ControlStatusWrapper] to display names of zones. * @param favoriteCallback this callback will be called whenever the favorite state of the * [Control] this view represents changes. */ -private class ControlHolder(view: View, val favoriteCallback: ModelFavoriteChanger) : Holder(view) { +internal class ControlHolder( + view: View, + val favoriteCallback: ModelFavoriteChanger +) : Holder(view) { private val icon: ImageView = itemView.requireViewById(R.id.icon) private val title: TextView = itemView.requireViewById(R.id.title) private val subtitle: TextView = itemView.requireViewById(R.id.subtitle) @@ -144,20 +185,23 @@ private class ControlHolder(view: View, val favoriteCallback: ModelFavoriteChang } override fun bindData(wrapper: ElementWrapper) { - wrapper as ControlWrapper - val data = wrapper.controlStatus - val renderInfo = getRenderInfo(data.component, data.control.deviceType) - title.text = data.control.title - subtitle.text = data.control.subtitle - favorite.isChecked = data.favorite - removed.text = if (data.removed) "Removed" else "" + wrapper as ControlInterface + val renderInfo = getRenderInfo(wrapper.component, wrapper.deviceType) + title.text = wrapper.title + subtitle.text = wrapper.subtitle + favorite.isChecked = wrapper.favorite + removed.text = if (wrapper.removed) "Removed" else "" itemView.setOnClickListener { favorite.isChecked = !favorite.isChecked - favoriteCallback(data.control.controlId, favorite.isChecked) + favoriteCallback(wrapper.controlId, favorite.isChecked) } applyRenderInfo(renderInfo) } + override fun updateFavorite(favorite: Boolean) { + this.favorite.isChecked = favorite + } + private fun getRenderInfo( component: ComponentName, @DeviceTypes.DeviceType deviceType: Int diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsEditingActivity.kt b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsEditingActivity.kt new file mode 100644 index 000000000000..ee1ce7ab3d83 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsEditingActivity.kt @@ -0,0 +1,176 @@ +/* + * 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.controls.management + +import android.app.Activity +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.ViewStub +import android.widget.Button +import android.widget.TextView +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.android.systemui.R +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.controls.controller.ControlsControllerImpl +import com.android.systemui.controls.controller.StructureInfo +import com.android.systemui.settings.CurrentUserTracker +import javax.inject.Inject + +/** + * Activity for rearranging and removing controls for a given structure + */ +class ControlsEditingActivity @Inject constructor( + private val controller: ControlsControllerImpl, + broadcastDispatcher: BroadcastDispatcher +) : Activity() { + + companion object { + private const val TAG = "ControlsEditingActivity" + private const val EXTRA_STRUCTURE = ControlsFavoritingActivity.EXTRA_STRUCTURE + private val SUBTITLE_ID = R.string.controls_favorite_rearrange + private val EMPTY_TEXT_ID = R.string.controls_favorite_removed + } + + private lateinit var component: ComponentName + private lateinit var structure: CharSequence + private lateinit var model: FavoritesModel + private lateinit var subtitle: TextView + private lateinit var saveButton: View + + private val currentUserTracker = object : CurrentUserTracker(broadcastDispatcher) { + private val startingUser = controller.currentUserId + + override fun onUserSwitched(newUserId: Int) { + if (newUserId != startingUser) { + stopTracking() + finish() + } + } + } + + override fun onBackPressed() { + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + intent.getParcelableExtra<ComponentName>(Intent.EXTRA_COMPONENT_NAME)?.let { + component = it + } ?: run(this::finish) + + intent.getCharSequenceExtra(EXTRA_STRUCTURE)?.let { + structure = it + } ?: run(this::finish) + + bindViews() + + bindButtons() + + setUpList() + + currentUserTracker.startTracking() + } + + private fun bindViews() { + setContentView(R.layout.controls_management) + requireViewById<ViewStub>(R.id.stub).apply { + layoutResource = R.layout.controls_management_editing + inflate() + } + requireViewById<TextView>(R.id.title).text = structure + subtitle = requireViewById<TextView>(R.id.subtitle).apply { + setText(SUBTITLE_ID) + } + } + + private fun bindButtons() { + requireViewById<Button>(R.id.other_apps).apply { + visibility = View.VISIBLE + setText(R.string.controls_menu_add) + setOnClickListener { + saveFavorites() + val intent = Intent(this@ControlsEditingActivity, + ControlsFavoritingActivity::class.java).apply { + putExtras(this@ControlsEditingActivity.intent) + putExtra(ControlsFavoritingActivity.EXTRA_SINGLE_STRUCTURE, true) + } + startActivity(intent) + finish() + } + } + + saveButton = requireViewById<Button>(R.id.done).apply { + isEnabled = false + setText(R.string.save) + setOnClickListener { + saveFavorites() + finishAffinity() + } + } + } + + private fun saveFavorites() { + controller.replaceFavoritesForStructure( + StructureInfo(component, structure, model.favorites)) + } + + private val favoritesModelCallback = object : FavoritesModel.FavoritesModelCallback { + override fun onNoneChanged(showNoFavorites: Boolean) { + if (showNoFavorites) { + subtitle.setText(EMPTY_TEXT_ID) + } else { + subtitle.setText(SUBTITLE_ID) + } + } + + override fun onFirstChange() { + saveButton.isEnabled = true + } + } + + private fun setUpList() { + val controls = controller.getFavoritesForStructure(component, structure) + model = FavoritesModel(component, controls, favoritesModelCallback) + val elevation = resources.getFloat(R.dimen.control_card_elevation) + val adapter = ControlAdapter(elevation) + val recycler = requireViewById<RecyclerView>(R.id.list) + val margin = resources + .getDimensionPixelSize(R.dimen.controls_card_margin) + val itemDecorator = MarginItemDecorator(margin, margin) + + recycler.apply { + this.adapter = adapter + layoutManager = GridLayoutManager(recycler.context, 2).apply { + spanSizeLookup = adapter.spanSizeLookup + } + addItemDecoration(itemDecorator) + } + adapter.changeModel(model) + model.attachAdapter(adapter) + ItemTouchHelper(model.itemTouchHelperCallback).attachToRecyclerView(recycler) + } + + override fun onDestroy() { + currentUserTracker.stopTracking() + super.onDestroy() + } +}
\ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsFavoritingActivity.kt b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsFavoritingActivity.kt index 070c4f36b39a..6f34deeb8547 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsFavoritingActivity.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsFavoritingActivity.kt @@ -61,6 +61,7 @@ class ControlsFavoritingActivity @Inject constructor( // If provided, show this structure page first const val EXTRA_STRUCTURE = "extra_structure" + const val EXTRA_SINGLE_STRUCTURE = "extra_single_structure" private const val TOOLTIP_PREFS_KEY = Prefs.Key.CONTROLS_STRUCTURE_SWIPE_TOOLTIP_COUNT private const val TOOLTIP_MAX_SHOWN = 2 } @@ -131,6 +132,12 @@ class ControlsFavoritingActivity @Inject constructor( currentUserTracker.startTracking() } + private val controlsModelCallback = object : ControlsModel.ControlsModelCallback { + override fun onFirstChange() { + doneButton.isEnabled = true + } + } + private fun loadControls() { component?.let { statusText.text = resources.getText(com.android.internal.R.string.loading) @@ -142,15 +149,20 @@ class ControlsFavoritingActivity @Inject constructor( val error = data.errorOnLoad val controlsByStructure = allControls.groupBy { it.control.structure ?: "" } listOfStructures = controlsByStructure.map { - StructureContainer(it.key, AllModel(it.value, favoriteKeys, emptyZoneString)) + StructureContainer(it.key, AllModel( + it.value, favoriteKeys, emptyZoneString, controlsModelCallback)) }.sortedWith(comparator) val structureIndex = listOfStructures.indexOfFirst { sc -> sc.structureName == structureExtra }.let { if (it == -1) 0 else it } + // If we were requested to show a single structure, set the list to just that one + if (intent.getBooleanExtra(EXTRA_SINGLE_STRUCTURE, false)) { + listOfStructures = listOf(listOfStructures[structureIndex]) + } + executor.execute { - doneButton.isEnabled = true structurePager.adapter = StructureAdapter(listOfStructures) structurePager.setCurrentItem(structureIndex) if (error) { @@ -275,7 +287,7 @@ class ControlsFavoritingActivity @Inject constructor( setOnClickListener { if (component == null) return@setOnClickListener listOfStructures.forEach { - val favoritesForStorage = it.model.favorites.map { it.build() } + val favoritesForStorage = it.model.favorites controller.replaceFavoritesForStructure( StructureInfo(component!!, it.structureName, favoritesForStorage) ) diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsModel.kt b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsModel.kt index a995a2ebfd25..37b6d15c0afe 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsModel.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsModel.kt @@ -16,6 +16,9 @@ package com.android.systemui.controls.management +import android.content.ComponentName +import androidx.recyclerview.widget.RecyclerView +import com.android.systemui.controls.ControlInterface import com.android.systemui.controls.ControlStatus import com.android.systemui.controls.controller.ControlInfo @@ -27,12 +30,12 @@ import com.android.systemui.controls.controller.ControlInfo interface ControlsModel { /** - * List of favorites (builders) in order. + * List of favorites in order. * * This should be obtained prior to storing the favorites using * [ControlsController.replaceFavoritesForComponent]. */ - val favorites: List<ControlInfo.Builder> + val favorites: List<ControlInfo> /** * List of all the elements to display by the corresponding [RecyclerView]. @@ -48,6 +51,24 @@ interface ControlsModel { * Move an item (in elements) from one position to another. */ fun onMoveItem(from: Int, to: Int) {} + + /** + * Attach an adapter to the model. + * + * This can be used to notify the adapter of changes in the model. + */ + fun attachAdapter(adapter: RecyclerView.Adapter<*>) {} + + /** + * Callback to notify elements (other than the adapter) of relevant changes in the model. + */ + interface ControlsModelCallback { + + /** + * Use to notify that the model has changed for the first time + */ + fun onFirstChange() + } } /** @@ -55,5 +76,29 @@ interface ControlsModel { * [ControlAdapter]. */ sealed class ElementWrapper + data class ZoneNameWrapper(val zoneName: CharSequence) : ElementWrapper() -data class ControlWrapper(val controlStatus: ControlStatus) : ElementWrapper()
\ No newline at end of file + +data class ControlStatusWrapper( + val controlStatus: ControlStatus +) : ElementWrapper(), ControlInterface by controlStatus + +data class ControlInfoWrapper( + override val component: ComponentName, + val controlInfo: ControlInfo, + override var favorite: Boolean +) : ElementWrapper(), ControlInterface { + override val controlId: String + get() = controlInfo.controlId + override val title: CharSequence + get() = controlInfo.controlTitle + override val subtitle: CharSequence + get() = controlInfo.controlSubtitle + override val deviceType: Int + get() = controlInfo.deviceType +} + +data class DividerWrapper( + var showNone: Boolean = false, + var showDivider: Boolean = false +) : ElementWrapper()
\ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/FavoriteModel.kt b/packages/SystemUI/src/com/android/systemui/controls/management/FavoriteModel.kt deleted file mode 100644 index 5c51e3dbe4ac..000000000000 --- a/packages/SystemUI/src/com/android/systemui/controls/management/FavoriteModel.kt +++ /dev/null @@ -1,145 +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.controls.management - -import android.text.TextUtils -import android.util.Log -import com.android.systemui.controls.ControlStatus -import java.util.Collections -import java.util.Comparator - -/** - * Model for keeping track of current favorites and their order. - * - * This model is to be used with two [ControlAdapter] one that shows only favorites in the current - * order and another that shows all controls, separated by zone. When the favorite state of any - * control is modified or when the favorites are reordered, the adapters are notified of the change. - * - * @param listControls list of all the [ControlStatus] to display. This includes controls currently - * marked as favorites as well as those that have been removed (not returned - * from load) - * @param listFavoritesIds list of the [Control.controlId] for all the favorites, including those - * that have been removed. - * @param favoritesAdapter [ControlAdapter] used by the [RecyclerView] that shows only favorites - * @param allAdapter [ControlAdapter] used by the [RecyclerView] that shows all controls - */ -class FavoriteModel( - private val listControls: List<ControlStatus>, - listFavoritesIds: List<String>, - private val favoritesAdapter: ControlAdapter, - private val allAdapter: ControlAdapter -) { - - companion object { - private const val TAG = "FavoriteModel" - } - - /** - * List of favorite controls ([ControlWrapper]) in order. - * - * Initially, this list will give a list of wrappers in the order specified by the constructor - * variable `listFavoriteIds`. - * - * As the favorites are added, removed or moved, this list will keep track of those changes. - */ - val favorites: List<ControlWrapper> = listFavoritesIds.map { id -> - ControlWrapper(listControls.first { it.control.controlId == id }) - }.toMutableList() - - /** - * List of all controls by zones. - * - * Lists all the controls with the zone names interleaved as a flat list. After each zone name, - * the controls in that zone are listed. Zones are listed in alphabetical order - */ - val all: List<ElementWrapper> = listControls.groupBy { it.control.zone } - .mapKeys { it.key ?: "" } // map null to empty - .toSortedMap(CharSequenceComparator()) - .flatMap { - val controls = it.value.map { ControlWrapper(it) } - if (!TextUtils.isEmpty(it.key)) { - listOf(ZoneNameWrapper(it.key)) + controls - } else { - controls - } - } - - /** - * Change the favorite status of a [Control]. - * - * This can be invoked from any of the [ControlAdapter]. It will change the status of that - * control and either add it to the list of favorites (at the end) or remove it from it. - * - * Removing the favorite status from a Removed control will make it disappear completely if - * changes are saved. - * - * @param controlId the id of the [Control] to change the status - * @param favorite `true` if and only if it's set to be a favorite. - */ - fun changeFavoriteStatus(controlId: String, favorite: Boolean) { - favorites as MutableList - val index = all.indexOfFirst { - it is ControlWrapper && it.controlStatus.control.controlId == controlId - } - val control = (all[index] as ControlWrapper).controlStatus - if (control.favorite == favorite) { - Log.d(TAG, "Changing favorite to same state for ${control.control.controlId} ") - return - } else { - control.favorite = favorite - } - allAdapter.notifyItemChanged(index) - if (favorite) { - favorites.add(all[index] as ControlWrapper) - favoritesAdapter.notifyItemInserted(favorites.size - 1) - } else { - val i = favorites.indexOfFirst { it.controlStatus.control.controlId == controlId } - favorites.removeAt(i) - favoritesAdapter.notifyItemRemoved(i) - } - } - - /** - * Move items in the model and notify the [favoritesAdapter]. - */ - fun onMoveItem(from: Int, to: Int) { - if (from < to) { - for (i in from until to) { - Collections.swap(favorites, i, i + 1) - } - } else { - for (i in from downTo to + 1) { - Collections.swap(favorites, i, i - 1) - } - } - favoritesAdapter.notifyItemMoved(from, to) - } -} - -/** - * Compares [CharSequence] as [String]. - * - * It will have empty strings as the first element - */ -class CharSequenceComparator : Comparator<CharSequence> { - override fun compare(p0: CharSequence?, p1: CharSequence?): Int { - if (p0 == null && p1 == null) return 0 - else if (p0 == null && p1 != null) return -1 - else if (p0 != null && p1 == null) return 1 - return p0.toString().compareTo(p1.toString()) - } -}
\ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/FavoritesModel.kt b/packages/SystemUI/src/com/android/systemui/controls/management/FavoritesModel.kt new file mode 100644 index 000000000000..411170cb322c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/controls/management/FavoritesModel.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.controls.management + +import android.content.ComponentName +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.android.systemui.controls.ControlInterface +import com.android.systemui.controls.controller.ControlInfo +import java.util.Collections + +/** + * Model used to show and rearrange favorites. + * + * The model will show all the favorite controls and a divider that can be toggled visible/gone. + * It will place the items selected as favorites before the divider and the ones unselected after. + * + * @property componentName used by the [ControlAdapter] to retrieve resources. + * @property favorites list of current favorites + * @property favoritesModelCallback callback to notify on first change and empty favorites + */ +class FavoritesModel( + private val componentName: ComponentName, + favorites: List<ControlInfo>, + private val favoritesModelCallback: FavoritesModelCallback +) : ControlsModel { + + private var adapter: RecyclerView.Adapter<*>? = null + private var modified = false + + override fun attachAdapter(adapter: RecyclerView.Adapter<*>) { + this.adapter = adapter + } + + override val favorites: List<ControlInfo> + get() = elements.take(dividerPosition).map { + (it as ControlInfoWrapper).controlInfo + } + + override val elements: List<ElementWrapper> = favorites.map { + ControlInfoWrapper(componentName, it, true) + } + DividerWrapper() + + /** + * Indicates the position of the divider to determine + */ + private var dividerPosition = elements.size - 1 + + override fun changeFavoriteStatus(controlId: String, favorite: Boolean) { + val position = elements.indexOfFirst { it is ControlInterface && it.controlId == controlId } + if (position == -1) { + return // controlId not found + } + if (position < dividerPosition && favorite || position > dividerPosition && !favorite) { + return // Does not change favorite status + } + if (favorite) { + onMoveItemInternal(position, dividerPosition) + } else { + onMoveItemInternal(position, elements.size - 1) + } + } + + override fun onMoveItem(from: Int, to: Int) { + onMoveItemInternal(from, to) + } + + private fun updateDividerNone(oldDividerPosition: Int, show: Boolean) { + (elements[oldDividerPosition] as DividerWrapper).showNone = show + favoritesModelCallback.onNoneChanged(show) + } + + private fun updateDividerShow(oldDividerPosition: Int, show: Boolean) { + (elements[oldDividerPosition] as DividerWrapper).showDivider = show + } + + /** + * Performs the update in the model. + * + * * update the favorite field of the [ControlInterface] + * * update the fields of the [DividerWrapper] + * * move the corresponding element in [elements] + * + * It may emit the following signals: + * * [RecyclerView.Adapter.notifyItemChanged] if a [ControlInterface.favorite] has changed + * (in the new position) or if the information in [DividerWrapper] has changed (in the + * old position). + * * [RecyclerView.Adapter.notifyItemMoved] + * * [FavoritesModelCallback.onNoneChanged] whenever we go from 1 to 0 favorites and back + * * [ControlsModel.ControlsModelCallback.onFirstChange] upon the first change in the model + */ + private fun onMoveItemInternal(from: Int, to: Int) { + if (from == dividerPosition) return // divider does not move + var changed = false + if (from < dividerPosition && to >= dividerPosition || + from > dividerPosition && to <= dividerPosition) { + if (from < dividerPosition && to >= dividerPosition) { + // favorite to not favorite + (elements[from] as ControlInfoWrapper).favorite = false + } else if (from > dividerPosition && to <= dividerPosition) { + // not favorite to favorite + (elements[from] as ControlInfoWrapper).favorite = true + } + changed = true + updateDivider(from, to) + } + moveElement(from, to) + adapter?.notifyItemMoved(from, to) + if (changed) { + adapter?.notifyItemChanged(to, Any()) + } + if (!modified) { + modified = true + favoritesModelCallback.onFirstChange() + } + } + + private fun updateDivider(from: Int, to: Int) { + var dividerChanged = false + val oldDividerPosition = dividerPosition + if (from < dividerPosition && to >= dividerPosition) { // favorite to not favorite + dividerPosition-- + if (dividerPosition == 0) { + updateDividerNone(oldDividerPosition, true) + dividerChanged = true + } + if (dividerPosition == elements.size - 2) { + updateDividerShow(oldDividerPosition, true) + dividerChanged = true + } + } else if (from > dividerPosition && to <= dividerPosition) { // not favorite to favorite + dividerPosition++ + if (dividerPosition == 1) { + updateDividerNone(oldDividerPosition, false) + dividerChanged = true + } + if (dividerPosition == elements.size - 1) { + updateDividerShow(oldDividerPosition, false) + dividerChanged = true + } + } + if (dividerChanged) { + adapter?.notifyItemChanged(oldDividerPosition) + } + } + + private fun moveElement(from: Int, to: Int) { + if (from < to) { + for (i in from until to) { + Collections.swap(elements, i, i + 1) + } + } else { + for (i in from downTo to + 1) { + Collections.swap(elements, i, i - 1) + } + } + } + + /** + * Touch helper to facilitate dragging in the [RecyclerView]. + * + * Only views above the divider line (favorites) can be dragged or accept drops. + */ + val itemTouchHelperCallback = object : ItemTouchHelper.SimpleCallback(0, 0) { + + private val MOVEMENT = ItemTouchHelper.UP or + ItemTouchHelper.DOWN or + ItemTouchHelper.LEFT or + ItemTouchHelper.RIGHT + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + onMoveItem(viewHolder.adapterPosition, target.adapterPosition) + return true + } + + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + if (viewHolder.adapterPosition < dividerPosition) { + return ItemTouchHelper.Callback.makeMovementFlags(MOVEMENT, 0) + } else { + return ItemTouchHelper.Callback.makeMovementFlags(0, 0) + } + } + + override fun canDropOver( + recyclerView: RecyclerView, + current: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return target.adapterPosition < dividerPosition + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + + override fun isItemViewSwipeEnabled() = false + } + + interface FavoritesModelCallback : ControlsModel.ControlsModelCallback { + fun onNoneChanged(showNoFavorites: Boolean) + } +}
\ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsUiControllerImpl.kt b/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsUiControllerImpl.kt index 39fbdcf7625f..fab6fc7357dd 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsUiControllerImpl.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsUiControllerImpl.kt @@ -33,16 +33,16 @@ import android.os.Process import android.provider.Settings import android.service.controls.Control import android.service.controls.actions.ControlAction -import android.util.TypedValue import android.util.Log -import android.view.animation.AccelerateInterpolator -import android.view.animation.DecelerateInterpolator +import android.util.TypedValue import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.View import android.view.View.MeasureSpec import android.view.ViewGroup import android.view.WindowManager +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.ImageView @@ -50,23 +50,21 @@ import android.widget.LinearLayout import android.widget.ListPopupWindow import android.widget.Space import android.widget.TextView +import com.android.systemui.R import com.android.systemui.controls.ControlsServiceInfo import com.android.systemui.controls.controller.ControlInfo import com.android.systemui.controls.controller.ControlsController import com.android.systemui.controls.controller.StructureInfo +import com.android.systemui.controls.management.ControlsEditingActivity import com.android.systemui.controls.management.ControlsFavoritingActivity import com.android.systemui.controls.management.ControlsListingController import com.android.systemui.controls.management.ControlsProviderSelectorActivity import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.util.concurrency.DelayableExecutor -import com.android.systemui.R - import dagger.Lazy - import java.text.Collator import java.util.function.Consumer - import javax.inject.Inject import javax.inject.Singleton @@ -212,14 +210,28 @@ class ControlsUiControllerImpl @Inject constructor ( } private fun startFavoritingActivity(context: Context, si: StructureInfo) { - val i = Intent(context, ControlsFavoritingActivity::class.java).apply { + startTargetedActivity(context, si, ControlsFavoritingActivity::class.java) + } + + private fun startEditingActivity(context: Context, si: StructureInfo) { + startTargetedActivity(context, si, ControlsEditingActivity::class.java) + } + + private fun startTargetedActivity(context: Context, si: StructureInfo, klazz: Class<*>) { + val i = Intent(context, klazz).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + } + putIntentExtras(i, si) + startActivity(context, i) + } + + private fun putIntentExtras(intent: Intent, si: StructureInfo) { + intent.apply { putExtra(ControlsFavoritingActivity.EXTRA_APP, - controlsListingController.get().getAppLabel(si.componentName)) + controlsListingController.get().getAppLabel(si.componentName)) putExtra(ControlsFavoritingActivity.EXTRA_STRUCTURE, si.structure) putExtra(Intent.EXTRA_COMPONENT_NAME, si.componentName) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) } - startActivity(context, i) } private fun startProviderSelectorActivity(context: Context) { @@ -255,6 +267,7 @@ class ControlsUiControllerImpl @Inject constructor ( private fun createMenu() { val items = arrayOf( context.resources.getString(R.string.controls_menu_add), + context.resources.getString(R.string.controls_menu_edit), "Reset" ) var adapter = ArrayAdapter<String>(context, R.layout.controls_more_item, items) @@ -275,8 +288,10 @@ class ControlsUiControllerImpl @Inject constructor ( when (pos) { // 0: Add Control 0 -> startFavoritingActivity(view.context, selectedStructure) - // 1: TEMPORARY for reset controls - 1 -> showResetConfirmation() + // 1: Edit controls + 1 -> startEditingActivity(view.context, selectedStructure) + // 2: TEMPORARY for reset controls + 2 -> showResetConfirmation() else -> Log.w(ControlsUiController.TAG, "Unsupported index ($pos) on 'more' menu selection") } diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsControllerImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsControllerImplTest.kt index 8630570c4e70..f6ee46b0303a 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsControllerImplTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsControllerImplTest.kt @@ -87,9 +87,6 @@ class ControlsControllerImplTest : SysuiTestCase() { private lateinit var structureInfoCaptor: ArgumentCaptor<StructureInfo> @Captor - private lateinit var booleanConsumer: ArgumentCaptor<Consumer<Boolean>> - - @Captor private lateinit var controlLoadCallbackCaptor: ArgumentCaptor<ControlsBindingController.LoadCallback> @Captor @@ -936,4 +933,33 @@ class ControlsControllerImplTest : SysuiTestCase() { verifyNoMoreInteractions(persistenceWrapper) verifyNoMoreInteractions(auxiliaryPersistenceWrapper) } + + @Test + fun testGetFavoritesForStructure() { + controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO) + controller.replaceFavoritesForStructure( + TEST_STRUCTURE_INFO_2.copy(componentName = TEST_COMPONENT)) + delayableExecutor.runAllReady() + + assertEquals(TEST_STRUCTURE_INFO.controls, + controller.getFavoritesForStructure(TEST_COMPONENT, TEST_STRUCTURE)) + assertEquals(TEST_STRUCTURE_INFO_2.controls, + controller.getFavoritesForStructure(TEST_COMPONENT, TEST_STRUCTURE_2)) + } + + @Test + fun testGetFavoritesForStructure_wrongStructure() { + controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO) + delayableExecutor.runAllReady() + + assertTrue(controller.getFavoritesForStructure(TEST_COMPONENT, TEST_STRUCTURE_2).isEmpty()) + } + + @Test + fun testGetFavoritesForStructure_wrongComponent() { + controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO) + delayableExecutor.runAllReady() + + assertTrue(controller.getFavoritesForStructure(TEST_COMPONENT_2, TEST_STRUCTURE).isEmpty()) + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/management/AllModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/management/AllModelTest.kt index 5e0d28f6f795..236384b09514 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/controls/management/AllModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/controls/management/AllModelTest.kt @@ -31,6 +31,8 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations @SmallTest @@ -43,6 +45,8 @@ class AllModelTest : SysuiTestCase() { @Mock lateinit var pendingIntent: PendingIntent + @Mock + lateinit var controlsModelCallback: ControlsModel.ControlsModelCallback val idPrefix = "controlId" val favoritesIndices = listOf(7, 3, 1, 9) @@ -84,7 +88,7 @@ class AllModelTest : SysuiTestCase() { it in favoritesIndices ) } - model = AllModel(controls, favoritesList, EMPTY_STRING) + model = AllModel(controls, favoritesList, EMPTY_STRING, controlsModelCallback) } @Test @@ -93,28 +97,28 @@ class AllModelTest : SysuiTestCase() { // Zones are sorted by order of appearance, with empty at the end with special header. val expected = listOf( ZoneNameWrapper("1"), - ControlWrapper(controls[0]), - ControlWrapper(controls[3]), - ControlWrapper(controls[6]), - ControlWrapper(controls[9]), + ControlStatusWrapper(controls[0]), + ControlStatusWrapper(controls[3]), + ControlStatusWrapper(controls[6]), + ControlStatusWrapper(controls[9]), ZoneNameWrapper("2"), - ControlWrapper(controls[1]), - ControlWrapper(controls[4]), - ControlWrapper(controls[7]), + ControlStatusWrapper(controls[1]), + ControlStatusWrapper(controls[4]), + ControlStatusWrapper(controls[7]), ZoneNameWrapper("0"), - ControlWrapper(controls[2]), - ControlWrapper(controls[5]), - ControlWrapper(controls[8]), + ControlStatusWrapper(controls[2]), + ControlStatusWrapper(controls[5]), + ControlStatusWrapper(controls[8]), ZoneNameWrapper(EMPTY_STRING), - ControlWrapper(controls[10]), - ControlWrapper(controls[11]) + ControlStatusWrapper(controls[10]), + ControlStatusWrapper(controls[11]) ) expected.zip(model.elements).forEachIndexed { index, it -> assertEquals("Error in item at index $index", it.first, it.second) } } - private fun sameControl(controlInfo: ControlInfo.Builder, control: Control): Boolean { + private fun sameControl(controlInfo: ControlInfo, control: Control): Boolean { return controlInfo.controlId == control.controlId && controlInfo.controlTitle == control.title && controlInfo.controlSubtitle == control.subtitle && @@ -124,10 +128,11 @@ class AllModelTest : SysuiTestCase() { @Test fun testAllEmpty_noHeader() { val selected_controls = listOf(controls[10], controls[11]) - val new_model = AllModel(selected_controls, emptyList(), EMPTY_STRING) + val new_model = AllModel(selected_controls, emptyList(), EMPTY_STRING, + controlsModelCallback) val expected = listOf( - ControlWrapper(controls[10]), - ControlWrapper(controls[11]) + ControlStatusWrapper(controls[10]), + ControlStatusWrapper(controls[11]) ) expected.zip(new_model.elements).forEachIndexed { index, it -> @@ -154,6 +159,8 @@ class AllModelTest : SysuiTestCase() { model.favorites.zip(expectedFavorites).forEach { assertTrue(sameControl(it.first, it.second)) } + + verify(controlsModelCallback).onFirstChange() } @Test @@ -163,10 +170,12 @@ class AllModelTest : SysuiTestCase() { model.changeFavoriteStatus(id, true) assertTrue( (model.elements.first { - it is ControlWrapper && it.controlStatus.control.controlId == id - } as ControlWrapper) + it is ControlStatusWrapper && it.controlStatus.control.controlId == id + } as ControlStatusWrapper) .controlStatus.favorite ) + + verify(controlsModelCallback).onFirstChange() } @Test @@ -180,6 +189,8 @@ class AllModelTest : SysuiTestCase() { model.favorites.zip(expectedFavorites).forEach { assertTrue(sameControl(it.first, it.second)) } + + verify(controlsModelCallback, never()).onFirstChange() } @Test @@ -194,6 +205,8 @@ class AllModelTest : SysuiTestCase() { model.favorites.zip(expectedFavorites).forEach { assertTrue(sameControl(it.first, it.second)) } + + verify(controlsModelCallback).onFirstChange() } @Test @@ -203,10 +216,12 @@ class AllModelTest : SysuiTestCase() { model.changeFavoriteStatus(id, false) assertFalse( (model.elements.first { - it is ControlWrapper && it.controlStatus.control.controlId == id - } as ControlWrapper) + it is ControlStatusWrapper && it.controlStatus.control.controlId == id + } as ControlStatusWrapper) .controlStatus.favorite ) + + verify(controlsModelCallback).onFirstChange() } @Test @@ -219,5 +234,7 @@ class AllModelTest : SysuiTestCase() { model.favorites.zip(expectedFavorites).forEach { assertTrue(sameControl(it.first, it.second)) } + + verify(controlsModelCallback, never()).onFirstChange() } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/management/FavoriteModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/management/FavoriteModelTest.kt deleted file mode 100644 index c330b38fed42..000000000000 --- a/packages/SystemUI/tests/src/com/android/systemui/controls/management/FavoriteModelTest.kt +++ /dev/null @@ -1,200 +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.controls.management - -import android.app.PendingIntent -import android.content.ComponentName -import android.service.controls.Control -import android.testing.AndroidTestingRunner -import androidx.test.filters.SmallTest -import com.android.systemui.SysuiTestCase -import com.android.systemui.controls.ControlStatus -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import org.mockito.Mock -import org.mockito.Mockito.verify -import org.mockito.Mockito.verifyNoMoreInteractions -import org.mockito.MockitoAnnotations - -open class FavoriteModelTest : SysuiTestCase() { - - @Mock - lateinit var pendingIntent: PendingIntent - @Mock - lateinit var allAdapter: ControlAdapter - @Mock - lateinit var favoritesAdapter: ControlAdapter - - val idPrefix = "controlId" - val favoritesIndices = listOf(7, 3, 1, 9) - val favoritesList = favoritesIndices.map { "controlId$it" } - lateinit var controls: List<ControlStatus> - - lateinit var model: FavoriteModel - - @Before - fun setUp() { - MockitoAnnotations.initMocks(this) - - // controlId0 --> zone = 0 - // controlId1 --> zone = 1, favorite - // controlId2 --> zone = 2 - // controlId3 --> zone = 0, favorite - // controlId4 --> zone = 1 - // controlId5 --> zone = 2 - // controlId6 --> zone = 0 - // controlId7 --> zone = 1, favorite - // controlId8 --> zone = 2 - // controlId9 --> zone = 0, favorite - controls = (0..9).map { - ControlStatus( - Control.StatelessBuilder("$idPrefix$it", pendingIntent) - .setZone((it % 3).toString()) - .build(), - ComponentName("", ""), - it in favoritesIndices - ) - } - - model = FavoriteModel(controls, favoritesList, favoritesAdapter, allAdapter) - } -} - -@SmallTest -@RunWith(AndroidTestingRunner::class) -class FavoriteModelNonParametrizedTests : FavoriteModelTest() { - @Test - fun testAll() { - // Zones are sorted alphabetically - val expected = listOf( - ZoneNameWrapper("0"), - ControlWrapper(controls[0]), - ControlWrapper(controls[3]), - ControlWrapper(controls[6]), - ControlWrapper(controls[9]), - ZoneNameWrapper("1"), - ControlWrapper(controls[1]), - ControlWrapper(controls[4]), - ControlWrapper(controls[7]), - ZoneNameWrapper("2"), - ControlWrapper(controls[2]), - ControlWrapper(controls[5]), - ControlWrapper(controls[8]) - ) - assertEquals(expected, model.all) - } - - @Test - fun testFavoritesInOrder() { - val expected = favoritesIndices.map { ControlWrapper(controls[it]) } - assertEquals(expected, model.favorites) - } - - @Test - fun testChangeFavoriteStatus_addFavorite() { - val controlToAdd = 6 - model.changeFavoriteStatus("$idPrefix$controlToAdd", true) - - val pair = model.all.findControl(controlToAdd) - pair?.let { - assertTrue(it.second.favorite) - assertEquals(it.second, model.favorites.last().controlStatus) - verify(favoritesAdapter).notifyItemInserted(model.favorites.size - 1) - verify(allAdapter).notifyItemChanged(it.first) - verifyNoMoreInteractions(favoritesAdapter, allAdapter) - } ?: run { - fail("control not found") - } - } - - @Test - fun testChangeFavoriteStatus_removeFavorite() { - val controlToRemove = 3 - model.changeFavoriteStatus("$idPrefix$controlToRemove", false) - - val pair = model.all.findControl(controlToRemove) - pair?.let { - assertFalse(it.second.favorite) - assertTrue(model.favorites.none { - it.controlStatus.control.controlId == "$idPrefix$controlToRemove" - }) - verify(favoritesAdapter).notifyItemRemoved(favoritesIndices.indexOf(controlToRemove)) - verify(allAdapter).notifyItemChanged(it.first) - verifyNoMoreInteractions(favoritesAdapter, allAdapter) - } ?: run { - fail("control not found") - } - } - - @Test - fun testChangeFavoriteStatus_sameStatus() { - model.changeFavoriteStatus("${idPrefix}7", true) - model.changeFavoriteStatus("${idPrefix}6", false) - - val expected = favoritesIndices.map { ControlWrapper(controls[it]) } - assertEquals(expected, model.favorites) - - verifyNoMoreInteractions(favoritesAdapter, allAdapter) - } - - private fun List<ElementWrapper>.findControl(controlIndex: Int): Pair<Int, ControlStatus>? { - val index = indexOfFirst { - it is ControlWrapper && - it.controlStatus.control.controlId == "$idPrefix$controlIndex" - } - return if (index == -1) null else index to (get(index) as ControlWrapper).controlStatus - } -} - -@SmallTest -@RunWith(Parameterized::class) -class FavoriteModelParameterizedTest(val from: Int, val to: Int) : FavoriteModelTest() { - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{0} -> {1}") - fun data(): Collection<Array<Int>> { - return (0..3).flatMap { from -> - (0..3).map { to -> - arrayOf(from, to) - } - }.filterNot { it[0] == it[1] } - } - } - - @Test - fun testMoveItem() { - val originalFavorites = model.favorites.toList() - val originalFavoritesIds = - model.favorites.map { it.controlStatus.control.controlId }.toSet() - model.onMoveItem(from, to) - assertEquals(originalFavorites[from], model.favorites[to]) - // Check that we still have the same favorites - assertEquals(originalFavoritesIds, - model.favorites.map { it.controlStatus.control.controlId }.toSet()) - - verify(favoritesAdapter).notifyItemMoved(from, to) - - verifyNoMoreInteractions(allAdapter, favoritesAdapter) - } -} diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/management/FavoritesModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/management/FavoritesModelTest.kt new file mode 100644 index 000000000000..ce33a8d49fac --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/controls/management/FavoritesModelTest.kt @@ -0,0 +1,291 @@ +/* + * 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.controls.management + +import android.content.ComponentName +import android.testing.AndroidTestingRunner +import androidx.recyclerview.widget.RecyclerView +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.controls.ControlInterface +import com.android.systemui.controls.controller.ControlInfo +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.eq +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mock +import org.mockito.Mockito.inOrder +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class FavoritesModelTest : SysuiTestCase() { + + companion object { + private val TEST_COMPONENT = ComponentName.unflattenFromString("test_pkg/.test_cls")!! + private val ID_PREFIX = "control" + private val INITIAL_FAVORITES = (0..5).map { + ControlInfo("$ID_PREFIX$it", "title$it", "subtitle$it", it) + } + } + + @Mock + private lateinit var callback: FavoritesModel.FavoritesModelCallback + @Mock + private lateinit var adapter: RecyclerView.Adapter<*> + private lateinit var model: FavoritesModel + private lateinit var dividerWrapper: DividerWrapper + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + model = FavoritesModel(TEST_COMPONENT, INITIAL_FAVORITES, callback) + model.attachAdapter(adapter) + dividerWrapper = model.elements.first { it is DividerWrapper } as DividerWrapper + } + + @After + fun testListConsistency() { + assertEquals(INITIAL_FAVORITES.size + 1, model.elements.toSet().size) + val dividerIndex = getDividerPosition() + model.elements.forEachIndexed { index, element -> + if (index == dividerIndex) { + assertEquals(dividerWrapper, element) + } else { + element as ControlInterface + assertEquals(index < dividerIndex, element.favorite) + } + } + assertEquals(model.favorites, model.elements.take(dividerIndex).map { + (it as ControlInfoWrapper).controlInfo + }) + } + + @Test + fun testInitialElements() { + val expected = INITIAL_FAVORITES.map { + ControlInfoWrapper(TEST_COMPONENT, it, true) + } + DividerWrapper() + assertEquals(expected, model.elements) + } + + @Test + fun testFavorites() { + assertEquals(INITIAL_FAVORITES, model.favorites) + } + + @Test + fun testRemoveFavorite_notInFavorites() { + val removed = 4 + val id = "$ID_PREFIX$removed" + + model.changeFavoriteStatus(id, false) + + assertTrue(model.favorites.none { it.controlId == id }) + + verify(callback).onFirstChange() + } + + @Test + fun testRemoveFavorite_endOfElements() { + val removed = 4 + val id = "$ID_PREFIX$removed" + model.changeFavoriteStatus(id, false) + + assertEquals(ControlInfoWrapper( + TEST_COMPONENT, INITIAL_FAVORITES[4], false), model.elements.last()) + verify(callback).onFirstChange() + } + + @Test + fun testRemoveFavorite_adapterNotified() { + val removed = 4 + val id = "$ID_PREFIX$removed" + model.changeFavoriteStatus(id, false) + + val lastPos = model.elements.size - 1 + verify(adapter).notifyItemChanged(eq(lastPos), any(Any::class.java)) + verify(adapter).notifyItemMoved(removed, lastPos) + + verify(callback).onFirstChange() + } + + @Test + fun testRemoveFavorite_dividerMovedBack() { + val oldDividerPosition = getDividerPosition() + val removed = 4 + val id = "$ID_PREFIX$removed" + model.changeFavoriteStatus(id, false) + + assertEquals(oldDividerPosition - 1, getDividerPosition()) + + verify(callback).onFirstChange() + } + + @Test + fun testRemoveFavorite_ShowDivider() { + val oldDividerPosition = getDividerPosition() + val removed = 4 + val id = "$ID_PREFIX$removed" + model.changeFavoriteStatus(id, false) + + assertTrue(dividerWrapper.showDivider) + verify(adapter).notifyItemChanged(oldDividerPosition) + + verify(callback).onFirstChange() + } + + @Test + fun testDoubleRemove_onlyOnce() { + val removed = 4 + val id = "$ID_PREFIX$removed" + model.changeFavoriteStatus(id, false) + model.changeFavoriteStatus(id, false) + + verify(adapter /* only once */).notifyItemChanged(anyInt(), any(Any::class.java)) + verify(adapter /* only once */).notifyItemMoved(anyInt(), anyInt()) + verify(adapter /* only once (divider) */).notifyItemChanged(anyInt()) + + verify(callback).onFirstChange() + } + + @Test + fun testRemoveTwo_InSameOrder() { + val removedFirst = 3 + val removedSecond = 0 + model.changeFavoriteStatus("$ID_PREFIX$removedFirst", false) + model.changeFavoriteStatus("$ID_PREFIX$removedSecond", false) + + assertEquals(listOf( + ControlInfoWrapper(TEST_COMPONENT, INITIAL_FAVORITES[removedFirst], false), + ControlInfoWrapper(TEST_COMPONENT, INITIAL_FAVORITES[removedSecond], false) + ), model.elements.takeLast(2)) + + verify(callback).onFirstChange() + } + + @Test + fun testRemoveAll_showNone() { + INITIAL_FAVORITES.forEach { + model.changeFavoriteStatus(it.controlId, false) + } + assertEquals(dividerWrapper, model.elements.first()) + assertTrue(dividerWrapper.showNone) + verify(adapter, times(2)).notifyItemChanged(anyInt()) // divider + verify(callback).onNoneChanged(true) + + verify(callback).onFirstChange() + } + + @Test + fun testAddFavorite_movedToEnd() { + val added = 2 + val id = "$ID_PREFIX$added" + model.changeFavoriteStatus(id, false) + model.changeFavoriteStatus(id, true) + + assertEquals(id, model.favorites.last().controlId) + + verify(callback).onFirstChange() + } + + @Test + fun testAddFavorite_onlyOnce() { + val added = 2 + val id = "$ID_PREFIX$added" + model.changeFavoriteStatus(id, false) + model.changeFavoriteStatus(id, true) + model.changeFavoriteStatus(id, true) + + // Once for remove and once for add + verify(adapter, times(2)).notifyItemChanged(anyInt(), any(Any::class.java)) + verify(adapter, times(2)).notifyItemMoved(anyInt(), anyInt()) + + verify(callback).onFirstChange() + } + + @Test + fun testAddFavorite_notRemoved() { + val added = 2 + val id = "$ID_PREFIX$added" + model.changeFavoriteStatus(id, true) + + verifyNoMoreInteractions(adapter) + + verify(callback, never()).onFirstChange() + } + + @Test + fun testAddOnlyRemovedFavorite_dividerStopsShowing() { + val added = 2 + val id = "$ID_PREFIX$added" + model.changeFavoriteStatus(id, false) + model.changeFavoriteStatus(id, true) + + assertFalse(dividerWrapper.showDivider) + val inOrder = inOrder(adapter) + inOrder.verify(adapter).notifyItemChanged(model.elements.size - 1) + inOrder.verify(adapter).notifyItemChanged(model.elements.size - 2) + + verify(callback).onFirstChange() + } + + @Test + fun testAddFirstFavorite_dividerNotShowsNone() { + INITIAL_FAVORITES.forEach { + model.changeFavoriteStatus(it.controlId, false) + } + + verify(callback).onNoneChanged(true) + + model.changeFavoriteStatus("${ID_PREFIX}3", true) + assertEquals(1, getDividerPosition()) + + verify(callback).onNoneChanged(false) + + verify(callback).onFirstChange() + } + + @Test + fun testMoveBetweenFavorites() { + val from = 2 + val to = 4 + + model.onMoveItem(from, to) + assertEquals( + listOf(0, 1, 3, 4, 2, 5).map { "$ID_PREFIX$it" }, + model.favorites.map(ControlInfo::controlId) + ) + verify(adapter).notifyItemMoved(from, to) + verify(adapter, never()).notifyItemChanged(anyInt(), any(Any::class.java)) + + verify(callback).onFirstChange() + } + + private fun getDividerPosition(): Int = model.elements.indexOf(dividerWrapper) +}
\ No newline at end of file |