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

Commit 841b1794 authored by Behnam Heydarshahi's avatar Behnam Heydarshahi
Browse files

Migrate sensor privacy toggle tiles

Namely Camera Access and Microphone Access

Fixes: 301056270
Flag: aconfig com.android.systemui.qs_new_tiles DEVELOPMENT
Test: atest SensorPrivacyToggleTileDataInteractorTest
SensorPrivacyToggleTileUserActionInteractorTest
SensorPrivacyToggleTileMapperTest

Change-Id: Ie1b57139cab790e5332412379cb283375a162abc
parent c3ce5d86
Loading
Loading
Loading
Loading
+102 −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.qs.tiles.impl.sensorprivacy.domain.interactor

import android.hardware.SensorPrivacyManager.Sensors.CAMERA
import android.hardware.SensorPrivacyManager.Sensors.MICROPHONE
import android.os.UserHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
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.sensorprivacy.SensorPrivacyToggleTileDataInteractor
import com.android.systemui.statusbar.policy.IndividualSensorPrivacyController
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toCollection
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito.verify

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class SensorPrivacyToggleTileDataInteractorTest : SysuiTestCase() {
    private val kosmos = Kosmos()
    private val testScope = kosmos.testScope
    private val mockSensorPrivacyController =
        mock<IndividualSensorPrivacyController> {
            whenever(isSensorBlocked(eq(CAMERA))).thenReturn(false) // determines initial value
        }
    private val testUser = UserHandle.of(1)
    private val underTest =
        SensorPrivacyToggleTileDataInteractor(
            testScope.testScheduler,
            mockSensorPrivacyController,
            CAMERA
        )

    @Test
    fun availability_isTrue() =
        testScope.runTest {
            whenever(mockSensorPrivacyController.supportsSensorToggle(eq(CAMERA))).thenReturn(true)

            val availability = underTest.availability(testUser).toCollection(mutableListOf())
            runCurrent()

            assertThat(availability).hasSize(1)
            assertThat(availability.last()).isTrue()
        }

    @Test
    fun tileData_matchesPrivacyControllerIsSensorBlocked() =
        testScope.runTest {
            val callbackCaptor = argumentCaptor<IndividualSensorPrivacyController.Callback>()
            val data by
                collectLastValue(
                    underTest.tileData(testUser, flowOf(DataUpdateTrigger.InitialRequest))
                )
            runCurrent()
            verify(mockSensorPrivacyController).addCallback(callbackCaptor.capture())
            val callback = callbackCaptor.value

            runCurrent()
            assertThat(data!!.isBlocked).isFalse()

            callback.onSensorBlockedChanged(CAMERA, true)
            runCurrent()
            assertThat(data!!.isBlocked).isTrue()

            callback.onSensorBlockedChanged(CAMERA, false)
            runCurrent()
            assertThat(data!!.isBlocked).isFalse()

            callback.onSensorBlockedChanged(MICROPHONE, true)
            runCurrent()
            assertThat(data!!.isBlocked).isFalse() // We're NOT listening for MIC sensor changes
        }
}
+168 −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.qs.tiles.impl.sensorprivacy.domain.interactor

import android.hardware.SensorPrivacyManager
import android.hardware.SensorPrivacyManager.Sensors.CAMERA
import android.hardware.SensorPrivacyManager.Sensors.MICROPHONE
import android.provider.Settings
import android.safetycenter.SafetyCenterManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository
import com.android.systemui.keyguard.domain.interactor.keyguardInteractor
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.plugins.activityStarter
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.sensorprivacy.domain.SensorPrivacyToggleTileUserActionInteractor
import com.android.systemui.qs.tiles.impl.sensorprivacy.domain.model.SensorPrivacyToggleTileModel
import com.android.systemui.statusbar.policy.IndividualSensorPrivacyController
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito.never
import org.mockito.Mockito.verify

@SmallTest
@RunWith(AndroidJUnit4::class)
class SensorPrivacyToggleTileUserActionInteractorTest : SysuiTestCase() {
    private val kosmos = Kosmos()
    private val inputHandler = FakeQSTileIntentUserInputHandler()
    private val keyguardInteractor = kosmos.keyguardInteractor
    // The keyguard repository below is the same one kosmos used to create the interactor above
    private val keyguardRepository = kosmos.fakeKeyguardRepository
    private val mockActivityStarter = kosmos.activityStarter
    private val mockSensorPrivacyController = mock<IndividualSensorPrivacyController>()
    private val fakeSafetyCenterManager = mock<SafetyCenterManager>()

    private val underTest =
        SensorPrivacyToggleTileUserActionInteractor(
            inputHandler,
            keyguardInteractor,
            mockActivityStarter,
            mockSensorPrivacyController,
            fakeSafetyCenterManager,
            CAMERA
        )

    @Test
    fun handleClickWhenNotBlocked() = runTest {
        val originalIsBlocked = false

        underTest.handleInput(
            QSTileInputTestKtx.click(SensorPrivacyToggleTileModel(originalIsBlocked))
        )

        verify(mockSensorPrivacyController)
            .setSensorBlocked(
                eq(SensorPrivacyManager.Sources.QS_TILE),
                eq(CAMERA),
                eq(!originalIsBlocked)
            )
    }

    @Test
    fun handleClickWhenBlocked() = runTest {
        val originalIsBlocked = true

        underTest.handleInput(
            QSTileInputTestKtx.click(SensorPrivacyToggleTileModel(originalIsBlocked))
        )

        verify(mockSensorPrivacyController)
            .setSensorBlocked(
                eq(SensorPrivacyManager.Sources.QS_TILE),
                eq(CAMERA),
                eq(!originalIsBlocked)
            )
    }

    @Test
    fun handleClick_whenKeyguardIsDismissableAndShowing_whenControllerRequiresAuth() = runTest {
        whenever(mockSensorPrivacyController.requiresAuthentication()).thenReturn(true)
        keyguardRepository.setKeyguardDismissible(true)
        keyguardRepository.setKeyguardShowing(true)
        val originalIsBlocked = true

        underTest.handleInput(
            QSTileInputTestKtx.click(SensorPrivacyToggleTileModel(originalIsBlocked))
        )

        verify(mockSensorPrivacyController, never())
            .setSensorBlocked(
                eq(SensorPrivacyManager.Sources.QS_TILE),
                eq(CAMERA),
                eq(!originalIsBlocked)
            )
        verify(mockActivityStarter).postQSRunnableDismissingKeyguard(any())
    }

    @Test
    fun handleLongClick_whenSafetyManagerEnabled_privacyControlsIntent() = runTest {
        whenever(fakeSafetyCenterManager.isSafetyCenterEnabled).thenReturn(true)

        underTest.handleInput(QSTileInputTestKtx.longClick(SensorPrivacyToggleTileModel(false)))

        QSTileIntentUserInputHandlerSubject.assertThat(inputHandler).handledOneIntentInput {
            assertThat(it.intent.action).isEqualTo(Settings.ACTION_PRIVACY_CONTROLS)
        }
    }

    @Test
    fun handleLongClick_whenSafetyManagerDisabled_privacySettingsIntent() = runTest {
        whenever(fakeSafetyCenterManager.isSafetyCenterEnabled).thenReturn(false)

        underTest.handleInput(QSTileInputTestKtx.longClick(SensorPrivacyToggleTileModel(false)))

        QSTileIntentUserInputHandlerSubject.assertThat(inputHandler).handledOneIntentInput {
            assertThat(it.intent.action).isEqualTo(Settings.ACTION_PRIVACY_SETTINGS)
        }
    }

    @Test
    fun handleClick_microphone_flipsController() = runTest {
        val micUserActionInteractor =
            SensorPrivacyToggleTileUserActionInteractor(
                inputHandler,
                keyguardInteractor,
                mockActivityStarter,
                mockSensorPrivacyController,
                fakeSafetyCenterManager,
                MICROPHONE
            )

        micUserActionInteractor.handleInput(
            QSTileInputTestKtx.click(SensorPrivacyToggleTileModel(false))
        )
        verify(mockSensorPrivacyController)
            .setSensorBlocked(eq(SensorPrivacyManager.Sources.QS_TILE), eq(MICROPHONE), eq(true))

        micUserActionInteractor.handleInput(
            QSTileInputTestKtx.click(SensorPrivacyToggleTileModel(true))
        )
        verify(mockSensorPrivacyController)
            .setSensorBlocked(eq(SensorPrivacyManager.Sources.QS_TILE), eq(MICROPHONE), eq(false))
    }
}
+161 −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.qs.tiles.impl.sensorprivacy.ui

import android.graphics.drawable.TestStubDrawable
import android.hardware.SensorPrivacyManager.Sensors.CAMERA
import android.hardware.SensorPrivacyManager.Sensors.MICROPHONE
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.sensorprivacy.domain.model.SensorPrivacyToggleTileModel
import com.android.systemui.qs.tiles.impl.sensorprivacy.qsCameraSensorPrivacyToggleTileConfig
import com.android.systemui.qs.tiles.impl.sensorprivacy.qsMicrophoneSensorPrivacyToggleTileConfig
import com.android.systemui.qs.tiles.impl.sensorprivacy.ui.SensorPrivacyTileResources.CameraPrivacyTileResources
import com.android.systemui.qs.tiles.impl.sensorprivacy.ui.SensorPrivacyTileResources.MicrophonePrivacyTileResources
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 SensorPrivacyToggleTileMapperTest : SysuiTestCase() {
    private val kosmos = Kosmos()
    private val cameraConfig = kosmos.qsCameraSensorPrivacyToggleTileConfig
    private val micConfig = kosmos.qsMicrophoneSensorPrivacyToggleTileConfig

    @Test
    fun mapCamera_notBlocked() {
        val mapper = createMapper(CameraPrivacyTileResources)
        val inputModel = SensorPrivacyToggleTileModel(false)

        val outputState = mapper.map(cameraConfig, inputModel)

        val expectedState =
            createSensorPrivacyToggleTileState(
                QSTileState.ActivationState.ACTIVE,
                context.getString(R.string.quick_settings_camera_mic_available),
                R.drawable.qs_camera_access_icon_on,
                null,
                CAMERA
            )
        QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState)
    }

    @Test
    fun mapCamera_blocked() {
        val mapper = createMapper(CameraPrivacyTileResources)
        val inputModel = SensorPrivacyToggleTileModel(true)

        val outputState = mapper.map(cameraConfig, inputModel)

        val expectedState =
            createSensorPrivacyToggleTileState(
                QSTileState.ActivationState.INACTIVE,
                context.getString(R.string.quick_settings_camera_mic_blocked),
                R.drawable.qs_camera_access_icon_off,
                null,
                CAMERA
            )
        QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState)
    }

    @Test
    fun mapMic_notBlocked() {
        val mapper = createMapper(MicrophonePrivacyTileResources)
        val inputModel = SensorPrivacyToggleTileModel(false)

        val outputState = mapper.map(micConfig, inputModel)

        val expectedState =
            createSensorPrivacyToggleTileState(
                QSTileState.ActivationState.ACTIVE,
                context.getString(R.string.quick_settings_camera_mic_available),
                R.drawable.qs_mic_access_on,
                null,
                MICROPHONE
            )
        QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState)
    }

    @Test
    fun mapMic_blocked() {
        val mapper = createMapper(MicrophonePrivacyTileResources)
        val inputModel = SensorPrivacyToggleTileModel(true)

        val outputState = mapper.map(micConfig, inputModel)

        val expectedState =
            createSensorPrivacyToggleTileState(
                QSTileState.ActivationState.INACTIVE,
                context.getString(R.string.quick_settings_camera_mic_blocked),
                R.drawable.qs_mic_access_off,
                null,
                MICROPHONE
            )
        QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState)
    }

    private fun createMapper(
        sensorResources: SensorPrivacyTileResources
    ): SensorPrivacyToggleTileMapper {
        val mapper =
            SensorPrivacyToggleTileMapper(
                context.orCreateTestableResources
                    .apply {
                        addOverride(R.drawable.qs_camera_access_icon_off, TestStubDrawable())
                        addOverride(R.drawable.qs_camera_access_icon_on, TestStubDrawable())
                        addOverride(R.drawable.qs_mic_access_off, TestStubDrawable())
                        addOverride(R.drawable.qs_mic_access_on, TestStubDrawable())
                    }
                    .resources,
                context.theme,
                sensorResources,
            )
        return mapper
    }

    private fun createSensorPrivacyToggleTileState(
        activationState: QSTileState.ActivationState,
        secondaryLabel: String,
        iconRes: Int,
        stateDescription: CharSequence?,
        sensorId: Int,
    ): QSTileState {
        val label =
            if (sensorId == CAMERA) context.getString(R.string.quick_settings_camera_label)
            else context.getString(R.string.quick_settings_mic_label)

        return QSTileState(
            { Icon.Loaded(context.getDrawable(iconRes)!!, null) },
            label,
            activationState,
            secondaryLabel,
            setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK),
            label,
            stateDescription,
            QSTileState.SideViewIcon.None,
            QSTileState.EnabledState.ENABLED,
            Switch::class.qualifiedName
        )
    }
}
+1 −1
Original line number Diff line number Diff line
@@ -193,7 +193,7 @@ constructor(
    val isKeyguardShowing: Flow<Boolean> = repository.isKeyguardShowing

    /** Whether the keyguard is dismissible or not. */
    val isKeyguardDismissible: Flow<Boolean> = repository.isKeyguardDismissible
    val isKeyguardDismissible: StateFlow<Boolean> = repository.isKeyguardDismissible

    /** Whether the keyguard is occluded (covered by an activity). */
    @Deprecated("Use KeyguardTransitionInteractor + KeyguardState.OCCLUDED")
+114 −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.qs.tiles.impl.sensorprivacy

import android.hardware.SensorPrivacyManager.Sensors.CAMERA
import android.hardware.SensorPrivacyManager.Sensors.MICROPHONE
import android.hardware.SensorPrivacyManager.Sensors.Sensor
import android.os.UserHandle
import android.provider.DeviceConfig
import android.util.Log
import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
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.sensorprivacy.domain.model.SensorPrivacyToggleTileModel
import com.android.systemui.statusbar.policy.IndividualSensorPrivacyController
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext

/** Observes SensorPrivacyToggle mode state changes providing the [SensorPrivacyToggleTileModel]. */
class SensorPrivacyToggleTileDataInteractor
@AssistedInject
constructor(
    @Background private val bgCoroutineContext: CoroutineContext,
    private val privacyController: IndividualSensorPrivacyController,
    @Assisted @Sensor private val sensorId: Int,
) : QSTileDataInteractor<SensorPrivacyToggleTileModel> {
    @AssistedFactory
    interface Factory {
        fun create(@Sensor id: Int): SensorPrivacyToggleTileDataInteractor
    }

    override fun tileData(
        user: UserHandle,
        triggers: Flow<DataUpdateTrigger>
    ): Flow<SensorPrivacyToggleTileModel> =
        conflatedCallbackFlow {
                val callback =
                    IndividualSensorPrivacyController.Callback { sensor, blocked ->
                        if (sensor == sensorId) trySend(SensorPrivacyToggleTileModel(blocked))
                    }
                privacyController.addCallback(callback) // does not emit an initial state
                awaitClose { privacyController.removeCallback(callback) }
            }
            .onStart {
                emit(SensorPrivacyToggleTileModel(privacyController.isSensorBlocked(sensorId)))
            }
            .distinctUntilChanged()
            .flowOn(bgCoroutineContext)

    override fun availability(user: UserHandle) =
        flow { emit(isAvailable()) }.flowOn(bgCoroutineContext)

    private suspend fun isAvailable(): Boolean {
        return privacyController.supportsSensorToggle(sensorId) && isSensorDeviceConfigSet()
    }

    private suspend fun isSensorDeviceConfigSet(): Boolean =
        withContext(bgCoroutineContext) {
            try {
                val deviceConfigName = getDeviceConfigName(sensorId)
                return@withContext DeviceConfig.getBoolean(
                    DeviceConfig.NAMESPACE_PRIVACY,
                    deviceConfigName,
                    true
                )
            } catch (exception: IllegalArgumentException) {
                Log.w(
                    TAG,
                    "isDeviceConfigSet for sensorId $sensorId: " +
                        "Defaulting to true due to exception. ",
                    exception
                )
                return@withContext true
            }
        }

    private fun getDeviceConfigName(sensorId: Int): String {
        if (sensorId == MICROPHONE) {
            return "mic_toggle_enabled"
        } else if (sensorId == CAMERA) {
            return "camera_toggle_enabled"
        } else {
            throw IllegalArgumentException("getDeviceConfigName: unexpected sensorId: $sensorId")
        }
    }

    private companion object {
        const val TAG = "SensorPrivacyToggleTileException"
    }
}
Loading