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 = TransitionSet() 62 .setOrdering(TransitionSet.ORDERING_SEQUENTIAL) 63 .setInterpolator(linearInterpolator) 64 .addTransition(hideIssueContentTransition) 65 .addTransition( 66 showResolvedImageTransition 67 .clone() 68 .addListener( 69 object : TransitionListenerAdapter() { 70 override fun onTransitionEnd( 71 transition: Transition 72 ) { 73 super.onTransitionEnd(transition) 74 startIssueResolvedAnimation( 75 resolvedIssueContentGroup, 76 resolvedImageView 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?, transitionSet 87 ) 88 89 // Setting INVISIBLE rather than GONE to ensure consistent card height between 90 // view groups. 91 defaultIssueContentGroup.visibility = View.INVISIBLE 92 93 // These two views are outside of the group since their visibility must be set 94 // independently of the rest of the group, and some frustrating constraints of 95 // constraint layout's behavior. See b/242705351 for context. 96 makeInvisibleIfVisible(holder.findViewById(R.id.issue_card_subtitle)) 97 makeInvisibleIfVisible(holder.findViewById(R.id.issue_card_protected_by_android)) 98 99 resolvedIssueContentGroup.visibility = View.VISIBLE 100 } 101 102 // Cancel animations if they are scrolled out of view (detached from recycler view) 103 holder.itemView.addOnAttachStateChangeListener( 104 object : View.OnAttachStateChangeListener { 105 override fun onViewAttachedToWindow(v: View) {} 106 override fun onViewDetachedFromWindow(v: View) { 107 holder.itemView.removeOnAttachStateChangeListener(this) 108 cancelIssueResolvedUiTransitionsAndMarkCompleted( 109 defaultIssueContentGroup, 110 resolvedIssueContentGroup, 111 resolvedImageView 112 ) 113 } 114 }) 115 } 116 makeInvisibleIfVisiblenull117 private fun makeInvisibleIfVisible(view: View?) { 118 if (view != null && view.visibility == View.VISIBLE) { 119 view.visibility = View.INVISIBLE 120 } 121 } 122 startIssueResolvedAnimationnull123 private fun startIssueResolvedAnimation( 124 resolvedIssueContentGroup: View, 125 resolvedImageView: ImageView 126 ) { 127 val animatedDrawable = resolvedImageView.drawable as AnimatedVectorDrawable 128 animatedDrawable.reset() 129 animatedDrawable.clearAnimationCallbacks() 130 animatedDrawable.registerAnimationCallback( 131 object : Animatable2.AnimationCallback() { 132 override fun onAnimationEnd(drawable: Drawable) { 133 super.onAnimationEnd(drawable) 134 transitionResolvedIssueUiToHiddenAndMarkComplete(resolvedIssueContentGroup) 135 } 136 }) 137 animatedDrawable.start() 138 } 139 transitionResolvedIssueUiToHiddenAndMarkCompletenull140 private fun transitionResolvedIssueUiToHiddenAndMarkComplete(resolvedIssueContentGroup: View) { 141 val hideTransition = hideResolvedUiTransition 142 .clone() 143 .setInterpolator(linearInterpolator) 144 .addListener( 145 object : TransitionListenerAdapter() { 146 override fun onTransitionEnd(transition: Transition) { 147 super.onTransitionEnd(transition) 148 callback.markIssueResolvedUiCompleted() 149 } 150 }) 151 TransitionManager.beginDelayedTransition( 152 resolvedIssueContentGroup.parent as ViewGroup, hideTransition 153 ) 154 resolvedIssueContentGroup.visibility = View.GONE 155 } 156 cancelIssueResolvedUiTransitionsAndMarkCompletednull157 private fun cancelIssueResolvedUiTransitionsAndMarkCompleted( 158 defaultIssueContentGroup: View, 159 resolvedIssueContentGroup: View, 160 resolvedImageView: ImageView 161 ) { 162 // Cancel any in flight initial fade (in and out) transitions 163 TransitionManager.endTransitions(defaultIssueContentGroup.parent as ViewGroup) 164 165 // Cancel any in flight resolved image animations 166 val animatedDrawable = resolvedImageView.drawable as AnimatedVectorDrawable 167 animatedDrawable.clearAnimationCallbacks() 168 animatedDrawable.stop() 169 170 // Cancel any in flight fade out transitions 171 TransitionManager.endTransitions(resolvedIssueContentGroup.parent as ViewGroup) 172 callback.markIssueResolvedUiCompleted() 173 } 174 175 interface AnimationCallback { markIssueResolvedUiCompletednull176 fun markIssueResolvedUiCompleted() 177 } 178 179 companion object { 180 /** 181 * Device config property for time in milliseconds to increase 182 * HIDE_RESOLVED_UI_TRANSITION_DELAY for use in testing. 183 */ 184 private const val PROPERTY_HIDE_RESOLVED_UI_TRANSITION_DELAY_MILLIS = 185 "safety_center_hide_resolved_ui_transition_delay_millis" 186 187 private val HIDE_ISSUE_CONTENT_TRANSITION_DURATION = Duration.ofMillis(333) 188 private val SHOW_RESOLVED_TEXT_TRANSITION_DELAY = Duration.ofMillis(133) 189 private val SHOW_RESOLVED_TEXT_TRANSITION_DURATION = Duration.ofMillis(250) 190 private val HIDE_RESOLVED_UI_TRANSITION_DURATION = Duration.ofMillis(167) 191 192 // Using getter due to reliance on DeviceConfig property modification in tests 193 private val hideResolvedUiTransitionDelay 194 get() = Duration.ofMillis( 195 DeviceConfig.getLong(DeviceConfig.NAMESPACE_PRIVACY, 196 PROPERTY_HIDE_RESOLVED_UI_TRANSITION_DELAY_MILLIS, 197 400)) 198 199 private val linearInterpolator = LinearInterpolator() 200 201 private val hideIssueContentTransition = 202 Fade(Fade.OUT).setDuration(HIDE_ISSUE_CONTENT_TRANSITION_DURATION.toMillis()) 203 204 private val showResolvedImageTransition = 205 Fade(Fade.IN) 206 // Fade is used for visibility transformation. Image to be shown immediately 207 .setDuration(0) 208 .addTarget(R.id.resolved_issue_image) 209 210 private val showResolvedTextTransition = Fade(Fade.IN) 211 .setStartDelay(SHOW_RESOLVED_TEXT_TRANSITION_DELAY.toMillis()) 212 .setDuration(SHOW_RESOLVED_TEXT_TRANSITION_DURATION.toMillis()) 213 .addTarget(R.id.resolved_issue_text) 214 215 private val hideResolvedUiTransition 216 get() = Fade(Fade.OUT) 217 .setStartDelay(hideResolvedUiTransitionDelay.toMillis()) 218 .setDuration(HIDE_RESOLVED_UI_TRANSITION_DURATION.toMillis()) 219 } 220 }