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