diff --git a/res/values/strings.xml b/res/values/strings.xml index a34f909260c..6395a6169db 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -13285,4 +13285,11 @@ Turn off airplane mode to use UWB + + + Camera access + + Microphone access + + For all apps and services diff --git a/res/xml/privacy_dashboard_settings.xml b/res/xml/privacy_dashboard_settings.xml index a1a608d20ac..0cf5f77ae04 100644 --- a/res/xml/privacy_dashboard_settings.xml +++ b/res/xml/privacy_dashboard_settings.xml @@ -48,26 +48,40 @@ - - - - - + + + + + + + + + + + updateState(screen.findPreference(mPreferenceKey)), + mCallbackExecutor); + } +} diff --git a/src/com/android/settings/utils/SensorPrivacyManagerHelper.java b/src/com/android/settings/utils/SensorPrivacyManagerHelper.java new file mode 100644 index 00000000000..13a987d73d4 --- /dev/null +++ b/src/com/android/settings/utils/SensorPrivacyManagerHelper.java @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2021 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.settings.utils; + +import android.content.Context; +import android.hardware.SensorPrivacyManager; +import android.hardware.SensorPrivacyManager.OnSensorPrivacyChangedListener; +import android.util.ArraySet; +import android.util.SparseArray; + +import java.util.concurrent.Executor; + +/** + * A class to help with calls to the sensor privacy manager. This class caches state when needed and + * multiplexes multiple listeners to a minimal set of binder calls. + */ +public class SensorPrivacyManagerHelper { + + public static final int MICROPHONE = SensorPrivacyManager.Sensors.MICROPHONE; + public static final int CAMERA = SensorPrivacyManager.Sensors.CAMERA; + + private static SensorPrivacyManagerHelper sInstance; + + private final SensorPrivacyManager mSensorPrivacyManager; + + private final SparseArray mCurrentUserCachedState = new SparseArray<>(); + private final SparseArray> mCachedState = new SparseArray<>(); + + private final SparseArray + mCurrentUserServiceListeners = new SparseArray<>(); + private final SparseArray> + mServiceListeners = new SparseArray<>(); + + private final ArraySet mCallbacks = new ArraySet<>(); + + private final Object mLock = new Object(); + + /** + * Callback for when the state of the sensor privacy changes. + */ + public interface Callback { + + /** + * Method invoked when the sensor privacy changes. + * @param sensor The sensor which changed + * @param blocked If the sensor is blocked + */ + void onSensorPrivacyChanged(int sensor, boolean blocked); + } + + private static class CallbackInfo { + static final int CURRENT_USER = -1; + + Callback mCallback; + Executor mExecutor; + int mSensor; + int mUserId; + + CallbackInfo(Callback callback, Executor executor, int sensor, int userId) { + mCallback = callback; + mExecutor = executor; + mSensor = sensor; + mUserId = userId; + } + } + + /** + * Gets the singleton instance + * @param context The context which is needed if the instance hasn't been created + * @return the instance + */ + public static SensorPrivacyManagerHelper getInstance(Context context) { + if (sInstance == null) { + sInstance = new SensorPrivacyManagerHelper(context); + } + return sInstance; + } + + /** + * Only to be used in tests + */ + private static void clearInstance() { + sInstance = null; + } + + private SensorPrivacyManagerHelper(Context context) { + mSensorPrivacyManager = context.getSystemService(SensorPrivacyManager.class); + } + + /** + * Checks if the given toggle is supported on this device + * @param sensor The sensor to check + * @return whether the toggle for the sensor is supported on this device. + */ + public boolean supportsSensorToggle(int sensor) { + return mSensorPrivacyManager.supportsSensorToggle(sensor); + } + + /** + * Checks if the sensor is blocked for the current user. If the user switches and the state of + * the new user is different, this value will change. + * @param sensor the sensor to check + * @return true if the sensor is blocked for the current user + */ + public boolean isSensorBlocked(int sensor) { + synchronized (mLock) { + Boolean blocked = mCurrentUserCachedState.get(sensor); + if (blocked == null) { + registerCurrentUserListenerIfNeeded(sensor); + + blocked = mSensorPrivacyManager.isSensorPrivacyEnabled(sensor); + mCurrentUserCachedState.put(sensor, blocked); + } + + return blocked; + } + } + + /** + * Checks if the sensor is or would be blocked if the given user is the foreground user + * @param sensor the sensor to check + * @param userId the user to check + * @return true if the sensor is or would be blocked if the given user is the foreground user + */ + public boolean isSensorBlocked(int sensor, int userId) { + synchronized (mLock) { + SparseArray userCachedState = createUserCachedStateIfNeededLocked(userId); + Boolean blocked = userCachedState.get(sensor); + if (blocked == null) { + registerListenerIfNeeded(sensor, userId); + + blocked = mSensorPrivacyManager.isSensorPrivacyEnabled(sensor); + userCachedState.put(sensor, blocked); + } + + return blocked; + } + } + + /** + * Sets the sensor privacy for the current user. + * @param sensor The sensor to set for + * @param blocked The state to set to + */ + public void setSensorBlocked(int sensor, boolean blocked) { + mSensorPrivacyManager.setSensorPrivacy(sensor, blocked); + } + + /** + * Sets the sensor privacy for the given user. + * @param sensor The sensor to set for + * @param blocked The state to set to + * @param userId The user to set for + */ + public void setSensorBlocked(int sensor, boolean blocked, int userId) { + mSensorPrivacyManager.setSensorPrivacy(sensor, blocked, userId); + } + + /** + * Adds a listener for the state of the current user. If the current user changes and the state + * of the new user is different, a callback will be received. + * @param sensor The sensor to watch + * @param callback The callback to invoke + * @param executor The executor to invoke on + */ + public void addSensorBlockedListener(int sensor, Callback callback, Executor executor) { + synchronized (mLock) { + mCallbacks.add(new CallbackInfo(callback, executor, sensor, CallbackInfo.CURRENT_USER)); + } + } + + /** + * Adds a listener for the state of the given user + * @param sensor The sensor to watch + * @param callback The callback to invoke + * @param executor The executor to invoke on + */ + public void addSensorBlockedListener(int sensor, int userId, Callback callback, + Executor executor) { + synchronized (mLock) { + mCallbacks.add(new CallbackInfo(callback, executor, sensor, userId)); + } + } + + /** + * Removes a callback + * @param callback The callback to remove + */ + public void removeBlockedListener(Callback callback) { + synchronized (mLock) { + mCallbacks.removeIf(callbackInfo -> callbackInfo.mCallback == callback); + } + } + + private void registerCurrentUserListenerIfNeeded(int sensor) { + synchronized (mLock) { + if (!mCurrentUserServiceListeners.contains(sensor)) { + OnSensorPrivacyChangedListener listener = (s, enabled) -> { + mCurrentUserCachedState.put(sensor, enabled); + dispatchStateChangedLocked(sensor, enabled, CallbackInfo.CURRENT_USER); + }; + mCurrentUserServiceListeners.put(sensor, listener); + mSensorPrivacyManager.addSensorPrivacyListener(sensor, listener); + } + } + } + + private void registerListenerIfNeeded(int sensor, int userId) { + synchronized (mLock) { + SparseArray + userServiceListeners = createUserServiceListenersIfNeededLocked(userId); + + if (!userServiceListeners.contains(sensor)) { + OnSensorPrivacyChangedListener listener = (s, enabled) -> { + SparseArray userCachedState = + createUserCachedStateIfNeededLocked(userId); + userCachedState.put(sensor, enabled); + dispatchStateChangedLocked(sensor, enabled, userId); + }; + mCurrentUserServiceListeners.put(sensor, listener); + mSensorPrivacyManager.addSensorPrivacyListener(sensor, listener); + } + } + } + + private void dispatchStateChangedLocked(int sensor, boolean blocked, int userId) { + for (CallbackInfo callbackInfo : mCallbacks) { + if (callbackInfo.mUserId == userId && callbackInfo.mSensor == sensor) { + Callback callback = callbackInfo.mCallback; + Executor executor = callbackInfo.mExecutor; + + executor.execute(() -> callback.onSensorPrivacyChanged(sensor, blocked)); + } + } + } + + private SparseArray createUserCachedStateIfNeededLocked(int userId) { + SparseArray userCachedState = mCachedState.get(userId); + if (userCachedState == null) { + userCachedState = new SparseArray<>(); + mCachedState.put(userId, userCachedState); + } + return userCachedState; + } + + private SparseArray createUserServiceListenersIfNeededLocked( + int userId) { + SparseArray userServiceListeners = + mServiceListeners.get(userId); + if (userServiceListeners == null) { + userServiceListeners = new SparseArray<>(); + mServiceListeners.put(userId, userServiceListeners); + } + return userServiceListeners; + } +} diff --git a/tests/robotests/src/com/android/settings/privacy/SensorToggleControllerTest.java b/tests/robotests/src/com/android/settings/privacy/SensorToggleControllerTest.java new file mode 100644 index 00000000000..b38dbe8fd61 --- /dev/null +++ b/tests/robotests/src/com/android/settings/privacy/SensorToggleControllerTest.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2019 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.settings.privacy; + +import static android.hardware.SensorPrivacyManager.Sensors.CAMERA; +import static android.hardware.SensorPrivacyManager.Sensors.MICROPHONE; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; + +import android.content.Context; +import android.hardware.SensorPrivacyManager; +import android.hardware.SensorPrivacyManager.OnSensorPrivacyChangedListener; +import android.util.ArraySet; + +import com.android.settings.utils.SensorPrivacyManagerHelper; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import java.lang.reflect.Method; +import java.util.Set; + +@RunWith(RobolectricTestRunner.class) +public class SensorToggleControllerTest { + + @Mock + private Context mContext; + @Mock + private SensorPrivacyManager mSensorPrivacyManager; + private boolean mMicState; + private boolean mCamState; + private Set mMicListeners; + private Set mCamListeners; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = Mockito.mock(Context.class); + mSensorPrivacyManager = Mockito.mock(SensorPrivacyManager.class); + + try { + Method clearInstance = + SensorPrivacyManagerHelper.class.getDeclaredMethod("clearInstance"); + clearInstance.setAccessible(true); + clearInstance.invoke(null); + } catch (Exception e) { + throw new RuntimeException(e); + } + mMicState = false; + mCamState = false; + mMicListeners = new ArraySet<>(); + mCamListeners = new ArraySet<>(); + + doReturn(0).when(mContext).getUserId(); + doReturn(mSensorPrivacyManager).when(mContext) + .getSystemService(SensorPrivacyManager.class); + + doAnswer(invocation -> mMicState) + .when(mSensorPrivacyManager).isSensorPrivacyEnabled(eq(MICROPHONE)); + doAnswer(invocation -> mCamState) + .when(mSensorPrivacyManager).isSensorPrivacyEnabled(eq(CAMERA)); + + doAnswer(invocation -> { + mMicState = invocation.getArgument(1); + for (OnSensorPrivacyChangedListener listener : mMicListeners) { + listener.onSensorPrivacyChanged(MICROPHONE, mMicState); + } + return null; + }).when(mSensorPrivacyManager).setSensorPrivacy(eq(MICROPHONE), anyBoolean()); + doAnswer(invocation -> { + mCamState = invocation.getArgument(1); + for (OnSensorPrivacyChangedListener listener : mMicListeners) { + listener.onSensorPrivacyChanged(CAMERA, mMicState); + } + return null; + }).when(mSensorPrivacyManager).setSensorPrivacy(eq(CAMERA), anyBoolean()); + + doAnswer(invocation -> mMicListeners.add(invocation.getArgument(1))) + .when(mSensorPrivacyManager).addSensorPrivacyListener(eq(MICROPHONE), any()); + doAnswer(invocation -> mCamListeners.add(invocation.getArgument(1))) + .when(mSensorPrivacyManager).addSensorPrivacyListener(eq(CAMERA), any()); + } + + @Test + public void isChecked_disableMicrophoneSensorPrivacy_returnTrue() { + mSensorPrivacyManager.setSensorPrivacy(MICROPHONE, false); + MicToggleController micToggleController = new MicToggleController(mContext, "mic_toggle"); + assertTrue(micToggleController.isChecked()); + } + + @Test + public void isChecked_enableMicrophoneSensorPrivacy_returnFalse() { + mSensorPrivacyManager.setSensorPrivacy(MICROPHONE, true); + MicToggleController micToggleController = new MicToggleController(mContext, "mic_toggle"); + assertFalse(micToggleController.isChecked()); + } + + @Test + public void isChecked_disableMicrophoneSensorPrivacyThenChanged_returnFalse() { + mSensorPrivacyManager.setSensorPrivacy(MICROPHONE, false); + MicToggleController micToggleController = new MicToggleController(mContext, "mic_toggle"); + mSensorPrivacyManager.setSensorPrivacy(MICROPHONE, true); + assertFalse(micToggleController.isChecked()); + } + + @Test + public void isChecked_enableMicrophoneSensorPrivacyThenChanged_returnTrue() { + mSensorPrivacyManager.setSensorPrivacy(MICROPHONE, true); + MicToggleController micToggleController = new MicToggleController(mContext, "mic_toggle"); + mSensorPrivacyManager.setSensorPrivacy(MICROPHONE, false); + assertTrue(micToggleController.isChecked()); + } + + @Test + public void isMicrophoneSensorPrivacyEnabled_uncheckMicToggle_returnTrue() { + mSensorPrivacyManager.setSensorPrivacy(MICROPHONE, false); + MicToggleController micToggleController = new MicToggleController(mContext, "mic_toggle"); + micToggleController.setChecked(false); + assertTrue(mMicState); + } + + @Test + public void isMicrophoneSensorPrivacyEnabled_checkMicToggle_returnFalse() { + mSensorPrivacyManager.setSensorPrivacy(MICROPHONE, true); + MicToggleController micToggleController = new MicToggleController(mContext, "mic_toggle"); + micToggleController.setChecked(true); + assertFalse(mMicState); + } + + @Test + public void isChecked_disableCameraSensorPrivacy_returnTrue() { + mSensorPrivacyManager.setSensorPrivacy(CAMERA, false); + CameraToggleController camToggleController = + new CameraToggleController(mContext, "cam_toggle"); + assertTrue(camToggleController.isChecked()); + } + + @Test + public void isChecked_enableCameraSensorPrivacy_returnFalse() { + mSensorPrivacyManager.setSensorPrivacy(CAMERA, true); + CameraToggleController camToggleController = + new CameraToggleController(mContext, "cam_toggle"); + assertFalse(camToggleController.isChecked()); + } + + @Test + public void isChecked_disableCameraSensorPrivacyThenChanged_returnFalse() { + mSensorPrivacyManager.setSensorPrivacy(CAMERA, false); + CameraToggleController camToggleController = + new CameraToggleController(mContext, "cam_toggle"); + mSensorPrivacyManager.setSensorPrivacy(CAMERA, true); + assertFalse(camToggleController.isChecked()); + } + + @Test + public void isChecked_enableCameraSensorPrivacyThenChanged_returnTrue() { + mSensorPrivacyManager.setSensorPrivacy(CAMERA, true); + CameraToggleController camToggleController = + new CameraToggleController(mContext, "cam_toggle"); + mSensorPrivacyManager.setSensorPrivacy(CAMERA, false); + assertTrue(camToggleController.isChecked()); + } + + @Test + public void isCameraSensorPrivacyEnabled_uncheckMicToggle_returnTrue() { + mSensorPrivacyManager.setSensorPrivacy(CAMERA, false); + CameraToggleController camToggleController = + new CameraToggleController(mContext, "cam_toggle"); + camToggleController.setChecked(false); + assertTrue(mCamState); + } + + @Test + public void isCameraSensorPrivacyEnabled_checkMicToggle_returnFalse() { + mSensorPrivacyManager.setSensorPrivacy(CAMERA, true); + CameraToggleController camToggleController = + new CameraToggleController(mContext, "cam_toggle"); + camToggleController.setChecked(true); + assertFalse(mCamState); + } +}