blob: 1c477991211a454cc8a1bac23a88b3b525b8102f [file] [log] [blame]
/*
* 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.systemui.privacy.television;
import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.UiThread;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.os.Handler;
import android.os.Looper;
import android.os.RemoteException;
import android.transition.AutoTransition;
import android.transition.ChangeBounds;
import android.transition.Fade;
import android.transition.Transition;
import android.transition.TransitionManager;
import android.transition.TransitionSet;
import android.util.ArraySet;
import android.util.Log;
import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.IWindowManager;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
import android.view.animation.AnimationUtils;
import android.view.animation.Interpolator;
import android.widget.ImageView;
import android.widget.LinearLayout;
import com.android.systemui.CoreStartable;
import com.android.systemui.R;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.privacy.PrivacyItem;
import com.android.systemui.privacy.PrivacyItemController;
import com.android.systemui.privacy.PrivacyType;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
/**
* A SystemUI component responsible for notifying the user whenever an application is
* recording audio, camera, the screen, or accessing the location.
*/
@SysUISingleton
public class TvPrivacyChipsController
implements CoreStartable, PrivacyItemController.Callback {
private static final String TAG = "TvPrivacyChipsController";
private static final boolean DEBUG = false;
// This title is used in CameraMicIndicatorsPermissionTest and
// RecognitionServiceMicIndicatorTest.
private static final String LAYOUT_PARAMS_TITLE = "MicrophoneCaptureIndicator";
// Chips configuration. We're not showing a location indicator on TV.
static final List<PrivacyItemsChip.ChipConfig> CHIPS = Arrays.asList(
new PrivacyItemsChip.ChipConfig(
Collections.singletonList(PrivacyType.TYPE_MEDIA_PROJECTION),
R.color.privacy_media_projection_chip,
/* collapseToDot= */ false),
new PrivacyItemsChip.ChipConfig(
Arrays.asList(PrivacyType.TYPE_CAMERA, PrivacyType.TYPE_MICROPHONE),
R.color.privacy_mic_cam_chip,
/* collapseToDot= */ true)
);
// Avoid multiple messages after rapid changes such as starting/stopping both camera and mic.
private static final int ACCESSIBILITY_ANNOUNCEMENT_DELAY_MS = 500;
/**
* Time to collect privacy item updates before applying them.
* Since MediaProjection and AppOps come from different data sources,
* PrivacyItem updates when screen & audio recording ends do not come at the same time.
* Without this, if eg. MediaProjection ends first, you'd see the microphone chip expand and
* almost immediately fade out as it is expanding. With this, the two chips disappear together.
*/
private static final int PRIVACY_ITEM_DEBOUNCE_TIMEOUT_MS = 200;
// How long chips stay expanded after an update.
private static final int EXPANDED_DURATION_MS = 4000;
private final Context mContext;
private final Handler mUiThreadHandler = new Handler(Looper.getMainLooper());
private final Runnable mCollapseRunnable = this::collapseChips;
private final Runnable mUpdatePrivacyItemsRunnable = this::updateChipsAndAnnounce;
private final Runnable mAccessibilityRunnable = this::makeAccessibilityAnnouncement;
private final PrivacyItemController mPrivacyItemController;
private final IWindowManager mIWindowManager;
private final Rect[] mBounds = new Rect[4];
private final TransitionSet mTransition;
private final TransitionSet mCollapseTransition;
private boolean mIsRtl;
@Nullable
private ViewGroup mChipsContainer;
@Nullable
private List<PrivacyItemsChip> mChips;
@NonNull
private List<PrivacyItem> mPrivacyItems = Collections.emptyList();
@NonNull
private final List<PrivacyItem> mItemsBeforeLastAnnouncement = new ArrayList<>();
@Inject
public TvPrivacyChipsController(Context context, PrivacyItemController privacyItemController,
IWindowManager iWindowManager) {
mContext = context;
if (DEBUG) Log.d(TAG, "TvPrivacyChipsController running");
mPrivacyItemController = privacyItemController;
mIWindowManager = iWindowManager;
Resources res = mContext.getResources();
mIsRtl = res.getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
updateStaticPrivacyIndicatorBounds();
Interpolator collapseInterpolator = AnimationUtils.loadInterpolator(context,
R.interpolator.tv_privacy_chip_collapse_interpolator);
Interpolator expandInterpolator = AnimationUtils.loadInterpolator(context,
R.interpolator.tv_privacy_chip_expand_interpolator);
TransitionSet chipFadeTransition = new TransitionSet()
.addTransition(new Fade(Fade.IN))
.addTransition(new Fade(Fade.OUT));
chipFadeTransition.setOrdering(TransitionSet.ORDERING_TOGETHER);
chipFadeTransition.excludeTarget(ImageView.class, true);
Transition chipBoundsExpandTransition = new ChangeBounds();
chipBoundsExpandTransition.excludeTarget(ImageView.class, true);
chipBoundsExpandTransition.setInterpolator(expandInterpolator);
Transition chipBoundsCollapseTransition = new ChangeBounds();
chipBoundsCollapseTransition.excludeTarget(ImageView.class, true);
chipBoundsCollapseTransition.setInterpolator(collapseInterpolator);
TransitionSet iconCollapseTransition = new AutoTransition();
iconCollapseTransition.setOrdering(TransitionSet.ORDERING_TOGETHER);
iconCollapseTransition.addTarget(ImageView.class);
iconCollapseTransition.setInterpolator(collapseInterpolator);
TransitionSet iconExpandTransition = new AutoTransition();
iconExpandTransition.setOrdering(TransitionSet.ORDERING_TOGETHER);
iconExpandTransition.addTarget(ImageView.class);
iconExpandTransition.setInterpolator(expandInterpolator);
mTransition = new TransitionSet()
.addTransition(chipFadeTransition)
.addTransition(chipBoundsExpandTransition)
.addTransition(iconExpandTransition)
.setOrdering(TransitionSet.ORDERING_TOGETHER)
.setDuration(res.getInteger(R.integer.privacy_chip_animation_millis));
mCollapseTransition = new TransitionSet()
.addTransition(chipFadeTransition)
.addTransition(chipBoundsCollapseTransition)
.addTransition(iconCollapseTransition)
.setOrdering(TransitionSet.ORDERING_TOGETHER)
.setDuration(res.getInteger(R.integer.privacy_chip_animation_millis));
Transition.TransitionListener transitionListener = new Transition.TransitionListener() {
@Override
public void onTransitionStart(Transition transition) {
if (DEBUG) Log.v(TAG, "onTransitionStart");
}
@Override
public void onTransitionEnd(Transition transition) {
if (DEBUG) Log.v(TAG, "onTransitionEnd");
if (mChips != null) {
boolean hasVisibleChip = false;
boolean hasExpandedChip = false;
for (PrivacyItemsChip chip : mChips) {
hasVisibleChip = hasVisibleChip || chip.getVisibility() == View.VISIBLE;
hasExpandedChip = hasExpandedChip || chip.isExpanded();
}
if (!hasVisibleChip) {
if (DEBUG) Log.d(TAG, "No chips visible anymore");
removeIndicatorView();
} else if (hasExpandedChip) {
if (DEBUG) Log.d(TAG, "Has expanded chips");
collapseLater();
}
}
}
@Override
public void onTransitionCancel(Transition transition) {
}
@Override
public void onTransitionPause(Transition transition) {
}
@Override
public void onTransitionResume(Transition transition) {
}
};
mTransition.addListener(transitionListener);
mCollapseTransition.addListener(transitionListener);
}
@Override
public void onConfigurationChanged(Configuration config) {
boolean updatedRtl = config.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
if (mIsRtl == updatedRtl) {
return;
}
mIsRtl = updatedRtl;
// Update privacy chip location.
if (mChipsContainer != null) {
removeIndicatorView();
createAndShowIndicator();
}
updateStaticPrivacyIndicatorBounds();
}
@Override
public void start() {
mPrivacyItemController.addCallback(this);
}
@UiThread
@Override
public void onPrivacyItemsChanged(List<PrivacyItem> privacyItems) {
if (DEBUG) Log.d(TAG, "onPrivacyItemsChanged");
List<PrivacyItem> filteredPrivacyItems = new ArrayList<>(privacyItems);
if (filteredPrivacyItems.removeIf(
privacyItem -> !isPrivacyTypeShown(privacyItem.getPrivacyType()))) {
if (DEBUG) Log.v(TAG, "Removed privacy items we don't show");
}
// Do they have the same elements? (order doesn't matter)
if (privacyItems.size() == mPrivacyItems.size() && mPrivacyItems.containsAll(
privacyItems)) {
if (DEBUG) Log.d(TAG, "No change to relevant privacy items");
return;
}
mPrivacyItems = privacyItems;
if (!mUiThreadHandler.hasCallbacks(mUpdatePrivacyItemsRunnable)) {
mUiThreadHandler.postDelayed(mUpdatePrivacyItemsRunnable,
PRIVACY_ITEM_DEBOUNCE_TIMEOUT_MS);
}
}
private boolean isPrivacyTypeShown(@NonNull PrivacyType type) {
for (PrivacyItemsChip.ChipConfig chip : CHIPS) {
if (chip.privacyTypes.contains(type)) {
return true;
}
}
return false;
}
@UiThread
private void updateChipsAndAnnounce() {
updateChips();
postAccessibilityAnnouncement();
}
private void updateStaticPrivacyIndicatorBounds() {
Resources res = mContext.getResources();
int mMaxExpandedWidth = res.getDimensionPixelSize(R.dimen.privacy_chips_max_width);
int mMaxExpandedHeight = res.getDimensionPixelSize(R.dimen.privacy_chip_height);
int mChipMarginTotal = 2 * res.getDimensionPixelSize(R.dimen.privacy_chip_margin);
final WindowManager windowManager = mContext.getSystemService(WindowManager.class);
Rect screenBounds = windowManager.getCurrentWindowMetrics().getBounds();
mBounds[0] = new Rect(
mIsRtl ? screenBounds.left
: screenBounds.right - mMaxExpandedWidth,
screenBounds.top,
mIsRtl ? screenBounds.left + mMaxExpandedWidth
: screenBounds.right,
screenBounds.top + mChipMarginTotal + mMaxExpandedHeight
);
if (DEBUG) Log.v(TAG, "privacy indicator bounds: " + mBounds[0].toShortString());
try {
mIWindowManager.updateStaticPrivacyIndicatorBounds(mContext.getDisplayId(), mBounds);
} catch (RemoteException e) {
Log.w(TAG, "could not update privacy indicator bounds");
}
}
@UiThread
private void updateChips() {
if (DEBUG) Log.d(TAG, "updateChips: " + mPrivacyItems.size() + " privacy items");
if (mChipsContainer == null) {
if (!mPrivacyItems.isEmpty()) {
createAndShowIndicator();
}
return;
}
Set<PrivacyType> activePrivacyTypes = new ArraySet<>();
mPrivacyItems.forEach(item -> activePrivacyTypes.add(item.getPrivacyType()));
TransitionManager.beginDelayedTransition(mChipsContainer, mTransition);
mChips.forEach(chip -> chip.expandForTypes(activePrivacyTypes));
}
/**
* Collapse the chip {@link #EXPANDED_DURATION_MS} from now.
*/
private void collapseLater() {
mUiThreadHandler.removeCallbacks(mCollapseRunnable);
if (DEBUG) Log.d(TAG, "Chips will collapse in " + EXPANDED_DURATION_MS + "ms");
mUiThreadHandler.postDelayed(mCollapseRunnable, EXPANDED_DURATION_MS);
}
private void collapseChips() {
if (DEBUG) Log.d(TAG, "collapseChips");
if (mChipsContainer == null) {
return;
}
TransitionManager.beginDelayedTransition(mChipsContainer, mCollapseTransition);
for (PrivacyItemsChip chip : mChips) {
chip.collapse();
}
}
@UiThread
private void createAndShowIndicator() {
if (DEBUG) Log.i(TAG, "Creating privacy indicators");
Context privacyChipContext = new ContextThemeWrapper(mContext, R.style.PrivacyChip);
mChips = new ArrayList<>();
mChipsContainer = (ViewGroup) LayoutInflater.from(privacyChipContext)
.inflate(R.layout.tv_privacy_chip_container, null);
int chipMargins = privacyChipContext.getResources()
.getDimensionPixelSize(R.dimen.privacy_chip_margin);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
lp.setMarginStart(chipMargins);
lp.setMarginEnd(chipMargins);
for (PrivacyItemsChip.ChipConfig chipConfig : CHIPS) {
PrivacyItemsChip chip = new PrivacyItemsChip(privacyChipContext, chipConfig);
mChipsContainer.addView(chip, lp);
mChips.add(chip);
}
final WindowManager windowManager = mContext.getSystemService(WindowManager.class);
windowManager.addView(mChipsContainer, getWindowLayoutParams());
final ViewGroup container = mChipsContainer;
mChipsContainer.getViewTreeObserver()
.addOnGlobalLayoutListener(
new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
if (DEBUG) Log.v(TAG, "Chips container laid out");
container.getViewTreeObserver().removeOnGlobalLayoutListener(this);
updateChips();
}
});
}
private WindowManager.LayoutParams getWindowLayoutParams() {
final WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(
WRAP_CONTENT,
WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT);
layoutParams.gravity = Gravity.TOP | (mIsRtl ? Gravity.LEFT : Gravity.RIGHT);
layoutParams.setTitle(LAYOUT_PARAMS_TITLE);
layoutParams.packageName = mContext.getPackageName();
return layoutParams;
}
@UiThread
private void removeIndicatorView() {
if (DEBUG) Log.d(TAG, "removeIndicatorView");
mUiThreadHandler.removeCallbacks(mCollapseRunnable);
final WindowManager windowManager = mContext.getSystemService(WindowManager.class);
if (windowManager != null && mChipsContainer != null) {
windowManager.removeView(mChipsContainer);
}
mChipsContainer = null;
mChips = null;
}
/**
* Schedules the accessibility announcement to be made after {@link
* #ACCESSIBILITY_ANNOUNCEMENT_DELAY_MS} (if possible). This is so that only one announcement is
* made instead of two separate ones if both the camera and the mic are started/stopped.
*/
@UiThread
private void postAccessibilityAnnouncement() {
mUiThreadHandler.removeCallbacks(mAccessibilityRunnable);
if (mPrivacyItems.size() == 0) {
// Announce immediately since announcement cannot be made once the chip is gone.
makeAccessibilityAnnouncement();
} else {
mUiThreadHandler.postDelayed(mAccessibilityRunnable,
ACCESSIBILITY_ANNOUNCEMENT_DELAY_MS);
}
}
private void makeAccessibilityAnnouncement() {
if (mChipsContainer == null) {
return;
}
boolean cameraWasRecording = listContainsPrivacyType(mItemsBeforeLastAnnouncement,
PrivacyType.TYPE_CAMERA);
boolean cameraIsRecording = listContainsPrivacyType(mPrivacyItems,
PrivacyType.TYPE_CAMERA);
boolean micWasRecording = listContainsPrivacyType(mItemsBeforeLastAnnouncement,
PrivacyType.TYPE_MICROPHONE);
boolean micIsRecording = listContainsPrivacyType(mPrivacyItems,
PrivacyType.TYPE_MICROPHONE);
boolean screenWasRecording = listContainsPrivacyType(mItemsBeforeLastAnnouncement,
PrivacyType.TYPE_MEDIA_PROJECTION);
boolean screenIsRecording = listContainsPrivacyType(mPrivacyItems,
PrivacyType.TYPE_MEDIA_PROJECTION);
int announcement = 0;
if (!cameraWasRecording && cameraIsRecording && !micWasRecording && micIsRecording) {
// Both started
announcement = R.string.mic_and_camera_recording_announcement;
} else if (cameraWasRecording && !cameraIsRecording && micWasRecording && !micIsRecording) {
// Both stopped
announcement = R.string.mic_camera_stopped_recording_announcement;
} else {
// Did the camera start or stop?
if (cameraWasRecording && !cameraIsRecording) {
announcement = R.string.camera_stopped_recording_announcement;
} else if (!cameraWasRecording && cameraIsRecording) {
announcement = R.string.camera_recording_announcement;
}
// Announce camera changes now since we might need a second announcement about the mic.
if (announcement != 0) {
mChipsContainer.announceForAccessibility(mContext.getString(announcement));
announcement = 0;
}
// Did the mic start or stop?
if (micWasRecording && !micIsRecording) {
announcement = R.string.mic_stopped_recording_announcement;
} else if (!micWasRecording && micIsRecording) {
announcement = R.string.mic_recording_announcement;
}
}
if (announcement != 0) {
mChipsContainer.announceForAccessibility(mContext.getString(announcement));
}
if (!screenWasRecording && screenIsRecording) {
mChipsContainer.announceForAccessibility(
mContext.getString(R.string.screen_recording_announcement));
} else if (screenWasRecording && !screenIsRecording) {
mChipsContainer.announceForAccessibility(
mContext.getString(R.string.screen_stopped_recording_announcement));
}
mItemsBeforeLastAnnouncement.clear();
mItemsBeforeLastAnnouncement.addAll(mPrivacyItems);
}
private boolean listContainsPrivacyType(List<PrivacyItem> list, PrivacyType privacyType) {
for (PrivacyItem item : list) {
if (item.getPrivacyType() == privacyType) {
return true;
}
}
return false;
}
}