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.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 25 import androidx.annotation.AnyThread; 26 import androidx.annotation.NonNull; 27 import androidx.annotation.Nullable; 28 import androidx.annotation.UiThread; 29 import androidx.constraintlayout.motion.widget.MotionLayout; 30 31 import com.android.systemui.R; 32 import com.android.systemui.car.statusicon.AnimatedStatusIcon; 33 34 import java.util.concurrent.Executors; 35 import java.util.concurrent.ScheduledExecutorService; 36 import java.util.concurrent.TimeUnit; 37 38 /** 39 * Car optimized Mic Privacy Chip View that is shown when microphone is being used. 40 * 41 * State flows: 42 * Base state: 43 * <ul> 44 * <li>INVISIBLE - Start Mic Use ->> Mic Status?</li> 45 * </ul> 46 * Mic On: 47 * <ul> 48 * <li>Mic Status? - On ->> ACTIVE_INIT</li> 49 * <li>ACTIVE_INIT - delay ->> ACTIVE/ACTIVE_SELECTED</li> 50 * <li>ACTIVE/ACTIVE_SELECTED - Stop Mic Use ->> INACTIVE/INACTIVE_SELECTED</li> 51 * <li>INACTIVE/INACTIVE_SELECTED - delay/close panel ->> INVISIBLE</li> 52 * </ul> 53 * Mic Off: 54 * <ul> 55 * <li>Mic Status? - Off ->> MICROPHONE_OFF</li> 56 * <li>MICROPHONE_OFF - panel opened ->> MICROPHONE_OFF_SELECTED</li> 57 * </ul> 58 */ 59 public class MicPrivacyChip extends MotionLayout implements AnimatedStatusIcon { 60 private static final boolean DEBUG = Build.IS_DEBUGGABLE; 61 private static final String TAG = "MicPrivacyChip"; 62 private static final String TYPES_TEXT_MICROPHONE = "microphone"; 63 64 private final int mDelayPillToCircle; 65 private final int mDelayToNoMicUsage; 66 67 private AnimationStates mCurrentTransitionState; 68 private boolean mPanelOpen; 69 private boolean mIsInflated; 70 private boolean mIsMicrophoneEnabled; 71 private ScheduledExecutorService mExecutor; 72 MicPrivacyChip(@onNull Context context)73 public MicPrivacyChip(@NonNull Context context) { 74 this(context, /* attrs= */ null); 75 } 76 MicPrivacyChip(@onNull Context context, @Nullable AttributeSet attrs)77 public MicPrivacyChip(@NonNull Context context, @Nullable AttributeSet attrs) { 78 this(context, attrs, /* defStyleAttrs= */ 0); 79 } 80 MicPrivacyChip(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttrs)81 public MicPrivacyChip(@NonNull Context context, 82 @Nullable AttributeSet attrs, int defStyleAttrs) { 83 super(context, attrs, defStyleAttrs); 84 85 mDelayPillToCircle = getResources().getInteger(R.integer.privacy_chip_pill_to_circle_delay); 86 mDelayToNoMicUsage = getResources().getInteger(R.integer.privacy_chip_no_mic_usage_delay); 87 88 mExecutor = Executors.newSingleThreadScheduledExecutor(); 89 mIsInflated = false; 90 91 // Microphone is enabled by default (invisible state). 92 mIsMicrophoneEnabled = true; 93 } 94 95 @Override onFinishInflate()96 protected void onFinishInflate() { 97 super.onFinishInflate(); 98 99 mCurrentTransitionState = AnimationStates.INVISIBLE; 100 mIsInflated = true; 101 } 102 103 @Override setOnClickListener(View.OnClickListener onClickListener)104 public void setOnClickListener(View.OnClickListener onClickListener) { 105 // required for CTS tests. 106 super.setOnClickListener(onClickListener); 107 // required for rotary. 108 requireViewById(R.id.focus_view).setOnClickListener(onClickListener); 109 } 110 111 /** 112 * Sets whether microphone is enabled or disabled. 113 * If enabled, animates to {@link AnimationStates#INVISIBLE}. 114 * Otherwise, animates to {@link AnimationStates#MICROPHONE_OFF}. 115 */ 116 @UiThread setMicrophoneEnabled(boolean isMicrophoneEnabled)117 public void setMicrophoneEnabled(boolean isMicrophoneEnabled) { 118 if (DEBUG) Log.d(TAG, "Microphone enabled: " + isMicrophoneEnabled); 119 120 if (mIsMicrophoneEnabled == isMicrophoneEnabled) { 121 if (isMicrophoneEnabled) { 122 switch (mCurrentTransitionState) { 123 case INVISIBLE: 124 case ACTIVE: 125 case ACTIVE_SELECTED: 126 case INACTIVE: 127 case INACTIVE_SELECTED: 128 case ACTIVE_INIT: 129 return; 130 } 131 } else { 132 if (mCurrentTransitionState == AnimationStates.MICROPHONE_OFF 133 || mCurrentTransitionState == AnimationStates.MICROPHONE_OFF_SELECTED) { 134 return; 135 } 136 } 137 } 138 139 mIsMicrophoneEnabled = isMicrophoneEnabled; 140 141 if (!mIsInflated) { 142 if (DEBUG) Log.d(TAG, "Layout not inflated"); 143 144 return; 145 } 146 147 if (mIsMicrophoneEnabled) { 148 if (mPanelOpen) { 149 setTransition(R.id.inactiveSelectedFromMicOffSelected); 150 } else { 151 setTransition(R.id.invisibleFromMicOff); 152 } 153 } else { 154 if (mPanelOpen) { 155 switch (mCurrentTransitionState) { 156 case INVISIBLE: 157 setTransition(R.id.micOffSelectedFromInvisible); 158 break; 159 case ACTIVE_INIT: 160 setTransition(R.id.micOffSelectedFromActiveInit); 161 break; 162 case ACTIVE: 163 setTransition(R.id.micOffSelectedFromActive); 164 break; 165 case ACTIVE_SELECTED: 166 setTransition(R.id.micOffSelectedFromActiveSelected); 167 break; 168 case INACTIVE: 169 setTransition(R.id.micOffSelectedFromInactive); 170 break; 171 case INACTIVE_SELECTED: 172 setTransition(R.id.micOffSelectedFromInactiveSelected); 173 break; 174 default: 175 return; 176 } 177 } else { 178 switch (mCurrentTransitionState) { 179 case INVISIBLE: 180 setTransition(R.id.micOffFromInvisible); 181 break; 182 case ACTIVE_INIT: 183 setTransition(R.id.micOffFromActiveInit); 184 break; 185 case ACTIVE: 186 setTransition(R.id.micOffFromActive); 187 break; 188 case ACTIVE_SELECTED: 189 setTransition(R.id.micOffFromActiveSelected); 190 break; 191 case INACTIVE: 192 setTransition(R.id.micOffFromInactive); 193 break; 194 case INACTIVE_SELECTED: 195 setTransition(R.id.micOffFromInactiveSelected); 196 break; 197 default: 198 return; 199 } 200 } 201 } 202 203 mExecutor.shutdownNow(); 204 mExecutor = Executors.newSingleThreadScheduledExecutor(); 205 206 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. 207 208 // When microphone is off, mic privacy chip is always visible. 209 if (!mIsMicrophoneEnabled) setVisibility(View.VISIBLE); 210 setContentDescription(!mIsMicrophoneEnabled); 211 if (mIsMicrophoneEnabled) { 212 if (mPanelOpen) { 213 mCurrentTransitionState = AnimationStates.INACTIVE_SELECTED; 214 } else { 215 mCurrentTransitionState = AnimationStates.INVISIBLE; 216 } 217 } else { 218 if (mPanelOpen) { 219 mCurrentTransitionState = AnimationStates.MICROPHONE_OFF_SELECTED; 220 } else { 221 mCurrentTransitionState = AnimationStates.MICROPHONE_OFF; 222 } 223 } 224 transitionToEnd(); 225 if (mIsMicrophoneEnabled && !mPanelOpen) setVisibility(View.GONE); 226 } 227 setContentDescription(boolean isMicOff)228 private void setContentDescription(boolean isMicOff) { 229 String contentDescription; 230 if (isMicOff) { 231 contentDescription = getResources().getString(R.string.mic_privacy_chip_off_content); 232 } else { 233 contentDescription = getResources().getString( 234 R.string.ongoing_privacy_chip_content_multiple_apps, TYPES_TEXT_MICROPHONE); 235 } 236 237 setContentDescription(contentDescription); 238 } 239 240 /** 241 * Starts reveal animation for Mic Privacy Chip. 242 */ 243 @UiThread animateIn()244 public void animateIn() { 245 if (!mIsInflated) { 246 if (DEBUG) Log.d(TAG, "Layout not inflated"); 247 248 return; 249 } 250 251 if (mCurrentTransitionState == null) { 252 if (DEBUG) Log.d(TAG, "Current transition state is null or empty."); 253 254 return; 255 } 256 257 switch (mCurrentTransitionState) { 258 case INVISIBLE: 259 setTransition(mIsMicrophoneEnabled ? R.id.activeInitFromInvisible 260 : R.id.micOffFromInvisible); 261 break; 262 case INACTIVE: 263 setTransition(mIsMicrophoneEnabled ? R.id.activeInitFromInactive 264 : R.id.micOffFromInactive); 265 break; 266 case INACTIVE_SELECTED: 267 setTransition(mIsMicrophoneEnabled ? R.id.activeInitFromInactiveSelected 268 : R.id.micOffFromInactiveSelected); 269 break; 270 case MICROPHONE_OFF: 271 if (!mIsMicrophoneEnabled) { 272 if (DEBUG) { 273 Log.d(TAG, "No Transition."); 274 } 275 return; 276 } 277 278 setTransition(R.id.activeInitFromMicOff); 279 break; 280 case MICROPHONE_OFF_SELECTED: 281 if (!mIsMicrophoneEnabled) { 282 if (DEBUG) { 283 Log.d(TAG, "No Transition."); 284 } 285 return; 286 } 287 288 setTransition(R.id.activeInitFromMicOffSelected); 289 break; 290 default: 291 if (DEBUG) { 292 Log.d(TAG, "Early exit, mCurrentTransitionState= " 293 + mCurrentTransitionState); 294 } 295 296 return; 297 } 298 299 mExecutor.shutdownNow(); 300 mExecutor = Executors.newSingleThreadScheduledExecutor(); 301 302 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. 303 setContentDescription(false); 304 setVisibility(View.VISIBLE); 305 if (mIsMicrophoneEnabled) { 306 mCurrentTransitionState = AnimationStates.ACTIVE_INIT; 307 } else { 308 if (mPanelOpen) { 309 mCurrentTransitionState = AnimationStates.MICROPHONE_OFF_SELECTED; 310 } else { 311 mCurrentTransitionState = AnimationStates.MICROPHONE_OFF; 312 } 313 } 314 transitionToEnd(); 315 if (mIsMicrophoneEnabled) { 316 mExecutor.schedule(MicPrivacyChip.this::animateToOrangeCircle, mDelayPillToCircle, 317 TimeUnit.MILLISECONDS); 318 } 319 } 320 321 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. animateToOrangeCircle()322 private void animateToOrangeCircle() { 323 // Since this is launched using a {@link ScheduledExecutorService}, its UI based elements 324 // need to execute on main executor. 325 getContext().getMainExecutor().execute(() -> { 326 if (mPanelOpen) { 327 setTransition(R.id.activeSelectedFromActiveInit); 328 mCurrentTransitionState = AnimationStates.ACTIVE_SELECTED; 329 } else { 330 setTransition(R.id.activeFromActiveInit); 331 mCurrentTransitionState = AnimationStates.ACTIVE; 332 } 333 transitionToEnd(); 334 }); 335 } 336 337 /** 338 * Starts conceal animation for Mic Privacy Chip. 339 */ 340 @UiThread animateOut()341 public void animateOut() { 342 if (!mIsInflated) { 343 if (DEBUG) Log.d(TAG, "Layout not inflated"); 344 345 return; 346 } 347 348 if (mPanelOpen) { 349 switch (mCurrentTransitionState) { 350 case ACTIVE_INIT: 351 setTransition(R.id.inactiveSelectedFromActiveInit); 352 break; 353 case ACTIVE: 354 setTransition(R.id.inactiveSelectedFromActive); 355 break; 356 case ACTIVE_SELECTED: 357 setTransition(R.id.inactiveSelectedFromActiveSelected); 358 break; 359 default: 360 if (DEBUG) { 361 Log.d(TAG, "Early exit, mCurrentTransitionState= " 362 + mCurrentTransitionState); 363 } 364 365 return; 366 } 367 } else { 368 switch (mCurrentTransitionState) { 369 case ACTIVE_INIT: 370 setTransition(R.id.inactiveFromActiveInit); 371 break; 372 case ACTIVE: 373 setTransition(R.id.inactiveFromActive); 374 break; 375 case ACTIVE_SELECTED: 376 setTransition(R.id.inactiveFromActiveSelected); 377 break; 378 default: 379 if (DEBUG) { 380 Log.d(TAG, "Early exit, mCurrentTransitionState= " 381 + mCurrentTransitionState); 382 } 383 384 return; 385 } 386 } 387 388 mExecutor.shutdownNow(); 389 mExecutor = Executors.newSingleThreadScheduledExecutor(); 390 391 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. 392 mCurrentTransitionState = mPanelOpen 393 ? AnimationStates.INACTIVE_SELECTED 394 : AnimationStates.INACTIVE; 395 transitionToEnd(); 396 mExecutor.schedule(MicPrivacyChip.this::reset, mDelayToNoMicUsage, 397 TimeUnit.MILLISECONDS); 398 } 399 400 401 402 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. reset()403 private void reset() { 404 // Since this is launched using a {@link ScheduledExecutorService}, its UI based elements 405 // need to execute on main executor. 406 getContext().getMainExecutor().execute(() -> { 407 if (mIsMicrophoneEnabled && !mPanelOpen) { 408 setTransition(R.id.invisibleFromInactive); 409 mCurrentTransitionState = AnimationStates.INVISIBLE; 410 } else if (!mIsMicrophoneEnabled) { 411 if (mPanelOpen) { 412 setTransition(R.id.inactiveSelectedFromMicOffSelected); 413 mCurrentTransitionState = AnimationStates.INACTIVE_SELECTED; 414 } else { 415 setTransition(R.id.invisibleFromMicOff); 416 mCurrentTransitionState = AnimationStates.INVISIBLE; 417 } 418 } 419 420 transitionToEnd(); 421 422 if (!mPanelOpen) { 423 setVisibility(View.GONE); 424 } 425 }); 426 } 427 428 @AnyThread 429 @Override setIconHighlighted(boolean iconHighlighted)430 public void setIconHighlighted(boolean iconHighlighted) { 431 // UI based elements need to execute on main executor. 432 getContext().getMainExecutor().execute(() -> { 433 if (mPanelOpen == iconHighlighted) { 434 return; 435 } 436 437 mPanelOpen = iconHighlighted; 438 439 if (mIsMicrophoneEnabled) { 440 switch (mCurrentTransitionState) { 441 case ACTIVE: 442 if (mPanelOpen) { 443 setTransition(R.id.activeSelectedFromActive); 444 mCurrentTransitionState = AnimationStates.ACTIVE_SELECTED; 445 transitionToEnd(); 446 } 447 return; 448 case ACTIVE_SELECTED: 449 if (!mPanelOpen) { 450 setTransition(R.id.activeFromActiveSelected); 451 mCurrentTransitionState = AnimationStates.ACTIVE; 452 transitionToEnd(); 453 } 454 return; 455 case INACTIVE: 456 if (mPanelOpen) { 457 setTransition(R.id.inactiveSelectedFromInactive); 458 mCurrentTransitionState = AnimationStates.INACTIVE_SELECTED; 459 transitionToEnd(); 460 } 461 return; 462 case INACTIVE_SELECTED: 463 if (!mPanelOpen) { 464 setTransition(R.id.invisibleFromInactiveSelected); 465 mCurrentTransitionState = AnimationStates.INVISIBLE; 466 transitionToEnd(); 467 setVisibility(View.GONE); 468 } 469 return; 470 } 471 } else { 472 switch (mCurrentTransitionState) { 473 case MICROPHONE_OFF: 474 if (mPanelOpen) { 475 setTransition(R.id.micOffSelectedFromMicOff); 476 mCurrentTransitionState = AnimationStates.MICROPHONE_OFF_SELECTED; 477 transitionToEnd(); 478 } 479 return; 480 case MICROPHONE_OFF_SELECTED: 481 if (!mPanelOpen) { 482 setTransition(R.id.micOffFromMicOffSelected); 483 mCurrentTransitionState = AnimationStates.MICROPHONE_OFF; 484 transitionToEnd(); 485 } 486 return; 487 } 488 } 489 490 if (DEBUG) { 491 Log.d(TAG, "Early exit, mCurrentTransitionState= " 492 + mCurrentTransitionState); 493 } 494 }); 495 } 496 497 @Override setTransition(int transitionId)498 public void setTransition(int transitionId) { 499 if (DEBUG) { 500 Log.d(TAG, "Transition set: " + getResources().getResourceEntryName(transitionId)); 501 } 502 super.setTransition(transitionId); 503 } 504 505 private enum AnimationStates { 506 INVISIBLE, 507 ACTIVE_INIT, 508 ACTIVE, 509 ACTIVE_SELECTED, 510 INACTIVE, 511 INACTIVE_SELECTED, 512 MICROPHONE_OFF, 513 MICROPHONE_OFF_SELECTED, 514 } 515 } 516