| /* |
| * 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.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.ObjectAnimator; |
| import android.annotation.IntDef; |
| import android.annotation.UiThread; |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.graphics.PixelFormat; |
| import android.graphics.drawable.Drawable; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.util.Log; |
| import android.view.Gravity; |
| import android.view.LayoutInflater; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewTreeObserver; |
| import android.view.WindowManager; |
| import android.widget.ImageView; |
| import android.widget.LinearLayout; |
| |
| import androidx.annotation.NonNull; |
| |
| import com.android.systemui.R; |
| import com.android.systemui.SystemUI; |
| import com.android.systemui.dagger.SysUISingleton; |
| import com.android.systemui.privacy.PrivacyChipBuilder; |
| import com.android.systemui.privacy.PrivacyItem; |
| import com.android.systemui.privacy.PrivacyItemController; |
| import com.android.systemui.privacy.PrivacyType; |
| |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.LinkedList; |
| import java.util.List; |
| |
| import javax.inject.Inject; |
| |
| /** |
| * A SystemUI component responsible for notifying the user whenever an application is |
| * recording audio, accessing the camera or accessing the location. |
| */ |
| @SysUISingleton |
| public class TvOngoingPrivacyChip extends SystemUI implements PrivacyItemController.Callback, |
| PrivacyChipDrawable.PrivacyChipDrawableListener { |
| private static final String TAG = "TvOngoingPrivacyChip"; |
| private static final boolean DEBUG = false; |
| |
| // This title is used in CameraMicIndicatorsPermissionTest and |
| // RecognitionServiceMicIndicatorTest. |
| private static final String LAYOUT_PARAMS_TITLE = "MicrophoneCaptureIndicator"; |
| |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef(prefix = {"STATE_"}, value = { |
| STATE_NOT_SHOWN, |
| STATE_APPEARING, |
| STATE_EXPANDED, |
| STATE_COLLAPSED, |
| STATE_DISAPPEARING |
| }) |
| public @interface State { |
| } |
| |
| private static final int STATE_NOT_SHOWN = 0; |
| private static final int STATE_APPEARING = 1; |
| private static final int STATE_EXPANDED = 2; |
| private static final int STATE_COLLAPSED = 3; |
| private static final int STATE_DISAPPEARING = 4; |
| |
| // Avoid multiple messages after rapid changes such as starting/stopping both camera and mic. |
| private static final int ACCESSIBILITY_ANNOUNCEMENT_DELAY_MS = 500; |
| |
| private static final int EXPANDED_DURATION_MS = 4000; |
| public final int mAnimationDurationMs; |
| |
| private final Context mContext; |
| private final PrivacyItemController mPrivacyItemController; |
| |
| private ViewGroup mIndicatorView; |
| private boolean mViewAndWindowAdded; |
| private ObjectAnimator mAnimator; |
| |
| private boolean mMicCameraIndicatorFlagEnabled; |
| private boolean mAllIndicatorsEnabled; |
| |
| @NonNull |
| private List<PrivacyItem> mPrivacyItems = Collections.emptyList(); |
| |
| private LinearLayout mIconsContainer; |
| private final int mIconSize; |
| private final int mIconMarginStart; |
| |
| private PrivacyChipDrawable mChipDrawable; |
| |
| private final Handler mUiThreadHandler = new Handler(Looper.getMainLooper()); |
| private final Runnable mCollapseRunnable = this::collapseChip; |
| |
| private final Runnable mAccessibilityRunnable = this::makeAccessibilityAnnouncement; |
| private final List<PrivacyItem> mItemsBeforeLastAnnouncement = new LinkedList<>(); |
| |
| @State |
| private int mState = STATE_NOT_SHOWN; |
| |
| @Inject |
| public TvOngoingPrivacyChip(Context context, PrivacyItemController privacyItemController) { |
| super(context); |
| if (DEBUG) Log.d(TAG, "Privacy chip running"); |
| mContext = context; |
| mPrivacyItemController = privacyItemController; |
| |
| Resources res = mContext.getResources(); |
| mIconMarginStart = Math.round( |
| res.getDimension(R.dimen.privacy_chip_icon_margin_in_between)); |
| mIconSize = res.getDimensionPixelSize(R.dimen.privacy_chip_icon_size); |
| |
| mAnimationDurationMs = res.getInteger(R.integer.privacy_chip_animation_millis); |
| |
| mMicCameraIndicatorFlagEnabled = privacyItemController.getMicCameraAvailable(); |
| mAllIndicatorsEnabled = privacyItemController.getAllIndicatorsAvailable(); |
| |
| if (DEBUG) { |
| Log.d(TAG, "micCameraIndicators: " + mMicCameraIndicatorFlagEnabled); |
| Log.d(TAG, "allIndicators: " + mAllIndicatorsEnabled); |
| } |
| } |
| |
| @Override |
| public void start() { |
| mPrivacyItemController.addCallback(this); |
| } |
| |
| @Override |
| public void onPrivacyItemsChanged(@NonNull List<PrivacyItem> privacyItems) { |
| if (DEBUG) Log.d(TAG, "PrivacyItemsChanged"); |
| |
| List<PrivacyItem> updatedPrivacyItems = new ArrayList<>(privacyItems); |
| // Never show the location indicator on tv. |
| if (updatedPrivacyItems.removeIf( |
| privacyItem -> privacyItem.getPrivacyType() == PrivacyType.TYPE_LOCATION)) { |
| if (DEBUG) Log.v(TAG, "Removed the location item"); |
| } |
| |
| if (isChipDisabled()) { |
| fadeOutIndicator(); |
| mPrivacyItems = updatedPrivacyItems; |
| return; |
| } |
| |
| // Do they have the same elements? (order doesn't matter) |
| if (updatedPrivacyItems.size() == mPrivacyItems.size() |
| && mPrivacyItems.containsAll(updatedPrivacyItems)) { |
| if (DEBUG) Log.d(TAG, "List wasn't updated"); |
| return; |
| } |
| |
| mPrivacyItems = updatedPrivacyItems; |
| |
| postAccessibilityAnnouncement(); |
| updateChip(); |
| } |
| |
| private void updateChip() { |
| if (DEBUG) Log.d(TAG, mPrivacyItems.size() + " privacy items"); |
| |
| if (mPrivacyItems.isEmpty()) { |
| if (DEBUG) Log.d(TAG, "removing indicator (state: " + stateToString(mState) + ")"); |
| fadeOutIndicator(); |
| return; |
| } |
| |
| if (DEBUG) Log.d(TAG, "Current state: " + stateToString(mState)); |
| switch (mState) { |
| case STATE_NOT_SHOWN: |
| createAndShowIndicator(); |
| break; |
| case STATE_APPEARING: |
| case STATE_EXPANDED: |
| updateIcons(); |
| collapseLater(); |
| break; |
| case STATE_COLLAPSED: |
| case STATE_DISAPPEARING: |
| mState = STATE_EXPANDED; |
| updateIcons(); |
| animateIconAppearance(); |
| break; |
| } |
| } |
| |
| /** |
| * Collapse the chip EXPANDED_DURATION_MS from now. |
| */ |
| private void collapseLater() { |
| mUiThreadHandler.removeCallbacks(mCollapseRunnable); |
| if (DEBUG) Log.d(TAG, "chip will collapse in " + EXPANDED_DURATION_MS + "ms"); |
| mUiThreadHandler.postDelayed(mCollapseRunnable, EXPANDED_DURATION_MS); |
| } |
| |
| private void collapseChip() { |
| if (DEBUG) Log.d(TAG, "collapseChip"); |
| |
| if (mState != STATE_EXPANDED) { |
| return; |
| } |
| mState = STATE_COLLAPSED; |
| |
| if (mChipDrawable != null) { |
| mChipDrawable.collapse(); |
| } |
| animateIconDisappearance(); |
| } |
| |
| @Override |
| public void onFlagMicCameraChanged(boolean flag) { |
| if (DEBUG) Log.d(TAG, "mic/camera indicators enabled: " + flag); |
| mMicCameraIndicatorFlagEnabled = flag; |
| updateChipOnFlagChanged(); |
| } |
| |
| @Override |
| public void onFlagAllChanged(boolean flag) { |
| if (DEBUG) Log.d(TAG, "all indicators enabled: " + flag); |
| mAllIndicatorsEnabled = flag; |
| updateChipOnFlagChanged(); |
| } |
| |
| private boolean isChipDisabled() { |
| return !(mMicCameraIndicatorFlagEnabled || mAllIndicatorsEnabled); |
| } |
| |
| private void updateChipOnFlagChanged() { |
| if (isChipDisabled()) { |
| fadeOutIndicator(); |
| } else { |
| updateChip(); |
| } |
| } |
| |
| @UiThread |
| private void fadeOutIndicator() { |
| if (mState == STATE_NOT_SHOWN || mState == STATE_DISAPPEARING) return; |
| |
| mUiThreadHandler.removeCallbacks(mCollapseRunnable); |
| |
| if (mViewAndWindowAdded) { |
| mState = STATE_DISAPPEARING; |
| animateIconDisappearance(); |
| } else { |
| // Appearing animation has not started yet, as we were still waiting for the View to be |
| // laid out. |
| mState = STATE_NOT_SHOWN; |
| removeIndicatorView(); |
| } |
| if (mChipDrawable != null) { |
| mChipDrawable.updateIcons(0); |
| } |
| } |
| |
| @UiThread |
| private void createAndShowIndicator() { |
| mState = STATE_APPEARING; |
| |
| if (mIndicatorView != null || mViewAndWindowAdded) { |
| removeIndicatorView(); |
| } |
| |
| // Inflate the indicator view |
| mIndicatorView = (ViewGroup) LayoutInflater.from(mContext).inflate( |
| R.layout.tv_ongoing_privacy_chip, null); |
| |
| // 1. Set icon alpha to 0. |
| // 2. Wait until the window is shown and the view is laid out. |
| // 3. Start a "fade in" (alpha) animation. |
| mIndicatorView |
| .getViewTreeObserver() |
| .addOnGlobalLayoutListener( |
| new ViewTreeObserver.OnGlobalLayoutListener() { |
| @Override |
| public void onGlobalLayout() { |
| // State could have changed to NOT_SHOWN (if all the recorders are |
| // already gone) |
| if (mState != STATE_APPEARING) { |
| return; |
| } |
| |
| mViewAndWindowAdded = true; |
| // Remove the observer |
| mIndicatorView.getViewTreeObserver().removeOnGlobalLayoutListener( |
| this); |
| |
| postAccessibilityAnnouncement(); |
| animateIconAppearance(); |
| mChipDrawable.startInitialFadeIn(); |
| } |
| }); |
| |
| final boolean isRtl = mContext.getResources().getConfiguration().getLayoutDirection() |
| == View.LAYOUT_DIRECTION_RTL; |
| if (DEBUG) Log.d(TAG, "is RTL: " + isRtl); |
| |
| mChipDrawable = new PrivacyChipDrawable(mContext); |
| mChipDrawable.setListener(this); |
| mChipDrawable.setRtl(isRtl); |
| ImageView chipBackground = mIndicatorView.findViewById(R.id.chip_drawable); |
| if (chipBackground != null) { |
| chipBackground.setImageDrawable(mChipDrawable); |
| } |
| |
| mIconsContainer = mIndicatorView.findViewById(R.id.icons_container); |
| mIconsContainer.setAlpha(0f); |
| updateIcons(); |
| |
| 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 | (isRtl ? Gravity.LEFT : Gravity.RIGHT); |
| layoutParams.setTitle(LAYOUT_PARAMS_TITLE); |
| layoutParams.packageName = mContext.getPackageName(); |
| final WindowManager windowManager = mContext.getSystemService(WindowManager.class); |
| windowManager.addView(mIndicatorView, layoutParams); |
| } |
| |
| private void updateIcons() { |
| List<Drawable> icons = new PrivacyChipBuilder(mContext, mPrivacyItems).generateIcons(); |
| mIconsContainer.removeAllViews(); |
| for (int i = 0; i < icons.size(); i++) { |
| Drawable icon = icons.get(i); |
| icon.mutate().setTint(mContext.getColor(R.color.privacy_icon_tint)); |
| ImageView imageView = new ImageView(mContext); |
| imageView.setImageDrawable(icon); |
| imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); |
| mIconsContainer.addView(imageView, mIconSize, mIconSize); |
| if (i != 0) { |
| ViewGroup.MarginLayoutParams layoutParams = |
| (ViewGroup.MarginLayoutParams) imageView.getLayoutParams(); |
| layoutParams.setMarginStart(mIconMarginStart); |
| imageView.setLayoutParams(layoutParams); |
| } |
| } |
| if (mChipDrawable != null) { |
| mChipDrawable.updateIcons(icons.size()); |
| } |
| } |
| |
| private void animateIconAppearance() { |
| animateIconAlphaTo(1f); |
| } |
| |
| private void animateIconDisappearance() { |
| animateIconAlphaTo(0f); |
| } |
| |
| private void animateIconAlphaTo(float endValue) { |
| if (mAnimator == null) { |
| if (DEBUG) Log.d(TAG, "set up animator"); |
| |
| mAnimator = new ObjectAnimator(); |
| mAnimator.setTarget(mIconsContainer); |
| mAnimator.setProperty(View.ALPHA); |
| mAnimator.addListener(new AnimatorListenerAdapter() { |
| boolean mCancelled; |
| |
| @Override |
| public void onAnimationStart(Animator animation, boolean isReverse) { |
| if (DEBUG) Log.d(TAG, "AnimatorListenerAdapter#onAnimationStart"); |
| mCancelled = false; |
| } |
| |
| @Override |
| public void onAnimationCancel(Animator animation) { |
| if (DEBUG) Log.d(TAG, "AnimatorListenerAdapter#onAnimationCancel"); |
| mCancelled = true; |
| } |
| |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| if (DEBUG) Log.d(TAG, "AnimatorListenerAdapter#onAnimationEnd"); |
| // When ValueAnimator#cancel() is called it always calls onAnimationCancel(...) |
| // and then onAnimationEnd(...). We, however, only want to proceed here if the |
| // animation ended "naturally". |
| if (!mCancelled) { |
| onIconAnimationFinished(); |
| } |
| } |
| }); |
| } else if (mAnimator.isRunning()) { |
| if (DEBUG) Log.d(TAG, "cancel running animation"); |
| mAnimator.cancel(); |
| } |
| |
| final float currentValue = mIconsContainer.getAlpha(); |
| if (currentValue == endValue) { |
| if (DEBUG) Log.d(TAG, "alpha not changing"); |
| return; |
| } |
| if (DEBUG) Log.d(TAG, "animate alpha to " + endValue + " from " + currentValue); |
| |
| mAnimator.setDuration(mAnimationDurationMs); |
| mAnimator.setFloatValues(endValue); |
| mAnimator.start(); |
| } |
| |
| @Override |
| public void onFadeOutFinished() { |
| if (DEBUG) Log.d(TAG, "drawable fade-out finished"); |
| |
| if (mState == STATE_DISAPPEARING) { |
| removeIndicatorView(); |
| mState = STATE_NOT_SHOWN; |
| } |
| } |
| |
| private void onIconAnimationFinished() { |
| if (DEBUG) Log.d(TAG, "onAnimationFinished (icon fade)"); |
| |
| if (mState == STATE_APPEARING || mState == STATE_EXPANDED) { |
| collapseLater(); |
| } |
| |
| if (mState == STATE_APPEARING) { |
| mState = STATE_EXPANDED; |
| } else if (mState == STATE_DISAPPEARING) { |
| removeIndicatorView(); |
| mState = STATE_NOT_SHOWN; |
| } |
| } |
| |
| private void removeIndicatorView() { |
| if (DEBUG) Log.d(TAG, "removeIndicatorView"); |
| |
| final WindowManager windowManager = mContext.getSystemService(WindowManager.class); |
| if (windowManager != null && mIndicatorView != null) { |
| windowManager.removeView(mIndicatorView); |
| } |
| |
| mIndicatorView = null; |
| mAnimator = null; |
| |
| if (mChipDrawable != null) { |
| mChipDrawable.setListener(null); |
| mChipDrawable = null; |
| } |
| |
| mViewAndWindowAdded = false; |
| } |
| |
| /** |
| * Schedules the accessibility announcement to be made after {@code |
| * 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. |
| */ |
| 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 (mIndicatorView == 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); |
| |
| 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) { |
| mIndicatorView.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) { |
| mIndicatorView.announceForAccessibility(mContext.getString(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; |
| } |
| |
| /** |
| * Used in debug logs. |
| */ |
| private String stateToString(@State int state) { |
| switch (state) { |
| case STATE_NOT_SHOWN: |
| return "NOT_SHOWN"; |
| case STATE_APPEARING: |
| return "APPEARING"; |
| case STATE_EXPANDED: |
| return "EXPANDED"; |
| case STATE_COLLAPSED: |
| return "COLLAPSED"; |
| case STATE_DISAPPEARING: |
| return "DISAPPEARING"; |
| default: |
| return "INVALID"; |
| } |
| } |
| |
| } |