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