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