Controls UI - Structure switching

Create a popupdialog similiar to a spinner widget. Move 'add controls'
into there as a permanent item. Support multiple structures per app,
but also default empty structures to just use the app name.

Bug: 148207527
Test: visual
Change-Id: I77671bd40859dfb749a90064b654a0bd14526622
This commit is contained in:
Matt Pietal
2020-03-02 09:10:43 -05:00
parent 313f37de55
commit 638253a67a
15 changed files with 466 additions and 199 deletions

View File

@@ -0,0 +1,23 @@
<?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.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:tint="@color/control_secondary_text">
<solid android:color="#33000000" />
<size
android:height="1dp"
android:width="1dp" />
</shape>

View File

@@ -1,24 +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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View File

@@ -0,0 +1,50 @@
<!--
~ Copyright (C) 2019 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="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<Space
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="1dp" />
<ImageView
android:id="@+id/app_icon"
android:layout_gravity="center"
android:layout_width="34dp"
android:layout_height="24dp"
android:layout_marginEnd="10dp" />
<TextView
android:id="@+id/controls_spinner_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:layout_gravity="center"
android:textSize="25sp"
android:textColor="@color/control_secondary_text"
android:fontFamily="@*android:string/config_headlineFontFamily" />
<Space
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="1dp" />
</LinearLayout>

View File

@@ -14,44 +14,47 @@
~ limitations under the License.
-->
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:android="http://schemas.android.com/apk/res/android">
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
android:id="@+id/controls_header"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="20dp">
android:paddingTop="12dp">
<TextView
android:text="@string/quick_controls_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:gravity="center"
android:textSize="25sp"
android:textColor="@*android:color/foreground_material_dark"
android:fontFamily="@*android:string/config_headlineFontFamily"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Space
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="1dp" />
<ImageView
android:id="@+id/controls_more"
android:src="@drawable/ic_more_vert"
android:layout_width="34dp"
android:id="@+id/app_icon"
android:layout_gravity="center"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="10dp"
android:tint="@*android:color/foreground_material_dark"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" />
android:layout_marginEnd="10dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
style="@style/Control.Spinner.Header"
android:clickable="false"
android:id="@+id/app_or_structure_spinner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:layout_gravity="center"
android:ellipsize="end" />
<Space
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="1dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/global_actions_controls_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
android:orientation="vertical"
android:paddingTop="20dp" />
</merge>

View File

@@ -1217,6 +1217,7 @@
<!-- Home Controls -->
<dimen name="control_spacing">4dp</dimen>
<dimen name="control_list_divider">1dp</dimen>
<dimen name="control_corner_radius">15dp</dimen>
<dimen name="control_height">100dp</dimen>
<dimen name="control_padding">15dp</dimen>

View File

@@ -656,6 +656,12 @@
<item name="android:fontFamily">@*android:string/config_bodyFontFamily</item>
</style>
<style name="Control.Spinner.Header" parent="@*android:style/Widget.DeviceDefault.Spinner.DropDown">
<item name="android:textSize">25sp</item>
<item name="android:textColor">@color/control_primary_text</item>
<item name="android:fontFamily">@*android:string/config_headlineFontFamily</item>
</style>
<style name="TextAppearance.Control.Status">
<item name="android:textSize">12sp</item>
<item name="android:textColor">@color/control_primary_text</item>
@@ -669,5 +675,8 @@
<item name="android:textSize">12sp</item>
<item name="android:textColor">@color/control_secondary_text</item>
</style>
<style name="Control.ListPopupWindow" parent="@android:style/Widget.ListPopupWindow">
<item name="android:overlapAnchor">true</item>
</style>
</resources>

View File

@@ -162,11 +162,9 @@ open class ControlsBindingControllerImpl @Inject constructor(
override fun onComponentRemoved(componentName: ComponentName) {
backgroundExecutor.execute {
synchronized(componentMap) {
val removed = componentMap.remove(Key(componentName, currentUser))
removed?.let {
it.unbindService()
tokenMap.remove(it.token)
currentProvider?.let {
if (it.componentName == componentName) {
unbind()
}
}
}
@@ -182,16 +180,10 @@ open class ControlsBindingControllerImpl @Inject constructor(
private abstract inner class CallbackRunnable(val token: IBinder) : Runnable {
protected val provider: ControlsProviderLifecycleManager? = currentProvider
}
private inner class OnLoadRunnable(
token: IBinder,
val list: List<Control>,
val callback: ControlsBindingController.LoadCallback
) : CallbackRunnable(token) {
override fun run() {
if (provider == null) {
Log.e(TAG, "No provider found for token:$token")
Log.e(TAG, "No current provider set")
return
}
if (provider.user != currentUser) {
@@ -202,8 +194,21 @@ open class ControlsBindingControllerImpl @Inject constructor(
Log.e(TAG, "Provider for token:$token does not exist anymore")
return
}
doRun()
}
abstract fun doRun()
}
private inner class OnLoadRunnable(
token: IBinder,
val list: List<Control>,
val callback: ControlsBindingController.LoadCallback
) : CallbackRunnable(token) {
override fun doRun() {
callback.accept(list)
provider.unbindService()
provider?.unbindService()
}
}
@@ -211,14 +216,11 @@ open class ControlsBindingControllerImpl @Inject constructor(
token: IBinder,
val control: Control
) : CallbackRunnable(token) {
override fun run() {
override fun doRun() {
if (!refreshing.get()) {
Log.d(TAG, "onRefresh outside of window from:${provider?.componentName}")
}
if (provider?.user != currentUser) {
Log.e(TAG, "User ${provider?.user} is not current user")
return
}
provider?.let {
lazyController.get().refreshStatus(it.componentName, control)
}
@@ -229,7 +231,7 @@ open class ControlsBindingControllerImpl @Inject constructor(
token: IBinder,
val subscription: IControlsSubscription
) : CallbackRunnable(token) {
override fun run() {
override fun doRun() {
if (!refreshing.get()) {
Log.d(TAG, "onRefresh outside of window from '${provider?.componentName}'")
}
@@ -242,7 +244,7 @@ open class ControlsBindingControllerImpl @Inject constructor(
private inner class OnCompleteRunnable(
token: IBinder
) : CallbackRunnable(token) {
override fun run() {
override fun doRun() {
provider?.let {
Log.i(TAG, "onComplete receive from '${it.componentName}'")
}
@@ -253,7 +255,7 @@ open class ControlsBindingControllerImpl @Inject constructor(
token: IBinder,
val error: String
) : CallbackRunnable(token) {
override fun run() {
override fun doRun() {
provider?.let {
Log.e(TAG, "onError receive from '${it.componentName}': $error")
}
@@ -265,11 +267,7 @@ open class ControlsBindingControllerImpl @Inject constructor(
val controlId: String,
@ControlAction.ResponseResult val response: Int
) : CallbackRunnable(token) {
override fun run() {
if (provider?.user != currentUser) {
Log.e(TAG, "User ${provider?.user} is not current user")
return
}
override fun doRun() {
provider?.let {
lazyController.get().onActionResponse(it.componentName, controlId, response)
}
@@ -281,7 +279,7 @@ open class ControlsBindingControllerImpl @Inject constructor(
val error: String,
val callback: ControlsBindingController.LoadCallback
) : CallbackRunnable(token) {
override fun run() {
override fun doRun() {
callback.error(error)
provider?.let {
Log.e(TAG, "onError receive from '${it.componentName}': $error")

View File

@@ -145,21 +145,23 @@ class ControlsControllerImpl @Inject constructor (
* If some component has been removed, the new set of favorites will also be saved.
*/
private val listingCallback = object : ControlsListingController.ControlsListingCallback {
override fun onServicesUpdated(candidates: List<ControlsServiceInfo>) {
override fun onServicesUpdated(serviceInfos: List<ControlsServiceInfo>) {
executor.execute {
val candidateComponents = candidates.map(ControlsServiceInfo::componentName)
synchronized(currentFavorites) {
val components = currentFavorites.keys.toSet() // create a copy
components.forEach {
if (it !in candidateComponents) {
currentFavorites.remove(it)
bindingController.onComponentRemoved(it)
}
}
// Check if something has been removed, if so, store the new list
if (components.size > currentFavorites.size) {
persistenceWrapper.storeFavorites(favoritesAsListLocked())
}
val serviceInfoSet = serviceInfos.map(ControlsServiceInfo::componentName).toSet()
val favoriteComponentSet = Favorites.getAllStructures().map {
it.componentName
}.toSet()
var changed = false
favoriteComponentSet.subtract(serviceInfoSet).forEach {
changed = true
Favorites.removeStructures(it)
bindingController.onComponentRemoved(it)
}
// Check if something has been removed, if so, store the new list
if (changed) {
persistenceWrapper.storeFavorites(Favorites.getAllStructures())
}
}
}
@@ -183,6 +185,7 @@ class ControlsControllerImpl @Inject constructor (
if (shouldLoad) {
Favorites.load(persistenceWrapper.readFavorites())
listingController.addCallback(listingCallback)
}
}
@@ -220,39 +223,42 @@ class ControlsControllerImpl @Inject constructor (
componentName,
object : ControlsBindingController.LoadCallback {
override fun accept(controls: List<Control>) {
val favoritesForComponentKeys = Favorites
.getControlsForComponent(componentName).map { it.controlId }
executor.execute {
val favoritesForComponentKeys = Favorites
.getControlsForComponent(componentName).map { it.controlId }
val changed = Favorites.updateControls(componentName, controls)
if (changed) {
persistenceWrapper.storeFavorites(Favorites.getAllStructures())
}
val removed = findRemovedLocked(favoritesForComponentKeys.toSet(),
controls)
val controlsWithFavorite = controls.map {
ControlStatus(it, it.controlId in favoritesForComponentKeys)
}
val loadData = createLoadDataObject(
Favorites.getControlsForComponent(componentName)
.filter { it.controlId in removed }
.map { createRemovedStatus(componentName, it) } +
controlsWithFavorite,
favoritesForComponentKeys
)
val changed = Favorites.updateControls(componentName, controls)
if (changed) {
persistenceWrapper.storeFavorites(Favorites.getAllStructures())
}
val removed = findRemoved(favoritesForComponentKeys.toSet(), controls)
val controlsWithFavorite = controls.map {
ControlStatus(it, it.controlId in favoritesForComponentKeys)
}
val loadData = createLoadDataObject(
Favorites.getControlsForComponent(componentName)
.filter { it.controlId in removed }
.map { createRemovedStatus(componentName, it) } +
controlsWithFavorite,
favoritesForComponentKeys
)
dataCallback.accept(loadData)
dataCallback.accept(loadData)
}
}
override fun error(message: String) {
Favorites.getControlsForComponent(componentName).let { controls ->
val keys = controls.map { it.controlId }
val loadData = createLoadDataObject(
controls.map { createRemovedStatus(componentName, it, false) },
keys,
true
)
dataCallback.accept(loadData)
val loadData = Favorites.getControlsForComponent(componentName).let {
controls ->
val keys = controls.map { it.controlId }
createLoadDataObject(
controls.map { createRemovedStatus(componentName, it, false) },
keys,
true
)
}
dataCallback.accept(loadData)
}
}
)
@@ -278,7 +284,7 @@ class ControlsControllerImpl @Inject constructor (
return ControlStatus(control, true, setRemoved)
}
private fun findRemovedLocked(favoriteKeys: Set<String>, list: List<Control>): Set<String> {
private fun findRemoved(favoriteKeys: Set<String>, list: List<Control>): Set<String> {
val controlsKeys = list.map { it.controlId }
return favoriteKeys.minus(controlsKeys)
}
@@ -296,8 +302,10 @@ class ControlsControllerImpl @Inject constructor (
override fun replaceFavoritesForStructure(structureInfo: StructureInfo) {
if (!confirmAvailability()) return
Favorites.replaceControls(structureInfo)
persistenceWrapper.storeFavorites(Favorites.getAllStructures())
executor.execute {
Favorites.replaceControls(structureInfo)
persistenceWrapper.storeFavorites(Favorites.getAllStructures())
}
}
override fun refreshStatus(componentName: ComponentName, control: Control) {
@@ -358,6 +366,8 @@ class ControlsControllerImpl @Inject constructor (
/**
* Relies on immutable data for thread safety. When necessary to update favMap, use reassignment to
* replace it, which will not disrupt any ongoing map traversal.
*
* Update/replace calls should use thread isolation to avoid race conditions.
*/
private object Favorites {
private var favMap = mapOf<ComponentName, List<StructureInfo>>()
@@ -416,11 +426,17 @@ private object Favorites {
val newFavMap = favMap.toMutableMap()
newFavMap.put(componentName, structures)
favMap = newFavMap.toMap()
favMap = newFavMap
return true
}
fun removeStructures(componentName: ComponentName) {
val newFavMap = favMap.toMutableMap()
newFavMap.remove(componentName)
favMap = newFavMap
}
fun replaceControls(updatedStructure: StructureInfo) {
val newFavMap = favMap.toMutableMap()
val structures = mutableListOf<StructureInfo>()

View File

@@ -52,6 +52,10 @@ class ControlsFavoritePersistenceWrapper(
private const val TAG_ID = "id"
private const val TAG_TITLE = "title"
private const val TAG_TYPE = "type"
private const val TAG_VERSION = "version"
// must increment with every change to the XML structure
private const val VERSION = 1
}
/**
@@ -83,6 +87,10 @@ class ControlsFavoritePersistenceWrapper(
setOutput(writer, "utf-8")
setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true)
startDocument(null, true)
startTag(null, TAG_VERSION)
text("$VERSION")
endTag(null, TAG_VERSION)
startTag(null, TAG_STRUCTURES)
structures.forEach { s ->
startTag(null, TAG_STRUCTURE)

View File

@@ -26,15 +26,13 @@ import android.widget.TextView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import com.android.settingslib.applications.DefaultAppInfo
import com.android.settingslib.widget.CandidateInfo
import com.android.systemui.R
import com.android.systemui.controls.ControlsServiceInfo
import java.text.Collator
import java.util.concurrent.Executor
/**
* Adapter for binding [CandidateInfo] related to [ControlsProviderService].
* Adapter for binding [ControlsServiceInfo] related to [ControlsProviderService].
*
* This class handles subscribing and keeping track of the list of valid applications for
* displaying.
@@ -56,16 +54,16 @@ class AppAdapter(
private val resources: Resources
) : RecyclerView.Adapter<AppAdapter.Holder>() {
private var listOfServices = emptyList<CandidateInfo>()
private var listOfServices = emptyList<ControlsServiceInfo>()
private val callback = object : ControlsListingController.ControlsListingCallback {
override fun onServicesUpdated(candidates: List<ControlsServiceInfo>) {
override fun onServicesUpdated(serviceInfos: List<ControlsServiceInfo>) {
backgroundExecutor.execute {
val collator = Collator.getInstance(resources.configuration.locales[0])
val localeComparator = compareBy<CandidateInfo, CharSequence>(collator) {
val localeComparator = compareBy<ControlsServiceInfo, CharSequence>(collator) {
it.loadLabel()
}
listOfServices = candidates.sortedWith(localeComparator)
listOfServices = serviceInfos.sortedWith(localeComparator)
uiExecutor.execute(::notifyDataSetChanged)
}
}
@@ -101,11 +99,10 @@ class AppAdapter(
* Bind data to the view
* @param data Information about the [ControlsProviderService] to bind to the data
*/
fun bindData(data: CandidateInfo) {
fun bindData(data: ControlsServiceInfo) {
icon.setImageDrawable(data.loadIcon())
title.text = data.loadLabel()
favorites.text = favRenderer.renderFavoritesForComponent(
(data as DefaultAppInfo).componentName)
favorites.text = favRenderer.renderFavoritesForComponent(data.componentName)
}
}
}
@@ -123,4 +120,4 @@ class FavoritesRenderer(
return ""
}
}
}
}

View File

@@ -45,6 +45,6 @@ interface ControlsListingController :
@FunctionalInterface
interface ControlsListingCallback {
fun onServicesUpdated(candidates: List<ControlsServiceInfo>)
fun onServicesUpdated(serviceInfos: List<ControlsServiceInfo>)
}
}

View File

@@ -24,7 +24,6 @@ import android.os.UserHandle
import android.service.controls.ControlsProviderService
import android.util.Log
import com.android.internal.annotations.VisibleForTesting
import com.android.settingslib.applications.DefaultAppInfo
import com.android.settingslib.applications.ServiceListing
import com.android.settingslib.widget.CandidateInfo
import com.android.systemui.controls.ControlsServiceInfo
@@ -157,7 +156,7 @@ class ControlsListingControllerImpl @VisibleForTesting constructor(
* @return a label as returned by [CandidateInfo.loadLabel] or `null`.
*/
override fun getAppLabel(name: ComponentName): CharSequence? {
return getCurrentServices().firstOrNull { (it as? DefaultAppInfo)?.componentName == name }
return getCurrentServices().firstOrNull { it.componentName == name }
?.loadLabel()
}
}
}

View File

@@ -22,19 +22,25 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.graphics.drawable.Drawable
import android.os.IBinder
import android.service.controls.Control
import android.service.controls.TokenProvider
import android.util.Log
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListPopupWindow
import android.widget.Space
import com.android.settingslib.widget.CandidateInfo
import android.widget.TextView
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
@@ -43,7 +49,6 @@ 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.R
import com.android.systemui.controls.ControlsServiceInfo
import com.android.systemui.util.concurrency.DelayableExecutor
import dagger.Lazy
@@ -123,12 +128,17 @@ class ControlsUiControllerImpl @Inject constructor (
val context: Context,
@Main val uiExecutor: DelayableExecutor,
@Background val bgExecutor: DelayableExecutor,
val controlsListingController: Lazy<ControlsListingController>
val controlsListingController: Lazy<ControlsListingController>,
@Main val sharedPreferences: SharedPreferences
) : ControlsUiController {
companion object {
private const val PREF_COMPONENT = "controls_component"
private const val PREF_STRUCTURE = "controls_structure"
private val EMPTY_COMPONENT = ComponentName("", "")
private val EMPTY_STRUCTURE = StructureInfo(
ComponentName("", ""),
EMPTY_COMPONENT,
"",
mutableListOf<ControlInfo>()
)
@@ -139,45 +149,66 @@ class ControlsUiControllerImpl @Inject constructor (
private val controlsById = mutableMapOf<ControlKey, ControlWithState>()
private val controlViewsById = mutableMapOf<ControlKey, ControlViewHolder>()
private lateinit var parent: ViewGroup
private lateinit var lastItems: List<SelectionItem>
private var popup: ListPopupWindow? = null
private val addControlsItem = SelectionItem(
context.resources.getString(R.string.controls_providers_title),
"",
context.getDrawable(R.drawable.ic_add),
EMPTY_COMPONENT
)
override val available: Boolean
get() = controlsController.get().available
private val listingCallback = object : ControlsListingController.ControlsListingCallback {
override fun onServicesUpdated(candidates: List<ControlsServiceInfo>) {
bgExecutor.execute {
val collator = Collator.getInstance(context.resources.configuration.locales[0])
val localeComparator = compareBy<CandidateInfo, CharSequence>(collator) {
it.loadLabel()
}
private lateinit var listingCallback: ControlsListingController.ControlsListingCallback
val mList = candidates.toMutableList()
mList.sortWith(localeComparator)
loadInitialSetupViewIcons(mList.map { it.loadLabel() to it.loadIcon() })
private fun createCallback(
onResult: (List<SelectionItem>) -> Unit
): ControlsListingController.ControlsListingCallback {
return object : ControlsListingController.ControlsListingCallback {
override fun onServicesUpdated(serviceInfos: List<ControlsServiceInfo>) {
bgExecutor.execute {
val collator = Collator.getInstance(context.resources.configuration.locales[0])
val localeComparator = compareBy<ControlsServiceInfo, CharSequence>(collator) {
it.loadLabel()
}
val mList = serviceInfos.toMutableList()
mList.sortWith(localeComparator)
lastItems = mList.map {
SelectionItem(it.loadLabel(), "", it.loadIcon(), it.componentName)
}
uiExecutor.execute {
onResult(lastItems)
}
}
}
}
}
override fun show(parent: ViewGroup) {
Log.d(ControlsUiController.TAG, "show()")
this.parent = parent
allStructures = controlsController.get().getFavorites()
selectedStructure = loadPreference(allStructures)
if (allStructures.isEmpty()) {
showInitialSetupView()
if (selectedStructure.controls.isEmpty() && allStructures.size <= 1) {
// only show initial view if there are really no favorites across any structure
listingCallback = createCallback(::showInitialSetupView)
} else {
selectedStructure = allStructures.get(0)
selectedStructure.controls.map {
ControlWithState(selectedStructure.componentName, it, null)
}.associateByTo(controlsById) {
ControlKey(selectedStructure.componentName, it.ci.controlId)
}
showControlsView()
listingCallback = createCallback(::showControlsView)
}
controlsListingController.get().addCallback(listingCallback)
// Temp code to pass auth
tokenProviderConnection = TokenProviderConnection(controlsController.get(), context,
selectedStructure)
@@ -191,29 +222,21 @@ class ControlsUiControllerImpl @Inject constructor (
}
}
private fun showInitialSetupView() {
private fun showInitialSetupView(items: List<SelectionItem>) {
parent.removeAllViews()
val inflater = LayoutInflater.from(context)
inflater.inflate(R.layout.controls_no_favorites, parent, true)
val viewGroup = parent.requireViewById(R.id.controls_no_favorites_group) as ViewGroup
viewGroup.setOnClickListener(launchSelectorActivityListener(context))
controlsListingController.get().addCallback(listingCallback)
}
private fun loadInitialSetupViewIcons(icons: List<Pair<CharSequence, Drawable>>) {
uiExecutor.execute {
val viewGroup = parent.requireViewById(R.id.controls_icon_row) as ViewGroup
viewGroup.removeAllViews()
val inflater = LayoutInflater.from(context)
icons.forEach {
val imageView = inflater.inflate(R.layout.controls_icon, viewGroup, false)
as ImageView
imageView.setContentDescription(it.first)
imageView.setImageDrawable(it.second)
viewGroup.addView(imageView)
}
val iconRowGroup = parent.requireViewById(R.id.controls_icon_row) as ViewGroup
items.forEach {
val imageView = inflater.inflate(R.layout.controls_icon, viewGroup, false) as ImageView
imageView.setContentDescription(it.getTitle())
imageView.setImageDrawable(it.icon)
iconRowGroup.addView(imageView)
}
}
@@ -229,14 +252,16 @@ class ControlsUiControllerImpl @Inject constructor (
}
}
private fun showControlsView() {
private fun showControlsView(items: List<SelectionItem>) {
parent.removeAllViews()
controlViewsById.clear()
val inflater = LayoutInflater.from(context)
inflater.inflate(R.layout.controls_with_favorites, parent, true)
val listView = parent.requireViewById(R.id.global_actions_controls_list) as ViewGroup
var lastRow: ViewGroup = createRow(inflater, listView)
selectedStructure.controls.forEach {
Log.d(ControlsUiController.TAG, "favorited control id: " + it.controlId)
if (lastRow.getChildCount() == 2) {
lastRow = createRow(inflater, listView)
}
@@ -249,16 +274,109 @@ class ControlsUiControllerImpl @Inject constructor (
controlViewsById.put(key, cvh)
}
// add spacer if necessary to keep control size consistent
if ((selectedStructure.controls.size % 2) == 1) {
lastRow.addView(Space(context), LinearLayout.LayoutParams(0, 0, 1f))
}
val moreImageView = parent.requireViewById(R.id.controls_more) as View
moreImageView.setOnClickListener(launchSelectorActivityListener(context))
val itemsByComponent = items.associateBy { it.componentName }
var adapter = ItemAdapter(context, R.layout.controls_spinner_item).apply {
val listItems = allStructures.mapNotNull {
itemsByComponent.get(it.componentName)?.copy(structure = it.structure)
}
addAll(listItems + addControlsItem)
}
/*
* Default spinner widget does not work with the window type required
* for this dialog. Use a textView with the ListPopupWindow to achieve
* a similar effect
*/
parent.requireViewById<TextView>(R.id.app_or_structure_spinner).apply {
setText((adapter.findSelectionItem(selectedStructure) ?: adapter.getItem(0)).getTitle())
}
val anchor = parent.requireViewById<ViewGroup>(R.id.controls_header)
anchor.setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View) {
popup = ListPopupWindow(
ContextThemeWrapper(context, R.style.Control_ListPopupWindow))
popup?.apply {
setWindowLayoutType(WindowManager.LayoutParams.TYPE_VOLUME_OVERLAY)
setAnchorView(anchor)
setAdapter(adapter)
setModal(true)
setOnItemClickListener(object : AdapterView.OnItemClickListener {
override fun onItemClick(
parent: AdapterView<*>,
view: View,
pos: Int,
id: Long
) {
val listItem = parent.getItemAtPosition(pos) as SelectionItem
this@ControlsUiControllerImpl.switchAppOrStructure(listItem)
dismiss()
}
})
// need to call show() first in order to construct the listView
show()
getListView()?.apply {
setDividerHeight(
context.resources.getDimensionPixelSize(R.dimen.control_list_divider))
setDivider(
context.resources.getDrawable(R.drawable.controls_list_divider))
}
show()
}
}
})
parent.requireViewById<ImageView>(R.id.app_icon).apply {
setContentDescription("My Home")
setImageDrawable(items[0].icon)
}
}
private fun loadPreference(structures: List<StructureInfo>): StructureInfo {
if (structures.isEmpty()) return EMPTY_STRUCTURE
val component = sharedPreferences.getString(PREF_COMPONENT, null)?.let {
ComponentName.unflattenFromString(it)
} ?: EMPTY_COMPONENT
val structure = sharedPreferences.getString(PREF_STRUCTURE, "")
return structures.firstOrNull {
component == it.componentName && structure == it.structure
} ?: structures.get(0)
}
private fun updatePreferences(si: StructureInfo) {
sharedPreferences.edit()
.putString(PREF_COMPONENT, si.componentName.flattenToString())
.putString(PREF_STRUCTURE, si.structure.toString())
.commit()
}
private fun switchAppOrStructure(item: SelectionItem) {
if (item == addControlsItem) {
launchSelectorActivityListener(context)(parent)
} else {
val newSelection = allStructures.first {
it.structure == item.structure && it.componentName == item.componentName
}
if (newSelection != selectedStructure) {
selectedStructure = newSelection
updatePreferences(selectedStructure)
showControlsView(lastItems)
}
}
}
override fun hide() {
Log.d(ControlsUiController.TAG, "hide()")
popup?.dismiss()
controlsController.get().unsubscribe()
context.unbindService(tokenProviderConnection)
tokenProviderConnection = null
@@ -298,3 +416,46 @@ class ControlsUiControllerImpl @Inject constructor (
return row
}
}
private data class SelectionItem(
val appName: CharSequence,
val structure: CharSequence,
val icon: Drawable,
val componentName: ComponentName
) {
fun getTitle() = if (structure.isEmpty()) { appName } else { structure }
}
private class ItemAdapter(
val parentContext: Context,
val resource: Int
) : ArrayAdapter<SelectionItem>(parentContext, resource) {
val layoutInflater = LayoutInflater.from(context)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val item = getItem(position)
val view = convertView ?: layoutInflater.inflate(resource, parent, false)
view.requireViewById<TextView>(R.id.controls_spinner_item).apply {
setText(item.getTitle())
}
view.requireViewById<ImageView>(R.id.app_icon).apply {
setContentDescription(item.getTitle())
setImageDrawable(item.icon)
}
return view
}
fun findSelectionItem(si: StructureInfo): SelectionItem? {
var i = 0
while (i < getCount()) {
val item = getItem(i)
if (item.componentName == si.componentName &&
item.structure == si.structure) {
return item
}
i++
}
return null
}
}

View File

@@ -39,6 +39,7 @@ import org.mockito.Mockito
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
@@ -176,20 +177,13 @@ class ControlsBindingControllerImplTest : SysuiTestCase() {
@Test
fun testComponentRemoved_existingIsUnbound() {
controller.bindServices(listOf(
TEST_COMPONENT_NAME_1,
TEST_COMPONENT_NAME_2,
TEST_COMPONENT_NAME_3
))
controller.bindService(TEST_COMPONENT_NAME_1)
controller.onComponentRemoved(TEST_COMPONENT_NAME_2)
controller.onComponentRemoved(TEST_COMPONENT_NAME_1)
executor.runAllReady()
providers.forEach {
verify(it, if (it.componentName == TEST_COMPONENT_NAME_2) times(1) else never())
.unbindService()
}
verify(providers[0], times(1)).unbindService()
}
}

View File

@@ -47,12 +47,14 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.`when`
import org.mockito.Mockito.inOrder
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.reset
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
@@ -149,6 +151,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
assertTrue(controller.available)
verify(broadcastDispatcher).registerReceiver(
capture(broadcastReceiverCaptor), any(), any(), eq(UserHandle.ALL))
verify(listingController).addCallback(capture(listingCallbackCaptor))
}
@@ -210,6 +213,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
fun testSubscribeFavorites() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO_2)
delayableExecutor.runAllReady()
controller.subscribeToFavorites(TEST_STRUCTURE_INFO)
@@ -241,6 +245,8 @@ class ControlsControllerImplTest : SysuiTestCase() {
controlLoadCallbackCaptor.value.accept(listOf(control))
delayableExecutor.runAllReady()
assertTrue(loaded)
}
@@ -251,6 +257,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
val control2 = builderFromInfo(TEST_CONTROL_INFO_2).build()
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO_2)
delayableExecutor.runAllReady()
controller.loadForComponent(TEST_COMPONENT, Consumer { data ->
val controls = data.allControls
@@ -272,6 +279,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
capture(controlLoadCallbackCaptor))
controlLoadCallbackCaptor.value.accept(listOf(control, control2))
delayableExecutor.runAllReady()
assertTrue(loaded)
}
@@ -280,6 +288,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
fun testLoadForComponent_removed() {
var loaded = false
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
delayableExecutor.runAllReady()
controller.loadForComponent(TEST_COMPONENT, Consumer { data ->
val controls = data.allControls
@@ -300,6 +309,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
capture(controlLoadCallbackCaptor))
controlLoadCallbackCaptor.value.accept(emptyList())
delayableExecutor.runAllReady()
assertTrue(loaded)
}
@@ -308,6 +318,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
fun testErrorOnLoad_notRemoved() {
var loaded = false
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
delayableExecutor.runAllReady()
controller.loadForComponent(TEST_COMPONENT, Consumer { data ->
val controls = data.allControls
@@ -335,6 +346,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
@Test
fun testFavoriteInformationModifiedOnLoad() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
delayableExecutor.runAllReady()
val newControlInfo = TEST_CONTROL_INFO.copy(controlTitle = TEST_CONTROL_TITLE_2)
val control = builderFromInfo(newControlInfo).build()
@@ -345,6 +357,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
capture(controlLoadCallbackCaptor))
controlLoadCallbackCaptor.value.accept(listOf(control))
delayableExecutor.runAllReady()
val favorites = controller.getFavorites().flatMap { it.controls }
assertEquals(1, favorites.size)
@@ -370,6 +383,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
@Test
fun testSwitchUsers() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
delayableExecutor.runAllReady()
reset(persistenceWrapper)
val intent = Intent(Intent.ACTION_USER_SWITCHED).apply {
@@ -401,6 +415,8 @@ class ControlsControllerImplTest : SysuiTestCase() {
@Test
fun testDisableFeature_clearFavorites() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
delayableExecutor.runAllReady()
assertFalse(controller.getFavorites().isEmpty())
Settings.Secure.putIntForUser(mContext.contentResolver,
@@ -412,6 +428,8 @@ class ControlsControllerImplTest : SysuiTestCase() {
@Test
fun testDisableFeature_noChangeForNotCurrentUser() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
delayableExecutor.runAllReady()
Settings.Secure.putIntForUser(mContext.contentResolver,
ControlsControllerImpl.CONTROLS_AVAILABLE, 0, otherUser)
controller.settingObserver.onChange(false, ControlsControllerImpl.URI, otherUser)
@@ -440,6 +458,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
@Test
fun testCountFavoritesForComponent_singleComponent() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
delayableExecutor.runAllReady()
assertEquals(1, controller.countFavoritesForComponent(TEST_COMPONENT))
assertEquals(0, controller.countFavoritesForComponent(TEST_COMPONENT_2))
@@ -449,6 +468,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
fun testCountFavoritesForComponent_multipleComponents() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO_2)
delayableExecutor.runAllReady()
assertEquals(1, controller.countFavoritesForComponent(TEST_COMPONENT))
assertEquals(1, controller.countFavoritesForComponent(TEST_COMPONENT_2))
@@ -457,6 +477,8 @@ class ControlsControllerImplTest : SysuiTestCase() {
@Test
fun testGetFavoritesForComponent() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
delayableExecutor.runAllReady()
assertEquals(listOf(TEST_STRUCTURE_INFO),
controller.getFavoritesForComponent(TEST_COMPONENT))
}
@@ -464,6 +486,8 @@ class ControlsControllerImplTest : SysuiTestCase() {
@Test
fun testGetFavoritesForComponent_otherComponent() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO_2)
delayableExecutor.runAllReady()
assertTrue(controller.getFavoritesForComponent(TEST_COMPONENT).isEmpty())
}
@@ -477,6 +501,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
"Home",
listOf(TEST_CONTROL_INFO, controlInfo)
))
delayableExecutor.runAllReady()
assertEquals(listOf(TEST_CONTROL_INFO, controlInfo),
controller.getFavoritesForComponent(TEST_COMPONENT).flatMap { it.controls })
@@ -487,6 +512,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
"Home",
listOf(controlInfo, TEST_CONTROL_INFO)
))
delayableExecutor.runAllReady()
assertEquals(listOf(controlInfo, TEST_CONTROL_INFO),
controller.getFavoritesForComponent(TEST_COMPONENT).flatMap { it.controls })
@@ -495,6 +521,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
@Test
fun testReplaceFavoritesForStructure_noFavorites() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
delayableExecutor.runAllReady()
assertEquals(1, controller.countFavoritesForComponent(TEST_COMPONENT))
assertEquals(listOf(TEST_STRUCTURE_INFO),
@@ -505,6 +532,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
fun testReplaceFavoritesForStructure_differentComponentsAreFilteredOut() {
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO_2)
delayableExecutor.runAllReady()
assertEquals(1, controller.countFavoritesForComponent(TEST_COMPONENT))
assertEquals(listOf(TEST_CONTROL_INFO),
@@ -530,6 +558,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
"Home",
listOf(TEST_CONTROL_INFO)
))
delayableExecutor.runAllReady()
assertEquals(1, controller.countFavoritesForComponent(newComponent))
assertEquals(listOf(TEST_CONTROL_INFO), controller
@@ -543,6 +572,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
val listOrder1 = listOf(TEST_CONTROL_INFO, controlInfo)
val structure1 = StructureInfo(TEST_COMPONENT, "Home", listOrder1)
controller.replaceFavoritesForStructure(structure1)
delayableExecutor.runAllReady()
assertEquals(2, controller.countFavoritesForComponent(TEST_COMPONENT))
assertEquals(listOrder1, controller.getFavoritesForComponent(TEST_COMPONENT)
@@ -552,6 +582,7 @@ class ControlsControllerImplTest : SysuiTestCase() {
val structure2 = StructureInfo(TEST_COMPONENT, "Home", listOrder2)
controller.replaceFavoritesForStructure(structure2)
delayableExecutor.runAllReady()
assertEquals(2, controller.countFavoritesForComponent(TEST_COMPONENT))
assertEquals(listOrder2, controller.getFavoritesForComponent(TEST_COMPONENT)
@@ -560,7 +591,8 @@ class ControlsControllerImplTest : SysuiTestCase() {
@Test
fun testPackageRemoved_noFavorites_noRemovals() {
controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
delayableExecutor.runAllReady()
val serviceInfo = mock(ServiceInfo::class.java)
`when`(serviceInfo.componentName).thenReturn(TEST_COMPONENT)
@@ -569,21 +601,21 @@ class ControlsControllerImplTest : SysuiTestCase() {
// Don't want to check what happens before this call
reset(persistenceWrapper)
listingCallbackCaptor.value.onServicesUpdated(listOf(info))
delayableExecutor.runAllReady()
verify(bindingController, never()).onComponentRemoved(any())
assertEquals(1, controller.getFavoriteControls().size)
assertEquals(TEST_CONTROL_INFO, controller.getFavoriteControls()[0])
assertEquals(1, controller.getFavorites().size)
assertEquals(TEST_STRUCTURE_INFO, controller.getFavorites()[0])
verify(persistenceWrapper, never()).storeFavorites(ArgumentMatchers.anyList())
}
@Test
fun testPackageRemoved_hasFavorites() {
controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
controller.changeFavoriteStatus(TEST_CONTROL_INFO_2, true)
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO)
controller.replaceFavoritesForStructure(TEST_STRUCTURE_INFO_2)
delayableExecutor.runAllReady()
val serviceInfo = mock(ServiceInfo::class.java)
`when`(serviceInfo.componentName).thenReturn(TEST_COMPONENT)
@@ -591,14 +623,14 @@ class ControlsControllerImplTest : SysuiTestCase() {
// Don't want to check what happens before this call
reset(persistenceWrapper)
listingCallbackCaptor.value.onServicesUpdated(listOf(info))
listingCallbackCaptor.value.onServicesUpdated(listOf(info))
delayableExecutor.runAllReady()
verify(bindingController).onComponentRemoved(TEST_COMPONENT_2)
assertEquals(1, controller.getFavoriteControls().size)
assertEquals(TEST_CONTROL_INFO, controller.getFavoriteControls()[0])
assertEquals(1, controller.getFavorites().size)
assertEquals(TEST_STRUCTURE_INFO, controller.getFavorites()[0])
verify(persistenceWrapper).storeFavorites(ArgumentMatchers.anyList())
}