diff --git a/packages/SystemUI/res/values/config.xml b/packages/SystemUI/res/values/config.xml
index bb04c3b356289..61962256f93d6 100644
--- a/packages/SystemUI/res/values/config.xml
+++ b/packages/SystemUI/res/values/config.xml
@@ -107,7 +107,7 @@
- wifi,cell,battery,dnd,flashlight,rotation,bt,airplane,location,hotspot,inversion,saver,dark,work,cast,night,screenrecord,reverse,reduce_brightness,cameratoggle,mictoggle
+ wifi,cell,battery,dnd,flashlight,rotation,bt,airplane,location,hotspot,inversion,saver,dark,work,cast,night,screenrecord,reverse,reduce_brightness,cameratoggle,mictoggle,controls
diff --git a/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsDialog.kt b/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsDialog.kt
index f533cfb470768..db68d17461fab 100644
--- a/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsDialog.kt
+++ b/packages/SystemUI/src/com/android/systemui/controls/ui/ControlsDialog.kt
@@ -28,11 +28,12 @@ import android.view.WindowManager
import com.android.systemui.Interpolators
import com.android.systemui.R
import com.android.systemui.broadcast.BroadcastDispatcher
+import javax.inject.Inject
/**
* Show the controls space inside a dialog, as from the lock screen.
*/
-class ControlsDialog(
+class ControlsDialog @Inject constructor(
thisContext: Context,
val broadcastDispatcher: BroadcastDispatcher
) : Dialog(thisContext, R.style.Theme_SystemUI_Dialog_Control_LockScreen) {
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSFactoryImpl.java b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSFactoryImpl.java
index 11e6330d37f38..6983b38489f66 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSFactoryImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSFactoryImpl.java
@@ -37,6 +37,7 @@ import com.android.systemui.qs.tiles.CastTile;
import com.android.systemui.qs.tiles.CellularTile;
import com.android.systemui.qs.tiles.ColorInversionTile;
import com.android.systemui.qs.tiles.DataSaverTile;
+import com.android.systemui.qs.tiles.DeviceControlsTile;
import com.android.systemui.qs.tiles.DndTile;
import com.android.systemui.qs.tiles.FlashlightTile;
import com.android.systemui.qs.tiles.HotspotTile;
@@ -89,6 +90,7 @@ public class QSFactoryImpl implements QSFactory {
private final Provider mReduceBrightColorsTileProvider;
private final Provider mCameraToggleTileProvider;
private final Provider mMicrophoneToggleTileProvider;
+ private final Provider mDeviceControlsTileProvider;
private final Lazy mQsHostLazy;
private final Provider mCustomTileBuilderProvider;
@@ -123,7 +125,8 @@ public class QSFactoryImpl implements QSFactory {
Provider screenRecordTileProvider,
Provider reduceBrightColorsTileProvider,
Provider cameraToggleTileProvider,
- Provider microphoneToggleTileProvider) {
+ Provider microphoneToggleTileProvider,
+ Provider deviceControlsTileProvider) {
mQsHostLazy = qsHostLazy;
mCustomTileBuilderProvider = customTileBuilderProvider;
@@ -153,6 +156,7 @@ public class QSFactoryImpl implements QSFactory {
mReduceBrightColorsTileProvider = reduceBrightColorsTileProvider;
mCameraToggleTileProvider = cameraToggleTileProvider;
mMicrophoneToggleTileProvider = microphoneToggleTileProvider;
+ mDeviceControlsTileProvider = deviceControlsTileProvider;
}
public QSTile createTile(String tileSpec) {
@@ -212,6 +216,8 @@ public class QSFactoryImpl implements QSFactory {
return mCameraToggleTileProvider.get();
case "mictoggle":
return mMicrophoneToggleTileProvider.get();
+ case "controls":
+ return mDeviceControlsTileProvider.get();
}
// Custom tiles
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/DeviceControlsTile.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/DeviceControlsTile.kt
new file mode 100644
index 0000000000000..6176a5702dcff
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/DeviceControlsTile.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2021 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.qs.tiles
+
+import android.content.Intent
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import android.service.quicksettings.Tile
+import com.android.internal.logging.MetricsLogger
+import com.android.systemui.R
+import com.android.systemui.controls.ControlsServiceInfo
+import com.android.systemui.controls.dagger.ControlsComponent
+import com.android.systemui.controls.management.ControlsListingController
+import com.android.systemui.controls.ui.ControlsDialog
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.plugins.qs.QSTile
+import com.android.systemui.plugins.statusbar.StatusBarStateController
+import com.android.systemui.qs.QSHost
+import com.android.systemui.qs.logging.QSLogger
+import com.android.systemui.qs.tileimpl.QSTileImpl
+import com.android.systemui.statusbar.FeatureFlags
+import com.android.systemui.util.settings.GlobalSettings
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+import javax.inject.Provider
+
+class DeviceControlsTile @Inject constructor(
+ host: QSHost,
+ @Background backgroundLooper: Looper,
+ @Main mainHandler: Handler,
+ metricsLogger: MetricsLogger,
+ statusBarStateController: StatusBarStateController,
+ activityStarter: ActivityStarter,
+ qsLogger: QSLogger,
+ private val controlsComponent: ControlsComponent,
+ private val featureFlags: FeatureFlags,
+ private val dialogProvider: Provider,
+ globalSettings: GlobalSettings
+) : QSTileImpl(
+ host,
+ backgroundLooper,
+ mainHandler,
+ metricsLogger,
+ statusBarStateController,
+ activityStarter,
+ qsLogger
+) {
+
+ companion object {
+ const val SETTINGS_FLAG = "controls_lockscreen"
+ }
+
+ private val controlsLockscreen = globalSettings.getInt(SETTINGS_FLAG, 0) != 0
+ private var hasControlsApps = AtomicBoolean(false)
+ private val intent = Intent(Settings.ACTION_DEVICE_CONTROLS_SETTINGS)
+
+ private var controlsDialog: ControlsDialog? = null
+ private val icon = ResourceIcon.get(R.drawable.ic_device_light)
+
+ private val listingCallback = object : ControlsListingController.ControlsListingCallback {
+ override fun onServicesUpdated(serviceInfos: List) {
+ if (hasControlsApps.compareAndSet(serviceInfos.isEmpty(), serviceInfos.isNotEmpty())) {
+ refreshState()
+ }
+ }
+ }
+
+ init {
+ controlsComponent.getControlsListingController().ifPresent {
+ it.observe(this, listingCallback)
+ }
+ }
+
+ override fun isAvailable(): Boolean {
+ return featureFlags.isKeyguardLayoutEnabled &&
+ controlsLockscreen &&
+ controlsComponent.getControlsUiController().isPresent
+ }
+
+ override fun newTileState(): QSTile.State {
+ return QSTile.State().also {
+ it.state = Tile.STATE_UNAVAILABLE // Start unavailable matching `hasControlsApps`
+ }
+ }
+
+ override fun handleDestroy() {
+ dismissDialog()
+ super.handleDestroy()
+ }
+
+ private fun createDialog() {
+ if (controlsDialog?.isShowing != true) {
+ controlsDialog = dialogProvider.get()
+ }
+ }
+
+ private fun dismissDialog() {
+ controlsDialog?.dismiss()?.also {
+ controlsDialog = null
+ }
+ }
+
+ override fun handleClick() {
+ if (state.state != Tile.STATE_UNAVAILABLE) {
+ mUiHandler.post {
+ createDialog()
+ controlsDialog?.show(controlsComponent.getControlsUiController().get())
+ }
+ }
+ }
+
+ override fun handleUpdateState(state: QSTile.State, arg: Any?) {
+ state.label = tileLabel
+ state.secondaryLabel = ""
+ state.stateDescription = ""
+ state.contentDescription = state.label
+ state.icon = icon
+ if (hasControlsApps.get()) {
+ state.state = Tile.STATE_ACTIVE
+ if (controlsDialog == null) {
+ mUiHandler.post(this::createDialog)
+ }
+ } else {
+ state.state = Tile.STATE_UNAVAILABLE
+ dismissDialog()
+ }
+ }
+
+ override fun getMetricsCategory(): Int {
+ return 0
+ }
+
+ override fun getLongClickIntent(): Intent {
+ return intent
+ }
+
+ override fun getTileLabel(): CharSequence {
+ return mContext.getText(R.string.quick_controls_title)
+ }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/DeviceControlsTileTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/DeviceControlsTileTest.kt
new file mode 100644
index 0000000000000..b7b967866d473
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/DeviceControlsTileTest.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright (C) 2021 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.qs.tiles
+
+import android.os.Handler
+import android.provider.Settings
+import android.service.quicksettings.Tile
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import androidx.lifecycle.LifecycleOwner
+import androidx.test.filters.SmallTest
+import com.android.internal.logging.MetricsLogger
+import com.android.internal.logging.UiEventLogger
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.controls.ControlsServiceInfo
+import com.android.systemui.controls.controller.ControlsController
+import com.android.systemui.controls.dagger.ControlsComponent
+import com.android.systemui.controls.management.ControlsListingController
+import com.android.systemui.controls.ui.ControlsDialog
+import com.android.systemui.controls.ui.ControlsUiController
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.plugins.statusbar.StatusBarStateController
+import com.android.systemui.qs.QSHost
+import com.android.systemui.qs.logging.QSLogger
+import com.android.systemui.statusbar.FeatureFlags
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.capture
+import com.android.systemui.util.settings.FakeSettings
+import com.android.systemui.util.settings.GlobalSettings
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Captor
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class DeviceControlsTileTest : SysuiTestCase() {
+
+ @Mock
+ private lateinit var qsHost: QSHost
+ @Mock
+ private lateinit var metricsLogger: MetricsLogger
+ @Mock
+ private lateinit var statusBarStateController: StatusBarStateController
+ @Mock
+ private lateinit var activityStarter: ActivityStarter
+ @Mock
+ private lateinit var qsLogger: QSLogger
+ private lateinit var controlsComponent: ControlsComponent
+ @Mock
+ private lateinit var controlsUiController: ControlsUiController
+ @Mock
+ private lateinit var controlsListingController: ControlsListingController
+ @Mock
+ private lateinit var controlsController: ControlsController
+ @Mock
+ private lateinit var featureFlags: FeatureFlags
+ @Mock
+ private lateinit var controlsDialog: ControlsDialog
+ private lateinit var globalSettings: GlobalSettings
+ @Mock
+ private lateinit var serviceInfo: ControlsServiceInfo
+ @Mock
+ private lateinit var uiEventLogger: UiEventLogger
+ @Captor
+ private lateinit var listingCallbackCaptor:
+ ArgumentCaptor
+
+ private lateinit var testableLooper: TestableLooper
+ private lateinit var tile: DeviceControlsTile
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.initMocks(this)
+ testableLooper = TestableLooper.get(this)
+
+ `when`(qsHost.context).thenReturn(mContext)
+ `when`(qsHost.uiEventLogger).thenReturn(uiEventLogger)
+
+ controlsComponent = ControlsComponent(
+ true,
+ { controlsController },
+ { controlsUiController },
+ { controlsListingController }
+ )
+
+ globalSettings = FakeSettings()
+
+ globalSettings.putInt(DeviceControlsTile.SETTINGS_FLAG, 1)
+ `when`(featureFlags.isKeyguardLayoutEnabled).thenReturn(true)
+
+ tile = createTile()
+ }
+
+ @Test
+ fun testAvailable() {
+ assertThat(tile.isAvailable).isTrue()
+ }
+
+ @Test
+ fun testNotAvailableFeature() {
+ `when`(featureFlags.isKeyguardLayoutEnabled).thenReturn(false)
+
+ assertThat(tile.isAvailable).isFalse()
+ }
+
+ @Test
+ fun testNotAvailableControls() {
+ controlsComponent = ControlsComponent(
+ false,
+ { controlsController },
+ { controlsUiController },
+ { controlsListingController }
+ )
+ tile = createTile()
+
+ assertThat(tile.isAvailable).isFalse()
+ }
+
+ @Test
+ fun testNotAvailableFlag() {
+ globalSettings.putInt(DeviceControlsTile.SETTINGS_FLAG, 0)
+ tile = createTile()
+
+ assertThat(tile.isAvailable).isFalse()
+ }
+
+ @Test
+ fun testObservingCallback() {
+ verify(controlsListingController).observe(
+ any(LifecycleOwner::class.java),
+ any(ControlsListingController.ControlsListingCallback::class.java)
+ )
+ }
+
+ @Test
+ fun testLongClickIntent() {
+ assertThat(tile.longClickIntent.action).isEqualTo(Settings.ACTION_DEVICE_CONTROLS_SETTINGS)
+ }
+
+ @Test
+ fun testUnavailableByDefault() {
+ assertThat(tile.state.state).isEqualTo(Tile.STATE_UNAVAILABLE)
+ }
+
+ @Test
+ fun testStateUnavailableIfNoListings() {
+ verify(controlsListingController).observe(
+ any(LifecycleOwner::class.java),
+ capture(listingCallbackCaptor)
+ )
+
+ listingCallbackCaptor.value.onServicesUpdated(emptyList())
+ testableLooper.processAllMessages()
+
+ assertThat(tile.state.state).isEqualTo(Tile.STATE_UNAVAILABLE)
+ }
+
+ @Test
+ fun testStateAvailableIfListings() {
+ verify(controlsListingController).observe(
+ any(LifecycleOwner::class.java),
+ capture(listingCallbackCaptor)
+ )
+
+ listingCallbackCaptor.value.onServicesUpdated(listOf(serviceInfo))
+ testableLooper.processAllMessages()
+
+ assertThat(tile.state.state).isEqualTo(Tile.STATE_ACTIVE)
+ }
+
+ @Test
+ fun testMoveBetweenStates() {
+ verify(controlsListingController).observe(
+ any(LifecycleOwner::class.java),
+ capture(listingCallbackCaptor)
+ )
+
+ listingCallbackCaptor.value.onServicesUpdated(listOf(serviceInfo))
+ testableLooper.processAllMessages()
+
+ listingCallbackCaptor.value.onServicesUpdated(emptyList())
+ testableLooper.processAllMessages()
+
+ assertThat(tile.state.state).isEqualTo(Tile.STATE_UNAVAILABLE)
+ }
+
+ @Test
+ fun testNoDialogWhenUnavailable() {
+ tile.click()
+ testableLooper.processAllMessages()
+
+ verify(controlsDialog, never()).show(any(ControlsUiController::class.java))
+ }
+
+ @Test
+ fun testDialogShowWhenAvailable() {
+ verify(controlsListingController).observe(
+ any(LifecycleOwner::class.java),
+ capture(listingCallbackCaptor)
+ )
+
+ listingCallbackCaptor.value.onServicesUpdated(listOf(serviceInfo))
+ testableLooper.processAllMessages()
+
+ tile.click()
+ testableLooper.processAllMessages()
+
+ verify(controlsDialog).show(controlsUiController)
+ }
+
+ @Test
+ fun testDialogDismissedOnDestroy() {
+ verify(controlsListingController).observe(
+ any(LifecycleOwner::class.java),
+ capture(listingCallbackCaptor)
+ )
+
+ listingCallbackCaptor.value.onServicesUpdated(listOf(serviceInfo))
+ testableLooper.processAllMessages()
+
+ tile.click()
+ testableLooper.processAllMessages()
+
+ tile.destroy()
+ testableLooper.processAllMessages()
+ verify(controlsDialog).dismiss()
+ }
+
+ private fun createTile(): DeviceControlsTile {
+ return DeviceControlsTile(
+ qsHost,
+ testableLooper.looper,
+ Handler(testableLooper.looper),
+ metricsLogger,
+ statusBarStateController,
+ activityStarter,
+ qsLogger,
+ controlsComponent,
+ featureFlags,
+ { controlsDialog },
+ globalSettings
+ )
+ }
+}
\ No newline at end of file