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