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

Commit 999fe964 authored by Behnam Heydarshahi's avatar Behnam Heydarshahi Committed by Android (Google) Code Review
Browse files

Merge "Migrate sensor privacy toggle tiles" into main

parents db5e60c8 841b1794
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