| /* |
| * Copyright (C) 2018 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.appops; |
| |
| import static android.hardware.SensorPrivacyManager.Sensors.CAMERA; |
| import static android.hardware.SensorPrivacyManager.Sensors.MICROPHONE; |
| import static android.media.AudioManager.ACTION_MICROPHONE_MUTE_CHANGED; |
| |
| import android.app.AppOpsManager; |
| import android.content.BroadcastReceiver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.media.AudioManager; |
| import android.media.AudioRecordingConfiguration; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.os.UserHandle; |
| import android.permission.PermissionManager; |
| import android.util.ArraySet; |
| import android.util.Log; |
| import android.util.SparseArray; |
| |
| import androidx.annotation.WorkerThread; |
| |
| import com.android.internal.annotations.GuardedBy; |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.systemui.Dumpable; |
| import com.android.systemui.broadcast.BroadcastDispatcher; |
| import com.android.systemui.dagger.SysUISingleton; |
| import com.android.systemui.dagger.qualifiers.Background; |
| import com.android.systemui.dump.DumpManager; |
| import com.android.systemui.statusbar.policy.IndividualSensorPrivacyController; |
| import com.android.systemui.util.Assert; |
| import com.android.systemui.util.time.SystemClock; |
| |
| import java.io.PrintWriter; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Set; |
| |
| import javax.inject.Inject; |
| |
| /** |
| * Controller to keep track of applications that have requested access to given App Ops |
| * |
| * It can be subscribed to with callbacks. Additionally, it passes on the information to |
| * NotificationPresenter to be displayed to the user. |
| */ |
| @SysUISingleton |
| public class AppOpsControllerImpl extends BroadcastReceiver implements AppOpsController, |
| AppOpsManager.OnOpActiveChangedListener, |
| AppOpsManager.OnOpNotedInternalListener, IndividualSensorPrivacyController.Callback, |
| Dumpable { |
| |
| // This is the minimum time that we will keep AppOps that are noted on record. If multiple |
| // occurrences of the same (op, package, uid) happen in a shorter interval, they will not be |
| // notified to listeners. |
| private static final long NOTED_OP_TIME_DELAY_MS = 5000; |
| private static final String TAG = "AppOpsControllerImpl"; |
| private static final boolean DEBUG = false; |
| |
| private final BroadcastDispatcher mDispatcher; |
| private final Context mContext; |
| private final AppOpsManager mAppOps; |
| private final AudioManager mAudioManager; |
| private final IndividualSensorPrivacyController mSensorPrivacyController; |
| private final SystemClock mClock; |
| |
| private H mBGHandler; |
| private final List<AppOpsController.Callback> mCallbacks = new ArrayList<>(); |
| private final SparseArray<Set<Callback>> mCallbacksByCode = new SparseArray<>(); |
| private boolean mListening; |
| private boolean mMicMuted; |
| private boolean mCameraDisabled; |
| |
| @GuardedBy("mActiveItems") |
| private final List<AppOpItem> mActiveItems = new ArrayList<>(); |
| @GuardedBy("mNotedItems") |
| private final List<AppOpItem> mNotedItems = new ArrayList<>(); |
| @GuardedBy("mActiveItems") |
| private final SparseArray<ArrayList<AudioRecordingConfiguration>> mRecordingsByUid = |
| new SparseArray<>(); |
| |
| protected static final int[] OPS = new int[] { |
| AppOpsManager.OP_MONITOR_HIGH_POWER_LOCATION, |
| AppOpsManager.OP_CAMERA, |
| AppOpsManager.OP_PHONE_CALL_CAMERA, |
| AppOpsManager.OP_SYSTEM_ALERT_WINDOW, |
| AppOpsManager.OP_RECORD_AUDIO, |
| AppOpsManager.OP_RECEIVE_AMBIENT_TRIGGER_AUDIO, |
| AppOpsManager.OP_RECEIVE_EXPLICIT_USER_INTERACTION_AUDIO, |
| AppOpsManager.OP_PHONE_CALL_MICROPHONE, |
| AppOpsManager.OP_COARSE_LOCATION, |
| AppOpsManager.OP_FINE_LOCATION |
| }; |
| |
| @Inject |
| public AppOpsControllerImpl( |
| Context context, |
| @Background Looper bgLooper, |
| DumpManager dumpManager, |
| AudioManager audioManager, |
| IndividualSensorPrivacyController sensorPrivacyController, |
| BroadcastDispatcher dispatcher, |
| SystemClock clock |
| ) { |
| mDispatcher = dispatcher; |
| mAppOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE); |
| mBGHandler = new H(bgLooper); |
| final int numOps = OPS.length; |
| for (int i = 0; i < numOps; i++) { |
| mCallbacksByCode.put(OPS[i], new ArraySet<>()); |
| } |
| mAudioManager = audioManager; |
| mSensorPrivacyController = sensorPrivacyController; |
| mMicMuted = audioManager.isMicrophoneMute() |
| || mSensorPrivacyController.isSensorBlocked(MICROPHONE); |
| mCameraDisabled = mSensorPrivacyController.isSensorBlocked(CAMERA); |
| mContext = context; |
| mClock = clock; |
| dumpManager.registerDumpable(TAG, this); |
| } |
| |
| @VisibleForTesting |
| protected void setBGHandler(H handler) { |
| mBGHandler = handler; |
| } |
| |
| @VisibleForTesting |
| protected void setListening(boolean listening) { |
| mListening = listening; |
| if (listening) { |
| mAppOps.startWatchingActive(OPS, this); |
| mAppOps.startWatchingNoted(OPS, this); |
| mAudioManager.registerAudioRecordingCallback(mAudioRecordingCallback, mBGHandler); |
| mSensorPrivacyController.addCallback(this); |
| |
| mMicMuted = mAudioManager.isMicrophoneMute() |
| || mSensorPrivacyController.isSensorBlocked(MICROPHONE); |
| mCameraDisabled = mSensorPrivacyController.isSensorBlocked(CAMERA); |
| |
| mBGHandler.post(() -> mAudioRecordingCallback.onRecordingConfigChanged( |
| mAudioManager.getActiveRecordingConfigurations())); |
| mDispatcher.registerReceiverWithHandler(this, |
| new IntentFilter(ACTION_MICROPHONE_MUTE_CHANGED), mBGHandler); |
| |
| } else { |
| mAppOps.stopWatchingActive(this); |
| mAppOps.stopWatchingNoted(this); |
| mAudioManager.unregisterAudioRecordingCallback(mAudioRecordingCallback); |
| mSensorPrivacyController.removeCallback(this); |
| |
| mBGHandler.removeCallbacksAndMessages(null); // null removes all |
| mDispatcher.unregisterReceiver(this); |
| synchronized (mActiveItems) { |
| mActiveItems.clear(); |
| mRecordingsByUid.clear(); |
| } |
| synchronized (mNotedItems) { |
| mNotedItems.clear(); |
| } |
| } |
| } |
| |
| /** |
| * Adds a callback that will get notifified when an AppOp of the type the controller tracks |
| * changes |
| * |
| * @param callback Callback to report changes |
| * @param opsCodes App Ops the callback is interested in checking |
| * |
| * @see #removeCallback(int[], Callback) |
| */ |
| @Override |
| public void addCallback(int[] opsCodes, AppOpsController.Callback callback) { |
| boolean added = false; |
| final int numCodes = opsCodes.length; |
| for (int i = 0; i < numCodes; i++) { |
| if (mCallbacksByCode.contains(opsCodes[i])) { |
| mCallbacksByCode.get(opsCodes[i]).add(callback); |
| added = true; |
| } else { |
| if (DEBUG) Log.wtf(TAG, "APP_OP " + opsCodes[i] + " not supported"); |
| } |
| } |
| if (added) mCallbacks.add(callback); |
| if (!mCallbacks.isEmpty()) setListening(true); |
| } |
| |
| /** |
| * Removes a callback from those notified when an AppOp of the type the controller tracks |
| * changes |
| * |
| * @param callback Callback to stop reporting changes |
| * @param opsCodes App Ops the callback was interested in checking |
| * |
| * @see #addCallback(int[], Callback) |
| */ |
| @Override |
| public void removeCallback(int[] opsCodes, AppOpsController.Callback callback) { |
| final int numCodes = opsCodes.length; |
| for (int i = 0; i < numCodes; i++) { |
| if (mCallbacksByCode.contains(opsCodes[i])) { |
| mCallbacksByCode.get(opsCodes[i]).remove(callback); |
| } |
| } |
| mCallbacks.remove(callback); |
| if (mCallbacks.isEmpty()) setListening(false); |
| } |
| |
| // Find item number in list, only call if the list passed is locked |
| private AppOpItem getAppOpItemLocked(List<AppOpItem> appOpList, int code, int uid, |
| String packageName) { |
| final int itemsQ = appOpList.size(); |
| for (int i = 0; i < itemsQ; i++) { |
| AppOpItem item = appOpList.get(i); |
| if (item.getCode() == code && item.getUid() == uid |
| && item.getPackageName().equals(packageName)) { |
| return item; |
| } |
| } |
| return null; |
| } |
| |
| private boolean updateActives(int code, int uid, String packageName, boolean active) { |
| synchronized (mActiveItems) { |
| AppOpItem item = getAppOpItemLocked(mActiveItems, code, uid, packageName); |
| if (item == null && active) { |
| item = new AppOpItem(code, uid, packageName, mClock.elapsedRealtime()); |
| if (isOpMicrophone(code)) { |
| item.setDisabled(isAnyRecordingPausedLocked(uid)); |
| } else if (isOpCamera(code)) { |
| item.setDisabled(mCameraDisabled); |
| } |
| mActiveItems.add(item); |
| if (DEBUG) Log.w(TAG, "Added item: " + item.toString()); |
| return !item.isDisabled(); |
| } else if (item != null && !active) { |
| mActiveItems.remove(item); |
| if (DEBUG) Log.w(TAG, "Removed item: " + item.toString()); |
| return true; |
| } |
| return false; |
| } |
| } |
| |
| private void removeNoted(int code, int uid, String packageName) { |
| AppOpItem item; |
| synchronized (mNotedItems) { |
| item = getAppOpItemLocked(mNotedItems, code, uid, packageName); |
| if (item == null) return; |
| mNotedItems.remove(item); |
| if (DEBUG) Log.w(TAG, "Removed item: " + item.toString()); |
| } |
| boolean active; |
| // Check if the item is also active |
| synchronized (mActiveItems) { |
| active = getAppOpItemLocked(mActiveItems, code, uid, packageName) != null; |
| } |
| if (!active) { |
| notifySuscribersWorker(code, uid, packageName, false); |
| } |
| } |
| |
| private boolean addNoted(int code, int uid, String packageName) { |
| AppOpItem item; |
| boolean createdNew = false; |
| synchronized (mNotedItems) { |
| item = getAppOpItemLocked(mNotedItems, code, uid, packageName); |
| if (item == null) { |
| item = new AppOpItem(code, uid, packageName, mClock.elapsedRealtime()); |
| mNotedItems.add(item); |
| if (DEBUG) Log.w(TAG, "Added item: " + item.toString()); |
| createdNew = true; |
| } |
| } |
| // We should keep this so we make sure it cannot time out. |
| mBGHandler.removeCallbacksAndMessages(item); |
| mBGHandler.scheduleRemoval(item, NOTED_OP_TIME_DELAY_MS); |
| return createdNew; |
| } |
| |
| private boolean isUserVisible(String packageName) { |
| return PermissionManager.shouldShowPackageForIndicatorCached(mContext, packageName); |
| } |
| |
| @WorkerThread |
| public List<AppOpItem> getActiveAppOps() { |
| return getActiveAppOps(false); |
| } |
| |
| /** |
| * Returns a copy of the list containing all the active AppOps that the controller tracks. |
| * |
| * Call from a worker thread as it may perform long operations. |
| * |
| * @return List of active AppOps information |
| */ |
| @WorkerThread |
| public List<AppOpItem> getActiveAppOps(boolean showPaused) { |
| return getActiveAppOpsForUser(UserHandle.USER_ALL, showPaused); |
| } |
| |
| /** |
| * Returns a copy of the list containing all the active AppOps that the controller tracks, for |
| * a given user id. |
| * |
| * Call from a worker thread as it may perform long operations. |
| * |
| * @param userId User id to track, can be {@link UserHandle#USER_ALL} |
| * |
| * @return List of active AppOps information for that user id |
| */ |
| @WorkerThread |
| public List<AppOpItem> getActiveAppOpsForUser(int userId, boolean showPaused) { |
| Assert.isNotMainThread(); |
| List<AppOpItem> list = new ArrayList<>(); |
| synchronized (mActiveItems) { |
| final int numActiveItems = mActiveItems.size(); |
| for (int i = 0; i < numActiveItems; i++) { |
| AppOpItem item = mActiveItems.get(i); |
| if ((userId == UserHandle.USER_ALL |
| || UserHandle.getUserId(item.getUid()) == userId) |
| && isUserVisible(item.getPackageName()) |
| && (showPaused || !item.isDisabled())) { |
| list.add(item); |
| } |
| } |
| } |
| synchronized (mNotedItems) { |
| final int numNotedItems = mNotedItems.size(); |
| for (int i = 0; i < numNotedItems; i++) { |
| AppOpItem item = mNotedItems.get(i); |
| if ((userId == UserHandle.USER_ALL |
| || UserHandle.getUserId(item.getUid()) == userId) |
| && isUserVisible(item.getPackageName())) { |
| list.add(item); |
| } |
| } |
| } |
| return list; |
| } |
| |
| private void notifySuscribers(int code, int uid, String packageName, boolean active) { |
| mBGHandler.post(() -> notifySuscribersWorker(code, uid, packageName, active)); |
| } |
| |
| /** |
| * Required to override, delegate to other. Should not be called. |
| */ |
| public void onOpActiveChanged(String op, int uid, String packageName, boolean active) { |
| onOpActiveChanged(op, uid, packageName, null, active, |
| AppOpsManager.ATTRIBUTION_FLAGS_NONE, AppOpsManager.ATTRIBUTION_CHAIN_ID_NONE); |
| } |
| |
| // Get active app ops, and check if their attributions are trusted |
| @Override |
| public void onOpActiveChanged(String op, int uid, String packageName, String attributionTag, |
| boolean active, int attributionFlags, int attributionChainId) { |
| int code = AppOpsManager.strOpToOp(op); |
| if (DEBUG) { |
| Log.w(TAG, String.format("onActiveChanged(%d,%d,%s,%s,%d,%d)", code, uid, packageName, |
| Boolean.toString(active), attributionChainId, attributionFlags)); |
| } |
| if (active && attributionChainId != AppOpsManager.ATTRIBUTION_CHAIN_ID_NONE |
| && attributionFlags != AppOpsManager.ATTRIBUTION_FLAGS_NONE |
| && (attributionFlags & AppOpsManager.ATTRIBUTION_FLAG_ACCESSOR) == 0 |
| && (attributionFlags & AppOpsManager.ATTRIBUTION_FLAG_TRUSTED) == 0) { |
| // if this attribution chain isn't trusted, and this isn't the accessor, do not show it. |
| return; |
| } |
| boolean activeChanged = updateActives(code, uid, packageName, active); |
| if (!activeChanged) return; // early return |
| // Check if the item is also noted, in that case, there's no update. |
| boolean alsoNoted; |
| synchronized (mNotedItems) { |
| alsoNoted = getAppOpItemLocked(mNotedItems, code, uid, packageName) != null; |
| } |
| // If active is true, we only send the update if the op is not actively noted (already true) |
| // If active is false, we only send the update if the op is not actively noted (prevent |
| // early removal) |
| if (!alsoNoted) { |
| notifySuscribers(code, uid, packageName, active); |
| } |
| } |
| |
| @Override |
| public void onOpNoted(int code, int uid, String packageName, |
| String attributionTag, @AppOpsManager.OpFlags int flags, |
| @AppOpsManager.Mode int result) { |
| if (DEBUG) { |
| Log.w(TAG, "Noted op: " + code + " with result " |
| + AppOpsManager.MODE_NAMES[result] + " for package " + packageName); |
| } |
| if (result != AppOpsManager.MODE_ALLOWED) return; |
| boolean notedAdded = addNoted(code, uid, packageName); |
| if (!notedAdded) return; // early return |
| boolean alsoActive; |
| synchronized (mActiveItems) { |
| alsoActive = getAppOpItemLocked(mActiveItems, code, uid, packageName) != null; |
| } |
| if (!alsoActive) { |
| notifySuscribers(code, uid, packageName, true); |
| } |
| } |
| |
| private void notifySuscribersWorker(int code, int uid, String packageName, boolean active) { |
| if (mCallbacksByCode.contains(code) && isUserVisible(packageName)) { |
| if (DEBUG) Log.d(TAG, "Notifying of change in package " + packageName); |
| for (Callback cb: mCallbacksByCode.get(code)) { |
| cb.onActiveStateChanged(code, uid, packageName, active); |
| } |
| } |
| } |
| |
| @Override |
| public void dump(PrintWriter pw, String[] args) { |
| pw.println("AppOpsController state:"); |
| pw.println(" Listening: " + mListening); |
| pw.println(" Active Items:"); |
| for (int i = 0; i < mActiveItems.size(); i++) { |
| final AppOpItem item = mActiveItems.get(i); |
| pw.print(" "); pw.println(item.toString()); |
| } |
| pw.println(" Noted Items:"); |
| for (int i = 0; i < mNotedItems.size(); i++) { |
| final AppOpItem item = mNotedItems.get(i); |
| pw.print(" "); pw.println(item.toString()); |
| } |
| |
| } |
| |
| private boolean isAnyRecordingPausedLocked(int uid) { |
| if (mMicMuted) { |
| return true; |
| } |
| List<AudioRecordingConfiguration> configs = mRecordingsByUid.get(uid); |
| if (configs == null) return false; |
| int configsNum = configs.size(); |
| for (int i = 0; i < configsNum; i++) { |
| AudioRecordingConfiguration config = configs.get(i); |
| if (config.isClientSilenced()) return true; |
| } |
| return false; |
| } |
| |
| private void updateSensorDisabledStatus() { |
| synchronized (mActiveItems) { |
| int size = mActiveItems.size(); |
| for (int i = 0; i < size; i++) { |
| AppOpItem item = mActiveItems.get(i); |
| |
| boolean paused = false; |
| if (isOpMicrophone(item.getCode())) { |
| paused = isAnyRecordingPausedLocked(item.getUid()); |
| } else if (isOpCamera(item.getCode())) { |
| paused = mCameraDisabled; |
| } |
| |
| if (item.isDisabled() != paused) { |
| item.setDisabled(paused); |
| notifySuscribers( |
| item.getCode(), |
| item.getUid(), |
| item.getPackageName(), |
| !item.isDisabled() |
| ); |
| } |
| } |
| } |
| } |
| |
| private AudioManager.AudioRecordingCallback mAudioRecordingCallback = |
| new AudioManager.AudioRecordingCallback() { |
| @Override |
| public void onRecordingConfigChanged(List<AudioRecordingConfiguration> configs) { |
| synchronized (mActiveItems) { |
| mRecordingsByUid.clear(); |
| final int recordingsCount = configs.size(); |
| for (int i = 0; i < recordingsCount; i++) { |
| AudioRecordingConfiguration recording = configs.get(i); |
| |
| ArrayList<AudioRecordingConfiguration> recordings = mRecordingsByUid.get( |
| recording.getClientUid()); |
| if (recordings == null) { |
| recordings = new ArrayList<>(); |
| mRecordingsByUid.put(recording.getClientUid(), recordings); |
| } |
| recordings.add(recording); |
| } |
| } |
| updateSensorDisabledStatus(); |
| } |
| }; |
| |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| mMicMuted = mAudioManager.isMicrophoneMute() |
| || mSensorPrivacyController.isSensorBlocked(MICROPHONE); |
| updateSensorDisabledStatus(); |
| } |
| |
| @Override |
| public void onSensorBlockedChanged(int sensor, boolean blocked) { |
| mBGHandler.post(() -> { |
| if (sensor == CAMERA) { |
| mCameraDisabled = blocked; |
| } else if (sensor == MICROPHONE) { |
| mMicMuted = mAudioManager.isMicrophoneMute() || blocked; |
| } |
| updateSensorDisabledStatus(); |
| }); |
| } |
| |
| @Override |
| public boolean isMicMuted() { |
| return mMicMuted; |
| } |
| |
| private boolean isOpCamera(int op) { |
| return op == AppOpsManager.OP_CAMERA || op == AppOpsManager.OP_PHONE_CALL_CAMERA; |
| } |
| |
| private boolean isOpMicrophone(int op) { |
| return op == AppOpsManager.OP_RECORD_AUDIO || op == AppOpsManager.OP_PHONE_CALL_MICROPHONE |
| || op == AppOpsManager.OP_RECEIVE_AMBIENT_TRIGGER_AUDIO; |
| } |
| |
| protected class H extends Handler { |
| H(Looper looper) { |
| super(looper); |
| } |
| |
| public void scheduleRemoval(AppOpItem item, long timeToRemoval) { |
| removeCallbacksAndMessages(item); |
| postDelayed(new Runnable() { |
| @Override |
| public void run() { |
| removeNoted(item.getCode(), item.getUid(), item.getPackageName()); |
| } |
| }, item, timeToRemoval); |
| } |
| } |
| } |