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

Commit 49f82c76 authored by Miranda Kephart's avatar Miranda Kephart
Browse files

Force screenshot sound if camera open

In certain locales, it's required for the camera to make a sound when a
photo is taken, even if the phone is otherwise on silent. Originally, we
turned off this feature for screenshots (since most screenshots are just
of the screen and don't pose a privacy concern in the same way).
However, it's possible to take a screenshot *of the camera*.

This change forces the shutter sound on if it's required for the camera
and the camera is currently open.

Bug: 411503333
Fix: 411503333
Test: manual (by hardcoding the shutter sound to be forced on)
Flag: com.android.systemui.screenshot_force_shutter_sound
Change-Id: Ie4e16ce3846369b5a5c6bd24a24139eaf420d2e5
parent ef669624
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -725,6 +725,16 @@ flag {
    }
}

flag {
    name: "screenshot_force_shutter_sound"
    namespace: "systemui"
    description: "Force screenshot sound when camera sound is forced and camera is open"
    bug: "411503333"
    metadata {
        purpose: PURPOSE_BUGFIX
    }
}

flag {
    name: "screenshot_action_dismiss_system_windows"
    namespace: "systemui"
+29 −3
Original line number Diff line number Diff line
@@ -16,13 +16,13 @@

package com.android.systemui.screenshot

import android.media.MediaActionSound
import android.media.MediaPlayer
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import java.lang.IllegalStateException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
@@ -32,19 +32,27 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever

@SmallTest
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class ScreenshotSoundControllerTest : SysuiTestCase() {

    private val soundProvider = mock<ScreenshotSoundProvider>()
    private val mediaPlayer = mock<MediaPlayer>()
    private val mediaActionSound = mock<MediaActionSound>()
    private val soundPolicy = mock<ScreenshotSoundPolicy>()
    private val bgDispatcher = UnconfinedTestDispatcher()
    private val scope = TestScope(bgDispatcher)

    @Before
    fun setup() {
        whenever(soundProvider.getScreenshotSound()).thenReturn(mediaPlayer)
        whenever(soundProvider.getForcedShutterSound()).thenReturn(mediaActionSound)
        whenever(soundPolicy.shouldForceShutterSound()).thenReturn(false) // default
    }

    @Test
@@ -53,6 +61,7 @@ class ScreenshotSoundControllerTest : SysuiTestCase() {
        scope.advanceUntilIdle()

        verify(soundProvider).getScreenshotSound()
        verify(soundProvider).getForcedShutterSound()
    }

    @Test
@@ -78,6 +87,7 @@ class ScreenshotSoundControllerTest : SysuiTestCase() {
            advanceUntilIdle()

            verify(mediaPlayer).start()
            verify(mediaActionSound, never()).play(any())
        }

    @Test
@@ -91,6 +101,7 @@ class ScreenshotSoundControllerTest : SysuiTestCase() {

            verify(mediaPlayer).start()
            verify(mediaPlayer).release()
            verify(mediaActionSound).release()
        }

    @Test
@@ -102,8 +113,23 @@ class ScreenshotSoundControllerTest : SysuiTestCase() {
            advanceUntilIdle()

            verify(mediaPlayer).release()
            verify(mediaActionSound).release()
        }

    @Test
    fun screenshotSoundForced_usesShutterSound() {
        scope.runTest {
            whenever(soundPolicy.shouldForceShutterSound()).thenReturn(true)
            val controller = createController()

            controller.playScreenshotSound()
            advanceUntilIdle()

            verify(mediaPlayer, never()).start()
            verify(mediaActionSound).play(MediaActionSound.SHUTTER_CLICK)
        }
    }

    private fun createController() =
        ScreenshotSoundControllerImpl(soundProvider, scope, bgDispatcher)
        ScreenshotSoundControllerImpl(soundProvider, soundPolicy, scope, bgDispatcher)
}
+98 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.screenshot

import android.hardware.camera2.CameraManager
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.Flags
import com.android.systemui.SysuiTestCase
import com.google.common.truth.Truth.assertThat
import java.util.concurrent.Executor
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@SmallTest
@RunWith(AndroidJUnit4::class)
class ScreenshotSoundPolicyTest : SysuiTestCase() {
    private val cameraManager = mock<CameraManager>()
    private val shutterSoundPolicy = mock<MediaShutterSoundPolicy>()

    private lateinit var cameraAvailabilityCallback: CameraManager.AvailabilityCallback
    private lateinit var screenshotSoundPolicy: ScreenshotSoundPolicy

    @Before
    fun setup() {
        whenever(
                cameraManager.registerAvailabilityCallback(
                    any<Executor>(),
                    any<CameraManager.AvailabilityCallback>(),
                )
            )
            .thenAnswer {
                cameraAvailabilityCallback = it.arguments[1] as CameraManager.AvailabilityCallback
                return@thenAnswer Unit
            }
        screenshotSoundPolicy =
            ScreenshotSoundPolicy(cameraManager, shutterSoundPolicy, context.mainExecutor)
    }

    @Test
    @DisableFlags(Flags.FLAG_SCREENSHOT_FORCE_SHUTTER_SOUND)
    fun flagOff_shouldNotForce() {
        whenever(shutterSoundPolicy.mustPlayShutterSound()).thenReturn(true)
        verify(cameraManager, never()).registerAvailabilityCallback(any<Executor>(), any())

        assertThat(screenshotSoundPolicy.shouldForceShutterSound()).isFalse()
    }

    @Test
    @EnableFlags(Flags.FLAG_SCREENSHOT_FORCE_SHUTTER_SOUND)
    fun cameraOff_shouldNotForce() {
        whenever(shutterSoundPolicy.mustPlayShutterSound()).thenReturn(true)
        cameraAvailabilityCallback.onCameraOpened("testCameraId", "testPackageId")
        cameraAvailabilityCallback.onCameraClosed("testCameraId")

        assertThat(screenshotSoundPolicy.shouldForceShutterSound()).isFalse()
    }

    @Test
    @EnableFlags(Flags.FLAG_SCREENSHOT_FORCE_SHUTTER_SOUND)
    fun shutterNotForced_shouldNotForce() {
        whenever(shutterSoundPolicy.mustPlayShutterSound()).thenReturn(false)
        cameraAvailabilityCallback.onCameraOpened("testCameraId", "testPackageId")

        assertThat(screenshotSoundPolicy.shouldForceShutterSound()).isFalse()
    }

    @Test
    @EnableFlags(Flags.FLAG_SCREENSHOT_FORCE_SHUTTER_SOUND)
    fun shutterForcedAndCameraOpen_shouldForce() {
        whenever(shutterSoundPolicy.mustPlayShutterSound()).thenReturn(true)
        cameraAvailabilityCallback.onCameraOpened("testCameraId", "testPackageId")

        assertThat(screenshotSoundPolicy.shouldForceShutterSound()).isTrue()
    }
}
+20 −3
Original line number Diff line number Diff line
@@ -16,9 +16,11 @@

package com.android.systemui.screenshot

import android.media.MediaActionSound
import android.media.MediaPlayer
import android.util.Log
import com.android.app.tracing.coroutines.asyncTraced as async
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import javax.inject.Inject
@@ -27,7 +29,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.TimeoutCancellationException
import com.android.app.tracing.coroutines.launchTraced as launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout

@@ -55,8 +56,9 @@ class ScreenshotSoundControllerImpl
@Inject
constructor(
    private val soundProvider: ScreenshotSoundProvider,
    private val soundPolicy: ScreenshotSoundPolicy,
    @Application private val coroutineScope: CoroutineScope,
    @Background private val bgDispatcher: CoroutineDispatcher
    @Background private val bgDispatcher: CoroutineDispatcher,
) : ScreenshotSoundController {

    private val player: Deferred<MediaPlayer?> =
@@ -69,10 +71,19 @@ constructor(
            }
        }

    private val forcedShutterSound: Deferred<MediaActionSound?> =
        coroutineScope.async("loadForcedCameraSound", bgDispatcher) {
            soundProvider.getForcedShutterSound()
        }

    override suspend fun playScreenshotSound() {
        withContext(bgDispatcher) {
            try {
                if (soundPolicy.shouldForceShutterSound()) {
                    forcedShutterSound.await()?.play(MediaActionSound.SHUTTER_CLICK)
                } else {
                    player.await()?.start()
                }
            } catch (e: IllegalStateException) {
                Log.w(TAG, "Screenshot sound failed to play", e)
                releaseScreenshotSound()
@@ -86,6 +97,12 @@ constructor(
                withTimeout(1.seconds) { player.await()?.release() }
            } catch (e: TimeoutCancellationException) {
                player.cancel()
                Log.w(TAG, "Error releasing screenshot sound", e)
            }
            try {
                withTimeout(1.seconds) { forcedShutterSound.await()?.release() }
            } catch (e: TimeoutCancellationException) {
                forcedShutterSound.cancel()
                Log.w(TAG, "Error releasing shutter sound", e)
            }
        }
+62 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.screenshot

import android.hardware.camera2.CameraManager
import android.media.MediaActionSound
import com.android.systemui.Flags.screenshotForceShutterSound
import com.android.systemui.dagger.qualifiers.Main
import java.util.concurrent.Executor
import javax.inject.Inject

/** Policy class to determine if a shutter sound should be forced when taking a screenshot. */
open class ScreenshotSoundPolicy
@Inject
constructor(
    cameraManager: CameraManager,
    private val shutterPolicy: MediaShutterSoundPolicy,
    @Main private val mainExecutor: Executor,
) {
    private var cameraOpen: Boolean = false

    init {
        if (screenshotForceShutterSound()) {
            cameraManager.registerAvailabilityCallback(
                mainExecutor,
                object : CameraManager.AvailabilityCallback() {
                    override fun onCameraOpened(cameraId: String, packageId: String) {
                        cameraOpen = true
                    }

                    override fun onCameraClosed(cameraId: String) {
                        cameraOpen = false
                    }
                },
            )
        }
    }

    fun shouldForceShutterSound(): Boolean {
        return screenshotForceShutterSound() && shutterPolicy.mustPlayShutterSound() && cameraOpen
    }
}

class MediaShutterSoundPolicy @Inject constructor() {
    fun mustPlayShutterSound(): Boolean {
        return MediaActionSound.mustPlayShutterSound()
    }
}
Loading