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 android.content.Context
20 import android.graphics.drawable.Animatable2
21 import android.graphics.drawable.AnimatedVectorDrawable
22 import android.graphics.drawable.Drawable
23 import android.provider.DeviceConfig
24 import android.safetycenter.SafetyCenterIssue
25 import android.text.TextUtils
26 import android.transition.Fade
27 import android.transition.Transition
28 import android.transition.TransitionListenerAdapter
29 import android.transition.TransitionManager
30 import android.transition.TransitionSet
31 import android.view.View
32 import android.view.ViewGroup
33 import android.view.animation.LinearInterpolator
34 import android.widget.ImageView
35 import android.widget.TextView
36 import androidx.preference.PreferenceViewHolder
37 import com.android.permissioncontroller.R
38 import java.time.Duration
39 
40 class IssueCardAnimator(val callback: AnimationCallback) {
41 
transitionToIssueResolvedThenMarkCompletenull42     fun transitionToIssueResolvedThenMarkComplete(
43         context: Context,
44         holder: PreferenceViewHolder,
45         action: SafetyCenterIssue.Action
46     ) {
47         var successMessage = action.successMessage
48         if (TextUtils.isEmpty(successMessage)) {
49             successMessage = context.getString(R.string.safety_center_resolved_issue_fallback)
50         }
51         (holder.findViewById(R.id.resolved_issue_text) as TextView).text = successMessage
52         val resolvedImageView = holder.findViewById(R.id.resolved_issue_image) as ImageView
53         resolvedImageView.contentDescription = successMessage
54 
55         // Ensure AVD is reset before transition starts
56         (resolvedImageView.drawable as AnimatedVectorDrawable).reset()
57 
58         val defaultIssueContentGroup = holder.findViewById(R.id.default_issue_content)!!
59         val resolvedIssueContentGroup = holder.findViewById(R.id.resolved_issue_content)!!
60 
61         val transitionSet =
62             TransitionSet()
63                 .setOrdering(TransitionSet.ORDERING_SEQUENTIAL)
64                 .setInterpolator(linearInterpolator)
65                 .addTransition(hideIssueContentTransition)
66                 .addTransition(
67                     showResolvedImageTransition
68                         .clone()
69                         .addListener(
70                             object : TransitionListenerAdapter() {
71                                 override fun onTransitionEnd(transition: Transition) {
72                                     super.onTransitionEnd(transition)
73                                     startIssueResolvedAnimation(
74                                         resolvedIssueContentGroup,
75                                         resolvedImageView
76                                     )
77                                 }
78                             }
79                         )
80                 )
81                 .addTransition(showResolvedTextTransition)
82 
83         // Defer transition so that it's called after the root ViewGroup has been laid out.
84         holder.itemView.post {
85             TransitionManager.beginDelayedTransition(
86                 defaultIssueContentGroup.parent as ViewGroup?,
87                 transitionSet
88             )
89 
90             // Setting INVISIBLE rather than GONE to ensure consistent card height between
91             // view groups.
92             defaultIssueContentGroup.visibility = View.INVISIBLE
93 
94             // These views are outside of the group since their visibility must be set
95             // independently of the rest of the group, and some frustrating constraints of
96             // constraint layout's behavior. See b/242705351 for context.
97             makeInvisibleIfVisible(holder.findViewById(R.id.issue_card_attribution_title))
98             makeInvisibleIfVisible(holder.findViewById(R.id.issue_card_dismiss_btn))
99             makeInvisibleIfVisible(holder.findViewById(R.id.issue_card_subtitle))
100             makeInvisibleIfVisible(holder.findViewById(R.id.issue_card_protected_by_android))
101 
102             resolvedIssueContentGroup.visibility = View.VISIBLE
103         }
104 
105         // Cancel animations if they are scrolled out of view (detached from recycler view)
106         holder.itemView.addOnAttachStateChangeListener(
107             object : View.OnAttachStateChangeListener {
108                 override fun onViewAttachedToWindow(v: View) {}
109                 override fun onViewDetachedFromWindow(v: View) {
110                     holder.itemView.removeOnAttachStateChangeListener(this)
111                     cancelIssueResolvedUiTransitionsAndMarkCompleted(
112                         defaultIssueContentGroup,
113                         resolvedIssueContentGroup,
114                         resolvedImageView
115                     )
116                 }
117             }
118         )
119     }
120 
makeInvisibleIfVisiblenull121     private fun makeInvisibleIfVisible(view: View?) {
122         if (view != null && view.visibility == View.VISIBLE) {
123             view.visibility = View.INVISIBLE
124         }
125     }
126 
startIssueResolvedAnimationnull127     private fun startIssueResolvedAnimation(
128         resolvedIssueContentGroup: View,
129         resolvedImageView: ImageView
130     ) {
131         val animatedDrawable = resolvedImageView.drawable as AnimatedVectorDrawable
132         animatedDrawable.reset()
133         animatedDrawable.clearAnimationCallbacks()
134         animatedDrawable.registerAnimationCallback(
135             object : Animatable2.AnimationCallback() {
136                 override fun onAnimationEnd(drawable: Drawable) {
137                     super.onAnimationEnd(drawable)
138                     transitionResolvedIssueUiToHiddenAndMarkComplete(resolvedIssueContentGroup)
139                 }
140             }
141         )
142         animatedDrawable.start()
143     }
144 
transitionResolvedIssueUiToHiddenAndMarkCompletenull145     private fun transitionResolvedIssueUiToHiddenAndMarkComplete(resolvedIssueContentGroup: View) {
146         val hideTransition =
147             hideResolvedUiTransition
148                 .clone()
149                 .setInterpolator(linearInterpolator)
150                 .addListener(
151                     object : TransitionListenerAdapter() {
152                         override fun onTransitionEnd(transition: Transition) {
153                             super.onTransitionEnd(transition)
154                             callback.markIssueResolvedUiCompleted()
155                         }
156                     }
157                 )
158         TransitionManager.beginDelayedTransition(
159             resolvedIssueContentGroup.parent as ViewGroup,
160             hideTransition
161         )
162         resolvedIssueContentGroup.visibility = View.GONE
163     }
164 
cancelIssueResolvedUiTransitionsAndMarkCompletednull165     private fun cancelIssueResolvedUiTransitionsAndMarkCompleted(
166         defaultIssueContentGroup: View,
167         resolvedIssueContentGroup: View,
168         resolvedImageView: ImageView
169     ) {
170         // Cancel any in flight initial fade (in and out) transitions
171         TransitionManager.endTransitions(defaultIssueContentGroup.parent as ViewGroup)
172 
173         // Cancel any in flight resolved image animations
174         val animatedDrawable = resolvedImageView.drawable as AnimatedVectorDrawable
175         animatedDrawable.clearAnimationCallbacks()
176         animatedDrawable.stop()
177 
178         // Cancel any in flight fade out transitions
179         TransitionManager.endTransitions(resolvedIssueContentGroup.parent as ViewGroup)
180         callback.markIssueResolvedUiCompleted()
181     }
182 
183     interface AnimationCallback {
markIssueResolvedUiCompletednull184         fun markIssueResolvedUiCompleted()
185     }
186 
187     companion object {
188         /**
189          * Device config property for time in milliseconds to increase
190          * HIDE_RESOLVED_UI_TRANSITION_DELAY for use in testing.
191          */
192         private const val PROPERTY_HIDE_RESOLVED_UI_TRANSITION_DELAY_MILLIS =
193             "safety_center_hide_resolved_ui_transition_delay_millis"
194 
195         private val HIDE_ISSUE_CONTENT_TRANSITION_DURATION = Duration.ofMillis(333)
196         private val SHOW_RESOLVED_TEXT_TRANSITION_DELAY = Duration.ofMillis(133)
197         private val SHOW_RESOLVED_TEXT_TRANSITION_DURATION = Duration.ofMillis(250)
198         private val HIDE_RESOLVED_UI_TRANSITION_DURATION = Duration.ofMillis(167)
199 
200         // Using getter due to reliance on DeviceConfig property modification in tests
201         private val hideResolvedUiTransitionDelay
202             get() =
203                 Duration.ofMillis(
204                     DeviceConfig.getLong(
205                         DeviceConfig.NAMESPACE_PRIVACY,
206                         PROPERTY_HIDE_RESOLVED_UI_TRANSITION_DELAY_MILLIS,
207                         400
208                     )
209                 )
210 
211         private val linearInterpolator = LinearInterpolator()
212 
213         private val hideIssueContentTransition =
214             Fade(Fade.OUT).setDuration(HIDE_ISSUE_CONTENT_TRANSITION_DURATION.toMillis())
215 
216         private val showResolvedImageTransition =
217             Fade(Fade.IN)
218                 // Fade is used for visibility transformation. Image to be shown immediately
219                 .setDuration(0)
220                 .addTarget(R.id.resolved_issue_image)
221 
222         private val showResolvedTextTransition =
223             Fade(Fade.IN)
224                 .setStartDelay(SHOW_RESOLVED_TEXT_TRANSITION_DELAY.toMillis())
225                 .setDuration(SHOW_RESOLVED_TEXT_TRANSITION_DURATION.toMillis())
226                 .addTarget(R.id.resolved_issue_text)
227 
228         private val hideResolvedUiTransition
229             get() =
230                 Fade(Fade.OUT)
231                     .setStartDelay(hideResolvedUiTransitionDelay.toMillis())
232                     .setDuration(HIDE_RESOLVED_UI_TRANSITION_DURATION.toMillis())
233     }
234 }
235