diff options
| author | 2024-09-03 20:21:42 +0100 | |
|---|---|---|
| committer | 2024-09-06 13:28:13 +0100 | |
| commit | 18375b1bdf1e0bdd6ba54f1710486d90c44ea619 (patch) | |
| tree | a539e6d5a524dbd62891c4f78fc6ab4dc751f7a3 | |
| parent | 4d96988c635d722ef794625e0e91d379a303ef2d (diff) | |
Refactor CaptioningRepository to take current UserHandle into account.
CaptioningRepository now uses `createContextAsUser` to get a
CaptioningManager for current user.
Flag: EXEMPT BUGFIX
Fixes: 361042246
Test: atest VolumePanelScreenshotTest
Test: atest CaptioningViewModelTest
Change-Id: I32cec55231db16bc3b7401716326b8b8f2a631d7
9 files changed, 183 insertions, 134 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/CaptioningRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/CaptioningRepositoryTest.kt index dd85d9bd2d7c..fc57757c9a8c 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/CaptioningRepositoryTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/CaptioningRepositoryTest.kt @@ -20,11 +20,15 @@ import android.view.accessibility.CaptioningManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectValues +import com.android.systemui.kosmos.applicationCoroutineScope +import com.android.systemui.kosmos.testScope +import com.android.systemui.testKosmos +import com.android.systemui.user.data.repository.userRepository +import com.android.systemui.user.utils.FakeUserScopedService import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before @@ -39,10 +43,11 @@ import org.mockito.MockitoAnnotations @OptIn(ExperimentalCoroutinesApi::class) @SmallTest -@Suppress("UnspecifiedRegisterReceiverFlag") @RunWith(AndroidJUnit4::class) class CaptioningRepositoryTest : SysuiTestCase() { + private val kosmos = testKosmos() + @Captor private lateinit var listenerCaptor: ArgumentCaptor<CaptioningManager.CaptioningChangeListener> @@ -50,34 +55,33 @@ class CaptioningRepositoryTest : SysuiTestCase() { private lateinit var underTest: CaptioningRepository - private val testScope = TestScope() - @Before fun setup() { MockitoAnnotations.initMocks(this) underTest = - CaptioningRepositoryImpl( - captioningManager, - testScope.testScheduler, - testScope.backgroundScope - ) + with(kosmos) { + CaptioningRepositoryImpl( + FakeUserScopedService(captioningManager), + userRepository, + testScope.testScheduler, + applicationCoroutineScope, + ) + } } @Test fun isSystemAudioCaptioningEnabled_change_repositoryEmits() { - testScope.runTest { - `when`(captioningManager.isEnabled).thenReturn(false) - val isSystemAudioCaptioningEnabled = mutableListOf<Boolean>() - underTest.isSystemAudioCaptioningEnabled - .onEach { isSystemAudioCaptioningEnabled.add(it) } - .launchIn(backgroundScope) + kosmos.testScope.runTest { + `when`(captioningManager.isSystemAudioCaptioningEnabled).thenReturn(false) + val models by collectValues(underTest.captioningModel.filterNotNull()) runCurrent() + `when`(captioningManager.isSystemAudioCaptioningEnabled).thenReturn(true) triggerOnSystemAudioCaptioningChange() runCurrent() - assertThat(isSystemAudioCaptioningEnabled) + assertThat(models.map { it.isSystemAudioCaptioningEnabled }) .containsExactlyElementsIn(listOf(false, true)) .inOrder() } @@ -85,18 +89,16 @@ class CaptioningRepositoryTest : SysuiTestCase() { @Test fun isSystemAudioCaptioningUiEnabled_change_repositoryEmits() { - testScope.runTest { - `when`(captioningManager.isSystemAudioCaptioningUiEnabled).thenReturn(false) - val isSystemAudioCaptioningUiEnabled = mutableListOf<Boolean>() - underTest.isSystemAudioCaptioningUiEnabled - .onEach { isSystemAudioCaptioningUiEnabled.add(it) } - .launchIn(backgroundScope) + kosmos.testScope.runTest { + `when`(captioningManager.isEnabled).thenReturn(false) + val models by collectValues(underTest.captioningModel.filterNotNull()) runCurrent() + `when`(captioningManager.isSystemAudioCaptioningUiEnabled).thenReturn(true) triggerSystemAudioCaptioningUiChange() runCurrent() - assertThat(isSystemAudioCaptioningUiEnabled) + assertThat(models.map { it.isSystemAudioCaptioningUiEnabled }) .containsExactlyElementsIn(listOf(false, true)) .inOrder() } diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/data/model/CaptioningModel.kt b/packages/SystemUI/src/com/android/systemui/accessibility/data/model/CaptioningModel.kt new file mode 100644 index 000000000000..4eb2274cf129 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/data/model/CaptioningModel.kt @@ -0,0 +1,22 @@ +/* + * 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.accessibility.data.model + +data class CaptioningModel( + val isSystemAudioCaptioningUiEnabled: Boolean, + val isSystemAudioCaptioningEnabled: Boolean, +) diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/data/repository/CaptioningRepository.kt b/packages/SystemUI/src/com/android/systemui/accessibility/data/repository/CaptioningRepository.kt index bf749d4cfc35..5414b623ff97 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/data/repository/CaptioningRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/accessibility/data/repository/CaptioningRepository.kt @@ -16,98 +16,90 @@ package com.android.systemui.accessibility.data.repository +import android.annotation.SuppressLint import android.view.accessibility.CaptioningManager +import com.android.systemui.accessibility.data.model.CaptioningModel +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.user.data.repository.UserRepository +import com.android.systemui.user.utils.UserScopedService +import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow +import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext interface CaptioningRepository { - /** The system audio caption enabled state. */ - val isSystemAudioCaptioningEnabled: StateFlow<Boolean> + /** Current state of Live Captions. */ + val captioningModel: StateFlow<CaptioningModel?> - /** The system audio caption UI enabled state. */ - val isSystemAudioCaptioningUiEnabled: StateFlow<Boolean> - - /** Sets [isSystemAudioCaptioningEnabled]. */ + /** Sets [CaptioningModel.isSystemAudioCaptioningEnabled]. */ suspend fun setIsSystemAudioCaptioningEnabled(isEnabled: Boolean) } -class CaptioningRepositoryImpl( - private val captioningManager: CaptioningManager, - private val backgroundCoroutineContext: CoroutineContext, - coroutineScope: CoroutineScope, +@OptIn(ExperimentalCoroutinesApi::class) +class CaptioningRepositoryImpl +@Inject +constructor( + private val userScopedCaptioningManagerProvider: UserScopedService<CaptioningManager>, + userRepository: UserRepository, + @Background private val backgroundCoroutineContext: CoroutineContext, + @Application coroutineScope: CoroutineScope, ) : CaptioningRepository { - private val captioningChanges: SharedFlow<CaptioningChange> = - callbackFlow { - val listener = CaptioningChangeProducingListener(this) - captioningManager.addCaptioningChangeListener(listener) - awaitClose { captioningManager.removeCaptioningChangeListener(listener) } - } - .shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 0) - - override val isSystemAudioCaptioningEnabled: StateFlow<Boolean> = - captioningChanges - .filterIsInstance(CaptioningChange.IsSystemAudioCaptioningEnabled::class) - .map { it.isEnabled } - .onStart { emit(captioningManager.isSystemAudioCaptioningEnabled) } - .stateIn( - coroutineScope, - SharingStarted.WhileSubscribed(), - captioningManager.isSystemAudioCaptioningEnabled, - ) + @SuppressLint("NonInjectedService") // this uses user-aware context + private val captioningManager: StateFlow<CaptioningManager?> = + userRepository.selectedUser + .map { userScopedCaptioningManagerProvider.forUser(it.userInfo.userHandle) } + .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), null) - override val isSystemAudioCaptioningUiEnabled: StateFlow<Boolean> = - captioningChanges - .filterIsInstance(CaptioningChange.IsSystemUICaptioningEnabled::class) - .map { it.isEnabled } - .onStart { emit(captioningManager.isSystemAudioCaptioningUiEnabled) } - .stateIn( - coroutineScope, - SharingStarted.WhileSubscribed(), - captioningManager.isSystemAudioCaptioningUiEnabled, - ) + override val captioningModel: StateFlow<CaptioningModel?> = + captioningManager + .filterNotNull() + .flatMapLatest { it.captioningModel() } + .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), null) override suspend fun setIsSystemAudioCaptioningEnabled(isEnabled: Boolean) { withContext(backgroundCoroutineContext) { - captioningManager.isSystemAudioCaptioningEnabled = isEnabled + captioningManager.value?.isSystemAudioCaptioningEnabled = isEnabled } } - private sealed interface CaptioningChange { - - data class IsSystemAudioCaptioningEnabled(val isEnabled: Boolean) : CaptioningChange - - data class IsSystemUICaptioningEnabled(val isEnabled: Boolean) : CaptioningChange - } - - private class CaptioningChangeProducingListener( - private val scope: ProducerScope<CaptioningChange> - ) : CaptioningManager.CaptioningChangeListener() { - - override fun onSystemAudioCaptioningChanged(enabled: Boolean) { - emitChange(CaptioningChange.IsSystemAudioCaptioningEnabled(enabled)) - } - - override fun onSystemAudioCaptioningUiChanged(enabled: Boolean) { - emitChange(CaptioningChange.IsSystemUICaptioningEnabled(enabled)) - } - - private fun emitChange(change: CaptioningChange) { - scope.launch { scope.send(change) } - } + private fun CaptioningManager.captioningModel(): Flow<CaptioningModel> { + return conflatedCallbackFlow { + val listener = + object : CaptioningManager.CaptioningChangeListener() { + + override fun onSystemAudioCaptioningChanged(enabled: Boolean) { + trySend(Unit) + } + + override fun onSystemAudioCaptioningUiChanged(enabled: Boolean) { + trySend(Unit) + } + } + addCaptioningChangeListener(listener) + awaitClose { removeCaptioningChangeListener(listener) } + } + .onStart { emit(Unit) } + .map { + CaptioningModel( + isSystemAudioCaptioningEnabled = isSystemAudioCaptioningEnabled, + isSystemAudioCaptioningUiEnabled = isSystemAudioCaptioningUiEnabled, + ) + } + .flowOn(backgroundCoroutineContext) } } diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/domain/interactor/CaptioningInteractor.kt b/packages/SystemUI/src/com/android/systemui/accessibility/domain/interactor/CaptioningInteractor.kt index 1d493c697652..840edf44ecf5 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/domain/interactor/CaptioningInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/accessibility/domain/interactor/CaptioningInteractor.kt @@ -17,16 +17,22 @@ package com.android.systemui.accessibility.domain.interactor import com.android.systemui.accessibility.data.repository.CaptioningRepository -import kotlinx.coroutines.flow.StateFlow +import com.android.systemui.dagger.SysUISingleton +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map -class CaptioningInteractor(private val repository: CaptioningRepository) { +@SysUISingleton +class CaptioningInteractor @Inject constructor(private val repository: CaptioningRepository) { - val isSystemAudioCaptioningEnabled: StateFlow<Boolean> - get() = repository.isSystemAudioCaptioningEnabled + val isSystemAudioCaptioningEnabled: Flow<Boolean> = + repository.captioningModel.filterNotNull().map { it.isSystemAudioCaptioningEnabled } - val isSystemAudioCaptioningUiEnabled: StateFlow<Boolean> - get() = repository.isSystemAudioCaptioningUiEnabled + val isSystemAudioCaptioningUiEnabled: Flow<Boolean> = + repository.captioningModel.filterNotNull().map { it.isSystemAudioCaptioningUiEnabled } - suspend fun setIsSystemAudioCaptioningEnabled(enabled: Boolean) = + suspend fun setIsSystemAudioCaptioningEnabled(enabled: Boolean) { repository.setIsSystemAudioCaptioningEnabled(enabled) + } } diff --git a/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java b/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java index 21a704df074e..8818c3af4916 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java @@ -202,6 +202,13 @@ public class FrameworkServicesModule { return context.getSystemService(CaptioningManager.class); } + @Provides + @Singleton + static UserScopedService<CaptioningManager> provideUserScopedCaptioningManager( + Context context) { + return new UserScopedServiceImpl<>(context, CaptioningManager.class); + } + /** */ @Provides @Singleton diff --git a/packages/SystemUI/src/com/android/systemui/volume/dagger/CaptioningModule.kt b/packages/SystemUI/src/com/android/systemui/volume/dagger/CaptioningModule.kt index 9715772f089f..28a43df2bfb3 100644 --- a/packages/SystemUI/src/com/android/systemui/volume/dagger/CaptioningModule.kt +++ b/packages/SystemUI/src/com/android/systemui/volume/dagger/CaptioningModule.kt @@ -16,35 +16,16 @@ package com.android.systemui.volume.dagger -import android.view.accessibility.CaptioningManager import com.android.systemui.accessibility.data.repository.CaptioningRepository import com.android.systemui.accessibility.data.repository.CaptioningRepositoryImpl -import com.android.systemui.accessibility.domain.interactor.CaptioningInteractor import com.android.systemui.dagger.SysUISingleton -import com.android.systemui.dagger.qualifiers.Application -import com.android.systemui.dagger.qualifiers.Background +import dagger.Binds import dagger.Module -import dagger.Provides -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineScope @Module interface CaptioningModule { - companion object { - - @Provides - @SysUISingleton - fun provideCaptioningRepository( - captioningManager: CaptioningManager, - @Background coroutineContext: CoroutineContext, - @Application coroutineScope: CoroutineScope, - ): CaptioningRepository = - CaptioningRepositoryImpl(captioningManager, coroutineContext, coroutineScope) - - @Provides - @SysUISingleton - fun provideCaptioningInteractor(repository: CaptioningRepository): CaptioningInteractor = - CaptioningInteractor(repository) - } + @Binds + @SysUISingleton + fun bindCaptioningRepository(impl: CaptioningRepositoryImpl): CaptioningRepository } diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/captioning/domain/CaptioningAvailabilityCriteria.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/captioning/domain/CaptioningAvailabilityCriteria.kt index 52f2ce63ba21..2e5e389eba9c 100644 --- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/captioning/domain/CaptioningAvailabilityCriteria.kt +++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/captioning/domain/CaptioningAvailabilityCriteria.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn @VolumePanelScope class CaptioningAvailabilityCriteria @@ -45,7 +45,7 @@ constructor( else VolumePanelUiEvent.VOLUME_PANEL_LIVE_CAPTION_TOGGLE_GONE ) } - .shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1) + .stateIn(scope, SharingStarted.WhileSubscribed(), false) override fun isAvailable(): Flow<Boolean> = availability } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/accessibility/data/repository/FakeCaptioningRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/accessibility/data/repository/FakeCaptioningRepository.kt index 2a0e764279d6..a6394631d236 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/accessibility/data/repository/FakeCaptioningRepository.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/accessibility/data/repository/FakeCaptioningRepository.kt @@ -16,25 +16,31 @@ package com.android.systemui.accessibility.data.repository +import com.android.systemui.accessibility.data.model.CaptioningModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow class FakeCaptioningRepository : CaptioningRepository { - private val mutableIsSystemAudioCaptioningEnabled = MutableStateFlow(false) - override val isSystemAudioCaptioningEnabled: StateFlow<Boolean> - get() = mutableIsSystemAudioCaptioningEnabled.asStateFlow() - - private val mutableIsSystemAudioCaptioningUiEnabled = MutableStateFlow(false) - override val isSystemAudioCaptioningUiEnabled: StateFlow<Boolean> - get() = mutableIsSystemAudioCaptioningUiEnabled.asStateFlow() + private val mutableCaptioningModel = MutableStateFlow<CaptioningModel?>(null) + override val captioningModel: StateFlow<CaptioningModel?> = mutableCaptioningModel.asStateFlow() override suspend fun setIsSystemAudioCaptioningEnabled(isEnabled: Boolean) { - mutableIsSystemAudioCaptioningEnabled.value = isEnabled + mutableCaptioningModel.value = + CaptioningModel( + isSystemAudioCaptioningEnabled = isEnabled, + isSystemAudioCaptioningUiEnabled = + mutableCaptioningModel.value?.isSystemAudioCaptioningUiEnabled == true, + ) } - fun setIsSystemAudioCaptioningUiEnabled(isSystemAudioCaptioningUiEnabled: Boolean) { - mutableIsSystemAudioCaptioningUiEnabled.value = isSystemAudioCaptioningUiEnabled + fun setIsSystemAudioCaptioningUiEnabled(isEnabled: Boolean) { + mutableCaptioningModel.value = + CaptioningModel( + isSystemAudioCaptioningEnabled = + mutableCaptioningModel.value?.isSystemAudioCaptioningEnabled == true, + isSystemAudioCaptioningUiEnabled = isEnabled, + ) } } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/user/utils/FakeUserScopedService.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/user/utils/FakeUserScopedService.kt new file mode 100644 index 000000000000..78763f97adc3 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/user/utils/FakeUserScopedService.kt @@ -0,0 +1,33 @@ +/* + * 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.user.utils + +import android.os.UserHandle + +class FakeUserScopedService<T>(private val defaultImplementation: T) : UserScopedService<T> { + + private val implementations = mutableMapOf<UserHandle, T>() + + fun addImplementation(user: UserHandle, implementation: T) { + implementations[user] = implementation + } + + fun removeImplementation(user: UserHandle): T? = implementations.remove(user) + + override fun forUser(user: UserHandle): T = + implementations.getOrDefault(user, defaultImplementation) +} |