1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.privacy.television;
18 
19 import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
20 
21 import android.annotation.NonNull;
22 import android.annotation.Nullable;
23 import android.annotation.UiThread;
24 import android.content.Context;
25 import android.content.res.Configuration;
26 import android.content.res.Resources;
27 import android.graphics.PixelFormat;
28 import android.graphics.Rect;
29 import android.os.Handler;
30 import android.os.Looper;
31 import android.os.RemoteException;
32 import android.transition.AutoTransition;
33 import android.transition.ChangeBounds;
34 import android.transition.Fade;
35 import android.transition.Transition;
36 import android.transition.TransitionManager;
37 import android.transition.TransitionSet;
38 import android.util.ArraySet;
39 import android.util.Log;
40 import android.view.ContextThemeWrapper;
41 import android.view.Gravity;
42 import android.view.IWindowManager;
43 import android.view.LayoutInflater;
44 import android.view.View;
45 import android.view.ViewGroup;
46 import android.view.ViewTreeObserver;
47 import android.view.WindowManager;
48 import android.view.animation.AnimationUtils;
49 import android.view.animation.Interpolator;
50 import android.widget.ImageView;
51 import android.widget.LinearLayout;
52 
53 import com.android.systemui.CoreStartable;
54 import com.android.systemui.R;
55 import com.android.systemui.dagger.SysUISingleton;
56 import com.android.systemui.privacy.PrivacyItem;
57 import com.android.systemui.privacy.PrivacyItemController;
58 import com.android.systemui.privacy.PrivacyType;
59 
60 import java.util.ArrayList;
61 import java.util.Arrays;
62 import java.util.Collections;
63 import java.util.List;
64 import java.util.Set;
65 
66 import javax.inject.Inject;
67 
68 /**
69  * A SystemUI component responsible for notifying the user whenever an application is
70  * recording audio, camera, the screen, or accessing the location.
71  */
72 @SysUISingleton
73 public class TvPrivacyChipsController
74         implements CoreStartable, PrivacyItemController.Callback {
75     private static final String TAG = "TvPrivacyChipsController";
76     private static final boolean DEBUG = false;
77 
78     // This title is used in CameraMicIndicatorsPermissionTest and
79     // RecognitionServiceMicIndicatorTest.
80     private static final String LAYOUT_PARAMS_TITLE = "MicrophoneCaptureIndicator";
81 
82     // Chips configuration. We're not showing a location indicator on TV.
83     static final List<PrivacyItemsChip.ChipConfig> CHIPS = Arrays.asList(
84             new PrivacyItemsChip.ChipConfig(
85                     Collections.singletonList(PrivacyType.TYPE_MEDIA_PROJECTION),
86                     R.color.privacy_media_projection_chip,
87                     /* collapseToDot= */ false),
88             new PrivacyItemsChip.ChipConfig(
89                     Arrays.asList(PrivacyType.TYPE_CAMERA, PrivacyType.TYPE_MICROPHONE),
90                     R.color.privacy_mic_cam_chip,
91                     /* collapseToDot= */ true)
92     );
93 
94     // Avoid multiple messages after rapid changes such as starting/stopping both camera and mic.
95     private static final int ACCESSIBILITY_ANNOUNCEMENT_DELAY_MS = 500;
96 
97     /**
98      * Time to collect privacy item updates before applying them.
99      * Since MediaProjection and AppOps come from different data sources,
100      * PrivacyItem updates when screen & audio recording ends do not come at the same time.
101      * Without this, if eg. MediaProjection ends first, you'd see the microphone chip expand and
102      * almost immediately fade out as it is expanding. With this, the two chips disappear together.
103      */
104     private static final int PRIVACY_ITEM_DEBOUNCE_TIMEOUT_MS = 200;
105 
106     // How long chips stay expanded after an update.
107     private static final int EXPANDED_DURATION_MS = 4000;
108 
109     private final Context mContext;
110     private final Handler mUiThreadHandler = new Handler(Looper.getMainLooper());
111     private final Runnable mCollapseRunnable = this::collapseChips;
112     private final Runnable mUpdatePrivacyItemsRunnable = this::updateChipsAndAnnounce;
113     private final Runnable mAccessibilityRunnable = this::makeAccessibilityAnnouncement;
114 
115     private final PrivacyItemController mPrivacyItemController;
116     private final IWindowManager mIWindowManager;
117     private final Rect[] mBounds = new Rect[4];
118     private final TransitionSet mTransition;
119     private final TransitionSet mCollapseTransition;
120     private boolean mIsRtl;
121 
122     @Nullable
123     private ViewGroup mChipsContainer;
124     @Nullable
125     private List<PrivacyItemsChip> mChips;
126     @NonNull
127     private List<PrivacyItem> mPrivacyItems = Collections.emptyList();
128     @NonNull
129     private final List<PrivacyItem> mItemsBeforeLastAnnouncement = new ArrayList<>();
130 
131     @Inject
TvPrivacyChipsController(Context context, PrivacyItemController privacyItemController, IWindowManager iWindowManager)132     public TvPrivacyChipsController(Context context, PrivacyItemController privacyItemController,
133             IWindowManager iWindowManager) {
134         mContext = context;
135         if (DEBUG) Log.d(TAG, "TvPrivacyChipsController running");
136         mPrivacyItemController = privacyItemController;
137         mIWindowManager = iWindowManager;
138 
139         Resources res = mContext.getResources();
140         mIsRtl = res.getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
141         updateStaticPrivacyIndicatorBounds();
142 
143         Interpolator collapseInterpolator = AnimationUtils.loadInterpolator(context,
144                 R.interpolator.tv_privacy_chip_collapse_interpolator);
145         Interpolator expandInterpolator = AnimationUtils.loadInterpolator(context,
146                 R.interpolator.tv_privacy_chip_expand_interpolator);
147 
148         TransitionSet chipFadeTransition = new TransitionSet()
149                 .addTransition(new Fade(Fade.IN))
150                 .addTransition(new Fade(Fade.OUT));
151         chipFadeTransition.setOrdering(TransitionSet.ORDERING_TOGETHER);
152         chipFadeTransition.excludeTarget(ImageView.class, true);
153 
154         Transition chipBoundsExpandTransition = new ChangeBounds();
155         chipBoundsExpandTransition.excludeTarget(ImageView.class, true);
156         chipBoundsExpandTransition.setInterpolator(expandInterpolator);
157 
158         Transition chipBoundsCollapseTransition = new ChangeBounds();
159         chipBoundsCollapseTransition.excludeTarget(ImageView.class, true);
160         chipBoundsCollapseTransition.setInterpolator(collapseInterpolator);
161 
162         TransitionSet iconCollapseTransition = new AutoTransition();
163         iconCollapseTransition.setOrdering(TransitionSet.ORDERING_TOGETHER);
164         iconCollapseTransition.addTarget(ImageView.class);
165         iconCollapseTransition.setInterpolator(collapseInterpolator);
166 
167         TransitionSet iconExpandTransition = new AutoTransition();
168         iconExpandTransition.setOrdering(TransitionSet.ORDERING_TOGETHER);
169         iconExpandTransition.addTarget(ImageView.class);
170         iconExpandTransition.setInterpolator(expandInterpolator);
171 
172         mTransition = new TransitionSet()
173                 .addTransition(chipFadeTransition)
174                 .addTransition(chipBoundsExpandTransition)
175                 .addTransition(iconExpandTransition)
176                 .setOrdering(TransitionSet.ORDERING_TOGETHER)
177                 .setDuration(res.getInteger(R.integer.privacy_chip_animation_millis));
178 
179         mCollapseTransition = new TransitionSet()
180                 .addTransition(chipFadeTransition)
181                 .addTransition(chipBoundsCollapseTransition)
182                 .addTransition(iconCollapseTransition)
183                 .setOrdering(TransitionSet.ORDERING_TOGETHER)
184                 .setDuration(res.getInteger(R.integer.privacy_chip_animation_millis));
185 
186         Transition.TransitionListener transitionListener = new Transition.TransitionListener() {
187             @Override
188             public void onTransitionStart(Transition transition) {
189                 if (DEBUG) Log.v(TAG, "onTransitionStart");
190             }
191 
192             @Override
193             public void onTransitionEnd(Transition transition) {
194                 if (DEBUG) Log.v(TAG, "onTransitionEnd");
195                 if (mChips != null) {
196                     boolean hasVisibleChip = false;
197                     boolean hasExpandedChip = false;
198                     for (PrivacyItemsChip chip : mChips) {
199                         hasVisibleChip = hasVisibleChip || chip.getVisibility() == View.VISIBLE;
200                         hasExpandedChip = hasExpandedChip || chip.isExpanded();
201                     }
202 
203                     if (!hasVisibleChip) {
204                         if (DEBUG) Log.d(TAG, "No chips visible anymore");
205                         removeIndicatorView();
206                     } else if (hasExpandedChip) {
207                         if (DEBUG) Log.d(TAG, "Has expanded chips");
208                         collapseLater();
209                     }
210                 }
211             }
212 
213             @Override
214             public void onTransitionCancel(Transition transition) {
215             }
216 
217             @Override
218             public void onTransitionPause(Transition transition) {
219             }
220 
221             @Override
222             public void onTransitionResume(Transition transition) {
223             }
224         };
225 
226         mTransition.addListener(transitionListener);
227         mCollapseTransition.addListener(transitionListener);
228     }
229 
230     @Override
onConfigurationChanged(Configuration config)231     public void onConfigurationChanged(Configuration config) {
232         boolean updatedRtl = config.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
233         if (mIsRtl == updatedRtl) {
234             return;
235         }
236         mIsRtl = updatedRtl;
237 
238         // Update privacy chip location.
239         if (mChipsContainer != null) {
240             removeIndicatorView();
241             createAndShowIndicator();
242         }
243         updateStaticPrivacyIndicatorBounds();
244     }
245 
246     @Override
start()247     public void start() {
248         mPrivacyItemController.addCallback(this);
249     }
250 
251     @UiThread
252     @Override
onPrivacyItemsChanged(List<PrivacyItem> privacyItems)253     public void onPrivacyItemsChanged(List<PrivacyItem> privacyItems) {
254         if (DEBUG) Log.d(TAG, "onPrivacyItemsChanged");
255 
256         List<PrivacyItem> filteredPrivacyItems = new ArrayList<>(privacyItems);
257         if (filteredPrivacyItems.removeIf(
258                 privacyItem -> !isPrivacyTypeShown(privacyItem.getPrivacyType()))) {
259             if (DEBUG) Log.v(TAG, "Removed privacy items we don't show");
260         }
261 
262         // Do they have the same elements? (order doesn't matter)
263         if (privacyItems.size() == mPrivacyItems.size() && mPrivacyItems.containsAll(
264                 privacyItems)) {
265             if (DEBUG) Log.d(TAG, "No change to relevant privacy items");
266             return;
267         }
268 
269         mPrivacyItems = privacyItems;
270 
271         if (!mUiThreadHandler.hasCallbacks(mUpdatePrivacyItemsRunnable)) {
272             mUiThreadHandler.postDelayed(mUpdatePrivacyItemsRunnable,
273                     PRIVACY_ITEM_DEBOUNCE_TIMEOUT_MS);
274         }
275     }
276 
isPrivacyTypeShown(@onNull PrivacyType type)277     private boolean isPrivacyTypeShown(@NonNull PrivacyType type) {
278         for (PrivacyItemsChip.ChipConfig chip : CHIPS) {
279             if (chip.privacyTypes.contains(type)) {
280                 return true;
281             }
282         }
283         return false;
284     }
285 
286     @UiThread
updateChipsAndAnnounce()287     private void updateChipsAndAnnounce() {
288         updateChips();
289         postAccessibilityAnnouncement();
290     }
291 
updateStaticPrivacyIndicatorBounds()292     private void updateStaticPrivacyIndicatorBounds() {
293         Resources res = mContext.getResources();
294         int mMaxExpandedWidth = res.getDimensionPixelSize(R.dimen.privacy_chips_max_width);
295         int mMaxExpandedHeight = res.getDimensionPixelSize(R.dimen.privacy_chip_height);
296         int mChipMarginTotal = 2 * res.getDimensionPixelSize(R.dimen.privacy_chip_margin);
297 
298         final WindowManager windowManager = mContext.getSystemService(WindowManager.class);
299         Rect screenBounds = windowManager.getCurrentWindowMetrics().getBounds();
300         mBounds[0] = new Rect(
301                 mIsRtl ? screenBounds.left
302                         : screenBounds.right - mMaxExpandedWidth,
303                 screenBounds.top,
304                 mIsRtl ? screenBounds.left + mMaxExpandedWidth
305                         : screenBounds.right,
306                 screenBounds.top + mChipMarginTotal + mMaxExpandedHeight
307         );
308 
309         if (DEBUG) Log.v(TAG, "privacy indicator bounds: " + mBounds[0].toShortString());
310 
311         try {
312             mIWindowManager.updateStaticPrivacyIndicatorBounds(mContext.getDisplayId(), mBounds);
313         } catch (RemoteException e) {
314             Log.w(TAG, "could not update privacy indicator bounds");
315         }
316     }
317 
318     @UiThread
updateChips()319     private void updateChips() {
320         if (DEBUG) Log.d(TAG, "updateChips: " + mPrivacyItems.size() + " privacy items");
321 
322         if (mChipsContainer == null) {
323             if (!mPrivacyItems.isEmpty()) {
324                 createAndShowIndicator();
325             }
326             return;
327         }
328 
329         Set<PrivacyType> activePrivacyTypes = new ArraySet<>();
330         mPrivacyItems.forEach(item -> activePrivacyTypes.add(item.getPrivacyType()));
331 
332         TransitionManager.beginDelayedTransition(mChipsContainer, mTransition);
333         mChips.forEach(chip -> chip.expandForTypes(activePrivacyTypes));
334     }
335 
336     /**
337      * Collapse the chip {@link #EXPANDED_DURATION_MS} from now.
338      */
collapseLater()339     private void collapseLater() {
340         mUiThreadHandler.removeCallbacks(mCollapseRunnable);
341         if (DEBUG) Log.d(TAG, "Chips will collapse in " + EXPANDED_DURATION_MS + "ms");
342         mUiThreadHandler.postDelayed(mCollapseRunnable, EXPANDED_DURATION_MS);
343     }
344 
collapseChips()345     private void collapseChips() {
346         if (DEBUG) Log.d(TAG, "collapseChips");
347         if (mChipsContainer == null) {
348             return;
349         }
350 
351         TransitionManager.beginDelayedTransition(mChipsContainer, mCollapseTransition);
352         for (PrivacyItemsChip chip : mChips) {
353             chip.collapse();
354         }
355     }
356 
357     @UiThread
createAndShowIndicator()358     private void createAndShowIndicator() {
359         if (DEBUG) Log.i(TAG, "Creating privacy indicators");
360 
361         Context privacyChipContext = new ContextThemeWrapper(mContext, R.style.PrivacyChip);
362         mChips = new ArrayList<>();
363         mChipsContainer = (ViewGroup) LayoutInflater.from(privacyChipContext)
364                 .inflate(R.layout.tv_privacy_chip_container, null);
365 
366         int chipMargins = privacyChipContext.getResources()
367                 .getDimensionPixelSize(R.dimen.privacy_chip_margin);
368         LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
369         lp.setMarginStart(chipMargins);
370         lp.setMarginEnd(chipMargins);
371 
372         for (PrivacyItemsChip.ChipConfig chipConfig : CHIPS) {
373             PrivacyItemsChip chip = new PrivacyItemsChip(privacyChipContext, chipConfig);
374             mChipsContainer.addView(chip, lp);
375             mChips.add(chip);
376         }
377 
378         final WindowManager windowManager = mContext.getSystemService(WindowManager.class);
379         windowManager.addView(mChipsContainer, getWindowLayoutParams());
380 
381         final ViewGroup container = mChipsContainer;
382         mChipsContainer.getViewTreeObserver()
383                 .addOnGlobalLayoutListener(
384                         new ViewTreeObserver.OnGlobalLayoutListener() {
385                             @Override
386                             public void onGlobalLayout() {
387                                 if (DEBUG) Log.v(TAG, "Chips container laid out");
388                                 container.getViewTreeObserver().removeOnGlobalLayoutListener(this);
389                                 updateChips();
390                             }
391                         });
392     }
393 
getWindowLayoutParams()394     private WindowManager.LayoutParams getWindowLayoutParams() {
395         final WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(
396                 WRAP_CONTENT,
397                 WRAP_CONTENT,
398                 WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY,
399                 WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
400                 PixelFormat.TRANSLUCENT);
401         layoutParams.gravity = Gravity.TOP | (mIsRtl ? Gravity.LEFT : Gravity.RIGHT);
402         layoutParams.setTitle(LAYOUT_PARAMS_TITLE);
403         layoutParams.packageName = mContext.getPackageName();
404         return layoutParams;
405     }
406 
407     @UiThread
removeIndicatorView()408     private void removeIndicatorView() {
409         if (DEBUG) Log.d(TAG, "removeIndicatorView");
410         mUiThreadHandler.removeCallbacks(mCollapseRunnable);
411 
412         final WindowManager windowManager = mContext.getSystemService(WindowManager.class);
413         if (windowManager != null && mChipsContainer != null) {
414             windowManager.removeView(mChipsContainer);
415         }
416 
417         mChipsContainer = null;
418         mChips = null;
419     }
420 
421     /**
422      * Schedules the accessibility announcement to be made after {@link
423      * #ACCESSIBILITY_ANNOUNCEMENT_DELAY_MS} (if possible). This is so that only one announcement is
424      * made instead of two separate ones if both the camera and the mic are started/stopped.
425      */
426     @UiThread
postAccessibilityAnnouncement()427     private void postAccessibilityAnnouncement() {
428         mUiThreadHandler.removeCallbacks(mAccessibilityRunnable);
429 
430         if (mPrivacyItems.size() == 0) {
431             // Announce immediately since announcement cannot be made once the chip is gone.
432             makeAccessibilityAnnouncement();
433         } else {
434             mUiThreadHandler.postDelayed(mAccessibilityRunnable,
435                     ACCESSIBILITY_ANNOUNCEMENT_DELAY_MS);
436         }
437     }
438 
makeAccessibilityAnnouncement()439     private void makeAccessibilityAnnouncement() {
440         if (mChipsContainer == null) {
441             return;
442         }
443 
444         boolean cameraWasRecording = listContainsPrivacyType(mItemsBeforeLastAnnouncement,
445                 PrivacyType.TYPE_CAMERA);
446         boolean cameraIsRecording = listContainsPrivacyType(mPrivacyItems,
447                 PrivacyType.TYPE_CAMERA);
448         boolean micWasRecording = listContainsPrivacyType(mItemsBeforeLastAnnouncement,
449                 PrivacyType.TYPE_MICROPHONE);
450         boolean micIsRecording = listContainsPrivacyType(mPrivacyItems,
451                 PrivacyType.TYPE_MICROPHONE);
452 
453         boolean screenWasRecording = listContainsPrivacyType(mItemsBeforeLastAnnouncement,
454                 PrivacyType.TYPE_MEDIA_PROJECTION);
455         boolean screenIsRecording = listContainsPrivacyType(mPrivacyItems,
456                 PrivacyType.TYPE_MEDIA_PROJECTION);
457 
458         int announcement = 0;
459         if (!cameraWasRecording && cameraIsRecording && !micWasRecording && micIsRecording) {
460             // Both started
461             announcement = R.string.mic_and_camera_recording_announcement;
462         } else if (cameraWasRecording && !cameraIsRecording && micWasRecording && !micIsRecording) {
463             // Both stopped
464             announcement = R.string.mic_camera_stopped_recording_announcement;
465         } else {
466             // Did the camera start or stop?
467             if (cameraWasRecording && !cameraIsRecording) {
468                 announcement = R.string.camera_stopped_recording_announcement;
469             } else if (!cameraWasRecording && cameraIsRecording) {
470                 announcement = R.string.camera_recording_announcement;
471             }
472 
473             // Announce camera changes now since we might need a second announcement about the mic.
474             if (announcement != 0) {
475                 mChipsContainer.announceForAccessibility(mContext.getString(announcement));
476                 announcement = 0;
477             }
478 
479             // Did the mic start or stop?
480             if (micWasRecording && !micIsRecording) {
481                 announcement = R.string.mic_stopped_recording_announcement;
482             } else if (!micWasRecording && micIsRecording) {
483                 announcement = R.string.mic_recording_announcement;
484             }
485         }
486 
487         if (announcement != 0) {
488             mChipsContainer.announceForAccessibility(mContext.getString(announcement));
489         }
490 
491         if (!screenWasRecording && screenIsRecording) {
492             mChipsContainer.announceForAccessibility(
493                     mContext.getString(R.string.screen_recording_announcement));
494         } else if (screenWasRecording && !screenIsRecording) {
495             mChipsContainer.announceForAccessibility(
496                     mContext.getString(R.string.screen_stopped_recording_announcement));
497         }
498 
499         mItemsBeforeLastAnnouncement.clear();
500         mItemsBeforeLastAnnouncement.addAll(mPrivacyItems);
501     }
502 
listContainsPrivacyType(List<PrivacyItem> list, PrivacyType privacyType)503     private boolean listContainsPrivacyType(List<PrivacyItem> list, PrivacyType privacyType) {
504         for (PrivacyItem item : list) {
505             if (item.getPrivacyType() == privacyType) {
506                 return true;
507             }
508         }
509         return false;
510     }
511 }
512