/* * 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 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 mChips; @NonNull private List mPrivacyItems = Collections.emptyList(); @NonNull private final List 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 privacyItems) { if (DEBUG) Log.d(TAG, "onPrivacyItemsChanged"); List 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 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 list, PrivacyType privacyType) { for (PrivacyItem item : list) { if (item.getPrivacyType() == privacyType) { return true; } } return false; } }