Merge "Add controls rearrange activity" into rvc-dev am: 0935694b5a

Change-Id: Ibf291eda825193cb6a062702c8fb3e381548ee33
This commit is contained in:
Fabian Kozynski
2020-04-10 21:06:52 +00:00
committed by Automerger Merge Worker
22 changed files with 1078 additions and 444 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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 &amp; 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>

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -147,6 +147,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]

View File

@@ -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")

View File

@@ -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
@@ -71,6 +72,13 @@ abstract class ControlsModule {
activity: ControlsFavoritingActivity
): Activity
@Binds
@IntoMap
@ClassKey(ControlsEditingActivity::class)
abstract fun provideControlsEditingActivity(
activity: ControlsEditingActivity
): Activity
@Binds
@IntoMap
@ClassKey(ControlsRequestDialog::class)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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)
)

View File

@@ -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()
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()

View File

@@ -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())
}
}

View File

@@ -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)
}
}

View File

@@ -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,16 +210,30 @@ class ControlsUiControllerImpl @Inject constructor (
}
private fun startFavoritingActivity(context: Context, si: StructureInfo) {
val i = Intent(context, ControlsFavoritingActivity::class.java).apply {
putExtra(ControlsFavoritingActivity.EXTRA_APP,
controlsListingController.get().getAppLabel(si.componentName))
putExtra(ControlsFavoritingActivity.EXTRA_STRUCTURE, si.structure)
putExtra(Intent.EXTRA_COMPONENT_NAME, si.componentName)
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))
putExtra(ControlsFavoritingActivity.EXTRA_STRUCTURE, si.structure)
putExtra(Intent.EXTRA_COMPONENT_NAME, si.componentName)
}
}
private fun startProviderSelectorActivity(context: Context) {
val i = Intent(context, ControlsProviderSelectorActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -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")
}

View File

@@ -86,9 +86,6 @@ class ControlsControllerImplTest : SysuiTestCase() {
@Captor
private lateinit var structureInfoCaptor: ArgumentCaptor<StructureInfo>
@Captor
private lateinit var booleanConsumer: ArgumentCaptor<Consumer<Boolean>>
@Captor
private lateinit var controlLoadCallbackCaptor:
ArgumentCaptor<ControlsBindingController.LoadCallback>
@@ -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())
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}