diff options
11 files changed, 770 insertions, 21 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/HearingDevicesTileMapperTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/HearingDevicesTileMapperTest.kt new file mode 100644 index 000000000000..cdf6bda91301 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/HearingDevicesTileMapperTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2024 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.impl.hearingdevices.domain + +import android.graphics.drawable.TestStubDrawable +import android.widget.Switch +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.tiles.impl.custom.QSTileStateSubject +import com.android.systemui.qs.tiles.impl.hearingdevices.domain.model.HearingDevicesTileModel +import com.android.systemui.qs.tiles.impl.hearingdevices.qsHearingDevicesTileConfig +import com.android.systemui.qs.tiles.viewmodel.QSTileState +import com.android.systemui.res.R +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class HearingDevicesTileMapperTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val qsTileConfig = kosmos.qsHearingDevicesTileConfig + private val mapper by lazy { + HearingDevicesTileMapper( + context.orCreateTestableResources + .apply { addOverride(R.drawable.qs_hearing_devices_icon, TestStubDrawable()) } + .resources, + context.theme, + ) + } + + @Test + fun map_anyActiveHearingDevice_anyPairedHearingDevice_activeState() { + val tileState: QSTileState = + mapper.map( + qsTileConfig, + HearingDevicesTileModel( + isAnyActiveHearingDevice = true, + isAnyPairedHearingDevice = true, + ), + ) + val expectedState = + createHearingDevicesTileState( + QSTileState.ActivationState.ACTIVE, + context.getString(R.string.quick_settings_hearing_devices_connected), + ) + QSTileStateSubject.assertThat(tileState).isEqualTo(expectedState) + } + + @Test + fun map_noActiveHearingDevice_anyPairedHearingDevice_inactiveState() { + val tileState: QSTileState = + mapper.map( + qsTileConfig, + HearingDevicesTileModel( + isAnyActiveHearingDevice = false, + isAnyPairedHearingDevice = true, + ), + ) + val expectedState = + createHearingDevicesTileState( + QSTileState.ActivationState.INACTIVE, + context.getString(R.string.quick_settings_hearing_devices_disconnected), + ) + QSTileStateSubject.assertThat(tileState).isEqualTo(expectedState) + } + + @Test + fun map_noActiveHearingDevice_noPairedHearingDevice_inactiveState() { + val tileState: QSTileState = + mapper.map( + qsTileConfig, + HearingDevicesTileModel( + isAnyActiveHearingDevice = false, + isAnyPairedHearingDevice = false, + ), + ) + val expectedState = + createHearingDevicesTileState(QSTileState.ActivationState.INACTIVE, secondaryLabel = "") + QSTileStateSubject.assertThat(tileState).isEqualTo(expectedState) + } + + private fun createHearingDevicesTileState( + activationState: QSTileState.ActivationState, + secondaryLabel: String, + ): QSTileState { + val label = context.getString(R.string.quick_settings_hearing_devices_label) + val iconRes = R.drawable.qs_hearing_devices_icon + return QSTileState( + { Icon.Loaded(context.getDrawable(iconRes)!!, null) }, + iconRes, + label, + activationState, + secondaryLabel, + setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK), + label, + null, + QSTileState.SideViewIcon.Chevron, + QSTileState.EnabledState.ENABLED, + Switch::class.qualifiedName, + ) + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileDataInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileDataInteractorTest.kt new file mode 100644 index 000000000000..1dfa2cd26491 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileDataInteractorTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2024 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.impl.hearingdevices.domain.interactor + +import android.os.UserHandle +import android.platform.test.annotations.DisableFlags +import android.platform.test.annotations.EnableFlags +import android.platform.test.annotations.EnabledOnRavenwood +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.Flags +import com.android.systemui.SysuiTestCase +import com.android.systemui.accessibility.hearingaid.HearingDevicesChecker +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.coroutines.collectValues +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.testScope +import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger +import com.android.systemui.qs.tiles.impl.hearingdevices.domain.model.HearingDevicesTileModel +import com.android.systemui.statusbar.policy.fakeBluetoothController +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@EnabledOnRavenwood +@RunWith(AndroidJUnit4::class) +class HearingDevicesTileDataInteractorTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val testScope = kosmos.testScope + private val testUser = UserHandle.of(1) + + private val controller = kosmos.fakeBluetoothController + private lateinit var underTest: HearingDevicesTileDataInteractor + + @Rule @JvmField val mockitoRule: MockitoRule = MockitoJUnit.rule() + @Mock private lateinit var checker: HearingDevicesChecker + + @Before + fun setup() { + underTest = HearingDevicesTileDataInteractor(testScope.testScheduler, controller, checker) + } + + @EnableFlags(Flags.FLAG_HEARING_AIDS_QS_TILE_DIALOG) + @Test + fun availability_flagEnabled_returnTrue() = + testScope.runTest { + val availability by collectLastValue(underTest.availability(testUser)) + + assertThat(availability).isTrue() + } + + @DisableFlags(Flags.FLAG_HEARING_AIDS_QS_TILE_DIALOG) + @Test + fun availability_flagDisabled_returnFalse() = + testScope.runTest { + val availability by collectLastValue(underTest.availability(testUser)) + + assertThat(availability).isFalse() + } + + @Test + fun tileData_bluetoothStateChanged_dataMatchesChecker() = + testScope.runTest { + val flowValues: List<HearingDevicesTileModel> by + collectValues( + underTest.tileData(testUser, flowOf(DataUpdateTrigger.InitialRequest)) + ) + runCurrent() + assertThat(flowValues.size).isEqualTo(1) // from addCallback in setup() + + whenever(checker.isAnyPairedHearingDevice).thenReturn(false) + whenever(checker.isAnyActiveHearingDevice).thenReturn(false) + controller.isBluetoothEnabled = false + runCurrent() + assertThat(flowValues.size).isEqualTo(1) // model unchanged, no new flow value + + whenever(checker.isAnyPairedHearingDevice).thenReturn(true) + whenever(checker.isAnyActiveHearingDevice).thenReturn(false) + controller.isBluetoothEnabled = true + runCurrent() + assertThat(flowValues.size).isEqualTo(2) + + whenever(checker.isAnyPairedHearingDevice).thenReturn(true) + whenever(checker.isAnyActiveHearingDevice).thenReturn(true) + controller.isBluetoothEnabled = true + runCurrent() + assertThat(flowValues.size).isEqualTo(3) + + assertThat(flowValues.map { it.isAnyPairedHearingDevice }) + .containsExactly(false, true, true) + .inOrder() + assertThat(flowValues.map { it.isAnyActiveHearingDevice }) + .containsExactly(false, false, true) + .inOrder() + } + + @Test + fun tileData_bluetoothDeviceChanged_dataMatchesChecker() = + testScope.runTest { + val flowValues: List<HearingDevicesTileModel> by + collectValues( + underTest.tileData(testUser, flowOf(DataUpdateTrigger.InitialRequest)) + ) + runCurrent() + assertThat(flowValues.size).isEqualTo(1) // from addCallback in setup() + + whenever(checker.isAnyPairedHearingDevice).thenReturn(false) + whenever(checker.isAnyActiveHearingDevice).thenReturn(false) + controller.onBluetoothDevicesChanged() + runCurrent() + assertThat(flowValues.size).isEqualTo(1) // model unchanged, no new flow value + + whenever(checker.isAnyPairedHearingDevice).thenReturn(true) + whenever(checker.isAnyActiveHearingDevice).thenReturn(false) + controller.onBluetoothDevicesChanged() + runCurrent() + assertThat(flowValues.size).isEqualTo(2) + + whenever(checker.isAnyPairedHearingDevice).thenReturn(true) + whenever(checker.isAnyActiveHearingDevice).thenReturn(true) + controller.onBluetoothDevicesChanged() + runCurrent() + assertThat(flowValues.size).isEqualTo(3) + + assertThat(flowValues.map { it.isAnyPairedHearingDevice }) + .containsExactly(false, true, true) + .inOrder() + assertThat(flowValues.map { it.isAnyActiveHearingDevice }) + .containsExactly(false, false, true) + .inOrder() + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileUserActionInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileUserActionInteractorTest.kt new file mode 100644 index 000000000000..00ee1c36590c --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileUserActionInteractorTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2024 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.impl.hearingdevices.domain.interactor + +import android.platform.test.annotations.EnabledOnRavenwood +import android.provider.Settings +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.accessibility.hearingaid.HearingDevicesDialogManager +import com.android.systemui.accessibility.hearingaid.HearingDevicesUiEventLogger.Companion.LAUNCH_SOURCE_QS_TILE +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.testScope +import com.android.systemui.qs.tiles.base.actions.FakeQSTileIntentUserInputHandler +import com.android.systemui.qs.tiles.base.actions.QSTileIntentUserInputHandlerSubject +import com.android.systemui.qs.tiles.base.interactor.QSTileInputTestKtx +import com.android.systemui.qs.tiles.impl.hearingdevices.domain.model.HearingDevicesTileModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mock +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.verify + +@SmallTest +@EnabledOnRavenwood +@RunWith(AndroidJUnit4::class) +class HearingDevicesTileUserActionInteractorTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val testScope = kosmos.testScope + private val inputHandler = FakeQSTileIntentUserInputHandler() + + private lateinit var underTest: HearingDevicesTileUserActionInteractor + + @Rule @JvmField val mockitoRule: MockitoRule = MockitoJUnit.rule() + @Mock private lateinit var dialogManager: HearingDevicesDialogManager + + @Before + fun setUp() { + underTest = + HearingDevicesTileUserActionInteractor( + testScope.coroutineContext, + inputHandler, + dialogManager, + ) + } + + @Test + fun handleClick_launchDialog() = + testScope.runTest { + val input = + HearingDevicesTileModel( + isAnyActiveHearingDevice = true, + isAnyPairedHearingDevice = true, + ) + + underTest.handleInput(QSTileInputTestKtx.click(input)) + + verify(dialogManager).showDialog(anyOrNull(), eq(LAUNCH_SOURCE_QS_TILE)) + } + + @Test + fun handleLongClick_launchSettings() = + testScope.runTest { + val input = + HearingDevicesTileModel( + isAnyActiveHearingDevice = true, + isAnyPairedHearingDevice = true, + ) + + underTest.handleInput(QSTileInputTestKtx.longClick(input)) + + QSTileIntentUserInputHandlerSubject.assertThat(inputHandler).handledOneIntentInput { + assertThat(it.intent.action).isEqualTo(Settings.ACTION_HEARING_DEVICES_SETTINGS) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt b/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt index cd9efaf6e6bb..610e3f8a8c84 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt +++ b/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt @@ -39,6 +39,10 @@ import com.android.systemui.qs.tiles.impl.fontscaling.domain.FontScalingTileMapp import com.android.systemui.qs.tiles.impl.fontscaling.domain.interactor.FontScalingTileDataInteractor import com.android.systemui.qs.tiles.impl.fontscaling.domain.interactor.FontScalingTileUserActionInteractor import com.android.systemui.qs.tiles.impl.fontscaling.domain.model.FontScalingTileModel +import com.android.systemui.qs.tiles.impl.hearingdevices.domain.HearingDevicesTileMapper +import com.android.systemui.qs.tiles.impl.hearingdevices.domain.interactor.HearingDevicesTileDataInteractor +import com.android.systemui.qs.tiles.impl.hearingdevices.domain.interactor.HearingDevicesTileUserActionInteractor +import com.android.systemui.qs.tiles.impl.hearingdevices.domain.model.HearingDevicesTileModel import com.android.systemui.qs.tiles.impl.inversion.domain.ColorInversionTileMapper import com.android.systemui.qs.tiles.impl.inversion.domain.interactor.ColorInversionTileDataInteractor import com.android.systemui.qs.tiles.impl.inversion.domain.interactor.ColorInversionUserActionInteractor @@ -159,6 +163,13 @@ interface QSAccessibilityModule { impl: NightDisplayTileDataInteractor ): QSTileAvailabilityInteractor + @Binds + @IntoMap + @StringKey(HEARING_DEVICES_TILE_SPEC) + fun provideHearingDevicesAvailabilityInteractor( + impl: HearingDevicesTileDataInteractor + ): QSTileAvailabilityInteractor + companion object { const val COLOR_CORRECTION_TILE_SPEC = "color_correction" const val COLOR_INVERSION_TILE_SPEC = "inversion" @@ -191,7 +202,7 @@ interface QSAccessibilityModule { factory: QSTileViewModelFactory.Static<ColorCorrectionTileModel>, mapper: ColorCorrectionTileMapper, stateInteractor: ColorCorrectionTileDataInteractor, - userActionInteractor: ColorCorrectionUserActionInteractor + userActionInteractor: ColorCorrectionUserActionInteractor, ): QSTileViewModel = factory.create( TileSpec.create(COLOR_CORRECTION_TILE_SPEC), @@ -223,7 +234,7 @@ interface QSAccessibilityModule { factory: QSTileViewModelFactory.Static<ColorInversionTileModel>, mapper: ColorInversionTileMapper, stateInteractor: ColorInversionTileDataInteractor, - userActionInteractor: ColorInversionUserActionInteractor + userActionInteractor: ColorInversionUserActionInteractor, ): QSTileViewModel = factory.create( TileSpec.create(COLOR_INVERSION_TILE_SPEC), @@ -255,7 +266,7 @@ interface QSAccessibilityModule { factory: QSTileViewModelFactory.Static<FontScalingTileModel>, mapper: FontScalingTileMapper, stateInteractor: FontScalingTileDataInteractor, - userActionInteractor: FontScalingTileUserActionInteractor + userActionInteractor: FontScalingTileUserActionInteractor, ): QSTileViewModel = factory.create( TileSpec.create(FONT_SCALING_TILE_SPEC), @@ -279,21 +290,6 @@ interface QSAccessibilityModule { category = TileCategory.DISPLAY, ) - @Provides - @IntoMap - @StringKey(HEARING_DEVICES_TILE_SPEC) - fun provideHearingDevicesTileConfig(uiEventLogger: QsEventLogger): QSTileConfig = - QSTileConfig( - tileSpec = TileSpec.create(HEARING_DEVICES_TILE_SPEC), - uiConfig = - QSTileUIConfig.Resource( - iconRes = R.drawable.qs_hearing_devices_icon, - labelRes = R.string.quick_settings_hearing_devices_label, - ), - instanceId = uiEventLogger.getNewInstanceId(), - category = TileCategory.ACCESSIBILITY, - ) - /** * Inject Reduce Bright Colors Tile into tileViewModelMap in QSModule. The tile is hidden * behind a flag. @@ -305,7 +301,7 @@ interface QSAccessibilityModule { factory: QSTileViewModelFactory.Static<ReduceBrightColorsTileModel>, mapper: ReduceBrightColorsTileMapper, stateInteractor: ReduceBrightColorsTileDataInteractor, - userActionInteractor: ReduceBrightColorsTileUserActionInteractor + userActionInteractor: ReduceBrightColorsTileUserActionInteractor, ): QSTileViewModel = if (Flags.qsNewTilesFuture()) factory.create( @@ -339,7 +335,7 @@ interface QSAccessibilityModule { factory: QSTileViewModelFactory.Static<OneHandedModeTileModel>, mapper: OneHandedModeTileMapper, stateInteractor: OneHandedModeTileDataInteractor, - userActionInteractor: OneHandedModeTileUserActionInteractor + userActionInteractor: OneHandedModeTileUserActionInteractor, ): QSTileViewModel = if (Flags.qsNewTilesFuture()) factory.create( @@ -376,7 +372,7 @@ interface QSAccessibilityModule { factory: QSTileViewModelFactory.Static<NightDisplayTileModel>, mapper: NightDisplayTileMapper, stateInteractor: NightDisplayTileDataInteractor, - userActionInteractor: NightDisplayTileUserActionInteractor + userActionInteractor: NightDisplayTileUserActionInteractor, ): QSTileViewModel = if (Flags.qsNewTilesFuture()) factory.create( @@ -386,5 +382,43 @@ interface QSAccessibilityModule { mapper, ) else StubQSTileViewModel + + @Provides + @IntoMap + @StringKey(HEARING_DEVICES_TILE_SPEC) + fun provideHearingDevicesTileConfig(uiEventLogger: QsEventLogger): QSTileConfig = + QSTileConfig( + tileSpec = TileSpec.create(HEARING_DEVICES_TILE_SPEC), + uiConfig = + QSTileUIConfig.Resource( + iconRes = R.drawable.qs_hearing_devices_icon, + labelRes = R.string.quick_settings_hearing_devices_label, + ), + instanceId = uiEventLogger.getNewInstanceId(), + category = TileCategory.ACCESSIBILITY, + ) + + /** + * Inject HearingDevices Tile into tileViewModelMap in QSModule. The tile is hidden behind a + * flag. + */ + @Provides + @IntoMap + @StringKey(HEARING_DEVICES_TILE_SPEC) + fun provideHearingDevicesTileViewModel( + factory: QSTileViewModelFactory.Static<HearingDevicesTileModel>, + mapper: HearingDevicesTileMapper, + stateInteractor: HearingDevicesTileDataInteractor, + userActionInteractor: HearingDevicesTileUserActionInteractor, + ): QSTileViewModel { + return if (Flags.hearingAidsQsTileDialog() && Flags.qsNewTilesFuture()) { + factory.create( + TileSpec.create(HEARING_DEVICES_TILE_SPEC), + userActionInteractor, + stateInteractor, + mapper, + ) + } else StubQSTileViewModel + } } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/HearingDevicesTileMapper.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/HearingDevicesTileMapper.kt new file mode 100644 index 000000000000..8dd611f9911a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/HearingDevicesTileMapper.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 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.impl.hearingdevices.domain + +import android.content.res.Resources +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.qs.tiles.base.interactor.QSTileDataToStateMapper +import com.android.systemui.qs.tiles.impl.hearingdevices.domain.model.HearingDevicesTileModel +import com.android.systemui.qs.tiles.viewmodel.QSTileConfig +import com.android.systemui.qs.tiles.viewmodel.QSTileState +import com.android.systemui.res.R +import javax.inject.Inject + +/** Maps [HearingDevicesTileModel] to [QSTileState]. */ +class HearingDevicesTileMapper +@Inject +constructor(@Main private val resources: Resources, private val theme: Resources.Theme) : + QSTileDataToStateMapper<HearingDevicesTileModel> { + + override fun map(config: QSTileConfig, data: HearingDevicesTileModel): QSTileState = + QSTileState.build(resources, theme, config.uiConfig) { + label = resources.getString(R.string.quick_settings_hearing_devices_label) + iconRes = R.drawable.qs_hearing_devices_icon + val loadedIcon = + Icon.Loaded(resources.getDrawable(iconRes!!, theme), contentDescription = null) + icon = { loadedIcon } + sideViewIcon = QSTileState.SideViewIcon.Chevron + contentDescription = label + if (data.isAnyActiveHearingDevice) { + activationState = QSTileState.ActivationState.ACTIVE + secondaryLabel = + resources.getString(R.string.quick_settings_hearing_devices_connected) + } else if (data.isAnyPairedHearingDevice) { + activationState = QSTileState.ActivationState.INACTIVE + secondaryLabel = + resources.getString(R.string.quick_settings_hearing_devices_disconnected) + } else { + activationState = QSTileState.ActivationState.INACTIVE + secondaryLabel = "" + } + supportedActions = + setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileDataInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileDataInteractor.kt new file mode 100644 index 000000000000..ec0a4e9db896 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileDataInteractor.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 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.impl.hearingdevices.domain.interactor + +import android.os.UserHandle +import com.android.systemui.Flags +import com.android.systemui.accessibility.hearingaid.HearingDevicesChecker +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger +import com.android.systemui.qs.tiles.base.interactor.QSTileDataInteractor +import com.android.systemui.qs.tiles.impl.hearingdevices.domain.model.HearingDevicesTileModel +import com.android.systemui.statusbar.policy.BluetoothController +import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn + +/** Observes hearing devices state changes providing the [HearingDevicesTileModel]. */ +class HearingDevicesTileDataInteractor +@Inject +constructor( + @Background private val backgroundContext: CoroutineContext, + private val bluetoothController: BluetoothController, + private val hearingDevicesChecker: HearingDevicesChecker, +) : QSTileDataInteractor<HearingDevicesTileModel> { + override fun tileData( + user: UserHandle, + triggers: Flow<DataUpdateTrigger>, + ): Flow<HearingDevicesTileModel> = + conflatedCallbackFlow { + val callback = + object : BluetoothController.Callback { + override fun onBluetoothStateChange(enabled: Boolean) { + trySend(getModel()) + } + + override fun onBluetoothDevicesChanged() { + trySend(getModel()) + } + } + bluetoothController.addCallback(callback) + awaitClose { bluetoothController.removeCallback(callback) } + } + .flowOn(backgroundContext) + .distinctUntilChanged() + + override fun availability(user: UserHandle): Flow<Boolean> = + flowOf(Flags.hearingAidsQsTileDialog()) + + private fun getModel() = + HearingDevicesTileModel( + hearingDevicesChecker.isAnyActiveHearingDevice, + hearingDevicesChecker.isAnyPairedHearingDevice, + ) +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileUserActionInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileUserActionInteractor.kt new file mode 100644 index 000000000000..5e7172ee3ba7 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/interactor/HearingDevicesTileUserActionInteractor.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024 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.impl.hearingdevices.domain.interactor + +import android.content.Intent +import android.provider.Settings +import com.android.systemui.accessibility.hearingaid.HearingDevicesDialogManager +import com.android.systemui.accessibility.hearingaid.HearingDevicesUiEventLogger.Companion.LAUNCH_SOURCE_QS_TILE +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.qs.tiles.base.actions.QSTileIntentUserInputHandler +import com.android.systemui.qs.tiles.base.interactor.QSTileInput +import com.android.systemui.qs.tiles.base.interactor.QSTileUserActionInteractor +import com.android.systemui.qs.tiles.impl.hearingdevices.domain.model.HearingDevicesTileModel +import com.android.systemui.qs.tiles.viewmodel.QSTileUserAction +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.withContext + +/** Handles hearing devices tile clicks. */ +class HearingDevicesTileUserActionInteractor +@Inject +constructor( + @Main private val mainContext: CoroutineContext, + private val qsTileIntentUserActionHandler: QSTileIntentUserInputHandler, + private val hearingDevicesDialogManager: HearingDevicesDialogManager, +) : QSTileUserActionInteractor<HearingDevicesTileModel> { + + override suspend fun handleInput(input: QSTileInput<HearingDevicesTileModel>) = + with(input) { + when (action) { + is QSTileUserAction.Click -> { + withContext(mainContext) { + hearingDevicesDialogManager.showDialog( + action.expandable, + LAUNCH_SOURCE_QS_TILE, + ) + } + } + is QSTileUserAction.LongClick -> { + qsTileIntentUserActionHandler.handle( + action.expandable, + Intent(Settings.ACTION_HEARING_DEVICES_SETTINGS), + ) + } + is QSTileUserAction.ToggleClick -> {} + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/model/HearingDevicesTileModel.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/model/HearingDevicesTileModel.kt new file mode 100644 index 000000000000..4e37b771c49b --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/hearingdevices/domain/model/HearingDevicesTileModel.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 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.impl.hearingdevices.domain.model + +/** Hearing devices tile model */ +data class HearingDevicesTileModel( + val isAnyActiveHearingDevice: Boolean, + val isAnyPairedHearingDevice: Boolean, +) diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/hearingdevices/HearingDevicesTileKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/hearingdevices/HearingDevicesTileKosmos.kt new file mode 100644 index 000000000000..e16756befb03 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/hearingdevices/HearingDevicesTileKosmos.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 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.impl.hearingdevices + +import com.android.systemui.accessibility.qs.QSAccessibilityModule +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.qsEventLogger + +val Kosmos.qsHearingDevicesTileConfig by + Kosmos.Fixture { QSAccessibilityModule.provideHearingDevicesTileConfig(qsEventLogger) } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/BluetoothControllerKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/BluetoothControllerKosmos.kt new file mode 100644 index 000000000000..14f4d75d5647 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/BluetoothControllerKosmos.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2024 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.statusbar.policy + +import com.android.systemui.kosmos.Kosmos + +val Kosmos.fakeBluetoothController by Kosmos.Fixture { FakeBluetoothController() } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeBluetoothController.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeBluetoothController.kt new file mode 100644 index 000000000000..4876cd8a6086 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/policy/FakeBluetoothController.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2024 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.statusbar.policy + +import android.bluetooth.BluetoothAdapter +import com.android.internal.annotations.VisibleForTesting +import com.android.settingslib.bluetooth.CachedBluetoothDevice +import com.android.systemui.statusbar.policy.BluetoothController.Callback +import java.io.PrintWriter +import java.util.Collections +import java.util.concurrent.Executor + +class FakeBluetoothController : BluetoothController { + + private var callbacks = mutableListOf<Callback>() + private var enabled = false + + override fun addCallback(listener: Callback) { + callbacks += listener + listener.onBluetoothStateChange(isBluetoothEnabled) + } + + override fun removeCallback(listener: Callback) { + callbacks -= listener + } + + override fun dump(pw: PrintWriter, args: Array<out String>) {} + + override fun isBluetoothSupported(): Boolean = false + + override fun isBluetoothEnabled(): Boolean = enabled + + override fun getBluetoothState(): Int = 0 + + override fun isBluetoothConnected(): Boolean = false + + override fun isBluetoothConnecting(): Boolean = false + + override fun isBluetoothAudioProfileOnly(): Boolean = false + + override fun isBluetoothAudioActive(): Boolean = false + + override fun getConnectedDeviceName(): String? = null + + override fun setBluetoothEnabled(enabled: Boolean) { + this.enabled = enabled + callbacks.forEach { it.onBluetoothStateChange(enabled) } + } + + override fun canConfigBluetooth(): Boolean = false + + override fun getConnectedDevices(): MutableList<CachedBluetoothDevice> = Collections.emptyList() + + override fun addOnMetadataChangedListener( + device: CachedBluetoothDevice?, + executor: Executor?, + listener: BluetoothAdapter.OnMetadataChangedListener?, + ) {} + + override fun removeOnMetadataChangedListener( + device: CachedBluetoothDevice?, + listener: BluetoothAdapter.OnMetadataChangedListener?, + ) {} + + /** Trigger the [Callback.onBluetoothDevicesChanged] method for all registered callbacks. */ + @VisibleForTesting + fun onBluetoothDevicesChanged() { + callbacks.forEach { it.onBluetoothDevicesChanged() } + } +} |