1 /*
2  * Copyright (C) 2021 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 package com.android.systemui.unfold.progress
17 
18 import android.animation.Animator
19 import android.animation.AnimatorListenerAdapter
20 import android.animation.ObjectAnimator
21 import android.animation.ValueAnimator
22 import android.content.Context
23 import android.os.Handler
24 import android.os.Trace
25 import android.util.FloatProperty
26 import android.util.Log
27 import android.view.animation.AnimationUtils.loadInterpolator
28 import androidx.dynamicanimation.animation.DynamicAnimation
29 import androidx.dynamicanimation.animation.FloatPropertyCompat
30 import androidx.dynamicanimation.animation.SpringAnimation
31 import androidx.dynamicanimation.animation.SpringForce
32 import com.android.systemui.unfold.UnfoldTransitionProgressProvider
33 import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener
34 import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_CLOSED
35 import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_FULL_OPEN
36 import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_HALF_OPEN
37 import com.android.systemui.unfold.updates.FOLD_UPDATE_START_CLOSING
38 import com.android.systemui.unfold.updates.FoldStateProvider
39 import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdate
40 import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdatesListener
41 import com.android.systemui.unfold.updates.name
42 import dagger.assisted.Assisted
43 import dagger.assisted.AssistedFactory
44 import dagger.assisted.AssistedInject
45 
46 /**
47  * Maps fold updates to unfold transition progress using DynamicAnimation.
48  *
49  * Note that all variable accesses must be done in the [Handler] provided in the constructor, that
50  * might be different than [mainHandler]. When a custom handler is provided, the [SpringAnimation]
51  * uses a scheduler different than the default one.
52  */
53 class PhysicsBasedUnfoldTransitionProgressProvider
54 @AssistedInject
55 constructor(
56     context: Context,
57     private val schedulerFactory: UnfoldFrameCallbackScheduler.Factory,
58     @Assisted private val foldStateProvider: FoldStateProvider,
59     @Assisted private val progressHandler: Handler,
60 ) : UnfoldTransitionProgressProvider, FoldUpdatesListener, DynamicAnimation.OnAnimationEndListener {
61 
62     private val emphasizedInterpolator =
63         loadInterpolator(context, android.R.interpolator.fast_out_extra_slow_in)
64 
65     private var cannedAnimator: ValueAnimator? = null
66 
67     private val springAnimation =
68         SpringAnimation(
69                 this,
70                 FloatPropertyCompat.createFloatPropertyCompat(AnimationProgressProperty)
71             )
<lambda>null72             .apply { addEndListener(this@PhysicsBasedUnfoldTransitionProgressProvider) }
73 
74     private var isTransitionRunning = false
75     private var isAnimatedCancelRunning = false
76 
77     private var transitionProgress: Float = 0.0f
78         set(value) {
79             assertInProgressThread()
80             if (isTransitionRunning) {
<lambda>null81                 listeners.forEach { it.onTransitionProgress(value) }
82             }
83             field = value
84         }
85 
86     private val listeners: MutableList<TransitionProgressListener> = mutableListOf()
87 
88     init {
<lambda>null89         progressHandler.post {
90             // The scheduler needs to be created in the progress handler in order to get the correct
91             // choreographer and frame callbacks. This is because the choreographer can be get only
92             // as a thread local.
93             springAnimation.scheduler = schedulerFactory.create()
94             foldStateProvider.addCallback(this)
95             foldStateProvider.start()
96         }
97     }
98 
destroynull99     override fun destroy() {
100         foldStateProvider.stop()
101     }
102 
onHingeAngleUpdatenull103     override fun onHingeAngleUpdate(angle: Float) {
104         assertInProgressThread()
105 
106         if (!isTransitionRunning || isAnimatedCancelRunning) return
107         val progress = saturate(angle / FINAL_HINGE_ANGLE_POSITION)
108         springAnimation.animateToFinalPosition(progress)
109     }
110 
saturatenull111     private fun saturate(amount: Float, low: Float = 0f, high: Float = 1f): Float =
112         if (amount < low) low else if (amount > high) high else amount
113 
114     override fun onFoldUpdate(@FoldUpdate update: Int) {
115         assertInProgressThread()
116         when (update) {
117             FOLD_UPDATE_FINISH_FULL_OPEN,
118             FOLD_UPDATE_FINISH_HALF_OPEN -> {
119                 // Do not cancel if we haven't started the transition yet.
120                 // This could happen when we fully unfolded the device before the screen
121                 // became available. In this case we start and immediately cancel the animation
122                 // in onUnfoldedScreenAvailable event handler, so we don't need to cancel it here.
123                 if (isTransitionRunning) {
124                     cancelTransition(endValue = 1f, animate = true)
125                 }
126             }
127             FOLD_UPDATE_FINISH_CLOSED -> {
128                 cancelTransition(endValue = 0f, animate = false)
129             }
130             FOLD_UPDATE_START_CLOSING -> {
131                 // The transition might be already running as the device might start closing several
132                 // times before reaching an end state.
133                 if (isTransitionRunning) {
134                     // If we are cancelling the animation, reset that so we can resume it normally.
135                     // The animation could be 'cancelled' when the user stops folding/unfolding
136                     // for some period of time or fully unfolds the device. In this case,
137                     // it is forced to run to the end ignoring all further hinge angle events.
138                     // By resetting this flag we allow reacting to hinge angle events again, so
139                     // the transition continues running.
140                     if (isAnimatedCancelRunning) {
141                         isAnimatedCancelRunning = false
142 
143                         // Switching to spring animation, start the animation if it
144                         // is not running already
145                         springAnimation.animateToFinalPosition(1.0f)
146 
147                         cannedAnimator?.removeAllListeners()
148                         cannedAnimator?.cancel()
149                         cannedAnimator = null
150                     }
151                 } else {
152                     startTransition(startValue = 1f)
153                 }
154             }
155         }
156 
157         if (DEBUG) {
158             Log.d(TAG, "onFoldUpdate = ${update.name()}")
159             Trace.setCounter("fold_update", update.toLong())
160         }
161     }
162 
onUnfoldedScreenAvailablenull163     override fun onUnfoldedScreenAvailable() {
164         startTransition(startValue = 0f)
165 
166         // Stop the animation if the device has already opened by the time when
167         // the display is available as we won't receive the full open event anymore
168         if (foldStateProvider.isFinishedOpening) {
169             cancelTransition(endValue = 1f, animate = true)
170         }
171     }
172 
cancelTransitionnull173     private fun cancelTransition(endValue: Float, animate: Boolean) {
174         assertInProgressThread()
175         if (isTransitionRunning && animate) {
176             if (endValue == 1.0f && !isAnimatedCancelRunning) {
177                 listeners.forEach { it.onTransitionFinishing() }
178             }
179 
180             isAnimatedCancelRunning = true
181 
182             if (USE_CANNED_ANIMATION) {
183                 startCannedCancelAnimation()
184             } else {
185                 springAnimation.animateToFinalPosition(endValue)
186             }
187         } else {
188             transitionProgress = endValue
189             isAnimatedCancelRunning = false
190             isTransitionRunning = false
191             springAnimation.cancel()
192             cannedAnimator?.removeAllListeners()
193             cannedAnimator?.cancel()
194             cannedAnimator = null
195 
196             listeners.forEach { it.onTransitionFinished() }
197 
198             if (DEBUG) {
199                 Log.d(TAG, "onTransitionFinished")
200             }
201         }
202     }
203 
onAnimationEndnull204     override fun onAnimationEnd(
205         animation: DynamicAnimation<out DynamicAnimation<*>>,
206         canceled: Boolean,
207         value: Float,
208         velocity: Float,
209     ) {
210         if (isAnimatedCancelRunning) {
211             cancelTransition(value, animate = false)
212         }
213     }
214 
onStartTransitionnull215     private fun onStartTransition() {
216         Trace.beginSection("$TAG#onStartTransition")
217         listeners.forEach { it.onTransitionStarted() }
218         Trace.endSection()
219 
220         isTransitionRunning = true
221 
222         if (DEBUG) {
223             Log.d(TAG, "onTransitionStarted")
224         }
225     }
226 
startTransitionnull227     private fun startTransition(startValue: Float) {
228         assertInProgressThread()
229         if (!isTransitionRunning) onStartTransition()
230 
231         springAnimation.apply {
232             spring =
233                 SpringForce().apply {
234                     finalPosition = startValue
235                     dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
236                     stiffness = SPRING_STIFFNESS
237                 }
238             minimumVisibleChange = MINIMAL_VISIBLE_CHANGE
239             setStartValue(startValue)
240             setMinValue(0f)
241             setMaxValue(1f)
242         }
243 
244         springAnimation.start()
245     }
246 
addCallbacknull247     override fun addCallback(listener: TransitionProgressListener) {
248         progressHandler.post { listeners.add(listener) }
249     }
250 
removeCallbacknull251     override fun removeCallback(listener: TransitionProgressListener) {
252         progressHandler.post { listeners.remove(listener) }
253     }
254 
startCannedCancelAnimationnull255     private fun startCannedCancelAnimation() {
256         assertInProgressThread()
257 
258         cannedAnimator?.cancel()
259         cannedAnimator = null
260 
261         // Temporary remove listener to cancel the spring animation without
262         // finishing the transition
263         springAnimation.removeEndListener(this)
264         springAnimation.cancel()
265         springAnimation.addEndListener(this)
266 
267         cannedAnimator =
268             ObjectAnimator.ofFloat(this, AnimationProgressProperty, transitionProgress, 1f).apply {
269                 addListener(CannedAnimationListener())
270                 duration =
271                     (CANNED_ANIMATION_DURATION_MS.toFloat() * (1f - transitionProgress)).toLong()
272                 interpolator = emphasizedInterpolator
273                 start()
274             }
275     }
276 
277     private inner class CannedAnimationListener : AnimatorListenerAdapter() {
onAnimationStartnull278         override fun onAnimationStart(animator: Animator) {
279             Trace.beginAsyncSection("$TAG#cannedAnimatorRunning", 0)
280         }
281 
onAnimationEndnull282         override fun onAnimationEnd(animator: Animator) {
283             cancelTransition(1f, animate = false)
284             Trace.endAsyncSection("$TAG#cannedAnimatorRunning", 0)
285         }
286     }
287 
288     private object AnimationProgressProperty :
289         FloatProperty<PhysicsBasedUnfoldTransitionProgressProvider>("animation_progress") {
290 
setValuenull291         override fun setValue(
292             provider: PhysicsBasedUnfoldTransitionProgressProvider,
293             value: Float,
294         ) {
295             provider.transitionProgress = value
296         }
297 
getnull298         override fun get(provider: PhysicsBasedUnfoldTransitionProgressProvider): Float =
299             provider.transitionProgress
300     }
301 
302     private fun assertInProgressThread() {
303         check(progressHandler.looper.isCurrentThread) {
304             val progressThread = progressHandler.looper.thread
305             val thisThread = Thread.currentThread()
306             """should be called from the progress thread.
307                 progressThread=$progressThread tid=${progressThread.id}
308                 Thread.currentThread()=$thisThread tid=${thisThread.id}"""
309                 .trimMargin()
310         }
311     }
312 
313     @AssistedFactory
314     interface Factory {
createnull315         fun create(
316             foldStateProvider: FoldStateProvider,
317             handler: Handler,
318         ): PhysicsBasedUnfoldTransitionProgressProvider
319     }
320 }
321 
322 private const val TAG = "PhysicsBasedUnfoldTransitionProgressProvider"
323 private const val DEBUG = true
324 
325 private const val USE_CANNED_ANIMATION = true
326 private const val CANNED_ANIMATION_DURATION_MS = 1000
327 private const val SPRING_STIFFNESS = 600.0f
328 private const val MINIMAL_VISIBLE_CHANGE = 0.001f
329 private const val FINAL_HINGE_ANGLE_POSITION = 165f
330