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