Add a prototype device controls tile

The tile is gated by controls_lockscreen setting, the feature flag and
the software feature.

Test: atest SystemUITests
Fixes: 179920615
Change-Id: I2d5715c7520e2604edd30688a82f56935c796a5c
This commit is contained in:
Fabian Kozynski
2021-02-10 14:26:35 -05:00
parent a6932f3df5
commit d437edd8fd
5 changed files with 434 additions and 3 deletions

View File

@@ -107,7 +107,7 @@
<!-- Tiles native to System UI. Order should match "quick_settings_tiles_default" -->
<string name="quick_settings_tiles_stock" translatable="false">
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
</string>
<!-- The tiles to display in QuickSettings -->

View File

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

View File

@@ -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<ReduceBrightColorsTile> mReduceBrightColorsTileProvider;
private final Provider<CameraToggleTile> mCameraToggleTileProvider;
private final Provider<MicrophoneToggleTile> mMicrophoneToggleTileProvider;
private final Provider<DeviceControlsTile> mDeviceControlsTileProvider;
private final Lazy<QSHost> mQsHostLazy;
private final Provider<CustomTile.Builder> mCustomTileBuilderProvider;
@@ -123,7 +125,8 @@ public class QSFactoryImpl implements QSFactory {
Provider<ScreenRecordTile> screenRecordTileProvider,
Provider<ReduceBrightColorsTile> reduceBrightColorsTileProvider,
Provider<CameraToggleTile> cameraToggleTileProvider,
Provider<MicrophoneToggleTile> microphoneToggleTileProvider) {
Provider<MicrophoneToggleTile> microphoneToggleTileProvider,
Provider<DeviceControlsTile> 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

View File

@@ -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<ControlsDialog>,
globalSettings: GlobalSettings
) : QSTileImpl<QSTile.State>(
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<ControlsServiceInfo>) {
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)
}
}

View File

@@ -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<ControlsListingController.ControlsListingCallback>
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
)
}
}