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.permissioncontroller.safetycenter.ui;
18 
19 import static android.os.Build.VERSION_CODES.TIRAMISU;
20 
21 import android.content.Context;
22 import android.graphics.drawable.Animatable2;
23 import android.graphics.drawable.AnimatedVectorDrawable;
24 import android.graphics.drawable.Drawable;
25 import android.os.Handler;
26 import android.os.Looper;
27 import android.safetycenter.SafetyCenterStatus;
28 import android.text.TextUtils;
29 import android.util.AttributeSet;
30 import android.util.Log;
31 import android.widget.ImageView;
32 import android.widget.TextView;
33 
34 import androidx.annotation.Nullable;
35 import androidx.annotation.RequiresApi;
36 import androidx.preference.Preference;
37 import androidx.preference.PreferenceViewHolder;
38 
39 import com.android.permissioncontroller.R;
40 import com.android.permissioncontroller.safetycenter.ui.model.SafetyCenterViewModel;
41 import com.android.permissioncontroller.safetycenter.ui.model.StatusUiData;
42 import com.android.permissioncontroller.safetycenter.ui.view.StatusCardView;
43 
44 import kotlin.Pair;
45 
46 import java.util.List;
47 import java.util.Objects;
48 
49 /** Preference which displays a visual representation of {@link SafetyCenterStatus}. */
50 @RequiresApi(TIRAMISU)
51 public class SafetyStatusPreference extends Preference implements ComparablePreference {
52 
53     private static final String TAG = "SafetyStatusPreference";
54 
55     @Nullable private StatusUiData mStatus;
56     @Nullable private SafetyCenterViewModel mViewModel;
57 
58     private final TextFadeAnimator mTitleTextAnimator = new TextFadeAnimator(R.id.status_title);
59 
60     private final TextFadeAnimator mSummaryTextAnimator = new TextFadeAnimator(R.id.status_summary);
61 
62     private final TextFadeAnimator mAllTextAnimator =
63             new TextFadeAnimator(List.of(R.id.status_title, R.id.status_summary));
64 
65     private boolean mFirstBind = true;
66 
SafetyStatusPreference(Context context, AttributeSet attrs)67     public SafetyStatusPreference(Context context, AttributeSet attrs) {
68         super(context, attrs);
69         setLayoutResource(R.layout.preference_safety_status);
70     }
71 
72     private boolean mIsTextChangeAnimationRunning;
73     private final SafetyStatusAnimationSequencer mSequencer = new SafetyStatusAnimationSequencer();
74 
75     @Override
onBindViewHolder(PreferenceViewHolder holder)76     public void onBindViewHolder(PreferenceViewHolder holder) {
77         super.onBindViewHolder(holder);
78         Log.v(TAG, String.format("onBindViewHolder called for status %s", mStatus));
79 
80         if (mStatus == null) {
81             return;
82         }
83 
84         Context context = getContext();
85         StatusCardView statusCardView = (StatusCardView) holder.itemView;
86         configureButtons(context, statusCardView);
87         statusCardView
88                 .getTitleAndSummaryContainerView()
89                 .setContentDescription(mStatus.getContentDescription(context));
90 
91         updateStatusIcon(statusCardView);
92 
93         updateStatusText(statusCardView.getTitleView(), statusCardView.getSummaryView());
94 
95         mFirstBind = false;
96     }
97 
configureButtons(Context context, StatusCardView statusCardView)98     private void configureButtons(Context context, StatusCardView statusCardView) {
99         statusCardView
100                 .getRescanButton()
101                 .setOnClickListener(
102                         unused -> {
103                             SafetyCenterViewModel viewModel = requireViewModel();
104                             viewModel.rescan();
105                             viewModel.getInteractionLogger().record(Action.SCAN_INITIATED);
106                         });
107         statusCardView
108                 .getReviewSettingsButton()
109                 .setOnClickListener(
110                         unused -> {
111                             SafetyCenterViewModel viewModel = requireViewModel();
112                             viewModel.navigateToSafetyCenter(
113                                     context, NavigationSource.QUICK_SETTINGS_TILE);
114                             viewModel.getInteractionLogger().record(Action.REVIEW_SETTINGS_CLICKED);
115                         });
116 
117         updateButtonState(statusCardView);
118     }
119 
updateButtonState(StatusCardView statusCardView)120     private void updateButtonState(StatusCardView statusCardView) {
121         if (mStatus == null) return; // Shouldn't happen in practice but we do it for null safety.
122         statusCardView.showButtons(mStatus);
123     }
124 
updateStatusText(TextView title, TextView summary)125     private void updateStatusText(TextView title, TextView summary) {
126         if (mFirstBind) {
127             title.setText(mStatus.getTitle());
128             summary.setText(mStatus.getSummary(getContext()));
129         }
130         runTextAnimationIfNeeded(title, summary);
131     }
132 
updateStatusIcon(StatusCardView statusCardView)133     private void updateStatusIcon(StatusCardView statusCardView) {
134         int severityLevel = mStatus.getSeverityLevel();
135         boolean isRefreshing = mStatus.isRefreshInProgress();
136 
137         handleAnimationSequencerAction(
138                 mSequencer.onUpdateReceived(isRefreshing, severityLevel),
139                 statusCardView,
140                 /* scanningAnimation= */ null);
141     }
142 
runTextAnimationIfNeeded(TextView titleView, TextView summaryView)143     private void runTextAnimationIfNeeded(TextView titleView, TextView summaryView) {
144         if (mIsTextChangeAnimationRunning) {
145             return;
146         }
147         Log.v(TAG, "Starting status text animation");
148         String titleText = mStatus.getTitle().toString();
149         String summaryText = mStatus.getSummary(getContext()).toString();
150         boolean titleEquals = titleView.getText().toString().equals(titleText);
151         boolean summaryEquals = summaryView.getText().toString().equals(summaryText);
152         Runnable onFinish =
153                 () -> {
154                     Log.v(TAG, "Finishing status text animation");
155                     mIsTextChangeAnimationRunning = false;
156                     runTextAnimationIfNeeded(titleView, summaryView);
157                 };
158         mIsTextChangeAnimationRunning = !titleEquals || !summaryEquals;
159         if (!titleEquals && !summaryEquals) {
160             Pair<TextView, String> titleChange = new Pair<>(titleView, titleText);
161             Pair<TextView, String> summaryChange = new Pair<>(summaryView, summaryText);
162             mAllTextAnimator.animateChangeText(List.of(titleChange, summaryChange), onFinish);
163         } else if (!titleEquals) {
164             mTitleTextAnimator.animateChangeText(titleView, titleText, onFinish);
165         } else if (!summaryEquals) {
166             mSummaryTextAnimator.animateChangeText(summaryView, summaryText, onFinish);
167         }
168     }
169 
startScanningAnimation(StatusCardView statusCardView)170     private void startScanningAnimation(StatusCardView statusCardView) {
171         mSequencer.onStartScanningAnimationStart();
172         ImageView statusImage = statusCardView.getStatusImageView();
173         statusImage.setImageResource(
174                 StatusAnimationResolver.getScanningStartAnimation(
175                         mSequencer.getCurrentlyVisibleSeverityLevel()));
176         AnimatedVectorDrawable animation = (AnimatedVectorDrawable) statusImage.getDrawable();
177         animation.registerAnimationCallback(
178                 new Animatable2.AnimationCallback() {
179                     @Override
180                     public void onAnimationEnd(Drawable drawable) {
181                         handleAnimationSequencerAction(
182                                 mSequencer.onStartScanningAnimationEnd(),
183                                 statusCardView,
184                                 /* scanningAnimation= */ null);
185                     }
186                 });
187         animation.start();
188     }
189 
continueScanningAnimation(StatusCardView statusCardView)190     private void continueScanningAnimation(StatusCardView statusCardView) {
191         ImageView statusImage = statusCardView.getStatusImageView();
192 
193         // clear previous scan animation in case we need to continue with different severity level
194         Drawable statusDrawable = statusImage.getDrawable();
195         if (statusDrawable instanceof AnimatedVectorDrawable) {
196             ((AnimatedVectorDrawable) statusDrawable).clearAnimationCallbacks();
197         }
198 
199         statusImage.setImageResource(
200                 StatusAnimationResolver.getScanningAnimation(
201                         mSequencer.getCurrentlyVisibleSeverityLevel()));
202         AnimatedVectorDrawable scanningAnim = (AnimatedVectorDrawable) statusImage.getDrawable();
203         scanningAnim.registerAnimationCallback(
204                 new Animatable2.AnimationCallback() {
205                     @Override
206                     public void onAnimationEnd(Drawable drawable) {
207                         handleAnimationSequencerAction(
208                                 mSequencer.onContinueScanningAnimationEnd(
209                                         mStatus.isRefreshInProgress(), mStatus.getSeverityLevel()),
210                                 statusCardView,
211                                 scanningAnim);
212                     }
213                 });
214         scanningAnim.start();
215     }
216 
endScanningAnimation(StatusCardView statusCardView)217     private void endScanningAnimation(StatusCardView statusCardView) {
218         ImageView statusImage = statusCardView.getStatusImageView();
219         Drawable statusDrawable = statusImage.getDrawable();
220         int finishingSeverityLevel = mStatus.getSeverityLevel();
221         if (!(statusDrawable instanceof AnimatedVectorDrawable)) {
222             finishScanAnimation(statusCardView, finishingSeverityLevel);
223             return;
224         }
225         AnimatedVectorDrawable animatedStatusDrawable = (AnimatedVectorDrawable) statusDrawable;
226 
227         if (!animatedStatusDrawable.isRunning()) {
228             finishScanAnimation(statusCardView, finishingSeverityLevel);
229             return;
230         }
231 
232         int scanningSeverityLevel = mSequencer.getCurrentlyVisibleSeverityLevel();
233         animatedStatusDrawable.clearAnimationCallbacks();
234         animatedStatusDrawable.registerAnimationCallback(
235                 new Animatable2.AnimationCallback() {
236                     @Override
237                     public void onAnimationEnd(Drawable drawable) {
238                         statusImage.setImageResource(
239                                 StatusAnimationResolver.getScanningEndAnimation(
240                                         scanningSeverityLevel, finishingSeverityLevel));
241                         AnimatedVectorDrawable animatedDrawable =
242                                 (AnimatedVectorDrawable) statusImage.getDrawable();
243                         animatedDrawable.registerAnimationCallback(
244                                 new Animatable2.AnimationCallback() {
245                                     @Override
246                                     public void onAnimationEnd(Drawable drawable) {
247                                         super.onAnimationEnd(drawable);
248                                         finishScanAnimation(statusCardView, finishingSeverityLevel);
249                                     }
250                                 });
251                         animatedDrawable.start();
252                     }
253                 });
254     }
255 
finishScanAnimation(StatusCardView statusCardView, int finishedSeverityLevel)256     private void finishScanAnimation(StatusCardView statusCardView, int finishedSeverityLevel) {
257         updateButtonState(statusCardView);
258         handleAnimationSequencerAction(
259                 mSequencer.onFinishScanAnimationEnd(
260                         mStatus.isRefreshInProgress(), finishedSeverityLevel),
261                 statusCardView,
262                 /* scanningAnimation= */ null);
263     }
264 
startIconChangeAnimation(StatusCardView statusCardView)265     private void startIconChangeAnimation(StatusCardView statusCardView) {
266         int finalSeverityLevel = mStatus.getSeverityLevel();
267         int changeAnimationResId =
268                 StatusAnimationResolver.getStatusChangeAnimation(
269                         mSequencer.getCurrentlyVisibleSeverityLevel(), finalSeverityLevel);
270         if (changeAnimationResId == 0) {
271             handleAnimationSequencerAction(
272                     mSequencer.onCouldNotStartIconChangeAnimation(
273                             mStatus.isRefreshInProgress(), finalSeverityLevel),
274                     statusCardView,
275                     /* scanningAnimation= */ null);
276             return;
277         }
278         mSequencer.onIconChangeAnimationStart();
279         statusCardView.getStatusImageView().setImageResource(changeAnimationResId);
280         AnimatedVectorDrawable animation =
281                 (AnimatedVectorDrawable) statusCardView.getStatusImageView().getDrawable();
282         animation.clearAnimationCallbacks();
283         animation.registerAnimationCallback(
284                 new Animatable2.AnimationCallback() {
285                     @Override
286                     public void onAnimationEnd(Drawable drawable) {
287                         handleAnimationSequencerAction(
288                                 mSequencer.onIconChangeAnimationEnd(
289                                         mStatus.isRefreshInProgress(), finalSeverityLevel),
290                                 statusCardView,
291                                 /* scanningAnimation= */ null);
292                     }
293                 });
294         animation.start();
295     }
296 
handleAnimationSequencerAction( @ullable SafetyStatusAnimationSequencer.Action action, StatusCardView statusCardView, @Nullable AnimatedVectorDrawable scanningAnimation)297     private void handleAnimationSequencerAction(
298             @Nullable SafetyStatusAnimationSequencer.Action action,
299             StatusCardView statusCardView,
300             @Nullable AnimatedVectorDrawable scanningAnimation) {
301         if (action == null) {
302             return;
303         }
304         switch (action) {
305             case START_SCANNING_ANIMATION:
306                 startScanningAnimation(statusCardView);
307                 break;
308             case CONTINUE_SCANNING_ANIMATION:
309                 if (scanningAnimation != null) {
310                     scanningAnimation.start();
311                 } else {
312                     continueScanningAnimation(statusCardView);
313                 }
314                 break;
315             case RESET_SCANNING_ANIMATION:
316                 continueScanningAnimation(statusCardView);
317                 break;
318             case FINISH_SCANNING_ANIMATION:
319                 endScanningAnimation(statusCardView);
320                 break;
321             case START_ICON_CHANGE_ANIMATION:
322                 startIconChangeAnimation(statusCardView);
323                 break;
324             case CHANGE_ICON_WITHOUT_ANIMATION:
325                 setSettledStatus(statusCardView);
326                 break;
327         }
328     }
329 
setSettledStatus(StatusCardView statusCardView)330     private void setSettledStatus(StatusCardView statusCardView) {
331         Drawable statusDrawable = statusCardView.getStatusImageView().getDrawable();
332         if (statusDrawable instanceof AnimatedVectorDrawable) {
333             ((AnimatedVectorDrawable) statusDrawable).clearAnimationCallbacks();
334         }
335         statusCardView
336                 .getStatusImageView()
337                 .setImageResource(
338                         StatusUiData.Companion.getStatusImageResId(
339                                 mSequencer.getCurrentlyVisibleSeverityLevel()));
340     }
341 
setData(StatusUiData statusUiData)342     void setData(StatusUiData statusUiData) {
343         if (Objects.equals(mStatus, statusUiData)) {
344             return;
345         }
346 
347         mStatus = statusUiData;
348         Log.v(TAG, String.format("setData called for status %s", mStatus));
349         safeNotifyChanged();
350     }
351 
setViewModel(SafetyCenterViewModel viewModel)352     void setViewModel(SafetyCenterViewModel viewModel) {
353         mViewModel = Objects.requireNonNull(viewModel);
354     }
355 
requireViewModel()356     private SafetyCenterViewModel requireViewModel() {
357         return Objects.requireNonNull(mViewModel);
358     }
359 
360     // Calling notifyChanged while recyclerview is scrolling or computing layout will result in an
361     // IllegalStateException. Post to handler to wait for UI to settle.
safeNotifyChanged()362     private void safeNotifyChanged() {
363         new Handler(Looper.getMainLooper())
364                 .post(
365                         () -> {
366                             Log.v(
367                                     TAG,
368                                     String.format("Calling notifyChanged for status %s", mStatus));
369                             notifyChanged();
370                         });
371     }
372 
373     @Override
isSameItem(Preference preference)374     public boolean isSameItem(Preference preference) {
375         return preference instanceof SafetyStatusPreference
376                 && TextUtils.equals(getKey(), preference.getKey());
377     }
378 
379     @Override
hasSameContents(Preference preference)380     public boolean hasSameContents(Preference preference) {
381         if (!(preference instanceof SafetyStatusPreference)) {
382             return false;
383         }
384         SafetyStatusPreference other = (SafetyStatusPreference) preference;
385         return Objects.equals(mStatus, other.mStatus);
386     }
387 }
388