Adding controllers for Controls

This CL adds the following controllers for dealing with Controls in
SystemUI:

* ControlsController - Handles favorites and communication with other
controllers
* ControlsBindingController - Handles binding to the different services
and comunication with them.
* ControlsProviderLifecycleManager - Handles binding with one service
and forwards API calls to it.
* ControlsListingController - Handles finding and listing apps that
satisfy the service requirement

Additionally, this CL adds first versions of the management screen to
add or remove favorites. These are persisted.

To enable:
* adb shell settings put secure systemui.controls_available 1
* restart

To launch management activity:
* Enable controls
* adb shell am start
com.android.systemui/.controls.management.ControlsProviderSelectorActivity

Missing from this CL:
* Multi user support
* Documentation in the controller classes
* Graceful rebinding
* Throttling of Binder calls
* Better dumps

Test: atest com.android.systemui.controls
Test: manual testing of favorite management activities
Bug: 147732882

Change-Id: Ib4932b7f8fd3f2e3ee0ef1c28ddda1bec66c41e4
diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml
index 2a1e74e..8c43a56 100644
--- a/packages/SystemUI/AndroidManifest.xml
+++ b/packages/SystemUI/AndroidManifest.xml
@@ -636,6 +636,23 @@
             </intent-filter>
         </activity>
 
+        <activity android:name=".controls.management.ControlsProviderSelectorActivity"
+                  android:label="Controls Providers"
+                  android:theme="@style/Theme.SystemUI"
+                  android:exported="true"
+                  android:excludeFromRecents="true"
+                  android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden"
+                  android:visibleToInstantApps="true">
+        </activity>
+
+        <activity android:name=".controls.management.ControlsFavoritingActivity"
+                  android:parentActivityName=".controls.management.ControlsProviderSelectorActivity"
+                  android:theme="@style/Theme.SystemUI"
+                  android:excludeFromRecents="true"
+                  android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden"
+                  android:visibleToInstantApps="true">
+        </activity>
+
         <!-- Doze with notifications, run in main sysui process for every user  -->
         <service
             android:name=".doze.DozeService"
diff --git a/packages/SystemUI/res/layout/app_item.xml b/packages/SystemUI/res/layout/app_item.xml
new file mode 100644
index 0000000..83e7887
--- /dev/null
+++ b/packages/SystemUI/res/layout/app_item.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  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:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:background="?android:attr/selectableItemBackground"
+    android:gravity="center_vertical"
+    android:minHeight="?android:attr/listPreferredItemHeightSmall"
+    android:paddingStart="?android:attr/listPreferredItemPaddingStart"
+    android:paddingEnd="?android:attr/listPreferredItemPaddingEnd">
+
+    <LinearLayout
+        android:id="@+id/icon_frame"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:gravity="start|center_vertical"
+        android:minWidth="56dp"
+        android:orientation="horizontal"
+        android:paddingEnd="8dp"
+        android:paddingTop="4dp"
+        android:paddingBottom="4dp">
+        <ImageView
+            android:id="@android:id/icon"
+            android:layout_width="@dimen/app_icon_size"
+            android:layout_height="@dimen/app_icon_size"/>
+    </LinearLayout>
+
+    <LinearLayout
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:orientation="vertical"
+        android:paddingTop="16dp"
+        android:paddingBottom="16dp">
+
+        <TextView
+            android:id="@android:id/title"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:ellipsize="marquee"
+            android:fadingEdge="horizontal"
+            android:singleLine="true"
+            android:textAppearance="?android:attr/textAppearanceListItem"/>
+
+    </LinearLayout>
+
+    <LinearLayout
+        android:id="@android:id/widget_frame"
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent"
+        android:gravity="center_vertical|end"
+        android:minWidth="64dp"
+        android:orientation="vertical"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout/control_item.xml b/packages/SystemUI/res/layout/control_item.xml
new file mode 100644
index 0000000..85701aa
--- /dev/null
+++ b/packages/SystemUI/res/layout/control_item.xml
@@ -0,0 +1,72 @@
+<?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.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="100dp"
+    android:padding="15dp"
+    android:clickable="true"
+    android:focusable="true">
+
+    <ImageView
+        android:id="@+id/icon"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <TextView
+        android:id="@+id/status"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textSize="12sp"
+        android:textColor="?android:attr/textColorPrimary"
+        android:fontFamily="@*android:string/config_bodyFontFamily"
+        android:paddingLeft="3dp"
+        app:layout_constraintBottom_toBottomOf="@+id/icon"
+        app:layout_constraintStart_toEndOf="@+id/icon" />
+
+    <TextView
+        android:id="@+id/title"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textSize="18sp"
+        android:textColor="?android:attr/textColorPrimary"
+        android:fontFamily="@*android:string/config_headlineFontFamily"
+        app:layout_constraintBottom_toTopOf="@+id/subtitle"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/icon" />
+
+    <TextView
+        android:id="@+id/subtitle"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textSize="16sp"
+        android:textColor="?android:attr/textColorSecondary"
+        android:fontFamily="@*android:string/config_headlineFontFamily"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintStart_toStartOf="parent" />
+
+    <CheckBox
+        android:id="@+id/favorite"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintBottom_toBottomOf="parent"/>
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml
index da0323a..9321e21 100644
--- a/packages/SystemUI/res/values/dimens.xml
+++ b/packages/SystemUI/res/values/dimens.xml
@@ -1151,4 +1151,5 @@
     <dimen name="magnifier_up_down_controls_width">45dp</dimen>
     <dimen name="magnifier_up_down_controls_height">40dp</dimen>
 
+    <dimen name="app_icon_size">32dp</dimen>
 </resources>
diff --git a/packages/SystemUI/src/com/android/systemui/controls/ControlStatus.kt b/packages/SystemUI/src/com/android/systemui/controls/ControlStatus.kt
new file mode 100644
index 0000000..e6cdf50
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/ControlStatus.kt
@@ -0,0 +1,21 @@
+/*
+ * 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
+
+import android.service.controls.Control
+
+data class ControlStatus(val control: Control, val favorite: Boolean, val removed: Boolean = false)
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/ControlsServiceInfo.kt b/packages/SystemUI/src/com/android/systemui/controls/ControlsServiceInfo.kt
new file mode 100644
index 0000000..265ddd8
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/ControlsServiceInfo.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.controls
+
+import android.content.Context
+import android.content.pm.ServiceInfo
+import com.android.settingslib.applications.DefaultAppInfo
+
+class ControlsServiceInfo(
+    context: Context,
+    serviceInfo: ServiceInfo
+) : DefaultAppInfo(
+    context,
+    context.packageManager,
+    context.userId,
+    serviceInfo.componentName
+)
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlInfo.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlInfo.kt
new file mode 100644
index 0000000..b6cca3f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlInfo.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.controller
+
+import android.content.ComponentName
+import android.service.controls.DeviceTypes
+import android.util.Log
+
+/**
+ * Stores basic information about a [Control] to persist and keep track of favorites.
+ */
+data class ControlInfo(
+    val component: ComponentName,
+    val controlId: String,
+    val controlTitle: CharSequence,
+    @DeviceTypes.DeviceType val deviceType: Int
+) {
+
+    companion object {
+        private const val TAG = "ControlInfo"
+        private const val SEPARATOR = ":"
+        fun createFromString(string: String): ControlInfo? {
+            val parts = string.split(SEPARATOR)
+            val component = ComponentName.unflattenFromString(parts[0])
+            if (parts.size != 4 || component == null) {
+                Log.e(TAG, "Cannot parse ControlInfo from $string")
+                return null
+            }
+            val type = try {
+                parts[3].toInt()
+            } catch (e: Exception) {
+                Log.e(TAG, "Cannot parse deviceType from ${parts[3]}")
+                return null
+            }
+            return ControlInfo(
+                    component,
+                    parts[1],
+                    parts[2],
+                    if (DeviceTypes.validDeviceType(type)) type else DeviceTypes.TYPE_UNKNOWN)
+        }
+    }
+    override fun toString(): String {
+        return component.flattenToString() +
+                "$SEPARATOR$controlId$SEPARATOR$controlTitle$SEPARATOR$deviceType"
+    }
+
+    class Builder {
+        lateinit var componentName: ComponentName
+        lateinit var controlId: String
+        lateinit var controlTitle: CharSequence
+        var deviceType: Int = DeviceTypes.TYPE_UNKNOWN
+
+        fun build() = ControlInfo(componentName, controlId, controlTitle, deviceType)
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsBindingController.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsBindingController.kt
new file mode 100644
index 0000000..6b7fc4b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsBindingController.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.controls.controller
+
+import android.content.ComponentName
+import android.service.controls.Control
+import android.service.controls.actions.ControlAction
+
+interface ControlsBindingController {
+    fun bindAndLoad(component: ComponentName, callback: (List<Control>) -> Unit)
+    fun bindServices(components: List<ComponentName>)
+    fun subscribe(controls: List<ControlInfo>)
+    fun action(controlInfo: ControlInfo, action: ControlAction)
+    fun unsubscribe()
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsBindingControllerImpl.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsBindingControllerImpl.kt
new file mode 100644
index 0000000..80e48b9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsBindingControllerImpl.kt
@@ -0,0 +1,204 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.controls.controller
+
+import android.content.ComponentName
+import android.content.Context
+import android.os.IBinder
+import android.service.controls.Control
+import android.service.controls.IControlsProviderCallback
+import android.service.controls.actions.ControlAction
+import android.util.ArrayMap
+import android.util.Log
+import com.android.internal.annotations.GuardedBy
+import com.android.internal.annotations.VisibleForTesting
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.util.concurrency.DelayableExecutor
+import dagger.Lazy
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+@VisibleForTesting
+open class ControlsBindingControllerImpl @Inject constructor(
+    private val context: Context,
+    @Background private val backgroundExecutor: DelayableExecutor,
+    private val lazyController: Lazy<ControlsController>
+) : ControlsBindingController {
+
+    companion object {
+        private const val TAG = "ControlsBindingControllerImpl"
+    }
+
+    private val refreshing = AtomicBoolean(false)
+
+    @GuardedBy("componentMap")
+    private val tokenMap: MutableMap<IBinder, ControlsProviderLifecycleManager> =
+            ArrayMap<IBinder, ControlsProviderLifecycleManager>()
+    @GuardedBy("componentMap")
+    private val componentMap: MutableMap<ComponentName, ControlsProviderLifecycleManager> =
+            ArrayMap<ComponentName, ControlsProviderLifecycleManager>()
+
+    private val serviceCallback = object : IControlsProviderCallback.Stub() {
+        override fun onLoad(token: IBinder, controls: MutableList<Control>) {
+            backgroundExecutor.execute(OnLoadRunnable(token, controls))
+        }
+
+        override fun onRefreshState(token: IBinder, controlStates: List<Control>) {
+            if (!refreshing.get()) {
+                Log.d(TAG, "Refresh outside of window for token:$token")
+            } else {
+                backgroundExecutor.execute(OnRefreshStateRunnable(token, controlStates))
+            }
+        }
+
+        override fun onControlActionResponse(
+            token: IBinder,
+            controlId: String,
+            @ControlAction.ResponseResult response: Int
+        ) {
+            backgroundExecutor.execute(OnActionResponseRunnable(token, controlId, response))
+        }
+    }
+
+    @VisibleForTesting
+    internal open fun createProviderManager(component: ComponentName):
+            ControlsProviderLifecycleManager {
+        return ControlsProviderLifecycleManager(
+                context,
+                backgroundExecutor,
+                serviceCallback,
+                component
+        )
+    }
+
+    private fun retrieveLifecycleManager(component: ComponentName):
+            ControlsProviderLifecycleManager {
+        synchronized(componentMap) {
+            val provider = componentMap.getOrPut(component) {
+                createProviderManager(component)
+            }
+            tokenMap.putIfAbsent(provider.token, provider)
+            return provider
+        }
+    }
+
+    override fun bindAndLoad(component: ComponentName, callback: (List<Control>) -> Unit) {
+        val provider = retrieveLifecycleManager(component)
+        provider.maybeBindAndLoad(callback)
+    }
+
+    override fun subscribe(controls: List<ControlInfo>) {
+        val controlsByComponentName = controls.groupBy { it.component }
+        if (refreshing.compareAndSet(false, true)) {
+            controlsByComponentName.forEach {
+                val provider = retrieveLifecycleManager(it.key)
+                backgroundExecutor.execute {
+                    provider.maybeBindAndSubscribe(it.value.map { it.controlId })
+                }
+            }
+        }
+        // Unbind unneeded providers
+        val providersWithFavorites = controlsByComponentName.keys
+        synchronized(componentMap) {
+            componentMap.forEach {
+                if (it.key !in providersWithFavorites) {
+                    backgroundExecutor.execute { it.value.unbindService() }
+                }
+            }
+        }
+    }
+
+    override fun unsubscribe() {
+        if (refreshing.compareAndSet(true, false)) {
+            val providers = synchronized(componentMap) {
+                componentMap.values.toList()
+            }
+            providers.forEach {
+                backgroundExecutor.execute { it.unsubscribe() }
+            }
+        }
+    }
+
+    override fun action(controlInfo: ControlInfo, action: ControlAction) {
+        val provider = retrieveLifecycleManager(controlInfo.component)
+        provider.maybeBindAndSendAction(controlInfo.controlId, action)
+    }
+
+    override fun bindServices(components: List<ComponentName>) {
+        components.forEach {
+            val provider = retrieveLifecycleManager(it)
+            backgroundExecutor.execute { provider.bindPermanently() }
+        }
+    }
+
+    private abstract inner class CallbackRunnable(val token: IBinder) : Runnable {
+        protected val provider: ControlsProviderLifecycleManager? =
+                synchronized(componentMap) {
+                    tokenMap.get(token)
+                }
+    }
+
+    private inner class OnLoadRunnable(
+        token: IBinder,
+        val list: List<Control>
+    ) : CallbackRunnable(token) {
+        override fun run() {
+            if (provider == null) {
+                Log.e(TAG, "No provider found for token:$token")
+                return
+            }
+            synchronized(componentMap) {
+                if (token !in tokenMap.keys) {
+                    Log.e(TAG, "Provider for token:$token does not exist anymore")
+                    return
+                }
+            }
+            provider.lastLoadCallback?.invoke(list) ?: run {
+                Log.w(TAG, "Null callback")
+            }
+            provider.maybeUnbindAndRemoveCallback()
+        }
+    }
+
+    private inner class OnRefreshStateRunnable(
+        token: IBinder,
+        val list: List<Control>
+    ) : CallbackRunnable(token) {
+        override fun run() {
+            if (!refreshing.get()) {
+                Log.d(TAG, "onRefresh outside of window from:${provider?.componentName}")
+            }
+            provider?.let {
+                lazyController.get().refreshStatus(it.componentName, list)
+            }
+        }
+    }
+
+    private inner class OnActionResponseRunnable(
+        token: IBinder,
+        val controlId: String,
+        @ControlAction.ResponseResult val response: Int
+    ) : CallbackRunnable(token) {
+        override fun run() {
+            provider?.let {
+                lazyController.get().onActionResponse(it.componentName, controlId, response)
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsController.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsController.kt
new file mode 100644
index 0000000..4d95822
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsController.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.controller
+
+import android.content.ComponentName
+import android.service.controls.Control
+import android.service.controls.actions.ControlAction
+import com.android.systemui.controls.ControlStatus
+
+interface ControlsController {
+    val available: Boolean
+
+    fun getFavoriteControls(): List<ControlInfo>
+    fun loadForComponent(componentName: ComponentName, callback: (List<ControlStatus>) -> Unit)
+    fun subscribeToFavorites()
+    fun changeFavoriteStatus(controlInfo: ControlInfo, state: Boolean)
+    fun unsubscribe()
+    fun action(controlInfo: ControlInfo, action: ControlAction)
+    fun refreshStatus(componentName: ComponentName, controls: List<Control>)
+    fun onActionResponse(
+        componentName: ComponentName,
+        controlId: String,
+        @ControlAction.ResponseResult response: Int
+    )
+    fun clearFavorites()
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsControllerImpl.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsControllerImpl.kt
new file mode 100644
index 0000000..7e328e4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsControllerImpl.kt
@@ -0,0 +1,273 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.controls.controller
+
+import android.app.PendingIntent
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Environment
+import android.provider.Settings
+import android.service.controls.Control
+import android.service.controls.actions.ControlAction
+import android.util.ArrayMap
+import android.util.Log
+import com.android.internal.annotations.GuardedBy
+import com.android.systemui.DumpController
+import com.android.systemui.Dumpable
+import com.android.systemui.controls.ControlStatus
+import com.android.systemui.controls.management.ControlsFavoritingActivity
+import com.android.systemui.controls.ui.ControlsUiController
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.util.concurrency.DelayableExecutor
+import java.io.FileDescriptor
+import java.io.PrintWriter
+import java.util.Optional
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ControlsControllerImpl @Inject constructor (
+    private val context: Context,
+    @Background private val executor: DelayableExecutor,
+    private val uiController: ControlsUiController,
+    private val bindingController: ControlsBindingController,
+    private val optionalWrapper: Optional<ControlsFavoritePersistenceWrapper>,
+    dumpController: DumpController
+) : Dumpable, ControlsController {
+
+    companion object {
+        private const val TAG = "ControlsControllerImpl"
+        const val CONTROLS_AVAILABLE = "systemui.controls_available"
+    }
+
+    override val available = Settings.Secure.getInt(
+            context.contentResolver, CONTROLS_AVAILABLE, 0) != 0
+    val persistenceWrapper = optionalWrapper.orElseGet {
+        ControlsFavoritePersistenceWrapper(
+                Environment.buildPath(
+                        context.filesDir,
+                        ControlsFavoritePersistenceWrapper.FILE_NAME),
+                executor
+        )
+    }
+
+    // Map of map: ComponentName -> (String -> ControlInfo)
+    @GuardedBy("currentFavorites")
+    private val currentFavorites = ArrayMap<ComponentName, MutableMap<String, ControlInfo>>()
+
+    init {
+        if (available) {
+            dumpController.registerDumpable(this)
+            loadFavorites()
+        }
+    }
+
+    private fun loadFavorites() {
+        val infos = persistenceWrapper.readFavorites()
+        synchronized(currentFavorites) {
+            infos.forEach {
+                currentFavorites.getOrPut(it.component, { ArrayMap<String, ControlInfo>() })
+                        .put(it.controlId, it)
+            }
+        }
+    }
+
+    override fun loadForComponent(
+        componentName: ComponentName,
+        callback: (List<ControlStatus>) -> Unit
+    ) {
+        if (!available) {
+            Log.d(TAG, "Controls not available")
+            return
+        }
+        bindingController.bindAndLoad(componentName) {
+            synchronized(currentFavorites) {
+                val favoritesForComponentKeys: Set<String> =
+                        currentFavorites.get(componentName)?.keys ?: emptySet()
+                val changed = updateFavoritesLocked(componentName, it)
+                if (changed) {
+                    persistenceWrapper.storeFavorites(favoritesAsListLocked())
+                }
+                val removed = findRemovedLocked(favoritesForComponentKeys, it)
+                callback(removed.map { currentFavorites.getValue(componentName).getValue(it) }
+                            .map(::createRemovedStatus) +
+                        it.map { ControlStatus(it, it.controlId in favoritesForComponentKeys) })
+            }
+        }
+    }
+
+    private fun createRemovedStatus(controlInfo: ControlInfo): ControlStatus {
+        val intent = Intent(context, ControlsFavoritingActivity::class.java).apply {
+            putExtra(ControlsFavoritingActivity.EXTRA_COMPONENT, controlInfo.component)
+            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
+        }
+        val pendingIntent = PendingIntent.getActivity(context,
+                controlInfo.component.hashCode(),
+                intent,
+                0)
+        val control = Control.StatelessBuilder(controlInfo.controlId, pendingIntent)
+                .setTitle(controlInfo.controlTitle)
+                .setDeviceType(controlInfo.deviceType)
+                .build()
+        return ControlStatus(control, true, true)
+    }
+
+    @GuardedBy("currentFavorites")
+    private fun findRemovedLocked(favoriteKeys: Set<String>, list: List<Control>): Set<String> {
+        val controlsKeys = list.map { it.controlId }
+        return favoriteKeys.minus(controlsKeys)
+    }
+
+    @GuardedBy("currentFavorites")
+    private fun updateFavoritesLocked(componentName: ComponentName, list: List<Control>): Boolean {
+        val favorites = currentFavorites.get(componentName) ?: mutableMapOf()
+        val favoriteKeys = favorites.keys
+        if (favoriteKeys.isEmpty()) return false // early return
+        var changed = false
+        list.forEach {
+            if (it.controlId in favoriteKeys) {
+                val value = favorites.getValue(it.controlId)
+                if (value.controlTitle != it.title || value.deviceType != it.deviceType) {
+                    favorites[it.controlId] = value.copy(controlTitle = it.title,
+                            deviceType = it.deviceType)
+                    changed = true
+                }
+            }
+        }
+        return changed
+    }
+
+    @GuardedBy("currentFavorites")
+    private fun favoritesAsListLocked(): List<ControlInfo> {
+        return currentFavorites.flatMap { it.value.values }
+    }
+
+    override fun subscribeToFavorites() {
+        if (!available) {
+            Log.d(TAG, "Controls not available")
+            return
+        }
+        // Make a copy of the favorites list
+        val favorites = synchronized(currentFavorites) {
+            currentFavorites.flatMap { it.value.values.toList() }
+        }
+        bindingController.subscribe(favorites)
+    }
+
+    override fun unsubscribe() {
+        if (!available) {
+            Log.d(TAG, "Controls not available")
+            return
+        }
+        bindingController.unsubscribe()
+    }
+
+    override fun changeFavoriteStatus(controlInfo: ControlInfo, state: Boolean) {
+        if (!available) {
+            Log.d(TAG, "Controls not available")
+            return
+        }
+        var changed = false
+        val listOfControls = synchronized(currentFavorites) {
+            if (state) {
+                if (controlInfo.component !in currentFavorites) {
+                    currentFavorites.put(controlInfo.component, ArrayMap<String, ControlInfo>())
+                    changed = true
+                }
+                val controlsForComponent = currentFavorites.getValue(controlInfo.component)
+                if (controlInfo.controlId !in controlsForComponent) {
+                    controlsForComponent.put(controlInfo.controlId, controlInfo)
+                    changed = true
+                } else {
+                    if (controlsForComponent.getValue(controlInfo.controlId) != controlInfo) {
+                        controlsForComponent.put(controlInfo.controlId, controlInfo)
+                        changed = true
+                    }
+                }
+            } else {
+                changed = currentFavorites.get(controlInfo.component)
+                        ?.remove(controlInfo.controlId) != null
+            }
+            favoritesAsListLocked()
+        }
+        if (changed) {
+            persistenceWrapper.storeFavorites(listOfControls)
+        }
+    }
+
+    override fun refreshStatus(componentName: ComponentName, controls: List<Control>) {
+        if (!available) {
+            Log.d(TAG, "Controls not available")
+            return
+        }
+        executor.execute {
+            synchronized(currentFavorites) {
+                val changed = updateFavoritesLocked(componentName, controls)
+                if (changed) {
+                    persistenceWrapper.storeFavorites(favoritesAsListLocked())
+                }
+            }
+        }
+        uiController.onRefreshState(componentName, controls)
+    }
+
+    override fun onActionResponse(componentName: ComponentName, controlId: String, response: Int) {
+        if (!available) {
+            Log.d(TAG, "Controls not available")
+            return
+        }
+        uiController.onActionResponse(componentName, controlId, response)
+    }
+
+    override fun getFavoriteControls(): List<ControlInfo> {
+        if (!available) {
+            Log.d(TAG, "Controls not available")
+            return emptyList()
+        }
+        synchronized(currentFavorites) {
+            return favoritesAsListLocked()
+        }
+    }
+
+    override fun action(controlInfo: ControlInfo, action: ControlAction) {
+        bindingController.action(controlInfo, action)
+    }
+
+    override fun clearFavorites() {
+        val changed = synchronized(currentFavorites) {
+            currentFavorites.isNotEmpty().also {
+                currentFavorites.clear()
+            }
+        }
+        if (changed) {
+            persistenceWrapper.storeFavorites(emptyList())
+        }
+    }
+
+    override fun dump(fd: FileDescriptor, pw: PrintWriter, args: Array<out String>) {
+        pw.println("ControlsController state:")
+        pw.println("  Favorites:")
+        synchronized(currentFavorites) {
+            currentFavorites.forEach {
+                it.value.forEach {
+                    pw.println("    ${it.value}")
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsFavoritePersistenceWrapper.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsFavoritePersistenceWrapper.kt
new file mode 100644
index 0000000..6f2d71f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsFavoritePersistenceWrapper.kt
@@ -0,0 +1,134 @@
+/*
+ * 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.controller
+
+import android.app.ActivityManager
+import android.content.ComponentName
+import android.util.AtomicFile
+import android.util.Log
+import android.util.Slog
+import android.util.Xml
+import com.android.systemui.util.concurrency.DelayableExecutor
+import libcore.io.IoUtils
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserException
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.IOException
+
+class ControlsFavoritePersistenceWrapper(
+    val file: File,
+    val executor: DelayableExecutor
+) {
+
+    companion object {
+        private const val TAG = "ControlsFavoritePersistenceWrapper"
+        const val FILE_NAME = "controls_favorites.xml"
+        private const val TAG_CONTROLS = "controls"
+        private const val TAG_CONTROL = "control"
+        private const val TAG_COMPONENT = "component"
+        private const val TAG_ID = "id"
+        private const val TAG_TITLE = "title"
+        private const val TAG_TYPE = "type"
+    }
+
+    val currentUser: Int
+        get() = ActivityManager.getCurrentUser()
+
+    fun storeFavorites(list: List<ControlInfo>) {
+        executor.execute {
+            val atomicFile = AtomicFile(file)
+            val writer = try {
+                atomicFile.startWrite()
+            } catch (e: IOException) {
+                Log.e(TAG, "Failed to start write file", e)
+                return@execute
+            }
+            try {
+                Xml.newSerializer().apply {
+                    setOutput(writer, "utf-8")
+                    setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true)
+                    startDocument(null, true)
+                    startTag(null, TAG_CONTROLS)
+                    list.forEach {
+                        startTag(null, TAG_CONTROL)
+                        attribute(null, TAG_COMPONENT, it.component.flattenToString())
+                        attribute(null, TAG_ID, it.controlId)
+                        attribute(null, TAG_TITLE, it.controlTitle.toString())
+                        attribute(null, TAG_TYPE, it.deviceType.toString())
+                        endTag(null, TAG_CONTROL)
+                    }
+                    endTag(null, TAG_CONTROLS)
+                    endDocument()
+                    atomicFile.finishWrite(writer)
+                }
+            } catch (t: Throwable) {
+                Log.e(TAG, "Failed to write file, reverting to previous version")
+                atomicFile.failWrite(writer)
+            } finally {
+                IoUtils.closeQuietly(writer)
+            }
+        }
+    }
+
+    fun readFavorites(): List<ControlInfo> {
+        if (!file.exists()) {
+            Log.d(TAG, "No favorites, returning empty list")
+            return emptyList()
+        }
+        val reader = try {
+            FileInputStream(file)
+        } catch (fnfe: FileNotFoundException) {
+            Slog.i(TAG, "No file found")
+            return emptyList()
+        }
+        try {
+            val parser = Xml.newPullParser()
+            parser.setInput(reader, null)
+            return parseXml(parser)
+        } catch (e: XmlPullParserException) {
+            throw IllegalStateException("Failed parsing favorites file: $file", e)
+        } catch (e: IOException) {
+            throw IllegalStateException("Failed parsing favorites file: $file", e)
+        } finally {
+            IoUtils.closeQuietly(reader)
+        }
+    }
+
+    private fun parseXml(parser: XmlPullParser): List<ControlInfo> {
+        var type: Int = 0
+        val infos = mutableListOf<ControlInfo>()
+        while (parser.next().also { type = it } != XmlPullParser.END_DOCUMENT) {
+            if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) {
+                continue
+            }
+            val tagName = parser.name
+            if (tagName == TAG_CONTROL) {
+                val component = ComponentName.unflattenFromString(
+                        parser.getAttributeValue(null, TAG_COMPONENT))
+                val id = parser.getAttributeValue(null, TAG_ID)
+                val title = parser.getAttributeValue(null, TAG_TITLE)
+                val type = parser.getAttributeValue(null, TAG_TYPE)?.toInt()
+                if (component != null && id != null && title != null && type != null) {
+                    infos.add(ControlInfo(component, id, title, type))
+                }
+            }
+        }
+        return infos
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsProviderLifecycleManager.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsProviderLifecycleManager.kt
new file mode 100644
index 0000000..79057ad
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsProviderLifecycleManager.kt
@@ -0,0 +1,283 @@
+/*
+ * 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.controller
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Binder
+import android.os.Bundle
+import android.os.IBinder
+import android.os.RemoteException
+import android.service.controls.Control
+import android.service.controls.ControlsProviderService.CALLBACK_BINDER
+import android.service.controls.ControlsProviderService.CALLBACK_BUNDLE
+import android.service.controls.ControlsProviderService.CALLBACK_TOKEN
+import android.service.controls.IControlsProvider
+import android.service.controls.IControlsProviderCallback
+import android.service.controls.actions.ControlAction
+import android.util.ArraySet
+import android.util.Log
+import com.android.internal.annotations.GuardedBy
+import com.android.systemui.util.concurrency.DelayableExecutor
+import java.util.concurrent.TimeUnit
+
+typealias LoadCallback = (List<Control>) -> Unit
+class ControlsProviderLifecycleManager(
+    private val context: Context,
+    private val executor: DelayableExecutor,
+    private val serviceCallback: IControlsProviderCallback.Stub,
+    val componentName: ComponentName
+) : IBinder.DeathRecipient {
+
+    var lastLoadCallback: LoadCallback? = null
+        private set
+    val token: IBinder = Binder()
+    private var unbindImmediate = false
+    private var requiresBound = false
+    private var isBound = false
+    @GuardedBy("queuedMessages")
+    private val queuedMessages: MutableSet<Message> = ArraySet()
+    private var wrapper: ControlsProviderServiceWrapper? = null
+    private var bindTryCount = 0
+    private val TAG = javaClass.simpleName
+    private var onLoadCanceller: Runnable? = null
+
+    companion object {
+        private const val MSG_LOAD = 0
+        private const val MSG_SUBSCRIBE = 1
+        private const val MSG_UNSUBSCRIBE = 2
+        private const val MSG_ON_ACTION = 3
+        private const val MSG_UNBIND = 4
+        private const val BIND_RETRY_DELAY = 1000L // ms
+        private const val LOAD_TIMEOUT = 5000L // ms
+        private const val MAX_BIND_RETRIES = 5
+        private const val DEBUG = true
+        private val BIND_FLAGS = Context.BIND_AUTO_CREATE or Context.BIND_FOREGROUND_SERVICE or
+                Context.BIND_WAIVE_PRIORITY
+    }
+
+    private val intent = Intent().apply {
+        component = componentName
+        putExtra(CALLBACK_BUNDLE, Bundle().apply {
+            putBinder(CALLBACK_BINDER, serviceCallback)
+            putBinder(CALLBACK_TOKEN, token)
+        })
+    }
+
+    private fun bindService(bind: Boolean) {
+        requiresBound = bind
+        if (bind) {
+            if (bindTryCount == MAX_BIND_RETRIES) {
+                return
+            }
+            if (DEBUG) {
+                Log.d(TAG, "Binding service $intent")
+            }
+            bindTryCount++
+            try {
+                isBound = context.bindService(intent, serviceConnection, BIND_FLAGS)
+            } catch (e: SecurityException) {
+                Log.e(TAG, "Failed to bind to service", e)
+                isBound = false
+            }
+        } else {
+            if (DEBUG) {
+                Log.d(TAG, "Unbinding service $intent")
+            }
+            bindTryCount = 0
+            wrapper = null
+            if (isBound) {
+                context.unbindService(serviceConnection)
+                isBound = false
+            }
+        }
+    }
+
+    fun bindPermanently() {
+        unbindImmediate = false
+        unqueueMessage(Message.Unbind)
+        bindService(true)
+    }
+
+    private val serviceConnection = object : ServiceConnection {
+        override fun onServiceConnected(name: ComponentName, service: IBinder) {
+            if (DEBUG) Log.d(TAG, "onServiceConnected $name")
+            bindTryCount = 0
+            wrapper = ControlsProviderServiceWrapper(IControlsProvider.Stub.asInterface(service))
+            try {
+                service.linkToDeath(this@ControlsProviderLifecycleManager, 0)
+            } catch (_: RemoteException) {}
+            handlePendingMessages()
+        }
+
+        override fun onServiceDisconnected(name: ComponentName?) {
+            if (DEBUG) Log.d(TAG, "onServiceDisconnected $name")
+            isBound = false
+            bindService(false)
+        }
+    }
+
+    private fun handlePendingMessages() {
+        val queue = synchronized(queuedMessages) {
+            ArraySet(queuedMessages).also {
+                queuedMessages.clear()
+            }
+        }
+        if (Message.Unbind in queue) {
+            bindService(false)
+            return
+        }
+        if (Message.Load in queue) {
+            load()
+        }
+        queue.filter { it is Message.Subscribe }.flatMap { (it as Message.Subscribe).list }.run {
+            subscribe(this)
+        }
+        queue.filter { it is Message.Action }.forEach {
+            val msg = it as Message.Action
+            onAction(msg.id, msg.action)
+        }
+    }
+
+    override fun binderDied() {
+        if (wrapper == null) return
+        wrapper = null
+        if (requiresBound) {
+            if (DEBUG) {
+                Log.d(TAG, "binderDied")
+            }
+            // Try rebinding some time later
+        }
+    }
+
+    private fun queueMessage(message: Message) {
+        synchronized(queuedMessages) {
+            queuedMessages.add(message)
+        }
+    }
+
+    private fun unqueueMessage(message: Message) {
+        synchronized(queuedMessages) {
+            queuedMessages.removeIf { it.type == message.type }
+        }
+    }
+
+    private fun load() {
+        if (DEBUG) {
+            Log.d(TAG, "load $componentName")
+        }
+        if (!(wrapper?.load() ?: false)) {
+            queueMessage(Message.Load)
+            binderDied()
+        }
+    }
+
+    fun maybeBindAndLoad(callback: LoadCallback) {
+        unqueueMessage(Message.Unbind)
+        lastLoadCallback = callback
+        onLoadCanceller = executor.executeDelayed({
+            // Didn't receive a response in time, log and send back empty list
+            Log.d(TAG, "Timeout waiting onLoad for $componentName")
+            serviceCallback.onLoad(token, emptyList())
+        }, LOAD_TIMEOUT, TimeUnit.MILLISECONDS)
+        if (isBound) {
+            load()
+        } else {
+            queueMessage(Message.Load)
+            unbindImmediate = true
+            bindService(true)
+        }
+    }
+
+    fun maybeBindAndSubscribe(controlIds: List<String>) {
+        if (isBound) {
+            subscribe(controlIds)
+        } else {
+            queueMessage(Message.Subscribe(controlIds))
+            bindService(true)
+        }
+    }
+
+    private fun subscribe(controlIds: List<String>) {
+        if (DEBUG) {
+            Log.d(TAG, "subscribe $componentName - $controlIds")
+        }
+        if (!(wrapper?.subscribe(controlIds) ?: false)) {
+            queueMessage(Message.Subscribe(controlIds))
+            binderDied()
+        }
+    }
+
+    fun maybeBindAndSendAction(controlId: String, action: ControlAction) {
+        if (isBound) {
+            onAction(controlId, action)
+        } else {
+            queueMessage(Message.Action(controlId, action))
+            bindService(true)
+        }
+    }
+
+    private fun onAction(controlId: String, action: ControlAction) {
+        if (DEBUG) {
+            Log.d(TAG, "onAction $componentName - $controlId")
+        }
+        if (!(wrapper?.onAction(controlId, action) ?: false)) {
+            queueMessage(Message.Action(controlId, action))
+            binderDied()
+        }
+    }
+
+    fun unsubscribe() {
+        if (DEBUG) {
+            Log.d(TAG, "unsubscribe $componentName")
+        }
+        unqueueMessage(Message.Subscribe(emptyList())) // Removes all subscribe messages
+        wrapper?.unsubscribe()
+    }
+
+    fun maybeUnbindAndRemoveCallback() {
+        lastLoadCallback = null
+        onLoadCanceller?.run()
+        onLoadCanceller = null
+        if (unbindImmediate) {
+            bindService(false)
+        }
+    }
+
+    fun unbindService() {
+        unbindImmediate = true
+        maybeUnbindAndRemoveCallback()
+    }
+
+    sealed class Message {
+        abstract val type: Int
+        object Load : Message() {
+            override val type = MSG_LOAD
+        }
+        object Unbind : Message() {
+            override val type = MSG_UNBIND
+        }
+        class Subscribe(val list: List<String>) : Message() {
+            override val type = MSG_SUBSCRIBE
+        }
+        class Action(val id: String, val action: ControlAction) : Message() {
+            override val type = MSG_ON_ACTION
+        }
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsProviderServiceWrapper.kt b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsProviderServiceWrapper.kt
new file mode 100644
index 0000000..882a10d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/controller/ControlsProviderServiceWrapper.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.controls.controller
+
+import android.service.controls.actions.ControlAction
+import android.service.controls.IControlsProvider
+import android.util.Log
+
+class ControlsProviderServiceWrapper(val service: IControlsProvider) {
+    companion object {
+        private const val TAG = "ControlsProviderServiceWrapper"
+    }
+
+    private fun callThroughService(block: () -> Unit): Boolean {
+        try {
+            block()
+            return true
+        } catch (ex: Exception) {
+            Log.d(TAG, "Caught exception from ControlsProviderService", ex)
+            return false
+        }
+    }
+
+    fun load(): Boolean {
+        return callThroughService {
+            service.load()
+        }
+    }
+
+    fun subscribe(controlIds: List<String>): Boolean {
+        return callThroughService {
+            service.subscribe(controlIds)
+        }
+    }
+
+    fun unsubscribe(): Boolean {
+        return callThroughService {
+            service.unsubscribe()
+        }
+    }
+
+    fun onAction(controlId: String, action: ControlAction): Boolean {
+        return callThroughService {
+            service.onAction(controlId, action)
+        }
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/dagger/ControlsModule.kt b/packages/SystemUI/src/com/android/systemui/controls/dagger/ControlsModule.kt
new file mode 100644
index 0000000..859311e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/dagger/ControlsModule.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.controls.dagger
+
+import android.app.Activity
+import com.android.systemui.controls.controller.ControlsBindingController
+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.ControlsFavoritingActivity
+import com.android.systemui.controls.management.ControlsListingController
+import com.android.systemui.controls.management.ControlsListingControllerImpl
+import com.android.systemui.controls.management.ControlsProviderSelectorActivity
+import com.android.systemui.controls.ui.ControlsUiController
+import com.android.systemui.controls.ui.ControlsUiControllerImpl
+import dagger.Binds
+import dagger.BindsOptionalOf
+import dagger.Module
+import dagger.multibindings.ClassKey
+import dagger.multibindings.IntoMap
+
+@Module
+abstract class ControlsModule {
+
+    @Binds
+    abstract fun provideControlsListingController(
+        controller: ControlsListingControllerImpl
+    ): ControlsListingController
+
+    @Binds
+    abstract fun provideControlsController(controller: ControlsControllerImpl): ControlsController
+
+    @Binds
+    abstract fun provideControlsBindingController(
+        controller: ControlsBindingControllerImpl
+    ): ControlsBindingController
+
+    @Binds
+    abstract fun provideUiController(controller: ControlsUiControllerImpl): ControlsUiController
+
+    @BindsOptionalOf
+    abstract fun optionalPersistenceWrapper(): ControlsFavoritePersistenceWrapper
+
+    @Binds
+    @IntoMap
+    @ClassKey(ControlsProviderSelectorActivity::class)
+    abstract fun provideControlsProviderActivity(
+        activity: ControlsProviderSelectorActivity
+    ): Activity
+
+    @Binds
+    @IntoMap
+    @ClassKey(ControlsFavoritingActivity::class)
+    abstract fun provideControlsFavoritingActivity(
+        activity: ControlsFavoritingActivity
+    ): Activity
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/AppAdapter.kt b/packages/SystemUI/src/com/android/systemui/controls/management/AppAdapter.kt
new file mode 100644
index 0000000..d62bb4d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/management/AppAdapter.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.controls.management
+
+import android.content.ComponentName
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.lifecycle.Lifecycle
+import androidx.recyclerview.widget.RecyclerView
+import com.android.settingslib.widget.CandidateInfo
+import com.android.systemui.R
+import java.util.concurrent.Executor
+
+/**
+ * Adapter for binding [CandidateInfo] related to [ControlsProviderService].
+ *
+ * This class handles subscribing and keeping track of the list of valid applications for
+ * displaying.
+ *
+ * @param uiExecutor an executor on the view thread of the containing [RecyclerView]
+ * @param lifecycle the lifecycle of the containing [LifecycleOwner] to control listening status
+ * @param controlsListingController the controller to keep track of valid applications
+ * @param layoutInflater an inflater for the views in the containing [RecyclerView]
+ * @param onAppSelected a callback to indicate that an app has been selected in the list.
+ */
+class AppAdapter(
+    uiExecutor: Executor,
+    lifecycle: Lifecycle,
+    controlsListingController: ControlsListingController,
+    private val layoutInflater: LayoutInflater,
+    private val onAppSelected: (ComponentName?) -> Unit = {}
+) : RecyclerView.Adapter<AppAdapter.Holder>() {
+
+    private var listOfServices = emptyList<CandidateInfo>()
+
+    private val callback = object : ControlsListingController.ControlsListingCallback {
+        override fun onServicesUpdated(list: List<CandidateInfo>) {
+            uiExecutor.execute {
+                listOfServices = list
+                notifyDataSetChanged()
+            }
+        }
+    }
+
+    init {
+        controlsListingController.observe(lifecycle, callback)
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, i: Int): Holder {
+        return Holder(layoutInflater.inflate(R.layout.app_item, parent, false))
+    }
+
+    override fun getItemCount() = listOfServices.size
+
+    override fun onBindViewHolder(holder: Holder, index: Int) {
+        holder.bindData(listOfServices[index])
+        holder.itemView.setOnClickListener {
+            onAppSelected(ComponentName.unflattenFromString(listOfServices[index].key))
+        }
+    }
+
+    /**
+     * Holder for binding views in the [RecyclerView]-
+     */
+    class Holder(view: View) : RecyclerView.ViewHolder(view) {
+        private val icon: ImageView = itemView.requireViewById(com.android.internal.R.id.icon)
+        private val title: TextView = itemView.requireViewById(com.android.internal.R.id.title)
+
+        /**
+         * Bind data to the view
+         * @param data Information about the [ControlsProviderService] to bind to the data
+         */
+        fun bindData(data: CandidateInfo) {
+            icon.setImageDrawable(data.loadIcon())
+            title.text = data.loadLabel()
+        }
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/ControlAdapter.kt b/packages/SystemUI/src/com/android/systemui/controls/management/ControlAdapter.kt
new file mode 100644
index 0000000..e6d3c26
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/management/ControlAdapter.kt
@@ -0,0 +1,89 @@
+/*
+ * 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.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.CheckBox
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.android.systemui.R
+import com.android.systemui.controls.ControlStatus
+import com.android.systemui.controls.controller.ControlInfo
+
+/**
+ * Adapter for binding [Control] information to views.
+ *
+ * @param layoutInflater an inflater for the views in the containing [RecyclerView]
+ * @param favoriteCallback a callback to be called when the favorite status of a [Control] is
+ *                         changed. The callback will take a [ControlInfo.Builder] that's
+ *                         pre-populated with the [Control] information and the new favorite
+ *                         status.
+ */
+class ControlAdapter(
+    private val layoutInflater: LayoutInflater,
+    private val favoriteCallback: (ControlInfo.Builder, Boolean) -> Unit
+) : RecyclerView.Adapter<ControlAdapter.Holder>() {
+
+    var listOfControls = emptyList<ControlStatus>()
+
+    override fun onCreateViewHolder(parent: ViewGroup, i: Int): Holder {
+        return Holder(layoutInflater.inflate(R.layout.control_item, parent, false))
+    }
+
+    override fun getItemCount() = listOfControls.size
+
+    override fun onBindViewHolder(holder: Holder, index: Int) {
+        holder.bindData(listOfControls[index], favoriteCallback)
+    }
+
+    /**
+     * Holder for binding views in the [RecyclerView]-
+     */
+    class Holder(view: View) : RecyclerView.ViewHolder(view) {
+        private val title: TextView = itemView.requireViewById(R.id.title)
+        private val subtitle: TextView = itemView.requireViewById(R.id.subtitle)
+        private val favorite: CheckBox = itemView.requireViewById(R.id.favorite)
+
+        /**
+         * Bind data to the view
+         * @param data information about the [Control]
+         * @param callback a callback to be called when the favorite status of the [Control] is
+         *                 changed. The callback will take a [ControlInfo.Builder] that's
+         *                 pre-populated with the [Control] information and the new favorite status.
+         */
+        fun bindData(data: ControlStatus, callback: (ControlInfo.Builder, Boolean) -> Unit) {
+            title.text = data.control.title
+            subtitle.text = data.control.subtitle
+            favorite.isChecked = data.favorite
+            favorite.setOnClickListener {
+                val infoBuilder = ControlInfo.Builder().apply {
+                    controlId = data.control.controlId
+                    controlTitle = data.control.title
+                    deviceType = data.control.deviceType
+                }
+                callback(infoBuilder, favorite.isChecked)
+            }
+        }
+    }
+
+    fun setItems(list: List<ControlStatus>) {
+        listOfControls = list
+        notifyDataSetChanged()
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsFavoritingActivity.kt b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsFavoritingActivity.kt
new file mode 100644
index 0000000..01c4fef
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsFavoritingActivity.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.os.Bundle
+import android.view.LayoutInflater
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.systemui.controls.controller.ControlInfo
+import com.android.systemui.controls.controller.ControlsControllerImpl
+import com.android.systemui.dagger.qualifiers.Main
+import java.util.concurrent.Executor
+import javax.inject.Inject
+
+class ControlsFavoritingActivity @Inject constructor(
+    @Main private val executor: Executor,
+    private val controller: ControlsControllerImpl
+) : Activity() {
+
+    companion object {
+        private const val TAG = "ControlsFavoritingActivity"
+        const val EXTRA_APP = "extra_app_label"
+        const val EXTRA_COMPONENT = "extra_component"
+    }
+
+    private lateinit var recyclerView: RecyclerView
+    private lateinit var adapter: ControlAdapter
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        val app = intent.getCharSequenceExtra(EXTRA_APP)
+        val component = intent.getParcelableExtra<ComponentName>(EXTRA_COMPONENT)
+
+        // If we have no component name, there's not much we can do.
+        val callback = component?.let {
+            { infoBuilder: ControlInfo.Builder, status: Boolean ->
+                infoBuilder.componentName = it
+                controller.changeFavoriteStatus(infoBuilder.build(), status)
+            }
+        } ?: { _, _ -> Unit }
+
+        recyclerView = RecyclerView(applicationContext)
+        adapter = ControlAdapter(LayoutInflater.from(applicationContext), callback)
+        recyclerView.adapter = adapter
+        recyclerView.layoutManager = LinearLayoutManager(applicationContext)
+
+        if (app != null) {
+            setTitle("Controls for $app")
+        } else {
+            setTitle("Controls")
+        }
+        setContentView(recyclerView)
+
+        component?.let {
+            controller.loadForComponent(it) {
+                executor.execute {
+                    adapter.setItems(it)
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsListingController.kt b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsListingController.kt
new file mode 100644
index 0000000..09e0ce9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsListingController.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.controls.management
+
+import android.content.ComponentName
+import com.android.settingslib.widget.CandidateInfo
+import com.android.systemui.statusbar.policy.CallbackController
+
+interface ControlsListingController :
+        CallbackController<ControlsListingController.ControlsListingCallback> {
+
+    fun getCurrentServices(): List<CandidateInfo>
+    fun getAppLabel(name: ComponentName): CharSequence? = ""
+
+    interface ControlsListingCallback {
+        fun onServicesUpdated(list: List<CandidateInfo>)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsListingControllerImpl.kt b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsListingControllerImpl.kt
new file mode 100644
index 0000000..9372162
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsListingControllerImpl.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.controls.management
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.ServiceInfo
+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
+import com.android.systemui.dagger.qualifiers.Background
+import java.util.concurrent.Executor
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Provides a listing of components to be used as ControlsServiceProvider.
+ *
+ * This controller keeps track of components that satisfy:
+ *
+ * * Has an intent-filter responding to [ControlsProviderService.CONTROLS_ACTION]
+ * * Has the bind permission `android.permission.BIND_CONTROLS`
+ */
+@Singleton
+class ControlsListingControllerImpl @VisibleForTesting constructor(
+    private val context: Context,
+    @Background private val backgroundExecutor: Executor,
+    private val serviceListing: ServiceListing
+) : ControlsListingController {
+
+    @Inject
+    constructor(context: Context, executor: Executor): this(
+            context,
+            executor,
+            ServiceListing.Builder(context)
+                    .setIntentAction(ControlsProviderService.CONTROLS_ACTION)
+                    .setPermission("android.permission.BIND_CONTROLS")
+                    .setNoun("Controls Provider")
+                    .setSetting("controls_providers")
+                    .setTag("controls_providers")
+                    .build()
+    )
+
+    companion object {
+        private const val TAG = "ControlsListingControllerImpl"
+    }
+
+    private var availableServices = emptyList<ServiceInfo>()
+
+    init {
+        serviceListing.addCallback {
+            Log.d(TAG, "ServiceConfig reloaded")
+            availableServices = it.toList()
+
+            backgroundExecutor.execute {
+                callbacks.forEach {
+                    it.onServicesUpdated(getCurrentServices())
+                }
+            }
+        }
+    }
+
+    // All operations in background thread
+    private val callbacks = mutableSetOf<ControlsListingController.ControlsListingCallback>()
+
+    /**
+     * Adds a callback to this controller.
+     *
+     * The callback will be notified after it is added as well as any time that the valid
+     * components change.
+     *
+     * @param listener a callback to be notified
+     */
+    override fun addCallback(listener: ControlsListingController.ControlsListingCallback) {
+        backgroundExecutor.execute {
+            callbacks.add(listener)
+            if (callbacks.size == 1) {
+                serviceListing.setListening(true)
+                serviceListing.reload()
+            } else {
+                listener.onServicesUpdated(getCurrentServices())
+            }
+        }
+    }
+
+    /**
+     * Removes a callback from this controller.
+     *
+     * @param listener the callback to be removed.
+     */
+    override fun removeCallback(listener: ControlsListingController.ControlsListingCallback) {
+        backgroundExecutor.execute {
+            callbacks.remove(listener)
+            if (callbacks.size == 0) {
+                serviceListing.setListening(false)
+            }
+        }
+    }
+
+    /**
+     * @return a list of components that satisfy the requirements to be a
+     *         [ControlsProviderService]
+     */
+    override fun getCurrentServices(): List<CandidateInfo> =
+            availableServices.map { ControlsServiceInfo(context, it) }
+
+    /**
+     * Get the localized label for the component.
+     *
+     * @param name the name of the component
+     * @return a label as returned by [CandidateInfo.loadLabel] or `null`.
+     */
+    override fun getAppLabel(name: ComponentName): CharSequence? {
+        return getCurrentServices().firstOrNull { (it as? DefaultAppInfo)?.componentName == name }
+                ?.loadLabel()
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsProviderSelectorActivity.kt b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsProviderSelectorActivity.kt
new file mode 100644
index 0000000..69af516
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsProviderSelectorActivity.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.controls.management
+
+import android.content.ComponentName
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.util.LifecycleActivity
+import java.util.concurrent.Executor
+import javax.inject.Inject
+
+/**
+ * Activity to select an application to favorite the [Control] provided by them.
+ */
+class ControlsProviderSelectorActivity @Inject constructor(
+    @Main private val executor: Executor,
+    private val listingController: ControlsListingController
+) : LifecycleActivity() {
+
+    companion object {
+        private const val TAG = "ControlsProviderSelectorActivity"
+    }
+
+    private lateinit var recyclerView: RecyclerView
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        recyclerView = RecyclerView(applicationContext)
+        recyclerView.adapter = AppAdapter(executor, lifecycle, listingController,
+                LayoutInflater.from(this), ::launchFavoritingActivity)
+        recyclerView.layoutManager = LinearLayoutManager(applicationContext)
+
+        setContentView(recyclerView)
+    }
+
+    /**
+     * Launch the [ControlsFavoritingActivity] for the specified component.
+     * @param component a component name for a [ControlsProviderService]
+     */
+    fun launchFavoritingActivity(component: ComponentName?) {
+        component?.let {
+            val intent = Intent(applicationContext, ControlsFavoritingActivity::class.java).apply {
+                putExtra(ControlsFavoritingActivity.EXTRA_APP, listingController.getAppLabel(it))
+                putExtra(ControlsFavoritingActivity.EXTRA_COMPONENT, it)
+                flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
+            }
+            startActivity(intent)
+        }
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsUiController.kt b/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsUiController.kt
new file mode 100644
index 0000000..0270c2b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsUiController.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.ui
+
+import android.content.ComponentName
+import android.service.controls.Control
+import android.service.controls.actions.ControlAction
+
+interface ControlsUiController {
+    fun onRefreshState(componentName: ComponentName, controls: List<Control>)
+    fun onActionResponse(
+        componentName: ComponentName,
+        controlId: String,
+        @ControlAction.ResponseResult response: Int
+    )
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsUiControllerImpl.kt b/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsUiControllerImpl.kt
new file mode 100644
index 0000000..0ace126
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsUiControllerImpl.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.ui
+
+import android.content.ComponentName
+import android.service.controls.Control
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ControlsUiControllerImpl @Inject constructor() : ControlsUiController {
+
+    override fun onRefreshState(componentName: ComponentName, controls: List<Control>) {
+        TODO("not implemented")
+    }
+
+    override fun onActionResponse(componentName: ComponentName, controlId: String, response: Int) {
+        TODO("not implemented")
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/dagger/DependencyBinder.java b/packages/SystemUI/src/com/android/systemui/dagger/DependencyBinder.java
index 6744d74..4ddc1d6 100644
--- a/packages/SystemUI/src/com/android/systemui/dagger/DependencyBinder.java
+++ b/packages/SystemUI/src/com/android/systemui/dagger/DependencyBinder.java
@@ -20,6 +20,7 @@
 import com.android.systemui.appops.AppOpsController;
 import com.android.systemui.appops.AppOpsControllerImpl;
 import com.android.systemui.classifier.FalsingManagerProxy;
+import com.android.systemui.controls.dagger.ControlsModule;
 import com.android.systemui.doze.DozeHost;
 import com.android.systemui.plugins.ActivityStarter;
 import com.android.systemui.plugins.DarkIconDispatcher;
@@ -82,7 +83,7 @@
 /**
  * Maps interfaces to implementations for use with Dagger.
  */
-@Module
+@Module(includes = {ControlsModule.class})
 public abstract class DependencyBinder {
 
     /**
diff --git a/packages/SystemUI/src/com/android/systemui/util/LifecycleActivity.kt b/packages/SystemUI/src/com/android/systemui/util/LifecycleActivity.kt
new file mode 100644
index 0000000..e4b7a20
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/util/LifecycleActivity.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.util
+
+import android.app.Activity
+import android.os.Bundle
+import android.os.PersistableBundle
+import androidx.lifecycle.LifecycleOwner
+import com.android.settingslib.core.lifecycle.Lifecycle
+
+open class LifecycleActivity : Activity(), LifecycleOwner {
+
+    private val lifecycle = Lifecycle(this)
+
+    override fun getLifecycle() = lifecycle
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        lifecycle.onAttach(this)
+        lifecycle.onCreate(savedInstanceState)
+        lifecycle.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_CREATE)
+        super.onCreate(savedInstanceState)
+    }
+
+    override fun onCreate(
+        savedInstanceState: Bundle?,
+        persistentState: PersistableBundle?
+    ) {
+        lifecycle.onAttach(this)
+        lifecycle.onCreate(savedInstanceState)
+        lifecycle.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_CREATE)
+        super.onCreate(savedInstanceState, persistentState)
+    }
+
+    override fun onStart() {
+        lifecycle.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_START)
+        super.onStart()
+    }
+
+    override fun onResume() {
+        lifecycle.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_RESUME)
+        super.onResume()
+    }
+
+    override fun onPause() {
+        lifecycle.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_PAUSE)
+        super.onPause()
+    }
+
+    override fun onStop() {
+        lifecycle.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_STOP)
+        super.onStop()
+    }
+
+    override fun onDestroy() {
+        lifecycle.handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_DESTROY)
+        super.onDestroy()
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsBindingControllerImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsBindingControllerImplTest.kt
new file mode 100644
index 0000000..7c8c7c8
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsBindingControllerImplTest.kt
@@ -0,0 +1,169 @@
+/*
+ * 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.controller
+
+import android.content.ComponentName
+import android.content.Context
+import android.os.Binder
+import android.service.controls.Control
+import android.service.controls.DeviceTypes
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.util.concurrency.DelayableExecutor
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.time.FakeSystemClock
+import dagger.Lazy
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class ControlsBindingControllerTest : SysuiTestCase() {
+
+    companion object {
+        fun <T> any(): T = Mockito.any<T>()
+        private val TEST_COMPONENT_NAME_1 = ComponentName("TEST_PKG", "TEST_CLS_1")
+        private val TEST_COMPONENT_NAME_2 = ComponentName("TEST_PKG", "TEST_CLS_2")
+        private val TEST_COMPONENT_NAME_3 = ComponentName("TEST_PKG", "TEST_CLS_3")
+    }
+
+    @Mock
+    private lateinit var mockControlsController: ControlsController
+
+    private val executor = FakeExecutor(FakeSystemClock())
+    private lateinit var controller: ControlsBindingController
+    private val providers = TestableControlsBindingControllerImpl.providers
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+
+        controller = TestableControlsBindingControllerImpl(
+                mContext, executor, Lazy { mockControlsController })
+    }
+
+    @After
+    fun tearDown() {
+        executor.advanceClockToLast()
+        executor.runAllReady()
+        providers.clear()
+    }
+
+    @Test
+    fun testBindAndLoad() {
+        val callback: (List<Control>) -> Unit = {}
+        controller.bindAndLoad(TEST_COMPONENT_NAME_1, callback)
+
+        assertEquals(1, providers.size)
+        val provider = providers.first()
+        verify(provider).maybeBindAndLoad(callback)
+    }
+
+    @Test
+    fun testBindServices() {
+        controller.bindServices(listOf(TEST_COMPONENT_NAME_1, TEST_COMPONENT_NAME_2))
+        executor.runAllReady()
+
+        assertEquals(2, providers.size)
+        assertEquals(setOf(TEST_COMPONENT_NAME_1, TEST_COMPONENT_NAME_2),
+                providers.map { it.componentName }.toSet())
+        providers.forEach {
+            verify(it).bindPermanently()
+        }
+    }
+
+    @Test
+    fun testSubscribe() {
+        val controlInfo1 = ControlInfo(TEST_COMPONENT_NAME_1, "id_1", "", DeviceTypes.TYPE_UNKNOWN)
+        val controlInfo2 = ControlInfo(TEST_COMPONENT_NAME_2, "id_2", "", DeviceTypes.TYPE_UNKNOWN)
+        controller.bindServices(listOf(TEST_COMPONENT_NAME_3))
+
+        controller.subscribe(listOf(controlInfo1, controlInfo2))
+
+        executor.runAllReady()
+
+        assertEquals(3, providers.size)
+        val provider1 = providers.first { it.componentName == TEST_COMPONENT_NAME_1 }
+        val provider2 = providers.first { it.componentName == TEST_COMPONENT_NAME_2 }
+        val provider3 = providers.first { it.componentName == TEST_COMPONENT_NAME_3 }
+
+        verify(provider1).maybeBindAndSubscribe(listOf(controlInfo1.controlId))
+        verify(provider2).maybeBindAndSubscribe(listOf(controlInfo2.controlId))
+        verify(provider3, never()).maybeBindAndSubscribe(any())
+        verify(provider3).unbindService() // Not needed services will be unbound
+    }
+
+    @Test
+    fun testUnsubscribe_notRefreshing() {
+        controller.bindServices(listOf(TEST_COMPONENT_NAME_1, TEST_COMPONENT_NAME_2))
+        controller.unsubscribe()
+
+        executor.runAllReady()
+
+        providers.forEach {
+            verify(it, never()).unsubscribe()
+        }
+    }
+
+    @Test
+    fun testUnsubscribe_refreshing() {
+        val controlInfo1 = ControlInfo(TEST_COMPONENT_NAME_1, "id_1", "", DeviceTypes.TYPE_UNKNOWN)
+        val controlInfo2 = ControlInfo(TEST_COMPONENT_NAME_2, "id_2", "", DeviceTypes.TYPE_UNKNOWN)
+
+        controller.subscribe(listOf(controlInfo1, controlInfo2))
+
+        controller.unsubscribe()
+
+        executor.runAllReady()
+
+        providers.forEach {
+            verify(it).unsubscribe()
+        }
+    }
+}
+
+class TestableControlsBindingControllerImpl(
+    context: Context,
+    executor: DelayableExecutor,
+    lazyController: Lazy<ControlsController>
+) : ControlsBindingControllerImpl(context, executor, lazyController) {
+
+    companion object {
+        val providers = mutableSetOf<ControlsProviderLifecycleManager>()
+    }
+
+    override fun createProviderManager(component: ComponentName):
+            ControlsProviderLifecycleManager {
+        val provider = mock(ControlsProviderLifecycleManager::class.java)
+        val token = Binder()
+        `when`(provider.componentName).thenReturn(component)
+        `when`(provider.token).thenReturn(token)
+        providers.add(provider)
+        return provider
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsControllerImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsControllerImplTest.kt
new file mode 100644
index 0000000..a19c299
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsControllerImplTest.kt
@@ -0,0 +1,360 @@
+/*
+ * 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.controller
+
+import android.app.PendingIntent
+import android.content.ComponentName
+import android.provider.Settings
+import android.service.controls.Control
+import android.service.controls.DeviceTypes
+import android.service.controls.actions.ControlAction
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.systemui.DumpController
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.controls.ControlStatus
+import com.android.systemui.controls.ui.ControlsUiController
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.time.FakeSystemClock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Captor
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+import java.util.Optional
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class ControlsControllerImplTest : SysuiTestCase() {
+
+    @Mock
+    private lateinit var uiController: ControlsUiController
+    @Mock
+    private lateinit var bindingController: ControlsBindingController
+    @Mock
+    private lateinit var dumpController: DumpController
+    @Mock
+    private lateinit var pendingIntent: PendingIntent
+    @Mock
+    private lateinit var persistenceWrapper: ControlsFavoritePersistenceWrapper
+
+    @Captor
+    private lateinit var controlInfoListCaptor: ArgumentCaptor<List<ControlInfo>>
+    @Captor
+    private lateinit var controlLoadCallbackCaptor: ArgumentCaptor<(List<Control>) -> Unit>
+
+    private lateinit var delayableExecutor: FakeExecutor
+    private lateinit var controller: ControlsController
+
+    companion object {
+        fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
+        fun <T : Any> safeEq(value: T): T = eq(value) ?: value
+
+        private val TEST_COMPONENT = ComponentName("test.pkg", "test.class")
+        private const val TEST_CONTROL_ID = "control1"
+        private const val TEST_CONTROL_TITLE = "Test"
+        private const val TEST_DEVICE_TYPE = DeviceTypes.TYPE_AC_HEATER
+        private val TEST_CONTROL_INFO = ControlInfo(
+                TEST_COMPONENT, TEST_CONTROL_ID, TEST_CONTROL_TITLE, TEST_DEVICE_TYPE)
+
+        private val TEST_COMPONENT_2 = ComponentName("test.pkg", "test.class.2")
+        private const val TEST_CONTROL_ID_2 = "control2"
+        private const val TEST_CONTROL_TITLE_2 = "Test 2"
+        private const val TEST_DEVICE_TYPE_2 = DeviceTypes.TYPE_CAMERA
+        private val TEST_CONTROL_INFO_2 = ControlInfo(
+                TEST_COMPONENT_2, TEST_CONTROL_ID_2, TEST_CONTROL_TITLE_2, TEST_DEVICE_TYPE_2)
+    }
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+
+        Settings.Secure.putInt(mContext.contentResolver,
+                ControlsControllerImpl.CONTROLS_AVAILABLE, 1)
+
+        delayableExecutor = FakeExecutor(FakeSystemClock())
+
+        controller = ControlsControllerImpl(
+                mContext,
+                delayableExecutor,
+                uiController,
+                bindingController,
+                Optional.of(persistenceWrapper),
+                dumpController
+        )
+        assertTrue(controller.available)
+    }
+
+    private fun builderFromInfo(controlInfo: ControlInfo): Control.StatelessBuilder {
+        return Control.StatelessBuilder(controlInfo.controlId, pendingIntent)
+                .setDeviceType(controlInfo.deviceType).setTitle(controlInfo.controlTitle)
+    }
+
+    @Test
+    fun testStartWithoutFavorites() {
+        assertTrue(controller.getFavoriteControls().isEmpty())
+    }
+
+    @Test
+    fun testStartWithSavedFavorites() {
+        `when`(persistenceWrapper.readFavorites()).thenReturn(listOf(TEST_CONTROL_INFO))
+        val controller_other = ControlsControllerImpl(
+                mContext,
+                delayableExecutor,
+                uiController,
+                bindingController,
+                Optional.of(persistenceWrapper),
+                dumpController
+        )
+        assertEquals(listOf(TEST_CONTROL_INFO), controller_other.getFavoriteControls())
+    }
+
+    @Test
+    fun testAddFavorite() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+
+        val favorites = controller.getFavoriteControls()
+        assertTrue(TEST_CONTROL_INFO in favorites)
+        assertEquals(1, favorites.size)
+    }
+
+    @Test
+    fun testAddMultipleFavorites() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO_2, true)
+
+        val favorites = controller.getFavoriteControls()
+        assertTrue(TEST_CONTROL_INFO in favorites)
+        assertTrue(TEST_CONTROL_INFO_2 in favorites)
+        assertEquals(2, favorites.size)
+    }
+
+    @Test
+    fun testAddAndRemoveFavorite() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO_2, true)
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, false)
+
+        val favorites = controller.getFavoriteControls()
+        assertTrue(TEST_CONTROL_INFO !in favorites)
+        assertTrue(TEST_CONTROL_INFO_2 in favorites)
+        assertEquals(1, favorites.size)
+    }
+
+    @Test
+    fun testFavoritesSavedOnAdd() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+
+        verify(persistenceWrapper).storeFavorites(listOf(TEST_CONTROL_INFO))
+    }
+
+    @Test
+    fun testFavoritesSavedOnRemove() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+        reset(persistenceWrapper)
+
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, false)
+        verify(persistenceWrapper).storeFavorites(emptyList())
+    }
+
+    @Test
+    fun testFavoritesSavedOnChange() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+        val newControlInfo = TEST_CONTROL_INFO.copy(controlTitle = TEST_CONTROL_TITLE_2)
+        val control = builderFromInfo(newControlInfo).build()
+
+        controller.loadForComponent(TEST_COMPONENT) {}
+
+        reset(persistenceWrapper)
+        verify(bindingController).bindAndLoad(safeEq(TEST_COMPONENT),
+                capture(controlLoadCallbackCaptor))
+
+        controlLoadCallbackCaptor.value.invoke(listOf(control))
+
+        verify(persistenceWrapper).storeFavorites(listOf(newControlInfo))
+    }
+
+    @Test
+    fun testFavoritesNotSavedOnRedundantAdd() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+
+        reset(persistenceWrapper)
+
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+        verify(persistenceWrapper, never()).storeFavorites(ArgumentMatchers.anyList())
+    }
+
+    @Test
+    fun testFavoritesNotSavedOnNotRemove() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, false)
+        verify(persistenceWrapper, never()).storeFavorites(ArgumentMatchers.anyList())
+    }
+
+    @Test
+    fun testOnActionResponse() {
+        controller.onActionResponse(TEST_COMPONENT, TEST_CONTROL_ID, ControlAction.RESPONSE_OK)
+
+        verify(uiController).onActionResponse(TEST_COMPONENT, TEST_CONTROL_ID,
+                ControlAction.RESPONSE_OK)
+    }
+
+    @Test
+    fun testRefreshStatus() {
+        val list = listOf(Control.StatefulBuilder(TEST_CONTROL_ID, pendingIntent).build())
+        controller.refreshStatus(TEST_COMPONENT, list)
+
+        verify(uiController).onRefreshState(TEST_COMPONENT, list)
+    }
+
+    @Test
+    fun testUnsubscribe() {
+        controller.unsubscribe()
+        verify(bindingController).unsubscribe()
+    }
+
+    @Test
+    fun testSubscribeFavorites() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO_2, true)
+
+        controller.subscribeToFavorites()
+
+        verify(bindingController).subscribe(capture(controlInfoListCaptor))
+
+        assertTrue(TEST_CONTROL_INFO in controlInfoListCaptor.value)
+        assertTrue(TEST_CONTROL_INFO_2 in controlInfoListCaptor.value)
+    }
+
+    @Test
+    fun testLoadForComponent_noFavorites() {
+        var loaded = false
+        val control = builderFromInfo(TEST_CONTROL_INFO).build()
+
+        controller.loadForComponent(TEST_COMPONENT) {
+            loaded = true
+            assertEquals(1, it.size)
+            val controlStatus = it[0]
+            assertEquals(ControlStatus(control, false), controlStatus)
+        }
+
+        verify(bindingController).bindAndLoad(safeEq(TEST_COMPONENT),
+                capture(controlLoadCallbackCaptor))
+
+        controlLoadCallbackCaptor.value.invoke(listOf(control))
+
+        assertTrue(loaded)
+    }
+
+    @Test
+    fun testLoadForComponent_favorites() {
+        var loaded = false
+        val control = builderFromInfo(TEST_CONTROL_INFO).build()
+        val control2 = builderFromInfo(TEST_CONTROL_INFO_2).build()
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+
+        controller.loadForComponent(TEST_COMPONENT) {
+            loaded = true
+            assertEquals(2, it.size)
+            val controlStatus = it.first { it.control.controlId == TEST_CONTROL_ID }
+            assertEquals(ControlStatus(control, true), controlStatus)
+
+            val controlStatus2 = it.first { it.control.controlId == TEST_CONTROL_ID_2 }
+            assertEquals(ControlStatus(control2, false), controlStatus2)
+        }
+
+        verify(bindingController).bindAndLoad(safeEq(TEST_COMPONENT),
+                capture(controlLoadCallbackCaptor))
+
+        controlLoadCallbackCaptor.value.invoke(listOf(control, control2))
+
+        assertTrue(loaded)
+    }
+
+    @Test
+    fun testLoadForComponent_removed() {
+        var loaded = false
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+
+        controller.loadForComponent(TEST_COMPONENT) {
+            loaded = true
+            assertEquals(1, it.size)
+            val controlStatus = it[0]
+            assertEquals(TEST_CONTROL_ID, controlStatus.control.controlId)
+            assertTrue(controlStatus.favorite)
+            assertTrue(controlStatus.removed)
+        }
+
+        verify(bindingController).bindAndLoad(safeEq(TEST_COMPONENT),
+                capture(controlLoadCallbackCaptor))
+
+        controlLoadCallbackCaptor.value.invoke(emptyList())
+
+        assertTrue(loaded)
+    }
+
+    @Test
+    fun testFavoriteInformationModifiedOnLoad() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+        val newControlInfo = TEST_CONTROL_INFO.copy(controlTitle = TEST_CONTROL_TITLE_2)
+        val control = builderFromInfo(newControlInfo).build()
+
+        controller.loadForComponent(TEST_COMPONENT) {}
+
+        verify(bindingController).bindAndLoad(safeEq(TEST_COMPONENT),
+                capture(controlLoadCallbackCaptor))
+
+        controlLoadCallbackCaptor.value.invoke(listOf(control))
+
+        val favorites = controller.getFavoriteControls()
+        assertEquals(1, favorites.size)
+        assertEquals(newControlInfo, favorites[0])
+    }
+
+    @Test
+    fun testFavoriteInformationModifiedOnRefresh() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+        val newControlInfo = TEST_CONTROL_INFO.copy(controlTitle = TEST_CONTROL_TITLE_2)
+        val control = builderFromInfo(newControlInfo).build()
+
+        controller.refreshStatus(TEST_COMPONENT, listOf(control))
+
+        delayableExecutor.runAllReady()
+
+        val favorites = controller.getFavoriteControls()
+        assertEquals(1, favorites.size)
+        assertEquals(newControlInfo, favorites[0])
+    }
+
+    @Test
+    fun testClearFavorites() {
+        controller.changeFavoriteStatus(TEST_CONTROL_INFO, true)
+        assertEquals(1, controller.getFavoriteControls().size)
+
+        controller.clearFavorites()
+        assertTrue(controller.getFavoriteControls().isEmpty())
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsFavoritePersistenceWrapperTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsFavoritePersistenceWrapperTest.kt
new file mode 100644
index 0000000..c145c1f
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsFavoritePersistenceWrapperTest.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.controller
+
+import android.content.ComponentName
+import android.service.controls.DeviceTypes
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.time.FakeSystemClock
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class ControlsFavoritePersistenceWrapperTest : SysuiTestCase() {
+
+    private lateinit var file: File
+
+    private val executor = FakeExecutor(FakeSystemClock())
+
+    private lateinit var wrapper: ControlsFavoritePersistenceWrapper
+
+    @Before
+    fun setUp() {
+        file = File.createTempFile("controls_favorites", ".temp")
+        wrapper = ControlsFavoritePersistenceWrapper(file, executor)
+    }
+
+    @After
+    fun tearDown() {
+        if (file.exists() ?: false) {
+            file.delete()
+        }
+    }
+
+    @Test
+    fun testSaveAndRestore() {
+        val controlInfo1 = ControlInfo(
+                ComponentName.unflattenFromString("TEST_PKG/.TEST_CLS_1")!!,
+                "id1", "name_1", DeviceTypes.TYPE_UNKNOWN)
+        val controlInfo2 = ControlInfo(
+                ComponentName.unflattenFromString("TEST_PKG/.TEST_CLS_2")!!,
+                "id2", "name_2", DeviceTypes.TYPE_GENERIC_ON_OFF)
+        val list = listOf(controlInfo1, controlInfo2)
+
+        wrapper.storeFavorites(list)
+
+        executor.runAllReady()
+
+        assertEquals(list, wrapper.readFavorites())
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsProviderLifecycleManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsProviderLifecycleManagerTest.kt
new file mode 100644
index 0000000..556bb40
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsProviderLifecycleManagerTest.kt
@@ -0,0 +1,138 @@
+/*
+ * 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.controller
+
+import android.content.ComponentName
+import android.service.controls.Control
+import android.service.controls.IControlsProvider
+import android.service.controls.IControlsProviderCallback
+import android.service.controls.actions.ControlAction
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.util.concurrency.DelayableExecutor
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.time.FakeSystemClock
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class ControlsProviderLifecycleManagerTest : SysuiTestCase() {
+
+    @Mock
+    private lateinit var serviceCallback: IControlsProviderCallback.Stub
+    @Mock
+    private lateinit var service: IControlsProvider.Stub
+
+    private val componentName = ComponentName("test.pkg", "test.cls")
+    private lateinit var manager: ControlsProviderLifecycleManager
+    private lateinit var executor: DelayableExecutor
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+
+        mContext.addMockService(componentName, service)
+        executor = FakeExecutor(FakeSystemClock())
+        `when`(service.asBinder()).thenCallRealMethod()
+        `when`(service.queryLocalInterface(ArgumentMatchers.anyString())).thenReturn(service)
+
+        manager = ControlsProviderLifecycleManager(
+                context,
+                executor,
+                serviceCallback,
+                componentName
+        )
+    }
+
+    @After
+    fun tearDown() {
+        manager.unbindService()
+    }
+
+    @Test
+    fun testBindService() {
+        manager.bindPermanently()
+        assertTrue(mContext.isBound(componentName))
+    }
+
+    @Test
+    fun testUnbindService() {
+        manager.bindPermanently()
+        manager.unbindService()
+        assertFalse(mContext.isBound(componentName))
+    }
+
+    @Test
+    fun testMaybeBindAndLoad() {
+        val callback: (List<Control>) -> Unit = {}
+        manager.maybeBindAndLoad(callback)
+
+        verify(service).load()
+
+        assertTrue(mContext.isBound(componentName))
+        assertEquals(callback, manager.lastLoadCallback)
+    }
+
+    @Test
+    fun testMaybeUnbind_bindingAndCallback() {
+        manager.maybeBindAndLoad {}
+
+        manager.maybeUnbindAndRemoveCallback()
+        assertFalse(mContext.isBound(componentName))
+        assertNull(manager.lastLoadCallback)
+    }
+
+    @Test
+    fun testUnsubscribe() {
+        manager.bindPermanently()
+        manager.unsubscribe()
+
+        verify(service).unsubscribe()
+    }
+
+    @Test
+    fun testMaybeBindAndSubscribe() {
+        val list = listOf("TEST_ID")
+        manager.maybeBindAndSubscribe(list)
+
+        assertTrue(mContext.isBound(componentName))
+        verify(service).subscribe(list)
+    }
+
+    @Test
+    fun testMaybeBindAndAction() {
+        val controlId = "TEST_ID"
+        val action = ControlAction.UNKNOWN_ACTION
+        manager.maybeBindAndSendAction(controlId, action)
+
+        assertTrue(mContext.isBound(componentName))
+        verify(service).onAction(controlId, action)
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsProviderServiceWrapperTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsProviderServiceWrapperTest.kt
new file mode 100644
index 0000000..d6993c0
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/controls/controller/ControlsProviderServiceWrapperTest.kt
@@ -0,0 +1,127 @@
+/*
+ * 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.controller
+
+import android.os.RemoteException
+import android.service.controls.IControlsProvider
+import android.service.controls.actions.ControlAction
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+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.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.any
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class ControlsProviderServiceWrapperTest : SysuiTestCase() {
+
+    @Mock
+    private lateinit var service: IControlsProvider
+
+    private val exception = RemoteException()
+
+    private lateinit var wrapper: ControlsProviderServiceWrapper
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+
+        wrapper = ControlsProviderServiceWrapper(service)
+    }
+
+    @Test
+    fun testLoad_happyPath() {
+        val result = wrapper.load()
+
+        assertTrue(result)
+        verify(service).load()
+    }
+
+    @Test
+    fun testLoad_error() {
+        `when`(service.load()).thenThrow(exception)
+        val result = wrapper.load()
+
+        assertFalse(result)
+    }
+
+    @Test
+    fun testSubscribe_happyPath() {
+        val list = listOf("TEST_ID")
+        val result = wrapper.subscribe(list)
+
+        assertTrue(result)
+        verify(service).subscribe(list)
+    }
+
+    @Test
+    fun testSubscribe_error() {
+        `when`(service.subscribe(any())).thenThrow(exception)
+
+        val list = listOf("TEST_ID")
+        val result = wrapper.subscribe(list)
+
+        assertFalse(result)
+    }
+
+    @Test
+    fun testUnsubscribe_happyPath() {
+        val result = wrapper.unsubscribe()
+
+        assertTrue(result)
+        verify(service).unsubscribe()
+    }
+
+    @Test
+    fun testUnsubscribe_error() {
+        `when`(service.unsubscribe()).thenThrow(exception)
+        val result = wrapper.unsubscribe()
+
+        assertFalse(result)
+    }
+
+    @Test
+    fun testOnAction_happyPath() {
+        val id = "TEST_ID"
+        val action = ControlAction.UNKNOWN_ACTION
+
+        val result = wrapper.onAction(id, action)
+
+        assertTrue(result)
+        verify(service).onAction(id, action)
+    }
+
+    @Test
+    fun testOnAction_error() {
+        `when`(service.onAction(any(), any())).thenThrow(exception)
+
+        val id = "TEST_ID"
+        val action = ControlAction.UNKNOWN_ACTION
+
+        val result = wrapper.onAction(id, action)
+
+        assertFalse(result)
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/management/ControlsListingControllerImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/management/ControlsListingControllerImplTest.kt
new file mode 100644
index 0000000..f09aab9
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/controls/management/ControlsListingControllerImplTest.kt
@@ -0,0 +1,188 @@
+/*
+ * 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.content.pm.ServiceInfo
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.settingslib.applications.ServiceListing
+import com.android.settingslib.widget.CandidateInfo
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.time.FakeSystemClock
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class ControlsListingControllerImplTest : SysuiTestCase() {
+
+    companion object {
+        private const val TEST_LABEL = "TEST_LABEL"
+        private const val TEST_PERMISSION = "permission"
+        fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
+        fun <T> any(): T = Mockito.any<T>()
+    }
+
+    @Mock
+    private lateinit var mockSL: ServiceListing
+    @Mock
+    private lateinit var mockCallback: ControlsListingController.ControlsListingCallback
+    @Mock
+    private lateinit var mockCallbackOther: ControlsListingController.ControlsListingCallback
+    @Mock
+    private lateinit var serviceInfo: ServiceInfo
+    @Mock
+    private lateinit var componentName: ComponentName
+
+    private val executor = FakeExecutor(FakeSystemClock())
+
+    private lateinit var controller: ControlsListingControllerImpl
+
+    private var serviceListingCallbackCaptor =
+            ArgumentCaptor.forClass(ServiceListing.Callback::class.java)
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+
+        `when`(serviceInfo.componentName).thenReturn(componentName)
+
+        controller = ControlsListingControllerImpl(mContext, executor, mockSL)
+        verify(mockSL).addCallback(capture(serviceListingCallbackCaptor))
+    }
+
+    @After
+    fun tearDown() {
+        executor.advanceClockToLast()
+        executor.runAllReady()
+    }
+
+    @Test
+    fun testNoServices_notListening() {
+        assertTrue(controller.getCurrentServices().isEmpty())
+    }
+
+    @Test
+    fun testStartListening_onFirstCallback() {
+        controller.addCallback(mockCallback)
+        executor.runAllReady()
+
+        verify(mockSL).setListening(true)
+    }
+
+    @Test
+    fun testStartListening_onlyOnce() {
+        controller.addCallback(mockCallback)
+        controller.addCallback(mockCallbackOther)
+
+        executor.runAllReady()
+
+        verify(mockSL).setListening(true)
+    }
+
+    @Test
+    fun testStopListening_callbackRemoved() {
+        controller.addCallback(mockCallback)
+
+        executor.runAllReady()
+
+        controller.removeCallback(mockCallback)
+
+        executor.runAllReady()
+
+        verify(mockSL).setListening(false)
+    }
+
+    @Test
+    fun testStopListening_notWhileRemainingCallbacks() {
+        controller.addCallback(mockCallback)
+        controller.addCallback(mockCallbackOther)
+
+        executor.runAllReady()
+
+        controller.removeCallback(mockCallback)
+
+        executor.runAllReady()
+
+        verify(mockSL, never()).setListening(false)
+    }
+
+    @Test
+    fun testReloadOnFirstCallbackAdded() {
+        controller.addCallback(mockCallback)
+        executor.runAllReady()
+
+        verify(mockSL).reload()
+    }
+
+    @Test
+    fun testCallbackCalledWhenAdded() {
+        `when`(mockSL.reload()).then {
+            serviceListingCallbackCaptor.value.onServicesReloaded(emptyList())
+        }
+
+        controller.addCallback(mockCallback)
+        executor.runAllReady()
+        verify(mockCallback).onServicesUpdated(any())
+        reset(mockCallback)
+
+        controller.addCallback(mockCallbackOther)
+        executor.runAllReady()
+        verify(mockCallbackOther).onServicesUpdated(any())
+        verify(mockCallback, never()).onServicesUpdated(any())
+    }
+
+    @Test
+    fun testCallbackGetsList() {
+        val list = listOf(serviceInfo)
+        controller.addCallback(mockCallback)
+        controller.addCallback(mockCallbackOther)
+
+        @Suppress("unchecked_cast")
+        val captor: ArgumentCaptor<List<CandidateInfo>> =
+                ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor<List<CandidateInfo>>
+
+        executor.runAllReady()
+        reset(mockCallback)
+        reset(mockCallbackOther)
+
+        serviceListingCallbackCaptor.value.onServicesReloaded(list)
+
+        executor.runAllReady()
+        verify(mockCallback).onServicesUpdated(capture(captor))
+        assertEquals(1, captor.value.size)
+        assertEquals(componentName.flattenToString(), captor.value[0].key)
+
+        verify(mockCallbackOther).onServicesUpdated(capture(captor))
+        assertEquals(1, captor.value.size)
+        assertEquals(componentName.flattenToString(), captor.value[0].key)
+    }
+}
\ No newline at end of file