Use ambient light sensor to adjust camera privacy light brightness

If there is a light sensor available at boot then we will watch for
changing values and compute the integral of the values over the past
specified interval. Light sensor values are transformed with a logorithm
since it was observed that values increase very quickly with addition of
artificial light although as a human it feels that brightness is
increasing linearly (some light research tells this is called
Fechner's law)

Bug: 216107392
Test: CameraPrivacyLightControlllerTest

Change-Id: I94742d099d6a5d074173a21e629a3044f0a10c9a
This commit is contained in:
Evan Severson
2022-03-07 09:28:32 -08:00
parent 5815b8820e
commit 6614973779
5 changed files with 387 additions and 20 deletions

View File

@@ -448,5 +448,6 @@
<color name="accessibility_color_inversion_background">#546E7A</color>
<!-- Color of camera light when camera is in use -->
<color name="camera_privacy_light">#FFFFFF</color>
<color name="camera_privacy_light_day">#FFFFFF</color>
<color name="camera_privacy_light_night">#FFFFFF</color>
</resources>

View File

@@ -5790,4 +5790,9 @@
<!-- List of the labels of requestable device state config values -->
<string-array name="config_deviceStatesAvailableForAppRequests"/>
<!-- Interval in milliseconds to average light sensor values for camera light brightness -->
<integer name="config_cameraPrivacyLightAlsAveragingIntervalMillis">3000</integer>
<!-- Light sensor's lux value to use as the threshold between using day or night brightness -->
<integer name="config_cameraPrivacyLightAlsNightThreshold">4</integer>
</resources>

View File

@@ -4754,7 +4754,10 @@
<!-- For VirtualDeviceManager -->
<java-symbol type="string" name="vdm_camera_access_denied" />
<java-symbol type="color" name="camera_privacy_light"/>
<java-symbol type="color" name="camera_privacy_light_day"/>
<java-symbol type="color" name="camera_privacy_light_night"/>
<java-symbol type="integer" name="config_cameraPrivacyLightAlsAveragingIntervalMillis"/>
<java-symbol type="integer" name="config_cameraPrivacyLightAlsNightThreshold"/>
<java-symbol type="bool" name="config_bg_current_drain_monitor_enabled" />
<java-symbol type="array" name="config_bg_current_drain_threshold_to_restricted_bucket" />

View File

@@ -16,44 +16,105 @@
package com.android.server.sensorprivacy;
import static android.hardware.SensorManager.SENSOR_DELAY_NORMAL;
import android.annotation.ColorInt;
import android.app.AppOpsManager;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.hardware.lights.Light;
import android.hardware.lights.LightState;
import android.hardware.lights.LightsManager;
import android.hardware.lights.LightsRequest;
import android.os.Handler;
import android.os.HandlerExecutor;
import android.os.Looper;
import android.os.SystemClock;
import android.permission.PermissionManager;
import android.util.ArraySet;
import android.util.Pair;
import com.android.internal.R;
import com.android.internal.annotations.VisibleForTesting;
import com.android.server.FgThread;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;
class CameraPrivacyLightController implements AppOpsManager.OnOpActiveChangedListener {
class CameraPrivacyLightController implements AppOpsManager.OnOpActiveChangedListener,
SensorEventListener {
@VisibleForTesting
static final double LIGHT_VALUE_MULTIPLIER = 1 / Math.log(1.1);
private final Handler mHandler;
private final Executor mExecutor;
private final Context mContext;
private final AppOpsManager mAppOpsManager;
private final LightsManager mLightsManager;
private final SensorManager mSensorManager;
private final Set<String> mActivePackages = new ArraySet<>();
private final Set<String> mActivePhonePackages = new ArraySet<>();
private final int mCameraPrivacyLightColor;
private final List<Light> mCameraLights = new ArrayList<>();
private final AppOpsManager mAppOpsManager;
private LightsManager.LightsSession mLightsSession = null;
@ColorInt
private final int mDayColor;
@ColorInt
private final int mNightColor;
private final Sensor mLightSensor;
private boolean mIsAmbientLightListenerRegistered = false;
private final long mMovingAverageIntervalMillis;
/** When average of the time integral over the past {@link #mMovingAverageIntervalMillis}
* milliseconds of the log_1.1(lux(t)) is greater than this value, use the daytime brightness
* else use nighttime brightness. */
private final long mNightThreshold;
private final ArrayDeque<Pair<Long, Integer>> mAmbientLightValues = new ArrayDeque<>();
/** Tracks the Riemann sum of {@link #mAmbientLightValues} to avoid O(n) operations when sum is
* needed */
private long mAlvSum = 0;
private int mLastLightColor = 0;
/** The elapsed real time that the ALS was started watching */
private long mElapsedTimeStartedReading;
private final Object mDelayedUpdateToken = new Object();
// Can't mock static native methods, workaround for testing
private long mElapsedRealTime = -1;
CameraPrivacyLightController(Context context) {
this(context, FgThread.get().getLooper());
}
@VisibleForTesting
CameraPrivacyLightController(Context context, Looper looper) {
mContext = context;
mHandler = new Handler(looper);
mExecutor = new HandlerExecutor(mHandler);
mAppOpsManager = mContext.getSystemService(AppOpsManager.class);
mLightsManager = mContext.getSystemService(LightsManager.class);
mSensorManager = mContext.getSystemService(SensorManager.class);
mCameraPrivacyLightColor = mContext.getColor(R.color.camera_privacy_light);
mDayColor = mContext.getColor(R.color.camera_privacy_light_day);
mNightColor = mContext.getColor(R.color.camera_privacy_light_night);
mMovingAverageIntervalMillis = mContext.getResources()
.getInteger(R.integer.config_cameraPrivacyLightAlsAveragingIntervalMillis);
mNightThreshold = (long) (Math.log(mContext.getResources()
.getInteger(R.integer.config_cameraPrivacyLightAlsNightThreshold))
* LIGHT_VALUE_MULTIPLIER);
List<Light> lights = mLightsManager.getLights();
for (int i = 0; i < lights.size(); i++) {
@@ -64,12 +125,60 @@ class CameraPrivacyLightController implements AppOpsManager.OnOpActiveChangedLis
}
if (mCameraLights.isEmpty()) {
mLightSensor = null;
return;
}
mAppOpsManager.startWatchingActive(
new String[] {AppOpsManager.OPSTR_CAMERA, AppOpsManager.OPSTR_PHONE_CALL_CAMERA},
FgThread.getExecutor(), this);
mExecutor, this);
// It may be useful in the future to configure devices to know which lights are near which
// sensors so that we can control individual lights based on their environment.
mLightSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_LIGHT);
}
private void addElement(long time, int value) {
if (mAmbientLightValues.isEmpty()) {
// Eliminate the size == 1 edge case and assume the light value has been constant for
// the previous interval
mAmbientLightValues.add(new Pair<>(time - getCurrentIntervalMillis() - 1, value));
}
Pair<Long, Integer> lastElement = mAmbientLightValues.peekLast();
mAmbientLightValues.add(new Pair<>(time, value));
mAlvSum += (time - lastElement.first) * lastElement.second;
removeObsoleteData(time);
}
private void removeObsoleteData(long time) {
while (mAmbientLightValues.size() > 1) {
Pair<Long, Integer> element0 = mAmbientLightValues.pollFirst(); // NOTICE: POLL
Pair<Long, Integer> element1 = mAmbientLightValues.peekFirst(); // NOTICE: PEEK
if (element1.first > time - getCurrentIntervalMillis()) {
mAmbientLightValues.addFirst(element0);
break;
}
mAlvSum -= (element1.first - element0.first) * element0.second;
}
}
/**
* Gives the Riemann sum of {@link #mAmbientLightValues} where the part of the interval that
* stretches outside the time window is removed and the time since the last change is added in.
*/
private long getLiveAmbientLightTotal() {
if (mAmbientLightValues.isEmpty()) {
return mAlvSum;
}
long time = getElapsedRealTime();
removeObsoleteData(time);
Pair<Long, Integer> firstElement = mAmbientLightValues.peekFirst();
Pair<Long, Integer> lastElement = mAmbientLightValues.peekLast();
return mAlvSum - Math.max(0, time - getCurrentIntervalMillis() - firstElement.first)
* firstElement.second + (time - lastElement.first) * lastElement.second;
}
@Override
@@ -93,10 +202,16 @@ class CameraPrivacyLightController implements AppOpsManager.OnOpActiveChangedLis
}
private void updateLightSession() {
if (Looper.myLooper() != mHandler.getLooper()) {
mHandler.post(this::updateLightSession);
return;
}
Set<String> exemptedPackages = PermissionManager.getIndicatorExemptedPackages(mContext);
boolean shouldSessionEnd = exemptedPackages.containsAll(mActivePackages)
&& exemptedPackages.containsAll(mActivePhonePackages);
updateSensorListener(shouldSessionEnd);
if (shouldSessionEnd) {
if (mLightsSession == null) {
@@ -106,20 +221,73 @@ class CameraPrivacyLightController implements AppOpsManager.OnOpActiveChangedLis
mLightsSession.close();
mLightsSession = null;
} else {
if (mLightsSession != null) {
int lightColor;
if (mLightSensor != null && getLiveAmbientLightTotal()
< getCurrentIntervalMillis() * mNightThreshold) {
lightColor = mNightColor;
} else {
lightColor = mDayColor;
}
if (mLastLightColor == lightColor && mLightsSession != null) {
return;
}
mLastLightColor = lightColor;
LightsRequest.Builder requestBuilder = new LightsRequest.Builder();
for (int i = 0; i < mCameraLights.size(); i++) {
requestBuilder.addLight(mCameraLights.get(i),
new LightState.Builder()
.setColor(mCameraPrivacyLightColor)
.setColor(lightColor)
.build());
}
mLightsSession = mLightsManager.openSession(Integer.MAX_VALUE);
if (mLightsSession == null) {
mLightsSession = mLightsManager.openSession(Integer.MAX_VALUE);
}
mLightsSession.requestLights(requestBuilder.build());
}
}
private void updateSensorListener(boolean shouldSessionEnd) {
if (shouldSessionEnd && mIsAmbientLightListenerRegistered) {
mSensorManager.unregisterListener(this);
mIsAmbientLightListenerRegistered = false;
}
if (!shouldSessionEnd && !mIsAmbientLightListenerRegistered && mLightSensor != null) {
mSensorManager.registerListener(this, mLightSensor, SENSOR_DELAY_NORMAL, mHandler);
mIsAmbientLightListenerRegistered = true;
mElapsedTimeStartedReading = getElapsedRealTime();
}
}
private long getElapsedRealTime() {
return mElapsedRealTime == -1 ? SystemClock.elapsedRealtime() : mElapsedRealTime;
}
@VisibleForTesting
void setElapsedRealTime(long time) {
mElapsedRealTime = time;
}
@Override
public void onSensorChanged(SensorEvent event) {
// Using log space to represent human sensation (Fechner's Law) instead of lux
// because lux values causes bright flashes to skew the average very high.
addElement(event.timestamp, Math.max(0,
(int) (Math.log(event.values[0]) * LIGHT_VALUE_MULTIPLIER)));
updateLightSession();
mHandler.removeCallbacksAndMessages(mDelayedUpdateToken);
mHandler.postDelayed(CameraPrivacyLightController.this::updateLightSession,
mDelayedUpdateToken, mMovingAverageIntervalMillis);
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {}
private long getCurrentIntervalMillis() {
return Math.min(mMovingAverageIntervalMillis,
getElapsedRealTime() - mElapsedTimeStartedReading);
}
}

View File

@@ -24,20 +24,33 @@ import static com.android.dx.mockito.inline.extended.ExtendedMockito.verify;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import android.app.AppOpsManager;
import android.content.Context;
import android.content.res.Resources;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.hardware.lights.Light;
import android.hardware.lights.LightState;
import android.hardware.lights.LightsManager;
import android.hardware.lights.LightsRequest;
import android.os.Handler;
import android.os.Looper;
import android.permission.PermissionManager;
import android.util.ArraySet;
import com.android.dx.mockito.inline.extended.ExtendedMockito;
import com.android.internal.R;
import org.junit.After;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
@@ -53,26 +66,43 @@ import java.util.stream.Collectors;
public class CameraPrivacyLightControllerTest {
private int mDayColor = 1;
private int mNightColor = 0;
private int mCameraPrivacyLightAlsAveragingIntervalMillis = 5000;
private int mCameraPrivacyLightAlsNightThreshold = (int) getLightSensorValue(15);
private MockitoSession mMockitoSession;
@Mock
private Context mContext;
@Mock
private Resources mResources;
@Mock
private LightsManager mLightsManager;
@Mock
private AppOpsManager mAppOpsManager;
@Mock
private SensorManager mSensorManager;
@Mock
private LightsManager.LightsSession mLightsSession;
@Mock
private Sensor mLightSensor;
private ArgumentCaptor<AppOpsManager.OnOpActiveChangedListener> mAppOpsListenerCaptor =
ArgumentCaptor.forClass(AppOpsManager.OnOpActiveChangedListener.class);
private ArgumentCaptor<LightsRequest> mLightsRequestCaptor =
ArgumentCaptor.forClass(LightsRequest.class);
private ArgumentCaptor<SensorEventListener> mLightSensorListenerCaptor =
ArgumentCaptor.forClass(SensorEventListener.class);
private Set<String> mExemptedPackages = new ArraySet<>();
private List<Light> mLights = new ArrayList<>();
@@ -86,11 +116,22 @@ public class CameraPrivacyLightControllerTest {
.spyStatic(PermissionManager.class)
.startMocking();
doReturn(mDayColor).when(mContext).getColor(R.color.camera_privacy_light_day);
doReturn(mNightColor).when(mContext).getColor(R.color.camera_privacy_light_night);
doReturn(mResources).when(mContext).getResources();
doReturn(mCameraPrivacyLightAlsAveragingIntervalMillis).when(mResources)
.getInteger(R.integer.config_cameraPrivacyLightAlsAveragingIntervalMillis);
doReturn(mCameraPrivacyLightAlsNightThreshold).when(mResources)
.getInteger(R.integer.config_cameraPrivacyLightAlsNightThreshold);
doReturn(mLightsManager).when(mContext).getSystemService(LightsManager.class);
doReturn(mAppOpsManager).when(mContext).getSystemService(AppOpsManager.class);
doReturn(mSensorManager).when(mContext).getSystemService(SensorManager.class);
doReturn(mLights).when(mLightsManager).getLights();
doReturn(mLightsSession).when(mLightsManager).openSession(anyInt());
doReturn(mLightSensor).when(mSensorManager).getDefaultSensor(Sensor.TYPE_LIGHT);
doReturn(mExemptedPackages)
.when(() -> PermissionManager.getIndicatorExemptedPackages(any()));
@@ -107,7 +148,7 @@ public class CameraPrivacyLightControllerTest {
@Test
public void testAppsOpsListenerNotRegisteredWithoutCameraLights() {
mLights.add(getNextLight(false));
new CameraPrivacyLightController(mContext);
createCameraPrivacyLightController();
verify(mAppOpsManager, times(0)).startWatchingActive(any(), any(), any());
}
@@ -116,7 +157,7 @@ public class CameraPrivacyLightControllerTest {
public void testAppsOpsListenerRegisteredWithCameraLight() {
mLights.add(getNextLight(true));
new CameraPrivacyLightController(mContext);
createCameraPrivacyLightController();
verify(mAppOpsManager, times(1)).startWatchingActive(any(), any(), any());
}
@@ -128,14 +169,13 @@ public class CameraPrivacyLightControllerTest {
mLights.add(getNextLight(r.nextBoolean()));
}
new CameraPrivacyLightController(mContext);
createCameraPrivacyLightController();
// Verify no session has been opened at this point.
verify(mLightsManager, times(0)).openSession(anyInt());
// Set camera op as active.
verify(mAppOpsManager).startWatchingActive(any(), any(), mAppOpsListenerCaptor.capture());
mAppOpsListenerCaptor.getValue().onOpActiveChanged(OPSTR_CAMERA, 10101, "pkg1", true);
openCamera();
// Verify session has been opened exactly once
verify(mLightsManager, times(1)).openSession(anyInt());
@@ -161,7 +201,7 @@ public class CameraPrivacyLightControllerTest {
public void testWillOnlyOpenOnceWhenTwoPackagesStartOp() {
mLights.add(getNextLight(true));
new CameraPrivacyLightController(mContext);
createCameraPrivacyLightController();
verify(mAppOpsManager).startWatchingActive(any(), any(), mAppOpsListenerCaptor.capture());
@@ -176,7 +216,7 @@ public class CameraPrivacyLightControllerTest {
public void testWillCloseOnFinishOp() {
mLights.add(getNextLight(true));
new CameraPrivacyLightController(mContext);
createCameraPrivacyLightController();
verify(mAppOpsManager).startWatchingActive(any(), any(), mAppOpsListenerCaptor.capture());
@@ -192,7 +232,7 @@ public class CameraPrivacyLightControllerTest {
public void testWillCloseOnFinishOpForAllPackages() {
mLights.add(getNextLight(true));
new CameraPrivacyLightController(mContext);
createCameraPrivacyLightController();
int numUids = 100;
List<Integer> uids = new ArrayList<>(numUids);
@@ -226,7 +266,7 @@ public class CameraPrivacyLightControllerTest {
mLights.add(getNextLight(true));
mExemptedPackages.add("pkg1");
new CameraPrivacyLightController(mContext);
createCameraPrivacyLightController();
verify(mAppOpsManager).startWatchingActive(any(), any(), mAppOpsListenerCaptor.capture());
@@ -235,6 +275,147 @@ public class CameraPrivacyLightControllerTest {
verify(mLightsManager, times(0)).openSession(anyInt());
}
@Test
public void testNoLightSensor() {
mLights.add(getNextLight(true));
doReturn(null).when(mSensorManager).getDefaultSensor(Sensor.TYPE_LIGHT);
createCameraPrivacyLightController();
openCamera();
verify(mLightsSession).requestLights(mLightsRequestCaptor.capture());
LightsRequest lightsRequest = mLightsRequestCaptor.getValue();
for (LightState lightState : lightsRequest.getLightStates()) {
assertEquals(mDayColor, lightState.getColor());
}
}
@Test
public void testALSListenerNotRegisteredUntilCameraIsOpened() {
mLights.add(getNextLight(true));
Sensor sensor = mock(Sensor.class);
doReturn(sensor).when(mSensorManager).getDefaultSensor(Sensor.TYPE_LIGHT);
CameraPrivacyLightController cplc = createCameraPrivacyLightController();
verify(mSensorManager, never()).registerListener(any(SensorEventListener.class),
any(Sensor.class), anyInt(), any(Handler.class));
openCamera();
verify(mSensorManager, times(1)).registerListener(mLightSensorListenerCaptor.capture(),
any(Sensor.class), anyInt(), any(Handler.class));
mAppOpsListenerCaptor.getValue().onOpActiveChanged(OPSTR_CAMERA, 10001, "pkg", false);
verify(mSensorManager, times(1)).unregisterListener(mLightSensorListenerCaptor.getValue());
}
@Ignore
@Test
public void testDayColor() {
testBrightnessToColor(20, mDayColor);
}
@Ignore
@Test
public void testNightColor() {
testBrightnessToColor(10, mNightColor);
}
private void testBrightnessToColor(int brightnessValue, int color) {
mLights.add(getNextLight(true));
Sensor sensor = mock(Sensor.class);
doReturn(sensor).when(mSensorManager).getDefaultSensor(Sensor.TYPE_LIGHT);
CameraPrivacyLightController cplc = createCameraPrivacyLightController();
cplc.setElapsedRealTime(0);
openCamera();
verify(mSensorManager).registerListener(mLightSensorListenerCaptor.capture(),
any(Sensor.class), anyInt(), any(Handler.class));
SensorEventListener sensorListener = mLightSensorListenerCaptor.getValue();
float[] sensorEventValues = new float[1];
SensorEvent sensorEvent = new SensorEvent(sensor, 0, 0, sensorEventValues);
sensorEventValues[0] = getLightSensorValue(brightnessValue);
sensorListener.onSensorChanged(sensorEvent);
verify(mLightsSession, atLeastOnce()).requestLights(mLightsRequestCaptor.capture());
for (LightState lightState : mLightsRequestCaptor.getValue().getLightStates()) {
assertEquals(color, lightState.getColor());
}
}
@Ignore
@Test
public void testDayToNightTransistion() {
mLights.add(getNextLight(true));
Sensor sensor = mock(Sensor.class);
doReturn(sensor).when(mSensorManager).getDefaultSensor(Sensor.TYPE_LIGHT);
CameraPrivacyLightController cplc = createCameraPrivacyLightController();
cplc.setElapsedRealTime(0);
openCamera();
// There will be an initial call at brightness 0
verify(mLightsSession, times(1)).requestLights(any(LightsRequest.class));
verify(mSensorManager).registerListener(mLightSensorListenerCaptor.capture(),
any(Sensor.class), anyInt(), any(Handler.class));
SensorEventListener sensorListener = mLightSensorListenerCaptor.getValue();
onSensorEvent(cplc, sensorListener, sensor, 0, 20);
// 5 sec avg = 20
onSensorEvent(cplc, sensorListener, sensor, 5000, 30);
verify(mLightsSession, times(2)).requestLights(mLightsRequestCaptor.capture());
for (LightState lightState : mLightsRequestCaptor.getValue().getLightStates()) {
assertEquals(mDayColor, lightState.getColor());
}
// 5 sec avg = 22
onSensorEvent(cplc, sensorListener, sensor, 6000, 10);
// 5 sec avg = 18
onSensorEvent(cplc, sensorListener, sensor, 8000, 5);
// Should have always been day
verify(mLightsSession, times(2)).requestLights(mLightsRequestCaptor.capture());
for (LightState lightState : mLightsRequestCaptor.getValue().getLightStates()) {
assertEquals(mDayColor, lightState.getColor());
}
// 5 sec avg = 12
onSensorEvent(cplc, sensorListener, sensor, 10000, 5);
// Should now be night
verify(mLightsSession, times(3)).requestLights(mLightsRequestCaptor.capture());
for (LightState lightState : mLightsRequestCaptor.getValue().getLightStates()) {
assertEquals(mNightColor, lightState.getColor());
}
}
private void onSensorEvent(CameraPrivacyLightController cplc,
SensorEventListener sensorListener, Sensor sensor, long timestamp, int value) {
cplc.setElapsedRealTime(timestamp);
sensorListener.onSensorChanged(new SensorEvent(sensor, 0, timestamp,
new float[] {getLightSensorValue(value)}));
}
// Use the test thread so that the test is deterministic
private CameraPrivacyLightController createCameraPrivacyLightController() {
if (Looper.myLooper() == null) {
Looper.prepare();
}
return new CameraPrivacyLightController(mContext, Looper.myLooper());
}
private Light getNextLight(boolean cameraType) {
Light light = ExtendedMockito.mock(Light.class);
if (cameraType) {
@@ -245,4 +426,13 @@ public class CameraPrivacyLightControllerTest {
doReturn(mNextLightId++).when(light).getId();
return light;
}
private float getLightSensorValue(int i) {
return (float) Math.exp(i / CameraPrivacyLightController.LIGHT_VALUE_MULTIPLIER);
}
private void openCamera() {
verify(mAppOpsManager).startWatchingActive(any(), any(), mAppOpsListenerCaptor.capture());
mAppOpsListenerCaptor.getValue().onOpActiveChanged(OPSTR_CAMERA, 10001, "pkg", true);
}
}