Merge "Migrate HearingDevicesTile" into main
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 0000000..cdf6bda
--- /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 0000000..1dfa2cd
--- /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 0000000..00ee1c3
--- /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 cd9efaf..610e3f8a 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.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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
factory: QSTileViewModelFactory.Static<ReduceBrightColorsTileModel>,
mapper: ReduceBrightColorsTileMapper,
stateInteractor: ReduceBrightColorsTileDataInteractor,
- userActionInteractor: ReduceBrightColorsTileUserActionInteractor
+ userActionInteractor: ReduceBrightColorsTileUserActionInteractor,
): QSTileViewModel =
if (Flags.qsNewTilesFuture())
factory.create(
@@ -339,7 +335,7 @@
factory: QSTileViewModelFactory.Static<OneHandedModeTileModel>,
mapper: OneHandedModeTileMapper,
stateInteractor: OneHandedModeTileDataInteractor,
- userActionInteractor: OneHandedModeTileUserActionInteractor
+ userActionInteractor: OneHandedModeTileUserActionInteractor,
): QSTileViewModel =
if (Flags.qsNewTilesFuture())
factory.create(
@@ -376,7 +372,7 @@
factory: QSTileViewModelFactory.Static<NightDisplayTileModel>,
mapper: NightDisplayTileMapper,
stateInteractor: NightDisplayTileDataInteractor,
- userActionInteractor: NightDisplayTileUserActionInteractor
+ userActionInteractor: NightDisplayTileUserActionInteractor,
): QSTileViewModel =
if (Flags.qsNewTilesFuture())
factory.create(
@@ -386,5 +382,43 @@
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 0000000..8dd611f
--- /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 0000000..ec0a4e9
--- /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 0000000..5e7172e
--- /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 0000000..4e37b77
--- /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 0000000..e16756b
--- /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 0000000..14f4d75
--- /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 0000000..4876cd8
--- /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() }
+ }
+}