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