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.util.AttributeSet; 21 import android.util.Log; 22 import android.view.View; 23 24 import androidx.annotation.NonNull; 25 import androidx.annotation.Nullable; 26 import androidx.annotation.UiThread; 27 import androidx.constraintlayout.motion.widget.MotionLayout; 28 29 import com.android.systemui.R; 30 31 import java.util.concurrent.Executors; 32 import java.util.concurrent.ScheduledExecutorService; 33 import java.util.concurrent.TimeUnit; 34 35 /** 36 * Car optimized Mic Privacy Chip View that is shown when microphone is being used. 37 * 38 * State flows: 39 * Base state: 40 * <ul> 41 * <li>INVISIBLE - Start Mic Use ->> Mic Status?</li> 42 * </ul> 43 * Mic On: 44 * <ul> 45 * <li>Mic Status? - On ->> ACTIVE_INIT</li> 46 * <li>ACTIVE_INIT - delay ->> ACTIVE</li> 47 * <li>ACTIVE - Stop Mic Use ->> INACTIVE</li> 48 * <li>INACTIVE - delay ->> INVISIBLE</li> 49 * </ul> 50 * Mic Off: 51 * <ul> 52 * <li>Mic Status? - Off ->> MICROPHONE_OFF</li> 53 * </ul> 54 */ 55 public class MicPrivacyChip extends MotionLayout { 56 private final static boolean DEBUG = false; 57 private final static String TAG = "MicPrivacyChip"; 58 private final static String TYPES_TEXT_MICROPHONE = "microphone"; 59 60 private final int mDelayPillToCircle; 61 private final int mDelayToNoMicUsage; 62 63 private AnimationStates mCurrentTransitionState; 64 private boolean mIsInflated; 65 private boolean mIsMicrophoneEnabled; 66 private ScheduledExecutorService mExecutor; 67 MicPrivacyChip(@onNull Context context)68 public MicPrivacyChip(@NonNull Context context) { 69 this(context, /* attrs= */ null); 70 } 71 MicPrivacyChip(@onNull Context context, @Nullable AttributeSet attrs)72 public MicPrivacyChip(@NonNull Context context, @Nullable AttributeSet attrs) { 73 this(context, attrs, /* defStyleAttrs= */ 0); 74 } 75 MicPrivacyChip(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttrs)76 public MicPrivacyChip(@NonNull Context context, 77 @Nullable AttributeSet attrs, int defStyleAttrs) { 78 super(context, attrs, defStyleAttrs); 79 80 mDelayPillToCircle = getResources().getInteger(R.integer.privacy_chip_pill_to_circle_delay); 81 mDelayToNoMicUsage = getResources().getInteger(R.integer.privacy_chip_no_mic_usage_delay); 82 83 mExecutor = Executors.newSingleThreadScheduledExecutor(); 84 mIsInflated = false; 85 86 // Microphone is enabled by default (invisible state). 87 mIsMicrophoneEnabled = true; 88 } 89 90 @Override onFinishInflate()91 protected void onFinishInflate() { 92 super.onFinishInflate(); 93 94 mCurrentTransitionState = AnimationStates.INVISIBLE; 95 mIsInflated = true; 96 } 97 98 @Override setOnClickListener(View.OnClickListener onClickListener)99 public void setOnClickListener(View.OnClickListener onClickListener) { 100 // required for CTS tests. 101 super.setOnClickListener(onClickListener); 102 // required for rotary. 103 requireViewById(R.id.focus_view).setOnClickListener(onClickListener); 104 } 105 106 /** 107 * Sets whether microphone is enabled or disabled. 108 * If enabled, animates to {@link AnimationStates#INVISIBLE}. 109 * Otherwise, animates to {@link AnimationStates#MICROPHONE_OFF}. 110 */ 111 @UiThread setMicrophoneEnabled(boolean isMicrophoneEnabled)112 public void setMicrophoneEnabled(boolean isMicrophoneEnabled) { 113 if (DEBUG) Log.d(TAG, "Microphone enabled: " + isMicrophoneEnabled); 114 115 if (mIsMicrophoneEnabled == isMicrophoneEnabled) { 116 if (isMicrophoneEnabled) { 117 switch (mCurrentTransitionState) { 118 case INVISIBLE: 119 case ACTIVE: 120 case INACTIVE: 121 case ACTIVE_INIT: 122 return; 123 } 124 } else { 125 if (mCurrentTransitionState == AnimationStates.MICROPHONE_OFF) return; 126 } 127 } 128 129 mIsMicrophoneEnabled = isMicrophoneEnabled; 130 131 if (!mIsInflated) { 132 if (DEBUG) Log.d(TAG, "Layout not inflated"); 133 134 return; 135 } 136 137 if (mIsMicrophoneEnabled) { 138 if (DEBUG) Log.d(TAG, "setTransition: invisibleFromMicOff"); 139 setTransition(R.id.invisibleFromMicOff); 140 } else { 141 switch (mCurrentTransitionState) { 142 case INVISIBLE: 143 if (DEBUG) Log.d(TAG, "setTransition: micOffFromInvisible"); 144 setTransition(R.id.micOffFromInvisible); 145 break; 146 case ACTIVE_INIT: 147 if (DEBUG) Log.d(TAG, "setTransition: micOffFromActiveInit"); 148 setTransition(R.id.micOffFromActiveInit); 149 break; 150 case ACTIVE: 151 if (DEBUG) Log.d(TAG, "setTransition: micOffFromActive"); 152 setTransition(R.id.micOffFromActive); 153 break; 154 case INACTIVE: 155 if (DEBUG) Log.d(TAG, "setTransition: micOffFromInactive"); 156 setTransition(R.id.micOffFromInactive); 157 break; 158 default: 159 return; 160 } 161 } 162 163 mExecutor.shutdownNow(); 164 mExecutor = Executors.newSingleThreadScheduledExecutor(); 165 166 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. 167 168 // When microphone is off, mic privacy chip is always visible. 169 if (!mIsMicrophoneEnabled) setVisibility(View.VISIBLE); 170 setContentDescription(!mIsMicrophoneEnabled); 171 mCurrentTransitionState = mIsMicrophoneEnabled ? MicPrivacyChip.AnimationStates.INVISIBLE 172 : MicPrivacyChip.AnimationStates.MICROPHONE_OFF; 173 transitionToEnd(); 174 // When microphone is on, after animation we hide mic privacy chip until mic is next used. 175 if (mIsMicrophoneEnabled) setVisibility(View.GONE); 176 } 177 setContentDescription(boolean isMicOff)178 private void setContentDescription(boolean isMicOff) { 179 String contentDescription; 180 if (isMicOff) { 181 contentDescription = getResources().getString(R.string.mic_privacy_chip_off_content); 182 } else { 183 contentDescription = getResources().getString( 184 R.string.ongoing_privacy_chip_content_multiple_apps, TYPES_TEXT_MICROPHONE); 185 } 186 187 setContentDescription(contentDescription); 188 } 189 190 /** 191 * Starts reveal animation for Mic Privacy Chip. 192 */ 193 @UiThread animateIn()194 public void animateIn() { 195 if (!mIsInflated) { 196 if (DEBUG) Log.d(TAG, "Layout not inflated"); 197 198 return; 199 } 200 201 if (mCurrentTransitionState == null) { 202 if (DEBUG) Log.d(TAG, "Current transition state is null or empty."); 203 204 return; 205 } 206 207 switch (mCurrentTransitionState) { 208 case INVISIBLE: 209 if (DEBUG) { 210 Log.d(TAG, mIsMicrophoneEnabled ? "setTransition: activeInitFromInvisible" 211 : "setTransition: micOffFromInvisible"); 212 } 213 setTransition(mIsMicrophoneEnabled ? R.id.activeInitFromInvisible 214 : R.id.micOffFromInvisible); 215 break; 216 case INACTIVE: 217 if (DEBUG) { 218 Log.d(TAG, mIsMicrophoneEnabled ? "setTransition: activeInitFromInactive" 219 : "setTransition: micOffFromInactive"); 220 } 221 222 setTransition(mIsMicrophoneEnabled ? R.id.activeInitFromInactive 223 : R.id.micOffFromInactive); 224 break; 225 case MICROPHONE_OFF: 226 if (DEBUG) { 227 Log.d(TAG, mIsMicrophoneEnabled ? "setTransition: activeInitFromMicOff" 228 : "No Transition."); 229 } 230 231 if (!mIsMicrophoneEnabled) { 232 return; 233 } 234 235 setTransition(R.id.activeInitFromMicOff); 236 break; 237 default: 238 if (DEBUG) { 239 Log.d(TAG, "Early exit, mCurrentTransitionState= " 240 + mCurrentTransitionState); 241 } 242 243 return; 244 } 245 246 mExecutor.shutdownNow(); 247 mExecutor = Executors.newSingleThreadScheduledExecutor(); 248 249 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. 250 setContentDescription(false); 251 setVisibility(View.VISIBLE); 252 transitionToEnd(); 253 mCurrentTransitionState = AnimationStates.ACTIVE_INIT; 254 if (mIsMicrophoneEnabled) { 255 mExecutor.schedule(MicPrivacyChip.this::animateToOrangeCircle, mDelayPillToCircle, 256 TimeUnit.MILLISECONDS); 257 } 258 } 259 260 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. animateToOrangeCircle()261 private void animateToOrangeCircle() { 262 setTransition(R.id.activeFromActiveInit); 263 264 // Since this is launched using a {@link ScheduledExecutorService}, its UI based elements 265 // need to execute on main executor. 266 getContext().getMainExecutor().execute(() -> { 267 mCurrentTransitionState = AnimationStates.ACTIVE; 268 transitionToEnd(); 269 }); 270 } 271 272 /** 273 * Starts conceal animation for Mic Privacy Chip. 274 */ 275 @UiThread animateOut()276 public void animateOut() { 277 if (!mIsInflated) { 278 if (DEBUG) Log.d(TAG, "Layout not inflated"); 279 280 return; 281 } 282 283 switch (mCurrentTransitionState) { 284 case ACTIVE_INIT: 285 if (DEBUG) Log.d(TAG, "setTransition: inactiveFromActiveInit"); 286 287 setTransition(R.id.inactiveFromActiveInit); 288 break; 289 case ACTIVE: 290 if (DEBUG) Log.d(TAG, "setTransition: inactiveFromActive"); 291 292 setTransition(R.id.inactiveFromActive); 293 break; 294 default: 295 if (DEBUG) { 296 Log.d(TAG, "Early exit, mCurrentTransitionState= " 297 + mCurrentTransitionState); 298 } 299 300 return; 301 } 302 303 mExecutor.shutdownNow(); 304 mExecutor = Executors.newSingleThreadScheduledExecutor(); 305 306 if (mCurrentTransitionState.equals(AnimationStates.MICROPHONE_OFF)) { 307 mCurrentTransitionState = AnimationStates.INACTIVE; 308 mExecutor.schedule(MicPrivacyChip.this::reset, mDelayToNoMicUsage, 309 TimeUnit.MILLISECONDS); 310 return; 311 } 312 313 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. 314 mCurrentTransitionState = AnimationStates.INACTIVE; 315 transitionToEnd(); 316 mExecutor.schedule(MicPrivacyChip.this::reset, mDelayToNoMicUsage, 317 TimeUnit.MILLISECONDS); 318 } 319 320 // TODO(182938429): Use Transition Listeners once ConstraintLayout 2.0.0 is being used. reset()321 private void reset() { 322 if (mIsMicrophoneEnabled) { 323 if (DEBUG) Log.d(TAG, "setTransition: invisibleFromInactive"); 324 325 setTransition(R.id.invisibleFromInactive); 326 } else { 327 if (DEBUG) Log.d(TAG, "setTransition: invisibleFromMicOff"); 328 329 setTransition(R.id.invisibleFromMicOff); 330 } 331 332 // Since this is launched using a {@link ScheduledExecutorService}, its UI based elements 333 // need to execute on main executor. 334 getContext().getMainExecutor().execute(() -> { 335 mCurrentTransitionState = AnimationStates.INVISIBLE; 336 transitionToEnd(); 337 setVisibility(View.GONE); 338 }); 339 } 340 341 private enum AnimationStates { 342 INVISIBLE, 343 ACTIVE_INIT, 344 ACTIVE, 345 INACTIVE, 346 MICROPHONE_OFF, 347 } 348 } 349