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