1 /*
2  * Copyright (C) 2023 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 android.animation.Animator;
20 import android.animation.AnimatorListenerAdapter;
21 import android.animation.ArgbEvaluator;
22 import android.animation.ValueAnimator;
23 import android.content.Context;
24 import android.text.TextUtils;
25 import android.util.Log;
26 import android.util.TypedValue;
27 import android.view.View;
28 
29 import androidx.preference.PreferenceGroup;
30 import androidx.preference.PreferenceGroupAdapter;
31 import androidx.preference.PreferenceViewHolder;
32 import androidx.recyclerview.widget.RecyclerView;
33 
34 import com.android.permissioncontroller.R;
35 
36 import com.google.android.material.appbar.AppBarLayout;
37 
38 /**
39  * {@link PreferenceGroupAdapter} used to scroll and highlight a search result. Note: this has been
40  * ported over from the Settings module, so refer to that before making any changes here.
41  *
42  * @see com.android.settings.widget.HighlightablePreferenceGroupAdapter
43  */
44 public class HighlightablePreferenceGroupAdapter extends PreferenceGroupAdapter {
45 
46     private static final String TAG = "HighlightableAdapter";
47     private static final long DELAY_COLLAPSE_DURATION_MILLIS = 300L;
48     private static final long DELAY_HIGHLIGHT_DURATION_MILLIS = 600L;
49     private static final long HIGHLIGHT_DURATION = 15000L;
50     private static final long HIGHLIGHT_FADE_OUT_DURATION = 500L;
51     private static final long HIGHLIGHT_FADE_IN_DURATION = 200L;
52 
53     private final int mHighlightColor;
54     private boolean mFadeInAnimated;
55 
56     private final int mNormalBackgroundRes;
57     private final String mHighlightKey;
58     private boolean mHighlightRequested;
59     private int mHighlightPosition = RecyclerView.NO_POSITION;
60 
HighlightablePreferenceGroupAdapter( PreferenceGroup preferenceGroup, String key, boolean highlightRequested)61     public HighlightablePreferenceGroupAdapter(
62             PreferenceGroup preferenceGroup, String key, boolean highlightRequested) {
63         super(preferenceGroup);
64         mHighlightKey = key;
65         mHighlightRequested = highlightRequested;
66         final Context context = preferenceGroup.getContext();
67         final TypedValue outValue = new TypedValue();
68         context.getTheme()
69                 .resolveAttribute(
70                         android.R.attr.selectableItemBackground, outValue, true /* resolveRefs */);
71         mNormalBackgroundRes = outValue.resourceId;
72         mHighlightColor = context.getColor(R.color.preference_highlight_color);
73     }
74 
75     @Override
onBindViewHolder(PreferenceViewHolder holder, int position)76     public void onBindViewHolder(PreferenceViewHolder holder, int position) {
77         super.onBindViewHolder(holder, position);
78         updateBackground(holder, position);
79     }
80 
updateBackground(PreferenceViewHolder holder, int position)81     private void updateBackground(PreferenceViewHolder holder, int position) {
82         View v = holder.itemView;
83         if (position == mHighlightPosition
84                 && (mHighlightKey != null
85                         && TextUtils.equals(mHighlightKey, getItem(position).getKey()))) {
86             // This position should be highlighted. If it's highlighted before - skip animation.
87             addHighlightBackground(holder, !mFadeInAnimated);
88         } else if (Boolean.TRUE.equals(v.getTag(R.id.preference_highlighted))) {
89             // View with highlight is reused for a view that should not have highlight
90             removeHighlightBackground(holder, false /* animate */);
91         }
92     }
93 
94     /**
95      * A function can highlight a specific setting in recycler view. note: Before highlighting a
96      * setting, screen collapses tool bar with an animation.
97      */
requestHighlight(View root, RecyclerView recyclerView, AppBarLayout appBarLayout)98     public void requestHighlight(View root, RecyclerView recyclerView, AppBarLayout appBarLayout) {
99         if (mHighlightRequested || recyclerView == null || TextUtils.isEmpty(mHighlightKey)) {
100             return;
101         }
102         final int position = getPreferenceAdapterPosition(mHighlightKey);
103         if (position < 0) {
104             return;
105         }
106 
107         // Highlight request accepted
108         mHighlightRequested = true;
109         // Collapse app bar after 300 milliseconds.
110         if (appBarLayout != null) {
111             root.postDelayed(
112                     () -> {
113                         appBarLayout.setExpanded(false, true);
114                     },
115                     DELAY_COLLAPSE_DURATION_MILLIS);
116         }
117 
118         // Remove the animator as early as possible to avoid a RecyclerView crash.
119         recyclerView.setItemAnimator(null);
120         // Scroll to correct position after 600 milliseconds.
121         root.postDelayed(
122                 () -> {
123                     if (ensureHighlightPosition()) {
124                         recyclerView.smoothScrollToPosition(mHighlightPosition);
125                     }
126                 },
127                 DELAY_HIGHLIGHT_DURATION_MILLIS);
128 
129         // Highlight preference after 900 milliseconds.
130         root.postDelayed(
131                 () -> {
132                     if (ensureHighlightPosition()) {
133                         notifyItemChanged(mHighlightPosition);
134                     }
135                 },
136                 DELAY_COLLAPSE_DURATION_MILLIS + DELAY_HIGHLIGHT_DURATION_MILLIS);
137     }
138 
139     /**
140      * Make sure we highlight the real-wanted position in case of preference position already
141      * changed when the delay time comes.
142      */
ensureHighlightPosition()143     private boolean ensureHighlightPosition() {
144         if (TextUtils.isEmpty(mHighlightKey)) {
145             return false;
146         }
147         final int position = getPreferenceAdapterPosition(mHighlightKey);
148         final boolean allowHighlight = position >= 0;
149         if (allowHighlight && mHighlightPosition != position) {
150             Log.w(TAG, "EnsureHighlight: position has changed since last highlight request");
151             // Make sure RecyclerView always uses latest correct position to avoid exceptions.
152             mHighlightPosition = position;
153         }
154         return allowHighlight;
155     }
156 
isHighlightRequested()157     public boolean isHighlightRequested() {
158         return mHighlightRequested;
159     }
160 
161     /** Remove the highlighted background with a delay */
requestRemoveHighlightDelayed(PreferenceViewHolder holder)162     public void requestRemoveHighlightDelayed(PreferenceViewHolder holder) {
163         final View v = holder.itemView;
164         v.postDelayed(
165                 () -> {
166                     mHighlightPosition = RecyclerView.NO_POSITION;
167                     removeHighlightBackground(holder, true /* animate */);
168                 },
169                 HIGHLIGHT_DURATION);
170     }
171 
addHighlightBackground(PreferenceViewHolder holder, boolean animate)172     private void addHighlightBackground(PreferenceViewHolder holder, boolean animate) {
173         final View v = holder.itemView;
174         v.setTag(R.id.preference_highlighted, true);
175         if (!animate) {
176             v.setBackgroundColor(mHighlightColor);
177             Log.d(TAG, "AddHighlight: Not animation requested - setting highlight background");
178             requestRemoveHighlightDelayed(holder);
179             return;
180         }
181         mFadeInAnimated = true;
182         final int colorFrom = mNormalBackgroundRes;
183         final int colorTo = mHighlightColor;
184         final ValueAnimator fadeInLoop =
185                 ValueAnimator.ofObject(new ArgbEvaluator(), colorFrom, colorTo);
186         fadeInLoop.setDuration(HIGHLIGHT_FADE_IN_DURATION);
187         fadeInLoop.addUpdateListener(
188                 animator -> v.setBackgroundColor((int) animator.getAnimatedValue()));
189         fadeInLoop.setRepeatMode(ValueAnimator.REVERSE);
190         fadeInLoop.setRepeatCount(4);
191         fadeInLoop.start();
192         Log.d(TAG, "AddHighlight: starting fade in animation");
193         holder.setIsRecyclable(false);
194         requestRemoveHighlightDelayed(holder);
195     }
196 
removeHighlightBackground(PreferenceViewHolder holder, boolean animate)197     private void removeHighlightBackground(PreferenceViewHolder holder, boolean animate) {
198         final View v = holder.itemView;
199         if (!animate) {
200             v.setTag(R.id.preference_highlighted, false);
201             v.setBackgroundResource(mNormalBackgroundRes);
202             Log.d(TAG, "RemoveHighlight: No animation requested - setting normal background");
203             return;
204         }
205 
206         if (!Boolean.TRUE.equals(v.getTag(R.id.preference_highlighted))) {
207             // Not highlighted, no-op
208             Log.d(TAG, "RemoveHighlight: Not highlighted - skipping");
209             return;
210         }
211         int colorFrom = mHighlightColor;
212         int colorTo = mNormalBackgroundRes;
213 
214         v.setTag(R.id.preference_highlighted, false);
215         final ValueAnimator colorAnimation =
216                 ValueAnimator.ofObject(new ArgbEvaluator(), colorFrom, colorTo);
217         colorAnimation.setDuration(HIGHLIGHT_FADE_OUT_DURATION);
218         colorAnimation.addUpdateListener(
219                 animator -> v.setBackgroundColor((int) animator.getAnimatedValue()));
220         colorAnimation.addListener(
221                 new AnimatorListenerAdapter() {
222                     @Override
223                     public void onAnimationEnd(Animator animation) {
224                         // Animation complete - the background is now white. Change to
225                         // mNormalBackgroundRes
226                         // so it is white and has ripple on touch.
227                         v.setBackgroundResource(mNormalBackgroundRes);
228                         holder.setIsRecyclable(true);
229                     }
230                 });
231         colorAnimation.start();
232         Log.d(TAG, "Starting fade out animation");
233     }
234 }
235