1 /* 2 * Copyright (C) 2022 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.car.privacy; 18 19 import android.content.Context; 20 import android.os.Build; 21 import android.util.AttributeSet; 22 import android.util.Log; 23 import android.view.View; 24 import android.widget.ImageView; 25 26 import androidx.annotation.AnyThread; 27 import androidx.annotation.DrawableRes; 28 import androidx.annotation.NonNull; 29 import androidx.annotation.Nullable; 30 import androidx.annotation.UiThread; 31 import androidx.constraintlayout.motion.widget.MotionLayout; 32 33 import com.android.systemui.R; 34 import com.android.systemui.car.statusicon.AnimatedStatusIcon; 35 36 import java.util.concurrent.Executors; 37 import java.util.concurrent.ScheduledExecutorService; 38 import java.util.concurrent.TimeUnit; 39 40 /** 41 * Car optimized Privacy Chip View that is shown when a {@link 42 * android.hardware.SensorPrivacyManager.Sensors.Sensor} (such as microphone and camera) is being 43 * used. 44 * 45 * State flows: 46 * Base state: 47 * <ul> 48 * <li>INVISIBLE - Start Sensor Use ->> Sensor Status?</li> 49 * </ul> 50 * Sensor On: 51 * <ul> 52 * <li>Sensor Status? - On ->> ACTIVE_INIT</li> 53 * <li>ACTIVE_INIT - delay ->> ACTIVE/ACTIVE_SELECTED</li> 54 * <li>ACTIVE/ACTIVE_SELECTED - Stop Sensor Use ->> INACTIVE/INACTIVE_SELECTED</li> 55 * <li>INACTIVE/INACTIVE_SELECTED - delay/close panel ->> INVISIBLE</li> 56 * </ul> 57 * Sensor Off: 58 * <ul> 59 * <li>Sensor Status? - Off ->> SENSOR_OFF</li> 60 * <li>SENSOR_OFF - panel opened ->> SENSOR_OFF_SELECTED</li> 61 * </ul> 62 */ 63 public abstract class PrivacyChip extends MotionLayout implements AnimatedStatusIcon { 64 private static final boolean DEBUG = Build.IS_DEBUGGABLE; 65 private static final String TAG = "PrivacyChip"; 66 67 private final int mDelayPillToCircle; 68 private final int mDelayToNoSensorUsage; 69 70 private AnimationStates mCurrentTransitionState; 71 private boolean mPanelOpen; 72 private boolean mIsInflated; 73 private boolean mIsSensorEnabled; 74 private ScheduledExecutorService mExecutor; 75 PrivacyChip(@onNull Context context)76 public PrivacyChip(@NonNull Context context) { 77 this(context, /* attrs= */ null); 78 } 79 PrivacyChip(@onNull Context context, @Nullable AttributeSet attrs)80 public PrivacyChip(@NonNull Context context, @Nullable AttributeSet attrs) { 81 this(context, attrs, /* defStyleAttrs= */ 0); 82 } 83 PrivacyChip(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttrs)84 public PrivacyChip(@NonNull Context context, 85 @Nullable AttributeSet attrs, int defStyleAttrs) { 86 super(context, attrs, defStyleAttrs); 87 88 mDelayPillToCircle = getResources().getInteger(R.integer.privacy_chip_pill_to_circle_delay); 89 mDelayToNoSensorUsage = 90 getResources().getInteger(R.integer.privacy_chip_no_sensor_usage_delay); 91 92 mExecutor = Executors.newSingleThreadScheduledExecutor(); 93 mIsInflated = false; 94 95 // The sensor is enabled by default (invisible state). 96 mIsSensorEnabled = true; 97 } 98 99 @Override onFinishInflate()100 protected void onFinishInflate() { 101 super.onFinishInflate(); 102 103 mCurrentTransitionState = AnimationStates.INVISIBLE; 104 mIsInflated = true; 105 106 ImageView lightMutedIcon = requireViewById(R.id.light_muted_icon); 107 lightMutedIcon.setImageResource(getLightMutedIconResourceId()); 108 ImageView darkMutedIcon = requireViewById(R.id.dark_muted_icon); 109 darkMutedIcon.setImageResource(getDarkMutedIconResourceId()); 110 ImageView lightIcon = requireViewById(R.id.light_icon); 111 lightIcon.setImageResource(getLightIconResourceId()); 112 ImageView darkIcon = requireViewById(R.id.dark_icon); 113 darkIcon.setImageResource(getDarkIconResourceId()); 114 115 setTransitionListener( 116 new MotionLayout.TransitionListener() { 117 @Override 118 public void onTransitionCompleted(MotionLayout motionLayout, int currentId) {} 119 120 @Override 121 public void onTransitionStarted(MotionLayout m, int startId, int endId) { 122 if (startId == R.id.active) { 123 showIndicatorBorder(false); 124 } 125 } 126 127 @Override 128 public void onTransitionChange( 129 MotionLayout m, int startId, int endId, float progress) { 130 // When R.id.activeFromActiveInit animation is done and the green 131 // indicator shows up, set its background with a drawable with border. 132 // Reset the background to default after that (in onTransitionStarted()). 133 if (Float.compare(progress, 1.0f) == 0 134 && startId == R.id.active_init && endId == R.id.active) { 135 showIndicatorBorder(true); 136 } 137 } 138 139 @Override 140 public void onTransitionTrigger( 141 MotionLayout m, int triggerId, boolean positive, float p) {} 142 }); 143 } 144 145 @Override setOnClickListener(View.OnClickListener onClickListener)146 public void setOnClickListener(View.OnClickListener onClickListener) { 147 // required for CTS tests. 148 super.setOnClickListener(onClickListener); 149 // required for rotary. 150 requireViewById(R.id.focus_view).setOnClickListener(onClickListener); 151 } 152 153 /** 154 * Sets whether the sensor is enabled or disabled. 155 * If enabled, animates to {@link AnimationStates#INVISIBLE}. 156 * Otherwise, animates to {@link AnimationStates#SENSOR_OFF}. 157 */ 158 @UiThread setSensorEnabled(boolean enabled)159 public void setSensorEnabled(boolean enabled) { 160 if (DEBUG) Log.d(TAG, getSensorNameWithFirstLetterCapitalized() + " enabled: " + enabled); 161 162 if (mIsSensorEnabled == enabled) { 163 if (enabled) { 164 switch (mCurrentTransitionState) { 165 case INVISIBLE: 166 case ACTIVE: 167 case ACTIVE_SELECTED: 168 case INACTIVE: 169 case INACTIVE_SELECTED: 170 case ACTIVE_INIT: 171 return; 172 } 173 } else { 174 if (mCurrentTransitionState == AnimationStates.SENSOR_OFF 175 || mCurrentTransitionState == AnimationStates.SENSOR_OFF_SELECTED) { 176 return; 177 } 178 } 179 } 180 181 mIsSensorEnabled = enabled; 182 183 if (!mIsInflated) { 184 if (DEBUG) Log.d(TAG, "Layout not inflated"); 185 186 return; 187 } 188 189 if (mIsSensorEnabled) { 190 if (mPanelOpen) { 191 setTransition(R.id.inactiveSelectedFromSensorOffSelected); 192 } else { 193 setTransition(R.id.invisibleFromSensorOff); 194 } 195 } else { 196 if (mPanelOpen) { 197 switch (mCurrentTransitionState) { 198 case INVISIBLE: 199 setTransition(R.id.sensorOffSelectedFromInvisible); 200 break; 201 case ACTIVE_INIT: 202 setTransition(R.id.sensorOffSelectedFromActiveInit); 203 break; 204 case ACTIVE: 205 setTransition(R.id.sensorOffSelectedFromActive); 206 break; 207 case ACTIVE_SELECTED: 208 setTransition(R.id.sensorOffSelectedFromActiveSelected); 209 break; 210 case INACTIVE: 211 setTransition(R.id.sensorOffSelectedFromInactive); 212 break; 213 case INACTIVE_SELECTED: 214 setTransition(R.id.sensorOffSelectedFromInactiveSelected); 215 break; 216 default: 217 return; 218 } 219 } else { 220 switch (mCurrentTransitionState) { 221 case INVISIBLE: 222 setTransition(R.id.sensorOffFromInvisible); 223 break; 224 case ACTIVE_INIT: 225 setTransition(R.id.sensorOffFromActiveInit); 226 break; 227 case ACTIVE: 228 setTransition(R.id.sensorOffFromActive); 229 break; 230 case ACTIVE_SELECTED: 231 setTransition(R.id.sensorOffFromActiveSelected); 232 break; 233 case INACTIVE: 234 setTransition(R.id.sensorOffFromInactive); 235 break; 236 case INACTIVE_SELECTED: 237 setTransition(R.id.sensorOffFromInactiveSelected); 238 break; 239 default: 240 return; 241 } 242 } 243 } 244 245 mExecutor.shutdownNow(); 246 mExecutor = Executors.newSingleThreadScheduledExecutor(); 247 248 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. 249 250 // When the sensor is off, privacy chip is always visible. 251 if (!mIsSensorEnabled) setVisibility(View.VISIBLE); 252 setContentDescription(!mIsSensorEnabled); 253 if (mIsSensorEnabled) { 254 if (mPanelOpen) { 255 mCurrentTransitionState = AnimationStates.INACTIVE_SELECTED; 256 } else { 257 mCurrentTransitionState = AnimationStates.INVISIBLE; 258 } 259 } else { 260 if (mPanelOpen) { 261 mCurrentTransitionState = AnimationStates.SENSOR_OFF_SELECTED; 262 } else { 263 mCurrentTransitionState = AnimationStates.SENSOR_OFF; 264 } 265 } 266 transitionToEnd(); 267 if (mIsSensorEnabled && !mPanelOpen) setVisibility(View.GONE); 268 } 269 setContentDescription(boolean isSensorOff)270 protected void setContentDescription(boolean isSensorOff) { 271 String contentDescription; 272 if (isSensorOff) { 273 contentDescription = getResources().getString(R.string.privacy_chip_off_content, 274 getSensorNameWithFirstLetterCapitalized()); 275 } else { 276 contentDescription = getResources().getString( 277 R.string.ongoing_privacy_chip_content_multiple_apps, getSensorName()); 278 } 279 setContentDescription(contentDescription); 280 } 281 282 /** 283 * Starts reveal animation for Privacy Chip. 284 */ 285 @UiThread animateIn()286 public void animateIn() { 287 if (!mIsInflated) { 288 if (DEBUG) Log.d(TAG, "Layout not inflated"); 289 290 return; 291 } 292 293 if (mCurrentTransitionState == null) { 294 if (DEBUG) Log.d(TAG, "Current transition state is null or empty."); 295 296 return; 297 } 298 299 switch (mCurrentTransitionState) { 300 case INVISIBLE: 301 setTransition(mIsSensorEnabled ? R.id.activeInitFromInvisible 302 : R.id.sensorOffFromInvisible); 303 break; 304 case INACTIVE: 305 setTransition(mIsSensorEnabled ? R.id.activeInitFromInactive 306 : R.id.sensorOffFromInactive); 307 break; 308 case INACTIVE_SELECTED: 309 setTransition(mIsSensorEnabled ? R.id.activeInitFromInactiveSelected 310 : R.id.sensorOffFromInactiveSelected); 311 break; 312 case SENSOR_OFF: 313 if (!mIsSensorEnabled) { 314 if (DEBUG) { 315 Log.d(TAG, "No Transition."); 316 } 317 return; 318 } 319 320 setTransition(R.id.activeInitFromSensorOff); 321 break; 322 case SENSOR_OFF_SELECTED: 323 if (!mIsSensorEnabled) { 324 if (DEBUG) { 325 Log.d(TAG, "No Transition."); 326 } 327 return; 328 } 329 330 setTransition(R.id.activeInitFromSensorOffSelected); 331 break; 332 default: 333 if (DEBUG) { 334 Log.d(TAG, "Early exit, mCurrentTransitionState= " 335 + mCurrentTransitionState); 336 } 337 338 return; 339 } 340 341 mExecutor.shutdownNow(); 342 mExecutor = Executors.newSingleThreadScheduledExecutor(); 343 344 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. 345 setContentDescription(false); 346 setVisibility(View.VISIBLE); 347 if (mIsSensorEnabled) { 348 mCurrentTransitionState = AnimationStates.ACTIVE_INIT; 349 } else { 350 if (mPanelOpen) { 351 mCurrentTransitionState = AnimationStates.SENSOR_OFF_SELECTED; 352 } else { 353 mCurrentTransitionState = AnimationStates.SENSOR_OFF; 354 } 355 } 356 transitionToEnd(); 357 if (mIsSensorEnabled) { 358 mExecutor.schedule(PrivacyChip.this::animateToOrangeCircle, mDelayPillToCircle, 359 TimeUnit.MILLISECONDS); 360 } 361 } 362 363 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. animateToOrangeCircle()364 private void animateToOrangeCircle() { 365 // Since this is launched using a {@link ScheduledExecutorService}, its UI based elements 366 // need to execute on main executor. 367 getContext().getMainExecutor().execute(() -> { 368 if (mPanelOpen) { 369 setTransition(R.id.activeSelectedFromActiveInit); 370 mCurrentTransitionState = AnimationStates.ACTIVE_SELECTED; 371 } else { 372 setTransition(R.id.activeFromActiveInit); 373 mCurrentTransitionState = AnimationStates.ACTIVE; 374 } 375 transitionToEnd(); 376 }); 377 } 378 showIndicatorBorder(boolean show)379 private void showIndicatorBorder(boolean show) { 380 // Since this is launched using a {@link ScheduledExecutorService}, its UI based elements 381 // need to execute on main executor. 382 getContext().getMainExecutor().execute(() -> { 383 View activeBackground = findViewById(R.id.active_background); 384 activeBackground.setBackground(getContext().getDrawable(show 385 ? R.drawable.privacy_chip_active_background_pill_with_border 386 : R.drawable.privacy_chip_active_background_pill)); 387 }); 388 } 389 390 /** 391 * Starts conceal animation for Privacy Chip. 392 */ 393 @UiThread animateOut()394 public void animateOut() { 395 if (!mIsInflated) { 396 if (DEBUG) Log.d(TAG, "Layout not inflated"); 397 398 return; 399 } 400 401 if (mPanelOpen) { 402 switch (mCurrentTransitionState) { 403 case ACTIVE_INIT: 404 setTransition(R.id.inactiveSelectedFromActiveInit); 405 break; 406 case ACTIVE: 407 setTransition(R.id.inactiveSelectedFromActive); 408 break; 409 case ACTIVE_SELECTED: 410 setTransition(R.id.inactiveSelectedFromActiveSelected); 411 break; 412 default: 413 if (DEBUG) { 414 Log.d(TAG, "Early exit, mCurrentTransitionState= " 415 + mCurrentTransitionState); 416 } 417 418 return; 419 } 420 } else { 421 switch (mCurrentTransitionState) { 422 case ACTIVE_INIT: 423 setTransition(R.id.inactiveFromActiveInit); 424 break; 425 case ACTIVE: 426 setTransition(R.id.inactiveFromActive); 427 break; 428 case ACTIVE_SELECTED: 429 setTransition(R.id.inactiveFromActiveSelected); 430 break; 431 default: 432 if (DEBUG) { 433 Log.d(TAG, "Early exit, mCurrentTransitionState= " 434 + mCurrentTransitionState); 435 } 436 437 return; 438 } 439 } 440 441 mExecutor.shutdownNow(); 442 mExecutor = Executors.newSingleThreadScheduledExecutor(); 443 444 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. 445 mCurrentTransitionState = mPanelOpen 446 ? AnimationStates.INACTIVE_SELECTED 447 : AnimationStates.INACTIVE; 448 transitionToEnd(); 449 mExecutor.schedule(PrivacyChip.this::reset, mDelayToNoSensorUsage, 450 TimeUnit.MILLISECONDS); 451 } 452 453 454 455 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. reset()456 private void reset() { 457 // Since this is launched using a {@link ScheduledExecutorService}, its UI based elements 458 // need to execute on main executor. 459 getContext().getMainExecutor().execute(() -> { 460 if (mIsSensorEnabled && !mPanelOpen) { 461 setTransition(R.id.invisibleFromInactive); 462 mCurrentTransitionState = AnimationStates.INVISIBLE; 463 } else if (!mIsSensorEnabled) { 464 if (mPanelOpen) { 465 setTransition(R.id.inactiveSelectedFromSensorOffSelected); 466 mCurrentTransitionState = AnimationStates.INACTIVE_SELECTED; 467 } else { 468 setTransition(R.id.invisibleFromSensorOff); 469 mCurrentTransitionState = AnimationStates.INVISIBLE; 470 } 471 } 472 473 transitionToEnd(); 474 475 if (!mPanelOpen) { 476 setVisibility(View.GONE); 477 } 478 }); 479 } 480 481 @AnyThread 482 @Override setIconHighlighted(boolean iconHighlighted)483 public void setIconHighlighted(boolean iconHighlighted) { 484 // UI based elements need to execute on main executor. 485 getContext().getMainExecutor().execute(() -> { 486 if (mPanelOpen == iconHighlighted) { 487 return; 488 } 489 490 mPanelOpen = iconHighlighted; 491 492 if (mIsSensorEnabled) { 493 switch (mCurrentTransitionState) { 494 case ACTIVE: 495 if (mPanelOpen) { 496 setTransition(R.id.activeSelectedFromActive); 497 mCurrentTransitionState = AnimationStates.ACTIVE_SELECTED; 498 transitionToEnd(); 499 } 500 return; 501 case ACTIVE_SELECTED: 502 if (!mPanelOpen) { 503 setTransition(R.id.activeFromActiveSelected); 504 mCurrentTransitionState = AnimationStates.ACTIVE; 505 transitionToEnd(); 506 } 507 return; 508 case INACTIVE: 509 if (mPanelOpen) { 510 setTransition(R.id.inactiveSelectedFromInactive); 511 mCurrentTransitionState = AnimationStates.INACTIVE_SELECTED; 512 transitionToEnd(); 513 } 514 return; 515 case INACTIVE_SELECTED: 516 if (!mPanelOpen) { 517 setTransition(R.id.invisibleFromInactiveSelected); 518 mCurrentTransitionState = AnimationStates.INVISIBLE; 519 transitionToEnd(); 520 setVisibility(View.GONE); 521 } 522 return; 523 } 524 } else { 525 switch (mCurrentTransitionState) { 526 case SENSOR_OFF: 527 if (mPanelOpen) { 528 setTransition(R.id.sensorOffSelectedFromSensorOff); 529 mCurrentTransitionState = AnimationStates.SENSOR_OFF_SELECTED; 530 transitionToEnd(); 531 } 532 return; 533 case SENSOR_OFF_SELECTED: 534 if (!mPanelOpen) { 535 setTransition(R.id.sensorOffFromSensorOffSelected); 536 mCurrentTransitionState = AnimationStates.SENSOR_OFF; 537 transitionToEnd(); 538 } 539 return; 540 } 541 } 542 543 if (DEBUG) { 544 Log.d(TAG, "Early exit, mCurrentTransitionState= " 545 + mCurrentTransitionState); 546 } 547 }); 548 } 549 550 @Override setTransition(int transitionId)551 public void setTransition(int transitionId) { 552 if (DEBUG) { 553 Log.d(TAG, "Transition set: " + getResources().getResourceEntryName(transitionId)); 554 } 555 556 // Sometimes the alpha of the icon is reset to 0 incorrectly after several transitions, so 557 // set it to 1 before each transition as a workaround. This is fine as long as the 558 // visibility of the icon is set properly. See b/226651461. 559 View darkIcon = requireViewById(R.id.dark_icon); 560 darkIcon.setAlpha(1.0f); 561 562 super.setTransition(transitionId); 563 } 564 getLightMutedIconResourceId()565 protected abstract @DrawableRes int getLightMutedIconResourceId(); 566 getDarkMutedIconResourceId()567 protected abstract @DrawableRes int getDarkMutedIconResourceId(); 568 getLightIconResourceId()569 protected abstract @DrawableRes int getLightIconResourceId(); 570 getDarkIconResourceId()571 protected abstract @DrawableRes int getDarkIconResourceId(); 572 getSensorName()573 protected abstract String getSensorName(); 574 getSensorNameWithFirstLetterCapitalized()575 protected abstract String getSensorNameWithFirstLetterCapitalized(); 576 577 private enum AnimationStates { 578 INVISIBLE, 579 ACTIVE_INIT, 580 ACTIVE, 581 ACTIVE_SELECTED, 582 INACTIVE, 583 INACTIVE_SELECTED, 584 SENSOR_OFF, 585 SENSOR_OFF_SELECTED, 586 } 587 } 588