diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml index 791b832775715..c6f03271f9316 100644 --- a/packages/SystemUI/AndroidManifest.xml +++ b/packages/SystemUI/AndroidManifest.xml @@ -255,6 +255,9 @@ + + + diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/tv/TvStatusBar.java b/packages/SystemUI/src/com/android/systemui/statusbar/tv/TvStatusBar.java index 07985ab5a43c5..02ae1f8afcdf6 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/tv/TvStatusBar.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/tv/TvStatusBar.java @@ -27,6 +27,7 @@ import android.os.UserHandle; import com.android.internal.statusbar.IStatusBarService; import com.android.systemui.SystemUI; import com.android.systemui.statusbar.CommandQueue; +import com.android.systemui.statusbar.tv.micdisclosure.AudioRecordingDisclosureBar; import javax.inject.Inject; import javax.inject.Singleton; @@ -66,7 +67,8 @@ public class TvStatusBar extends SystemUI implements CommandQueue.Callbacks { // If the system process isn't there we're doomed anyway. } - new AudioRecordingDisclosureBar(mContext).start(); + // Creating AudioRecordingDisclosureBar and just letting it run + new AudioRecordingDisclosureBar(mContext); } @Override diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/AudioActivityObserver.java b/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/AudioActivityObserver.java new file mode 100644 index 0000000000000..87b3956060f3b --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/AudioActivityObserver.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2020 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.statusbar.tv.micdisclosure; + +import android.content.Context; + +import java.util.Set; + +/** + * A base class for implementing observers for different kinds of activities related to audio + * recording. These observers are to be initialized by {@link AudioRecordingDisclosureBar} and to + * report back to it. + */ +abstract class AudioActivityObserver { + + interface OnAudioActivityStateChangeListener { + void onAudioActivityStateChange(boolean active, String packageName); + } + + final Context mContext; + + final OnAudioActivityStateChangeListener mListener; + + AudioActivityObserver(Context context, OnAudioActivityStateChangeListener listener) { + mContext = context; + mListener = listener; + } + + abstract Set getActivePackages(); +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/tv/AudioRecordingDisclosureBar.java b/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/AudioRecordingDisclosureBar.java similarity index 81% rename from packages/SystemUI/src/com/android/systemui/statusbar/tv/AudioRecordingDisclosureBar.java rename to packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/AudioRecordingDisclosureBar.java index e70e30a5ab573..914868301d728 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/tv/AudioRecordingDisclosureBar.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/AudioRecordingDisclosureBar.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 The Android Open Source Project + * Copyright (C) 2020 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.systemui.statusbar.tv; +package com.android.systemui.statusbar.tv.micdisclosure; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; @@ -25,7 +25,6 @@ import android.animation.ObjectAnimator; import android.animation.PropertyValuesHolder; import android.annotation.IntDef; import android.annotation.UiThread; -import android.app.AppOpsManager; import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; @@ -40,6 +39,7 @@ import android.view.WindowManager; import android.widget.TextView; import com.android.systemui.R; +import com.android.systemui.statusbar.tv.TvStatusBar; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -54,9 +54,10 @@ import java.util.Set; * * @see TvStatusBar */ -class AudioRecordingDisclosureBar { - private static final String TAG = "AudioRecordingDisclosureBar"; - private static final boolean DEBUG = false; +public class AudioRecordingDisclosureBar implements + AudioActivityObserver.OnAudioActivityStateChangeListener { + private static final String TAG = "AudioRecordingDisclosure"; + static final boolean DEBUG = false; // This title is used to test the microphone disclosure indicator in // CtsSystemUiHostTestCases:TvMicrophoneCaptureIndicatorTest @@ -98,10 +99,12 @@ class AudioRecordingDisclosureBar { private TextView mTextView; @State private int mState = STATE_NOT_SHOWN; + /** - * Set of the applications that currently are conducting audio recording. + * Array of the observers that monitor different aspects of the system, such as AppOps and + * microphone foreground services */ - private final Set mActiveAudioRecordingPackages = new ArraySet<>(); + private final AudioActivityObserver[] mAudioActivityObservers; /** * Set of applications that we've notified the user about since the indicator came up. Meaning * that if an application is in this list then at some point since the indicator came up, it @@ -119,29 +122,52 @@ class AudioRecordingDisclosureBar { * one of the two aforementioned states. */ private final Queue mPendingNotificationPackages = new LinkedList<>(); + /** + * Set of applications for which we make an exception and do not show the indicator. This gets + * populated once - in {@link #AudioRecordingDisclosureBar(Context)}. + */ + private final Set mExemptPackages; - AudioRecordingDisclosureBar(Context context) { + public AudioRecordingDisclosureBar(Context context) { mContext = context; - } - void start() { - // Register AppOpsManager callback - final AppOpsManager appOpsManager = (AppOpsManager) mContext.getSystemService( - Context.APP_OPS_SERVICE); - appOpsManager.startWatchingActive( - new String[]{AppOpsManager.OPSTR_RECORD_AUDIO}, - mContext.getMainExecutor(), - new OnActiveRecordingListener()); + mExemptPackages = new ArraySet<>( + Arrays.asList(mContext.getResources().getStringArray( + R.array.audio_recording_disclosure_exempt_apps))); + + mAudioActivityObservers = new AudioActivityObserver[]{ + new RecordAudioAppOpObserver(mContext, this), + new MicrophoneForegroundServicesObserver(mContext, this), + }; } @UiThread - private void onStartedRecording(String packageName) { - if (!mActiveAudioRecordingPackages.add(packageName)) { - // This app is already known to perform recording + @Override + public void onAudioActivityStateChange(boolean active, String packageName) { + if (DEBUG) { + Log.d(TAG, + "onAudioActivityStateChange, packageName=" + packageName + ", active=" + + active); + } + + if (mExemptPackages.contains(packageName)) { + if (DEBUG) Log.d(TAG, " - exempt package: ignoring"); return; } + + if (active) { + showIndicatorForPackageIfNeeded(packageName); + } else { + hideIndicatorIfNeeded(); + } + } + + @UiThread + private void showIndicatorForPackageIfNeeded(String packageName) { + if (DEBUG) Log.d(TAG, "showIndicatorForPackageIfNeeded, packageName=" + packageName); if (!mSessionNotifiedPackages.add(packageName)) { // We've already notified user about this app, no need to do it again. + if (DEBUG) Log.d(TAG, " - already notified"); return; } @@ -167,23 +193,33 @@ class AudioRecordingDisclosureBar { } @UiThread - private void onDoneRecording(String packageName) { - if (!mActiveAudioRecordingPackages.remove(packageName)) { - // Was not marked as an active recorder, do nothing - return; + private void hideIndicatorIfNeeded() { + if (DEBUG) Log.d(TAG, "hideIndicatorIfNeeded"); + // If not MINIMIZED, will check whether the indicator should be hidden when the indicator + // comes to the STATE_MINIMIZED eventually. + if (mState != STATE_MINIMIZED) return; + + // If is in the STATE_MINIMIZED, but there are other active recorders - simply ignore. + for (int index = mAudioActivityObservers.length - 1; index >= 0; index--) { + for (String activePackage : mAudioActivityObservers[index].getActivePackages()) { + if (mExemptPackages.contains(activePackage)) continue; + if (DEBUG) Log.d(TAG, " - there are still ongoing activities"); + return; + } } - // If not MINIMIZED, will check whether the indicator should be hidden when the indicator - // comes to the STATE_MINIMIZED eventually. If is in the STATE_MINIMIZED, but there are - // other active recorders - simply ignore. - if (mState == STATE_MINIMIZED && mActiveAudioRecordingPackages.isEmpty()) { - mSessionNotifiedPackages.clear(); - hide(); - } + // Clear the state and hide the indicator. + mSessionNotifiedPackages.clear(); + hide(); } @UiThread private void show(String packageName) { + final String label = getApplicationLabel(packageName); + if (DEBUG) { + Log.d(TAG, "Showing indicator for " + packageName + " (" + label + ")..."); + } + // Inflate the indicator view mIndicatorView = LayoutInflater.from(mContext).inflate( R.layout.tv_audio_recording_indicator, @@ -196,7 +232,6 @@ class AudioRecordingDisclosureBar { mBgRight = mIndicatorView.findViewById(R.id.bg_right); // Set up the notification text - final String label = getApplicationLabel(packageName); mTextView.setText(mContext.getString(R.string.app_accessed_mic, label)); // Initially change the visibility to INVISIBLE, wait until and receives the size and @@ -260,6 +295,9 @@ class AudioRecordingDisclosureBar { @UiThread private void expand(String packageName) { final String label = getApplicationLabel(packageName); + if (DEBUG) { + Log.d(TAG, "Expanding for " + packageName + " (" + label + ")..."); + } mTextView.setText(mContext.getString(R.string.app_accessed_mic, label)); final AnimatorSet set = new AnimatorSet(); @@ -283,6 +321,7 @@ class AudioRecordingDisclosureBar { @UiThread private void minimize() { + if (DEBUG) Log.d(TAG, "Minimizing..."); final int targetOffset = mTextsContainers.getWidth(); final AnimatorSet set = new AnimatorSet(); set.playTogether( @@ -305,6 +344,7 @@ class AudioRecordingDisclosureBar { @UiThread private void hide() { + if (DEBUG) Log.d(TAG, "Hiding..."); final int targetOffset = mIndicatorView.getWidth() - (int) mIconTextsContainer.getTranslationX(); final AnimatorSet set = new AnimatorSet(); @@ -326,6 +366,7 @@ class AudioRecordingDisclosureBar { @UiThread private void onExpanded() { + if (DEBUG) Log.d(TAG, "Expanded"); mState = STATE_SHOWN; mIndicatorView.postDelayed(this::minimize, MAXIMIZED_DURATION); @@ -333,20 +374,21 @@ class AudioRecordingDisclosureBar { @UiThread private void onMinimized() { + if (DEBUG) Log.d(TAG, "Minimized"); mState = STATE_MINIMIZED; if (!mPendingNotificationPackages.isEmpty()) { // There is a new application that started recording, tell the user about it. expand(mPendingNotificationPackages.poll()); - } else if (mActiveAudioRecordingPackages.isEmpty()) { - // Nobody is recording anymore, clear state and remove the indicator. - mSessionNotifiedPackages.clear(); - hide(); + } else { + hideIndicatorIfNeeded(); } } @UiThread private void onHidden() { + if (DEBUG) Log.d(TAG, "Hidden"); + final WindowManager windowManager = (WindowManager) mContext.getSystemService( Context.WINDOW_SERVICE); windowManager.removeView(mIndicatorView); @@ -392,35 +434,4 @@ class AudioRecordingDisclosureBar { } return pm.getApplicationLabel(appInfo).toString(); } - - private class OnActiveRecordingListener implements AppOpsManager.OnOpActiveChangedListener { - private final Set mExemptApps; - - private OnActiveRecordingListener() { - mExemptApps = new ArraySet<>(Arrays.asList(mContext.getResources().getStringArray( - R.array.audio_recording_disclosure_exempt_apps))); - } - - @Override - public void onOpActiveChanged(String op, int uid, String packageName, boolean active) { - if (DEBUG) { - Log.d(TAG, - "OP_RECORD_AUDIO active change, active=" + active + ", app=" - + packageName); - } - - if (mExemptApps.contains(packageName)) { - if (DEBUG) { - Log.d(TAG, "\t- exempt app"); - } - return; - } - - if (active) { - onStartedRecording(packageName); - } else { - onDoneRecording(packageName); - } - } - } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/MicrophoneForegroundServicesObserver.java b/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/MicrophoneForegroundServicesObserver.java new file mode 100644 index 0000000000000..1ede88a260207 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/MicrophoneForegroundServicesObserver.java @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2020 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.statusbar.tv.micdisclosure; + +import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; + +import static com.android.systemui.statusbar.tv.micdisclosure.AudioRecordingDisclosureBar.DEBUG; + +import android.annotation.UiThread; +import android.app.ActivityManager; +import android.app.IActivityManager; +import android.app.IProcessObserver; +import android.content.Context; +import android.os.RemoteException; +import android.util.ArrayMap; +import android.util.Log; +import android.util.SparseArray; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The purpose of these class is to detect packages that are running foreground services of type + * 'microphone' and to report back to {@link AudioRecordingDisclosureBar}. + */ +class MicrophoneForegroundServicesObserver extends AudioActivityObserver { + private static final String TAG = "MicrophoneForegroundServicesObserver"; + private static final boolean ENABLED = true; + + private final IActivityManager mActivityManager; + /** + * A dictionary that maps PIDs to the package names. We only keep track of the PIDs that are + * "active" (those that are running FGS with FOREGROUND_SERVICE_TYPE_MICROPHONE flag). + */ + private final SparseArray mPidToPackages = new SparseArray<>(); + /** + * A dictionary that maps "active" packages to the number of the "active" processes associated + * with those packages. We really only need this in case when one application is running in + * multiple processes, so that we don't lose track of the package when one of its "active" + * processes ceases, while others remain "active". + */ + private final Map mPackageToProcessCount = new ArrayMap<>(); + + MicrophoneForegroundServicesObserver(Context context, + OnAudioActivityStateChangeListener listener) { + super(context, listener); + + mActivityManager = ActivityManager.getService(); + try { + mActivityManager.registerProcessObserver(mProcessObserver); + } catch (RemoteException e) { + Log.e(TAG, "Couldn't register process observer", e); + } + } + + @Override + Set getActivePackages() { + return ENABLED ? mPackageToProcessCount.keySet() : Collections.emptySet(); + } + + @UiThread + private void onProcessForegroundServicesChanged(int pid, boolean hasMicFgs) { + final String[] changedPackages; + if (hasMicFgs) { + if (mPidToPackages.contains(pid)) { + // We are already tracking this pid - ignore. + changedPackages = null; + } else { + changedPackages = getPackageNames(pid); + mPidToPackages.append(pid, changedPackages); + } + } else { + changedPackages = mPidToPackages.removeReturnOld(pid); + } + + if (changedPackages == null) { + return; + } + + for (int index = changedPackages.length - 1; index >= 0; index--) { + final String packageName = changedPackages[index]; + int processCount = mPackageToProcessCount.getOrDefault(packageName, 0); + final boolean shouldNotify; + if (hasMicFgs) { + processCount++; + shouldNotify = processCount == 1; + } else { + processCount--; + shouldNotify = processCount == 0; + } + if (processCount > 0) { + mPackageToProcessCount.put(packageName, processCount); + } else { + mPackageToProcessCount.remove(packageName); + } + if (shouldNotify) notifyPackageStateChanged(packageName, hasMicFgs); + } + } + + @UiThread + private void onProcessDied(int pid) { + final String[] packages = mPidToPackages.removeReturnOld(pid); + if (packages == null) { + // This PID was not active - ignore. + return; + } + + for (int index = packages.length - 1; index >= 0; index--) { + final String packageName = packages[index]; + int processCount = mPackageToProcessCount.getOrDefault(packageName, 0); + if (processCount <= 0) { + Log.e(TAG, "Bookkeeping error, process count for " + packageName + " is " + + processCount); + continue; + } + processCount--; + if (processCount > 0) { + mPackageToProcessCount.put(packageName, processCount); + } else { + mPackageToProcessCount.remove(packageName); + notifyPackageStateChanged(packageName, false); + } + } + } + + @UiThread + private void notifyPackageStateChanged(String packageName, boolean active) { + if (active) { + if (DEBUG) Log.d(TAG, "New microphone fgs detected, package=" + packageName); + } else { + if (DEBUG) Log.d(TAG, "Microphone fgs is gone, package=" + packageName); + } + + if (ENABLED) mListener.onAudioActivityStateChange(active, packageName); + } + + @UiThread + private String[] getPackageNames(int pid) { + final List runningApps; + try { + runningApps = mActivityManager.getRunningAppProcesses(); + } catch (RemoteException e) { + Log.d(TAG, "Couldn't get package name for pid=" + pid); + return null; + } + if (runningApps == null) { + Log.wtf(TAG, "No running apps reported"); + } + for (ActivityManager.RunningAppProcessInfo app : runningApps) { + if (app.pid == pid) { + return app.pkgList; + } + } + return null; + } + + private final IProcessObserver mProcessObserver = new IProcessObserver.Stub() { + @Override + public void onForegroundActivitiesChanged(int pid, int uid, boolean foregroundActivities) {} + + @Override + public void onForegroundServicesChanged(int pid, int uid, int serviceTypes) { + mContext.getMainExecutor().execute(() -> onProcessForegroundServicesChanged(pid, + (serviceTypes & FOREGROUND_SERVICE_TYPE_MICROPHONE) != 0)); + } + + @Override + public void onProcessDied(int pid, int uid) { + mContext.getMainExecutor().execute( + () -> MicrophoneForegroundServicesObserver.this.onProcessDied(pid)); + } + }; +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/RecordAudioAppOpObserver.java b/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/RecordAudioAppOpObserver.java new file mode 100644 index 0000000000000..b5b1c2b3018a4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/tv/micdisclosure/RecordAudioAppOpObserver.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2020 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.statusbar.tv.micdisclosure; + +import static com.android.systemui.statusbar.tv.micdisclosure.AudioRecordingDisclosureBar.DEBUG; + +import android.annotation.UiThread; +import android.app.AppOpsManager; +import android.content.Context; +import android.util.ArraySet; +import android.util.Log; + +import java.util.Set; + +/** + * The purpose of these class is to detect packages that are conducting audio recording (according + * to {@link AppOpsManager}) and report this to {@link AudioRecordingDisclosureBar}. + */ +class RecordAudioAppOpObserver extends AudioActivityObserver implements + AppOpsManager.OnOpActiveChangedListener { + private static final String TAG = "RecordAudioAppOpObserver"; + + /** + * Set of the applications that currently are conducting audio recording according to {@link + * AppOpsManager}. + */ + private final Set mActiveAudioRecordingPackages = new ArraySet<>(); + + RecordAudioAppOpObserver(Context context, OnAudioActivityStateChangeListener listener) { + super(context, listener); + + // Register AppOpsManager callback + final AppOpsManager appOpsManager = (AppOpsManager) mContext.getSystemService( + Context.APP_OPS_SERVICE); + appOpsManager.startWatchingActive( + new String[]{AppOpsManager.OPSTR_RECORD_AUDIO}, + mContext.getMainExecutor(), + this); + } + + @UiThread + @Override + Set getActivePackages() { + return mActiveAudioRecordingPackages; + } + + @UiThread + @Override + public void onOpActiveChanged(String op, int uid, String packageName, boolean active) { + if (DEBUG) { + Log.d(TAG, + "OP_RECORD_AUDIO active change, active=" + active + ", package=" + + packageName); + } + + if (active) { + if (mActiveAudioRecordingPackages.add(packageName)) { + mListener.onAudioActivityStateChange(true, packageName); + } + } else { + if (mActiveAudioRecordingPackages.remove(packageName)) { + mListener.onAudioActivityStateChange(false, packageName); + } + } + } +}