blob: ee2e7c73d0757efc3dadb44d04c7dabadf5c2f04 [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.permissioncontroller.safetycenter.ui;
import static android.os.Build.VERSION_CODES.TIRAMISU;
import static android.safetycenter.SafetyCenterStatus.OVERALL_SEVERITY_LEVEL_UNKNOWN;
import android.content.Context;
import android.graphics.drawable.Animatable2;
import android.graphics.drawable.AnimatedVectorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.Looper;
import android.safetycenter.SafetyCenterStatus;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;
import com.android.permissioncontroller.R;
import com.android.permissioncontroller.permission.utils.KotlinUtils;
import com.android.permissioncontroller.safetycenter.ui.model.SafetyCenterViewModel;
import com.android.permissioncontroller.safetycenter.ui.model.StatusUiData;
import com.google.android.material.button.MaterialButton;
import java.util.List;
import java.util.Objects;
import kotlin.Pair;
/** Preference which displays a visual representation of {@link SafetyCenterStatus}. */
@RequiresApi(TIRAMISU)
public class SafetyStatusPreference extends Preference implements ComparablePreference {
@Nullable private StatusUiData mStatus;
@Nullable private SafetyCenterViewModel mViewModel;
@NonNull
private final TextFadeAnimator mTitleTextAnimator = new TextFadeAnimator(R.id.status_title);
@NonNull
private final TextFadeAnimator mSummaryTextAnimator = new TextFadeAnimator(R.id.status_summary);
@NonNull
private final TextFadeAnimator mAllTextAnimator =
new TextFadeAnimator(List.of(R.id.status_title, R.id.status_summary));
private boolean mFirstBind = true;
public SafetyStatusPreference(Context context, AttributeSet attrs) {
super(context, attrs);
setLayoutResource(R.layout.preference_safety_status);
}
private boolean mIsScanAnimationRunning;
private boolean mIsIconChangeAnimationRunning;
private boolean mIsTextChangeAnimationRunning;
private int mQueuedScanAnimationSeverityLevel;
private int mQueuedIconAnimationSeverityLevel;
private int mSettledSeverityLevel = OVERALL_SEVERITY_LEVEL_UNKNOWN;
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
if (mStatus == null) {
return;
}
Context context = getContext();
ImageView statusImage = (ImageView) holder.findViewById(R.id.status_image);
MaterialButton rescanButton = (MaterialButton) holder.findViewById(R.id.rescan_button);
MaterialButton pendingActionsRescanButton =
(MaterialButton) holder.findViewById(R.id.pending_actions_rescan_button);
View reviewSettingsButton = holder.findViewById(R.id.review_settings_button);
if (mStatus.hasPendingActions()) {
reviewSettingsButton.setOnClickListener(
l -> {
requireViewModel()
.navigateToSafetyCenter(
context, NavigationSource.QUICK_SETTINGS_TILE);
requireViewModel()
.getInteractionLogger()
.record(Action.REVIEW_SETTINGS_CLICKED);
});
reviewSettingsButton.setVisibility(View.VISIBLE);
} else {
reviewSettingsButton.setVisibility(View.GONE);
}
rescanButton = updateRescanButtonUi(rescanButton, pendingActionsRescanButton);
setRescanButtonState(rescanButton);
holder.findViewById(R.id.status_title_and_summary)
.setContentDescription(mStatus.getContentDescription(context));
rescanButton.setOnClickListener(
unused -> {
SafetyCenterViewModel viewModel = requireViewModel();
viewModel.rescan();
viewModel.getInteractionLogger().record(Action.SCAN_INITIATED);
});
updateStatusIcon(statusImage, rescanButton);
TextView titleTextView = (TextView) holder.findViewById(R.id.status_title);
TextView summaryTextView = (TextView) holder.findViewById(R.id.status_summary);
updateStatusText(titleTextView, summaryTextView);
configureSafetyProtectionView(holder, context);
mFirstBind = false;
}
private void configureSafetyProtectionView(PreferenceViewHolder holder, Context context) {
View safetyProtectionSectionView = holder.findViewById(R.id.safety_protection_section_view);
if (KotlinUtils.INSTANCE.shouldShowSafetyProtectionResources(context)) {
// Hide the Safety Protection branding if there are any issue cards
safetyProtectionSectionView.setVisibility(
mStatus.hasIssues() ? View.GONE : View.VISIBLE);
}
if (safetyProtectionSectionView.getVisibility() == View.GONE) {
holder.itemView.setPaddingRelative(
holder.itemView.getPaddingStart(),
holder.itemView.getPaddingTop(),
holder.itemView.getPaddingEnd(),
/* bottom = */ getContext()
.getResources()
.getDimensionPixelSize(R.dimen.sc_card_margin_bottom));
} else {
holder.itemView.setPaddingRelative(
holder.itemView.getPaddingStart(),
holder.itemView.getPaddingTop(),
holder.itemView.getPaddingEnd(),
/* bottom = */ 0);
}
}
private void updateStatusText(TextView title, TextView summary) {
if (mFirstBind) {
title.setText(mStatus.getTitle());
summary.setText(mStatus.getSummary(getContext()));
}
runTextAnimationIfNeeded(title, summary);
}
private void updateStatusIcon(ImageView statusImage, View rescanButton) {
int severityLevel = mStatus.getSeverityLevel();
boolean isRefreshing = mStatus.isRefreshInProgress();
boolean shouldStartScanAnimation = isRefreshing && !mIsScanAnimationRunning;
boolean shouldEndScanAnimation = !isRefreshing && mIsScanAnimationRunning;
boolean shouldChangeIcon = mSettledSeverityLevel != severityLevel;
if (shouldStartScanAnimation && !mIsIconChangeAnimationRunning) {
mSettledSeverityLevel = severityLevel;
startScanningAnimation(statusImage);
} else if (shouldStartScanAnimation) {
mQueuedScanAnimationSeverityLevel = severityLevel;
} else if (mIsScanAnimationRunning && shouldChangeIcon) {
mSettledSeverityLevel = severityLevel;
continueScanningAnimation(statusImage);
} else if (shouldEndScanAnimation) {
endScanningAnimation(statusImage, rescanButton);
} else if (shouldChangeIcon && !mIsScanAnimationRunning) {
startIconChangeAnimation(statusImage);
} else if (shouldChangeIcon) {
mQueuedIconAnimationSeverityLevel = severityLevel;
} else if (!mIsScanAnimationRunning && !mIsIconChangeAnimationRunning) {
setSettledStatus(statusImage);
}
}
private void runTextAnimationIfNeeded(TextView titleView, TextView summaryView) {
if (mIsTextChangeAnimationRunning) {
return;
}
String titleText = mStatus.getTitle().toString();
String summaryText = mStatus.getSummary(getContext()).toString();
boolean titleEquals = titleView.getText().toString().equals(titleText);
boolean summaryEquals = summaryView.getText().toString().equals(summaryText);
Runnable onFinish =
() -> {
mIsTextChangeAnimationRunning = false;
runTextAnimationIfNeeded(titleView, summaryView);
};
mIsTextChangeAnimationRunning = !titleEquals || !summaryEquals;
if (!titleEquals && !summaryEquals) {
Pair<TextView, String> titleChange = new Pair<>(titleView, titleText);
Pair<TextView, String> summaryChange = new Pair<>(summaryView, summaryText);
mAllTextAnimator.animateChangeText(List.of(titleChange, summaryChange), onFinish);
} else if (!titleEquals) {
mTitleTextAnimator.animateChangeText(titleView, titleText, onFinish);
} else if (!summaryEquals) {
mSummaryTextAnimator.animateChangeText(summaryView, summaryText, onFinish);
}
}
private void startScanningAnimation(ImageView statusImage) {
mIsScanAnimationRunning = true;
statusImage.setImageResource(
StatusAnimationResolver.getScanningStartAnimation(mSettledSeverityLevel));
AnimatedVectorDrawable animation = (AnimatedVectorDrawable) statusImage.getDrawable();
animation.registerAnimationCallback(
new Animatable2.AnimationCallback() {
@Override
public void onAnimationEnd(Drawable drawable) {
continueScanningAnimation(statusImage);
}
});
animation.start();
}
private void continueScanningAnimation(ImageView statusImage) {
// clear previous scan animation in case we need to continue with different severity level
Drawable statusDrawable = statusImage.getDrawable();
if (statusDrawable instanceof AnimatedVectorDrawable) {
((AnimatedVectorDrawable) statusDrawable).clearAnimationCallbacks();
}
statusImage.setImageResource(
StatusAnimationResolver.getScanningAnimation(mSettledSeverityLevel));
AnimatedVectorDrawable scanningAnim = (AnimatedVectorDrawable) statusImage.getDrawable();
scanningAnim.registerAnimationCallback(
new Animatable2.AnimationCallback() {
@Override
public void onAnimationEnd(Drawable drawable) {
if (mIsScanAnimationRunning && mStatus.isRefreshInProgress()) {
scanningAnim.start();
} else {
scanningAnim.clearAnimationCallbacks();
}
}
});
scanningAnim.start();
}
private void endScanningAnimation(ImageView statusImage, View rescanButton) {
Drawable statusDrawable = statusImage.getDrawable();
if (!(statusDrawable instanceof AnimatedVectorDrawable)) {
finishScanAnimation(statusImage, rescanButton);
return;
}
AnimatedVectorDrawable animatedStatusDrawable = (AnimatedVectorDrawable) statusDrawable;
if (!animatedStatusDrawable.isRunning()) {
finishScanAnimation(statusImage, rescanButton);
return;
}
int scanningSeverityLevel = mSettledSeverityLevel;
animatedStatusDrawable.clearAnimationCallbacks();
animatedStatusDrawable.registerAnimationCallback(
new Animatable2.AnimationCallback() {
@Override
public void onAnimationEnd(Drawable drawable) {
statusImage.setImageResource(
StatusAnimationResolver.getScanningEndAnimation(
scanningSeverityLevel, mStatus.getSeverityLevel()));
AnimatedVectorDrawable animatedDrawable =
(AnimatedVectorDrawable) statusImage.getDrawable();
animatedDrawable.registerAnimationCallback(
new Animatable2.AnimationCallback() {
@Override
public void onAnimationEnd(Drawable drawable) {
super.onAnimationEnd(drawable);
finishScanAnimation(statusImage, rescanButton);
}
});
animatedDrawable.start();
}
});
}
private void finishScanAnimation(ImageView statusImage, View rescanButton) {
mIsScanAnimationRunning = false;
setRescanButtonState(rescanButton);
setSettledStatus(statusImage);
handleQueuedAction(statusImage);
}
private void startIconChangeAnimation(ImageView statusImage) {
int changeAnimationResId =
StatusAnimationResolver.getStatusChangeAnimation(
mSettledSeverityLevel, mStatus.getSeverityLevel());
if (changeAnimationResId == 0) {
setSettledStatus(statusImage);
return;
}
mIsIconChangeAnimationRunning = true;
statusImage.setImageResource(changeAnimationResId);
AnimatedVectorDrawable animation = (AnimatedVectorDrawable) statusImage.getDrawable();
animation.clearAnimationCallbacks();
animation.registerAnimationCallback(
new Animatable2.AnimationCallback() {
@Override
public void onAnimationEnd(Drawable drawable) {
mIsIconChangeAnimationRunning = false;
setSettledStatus(statusImage);
handleQueuedAction(statusImage);
}
});
animation.start();
}
private void setSettledStatus(ImageView statusImage) {
Drawable statusDrawable = statusImage.getDrawable();
if (statusDrawable instanceof AnimatedVectorDrawable) {
((AnimatedVectorDrawable) statusDrawable).clearAnimationCallbacks();
}
mSettledSeverityLevel = mStatus.getSeverityLevel();
statusImage.setImageResource(mStatus.getStatusImageResId());
}
private void handleQueuedAction(ImageView statusImage) {
if (mQueuedScanAnimationSeverityLevel != 0) {
mQueuedScanAnimationSeverityLevel = 0;
startScanningAnimation(statusImage);
} else if (mQueuedIconAnimationSeverityLevel != 0) {
mQueuedIconAnimationSeverityLevel = 0;
startIconChangeAnimation(statusImage);
}
}
/**
* Updates UI for the rescan button depending on the pending actions state and returns the
* correctly styled rescan button
*/
private MaterialButton updateRescanButtonUi(
MaterialButton rescanButton, MaterialButton pendingActionsRescanButton) {
if (mStatus.hasPendingActions()) {
rescanButton.setVisibility(View.GONE);
pendingActionsRescanButton.setVisibility(View.VISIBLE);
return pendingActionsRescanButton;
}
pendingActionsRescanButton.setVisibility(View.GONE);
rescanButton.setVisibility(View.VISIBLE);
return rescanButton;
}
void setData(StatusUiData statusUiData) {
mStatus = statusUiData;
safeNotifyChanged();
}
void setViewModel(SafetyCenterViewModel viewModel) {
mViewModel = Objects.requireNonNull(viewModel);
}
private SafetyCenterViewModel requireViewModel() {
return Objects.requireNonNull(mViewModel);
}
private void setRescanButtonState(View rescanButton) {
rescanButton.setVisibility(mStatus.shouldShowRescanButton() ? View.VISIBLE : View.GONE);
rescanButton.setEnabled(!mStatus.isRefreshInProgress());
}
// Calling notifyChanged while recyclerview is scrolling or computing layout will result in an
// IllegalStateException. Post to handler to wait for UI to settle.
private void safeNotifyChanged() {
new Handler(Looper.getMainLooper()).post(this::notifyChanged);
}
@Override
public boolean isSameItem(@NonNull Preference preference) {
return preference instanceof SafetyStatusPreference
&& TextUtils.equals(getKey(), preference.getKey());
}
@Override
public boolean hasSameContents(@NonNull Preference preference) {
if (!(preference instanceof SafetyStatusPreference)) {
return false;
}
SafetyStatusPreference other = (SafetyStatusPreference) preference;
return Objects.equals(mStatus, other.mStatus);
}
}