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