Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 40bdf7db authored by Anton Potapov's avatar Anton Potapov Committed by Android (Google) Code Review
Browse files

Merge "Refactor CaptioningRepository to take current UserHandle into account." into main

parents fb485147 18375b1b
Loading
Loading
Loading
Loading
+27 −25
Original line number Diff line number Diff line
@@ -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 =
            with(kosmos) {
                CaptioningRepositoryImpl(
                captioningManager,
                    FakeUserScopedService(captioningManager),
                    userRepository,
                    testScope.testScheduler,
                testScope.backgroundScope
                    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()
        }
+22 −0
Original line number Diff line number Diff line
/*
 * 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,
)
+59 −67
Original line number Diff line number Diff line
@@ -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)
    @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 isSystemAudioCaptioningEnabled: StateFlow<Boolean> =
        captioningChanges
            .filterIsInstance(CaptioningChange.IsSystemAudioCaptioningEnabled::class)
            .map { it.isEnabled }
            .onStart { emit(captioningManager.isSystemAudioCaptioningEnabled) }
            .stateIn(
                coroutineScope,
                SharingStarted.WhileSubscribed(),
                captioningManager.isSystemAudioCaptioningEnabled,
            )

    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() {
    private fun CaptioningManager.captioningModel(): Flow<CaptioningModel> {
        return conflatedCallbackFlow {
                val listener =
                    object : CaptioningManager.CaptioningChangeListener() {

                        override fun onSystemAudioCaptioningChanged(enabled: Boolean) {
            emitChange(CaptioningChange.IsSystemAudioCaptioningEnabled(enabled))
                            trySend(Unit)
                        }

                        override fun onSystemAudioCaptioningUiChanged(enabled: Boolean) {
            emitChange(CaptioningChange.IsSystemUICaptioningEnabled(enabled))
                            trySend(Unit)
                        }

        private fun emitChange(change: CaptioningChange) {
            scope.launch { scope.send(change) }
                    }
                addCaptioningChangeListener(listener)
                awaitClose { removeCaptioningChangeListener(listener) }
            }
            .onStart { emit(Unit) }
            .map {
                CaptioningModel(
                    isSystemAudioCaptioningEnabled = isSystemAudioCaptioningEnabled,
                    isSystemAudioCaptioningUiEnabled = isSystemAudioCaptioningUiEnabled,
                )
            }
            .flowOn(backgroundCoroutineContext)
    }
}
+13 −7
Original line number Diff line number Diff line
@@ -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)
    }
}
+7 −0
Original line number Diff line number Diff line
@@ -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
Loading