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.updates
17 
18 import android.content.Context
19 import android.os.Handler
20 import android.util.Log
21 import androidx.annotation.FloatRange
22 import androidx.annotation.VisibleForTesting
23 import androidx.annotation.WorkerThread
24 import androidx.core.util.Consumer
25 import com.android.systemui.unfold.compat.INNER_SCREEN_SMALLEST_SCREEN_WIDTH_THRESHOLD_DP
26 import com.android.systemui.unfold.config.UnfoldTransitionConfig
27 import com.android.systemui.unfold.updates.hinge.FULLY_CLOSED_DEGREES
28 import com.android.systemui.unfold.updates.hinge.FULLY_OPEN_DEGREES
29 import com.android.systemui.unfold.updates.hinge.HingeAngleProvider
30 import com.android.systemui.unfold.updates.screen.ScreenStatusProvider
31 import com.android.systemui.unfold.util.CurrentActivityTypeProvider
32 import com.android.systemui.unfold.util.UnfoldKeyguardVisibilityProvider
33 import dagger.assisted.Assisted
34 import dagger.assisted.AssistedFactory
35 import dagger.assisted.AssistedInject
36 import java.util.concurrent.CopyOnWriteArrayList
37 import java.util.concurrent.Executor
38 
39 class DeviceFoldStateProvider
40 @AssistedInject
41 constructor(
42     config: UnfoldTransitionConfig,
43     private val context: Context,
44     private val screenStatusProvider: ScreenStatusProvider,
45     private val activityTypeProvider: CurrentActivityTypeProvider,
46     private val unfoldKeyguardVisibilityProvider: UnfoldKeyguardVisibilityProvider,
47     private val foldProvider: FoldProvider,
48     @Assisted private val hingeAngleProvider: HingeAngleProvider,
49     @Assisted private val rotationChangeProvider: RotationChangeProvider,
50     @Assisted private val progressHandler: Handler,
51 ) : FoldStateProvider {
52     private val outputListeners = CopyOnWriteArrayList<FoldStateProvider.FoldUpdatesListener>()
53 
54     @FoldStateProvider.FoldUpdate private var lastFoldUpdate: Int? = null
55 
56     @FloatRange(from = 0.0, to = 180.0) private var lastHingeAngle: Float = 0f
57     @FloatRange(from = 0.0, to = 180.0) private var lastHingeAngleBeforeTransition: Float = 0f
58 
59     private val hingeAngleListener = HingeAngleListener()
60     private val screenListener = ScreenStatusListener()
61     private val foldStateListener = FoldStateListener()
<lambda>null62     private val timeoutRunnable = Runnable { cancelAnimation() }
63     private val rotationListener = FoldRotationListener()
<lambda>null64     private val progressExecutor = Executor { progressHandler.post(it) }
65 
66     /**
67      * Time after which [FOLD_UPDATE_FINISH_HALF_OPEN] is emitted following a
68      * [FOLD_UPDATE_START_CLOSING] or [FOLD_UPDATE_START_OPENING] event, if an end state is not
69      * reached.
70      */
71     private val halfOpenedTimeoutMillis: Int = config.halfFoldedTimeoutMillis
72 
73     private var isFolded = false
74     private var isScreenOn = false
75     private var isUnfoldHandled = true
76     private var isStarted = false
77 
startnull78     override fun start() {
79         if (isStarted) return
80         foldProvider.registerCallback(foldStateListener, progressExecutor)
81         // TODO(b/277879146): get callbacks in the background
82         screenStatusProvider.addCallback(screenListener)
83         hingeAngleProvider.addCallback(hingeAngleListener)
84         rotationChangeProvider.addCallback(rotationListener)
85         activityTypeProvider.init()
86         isStarted = true
87     }
88 
stopnull89     override fun stop() {
90         screenStatusProvider.removeCallback(screenListener)
91         foldProvider.unregisterCallback(foldStateListener)
92         hingeAngleProvider.removeCallback(hingeAngleListener)
93         hingeAngleProvider.stop()
94         rotationChangeProvider.removeCallback(rotationListener)
95         activityTypeProvider.uninit()
96         isStarted = false
97     }
98 
addCallbacknull99     override fun addCallback(listener: FoldStateProvider.FoldUpdatesListener) {
100         outputListeners.add(listener)
101     }
102 
removeCallbacknull103     override fun removeCallback(listener: FoldStateProvider.FoldUpdatesListener) {
104         outputListeners.remove(listener)
105     }
106 
107     override val isFinishedOpening: Boolean
108         get() =
109             !isFolded &&
110                 (lastFoldUpdate == FOLD_UPDATE_FINISH_FULL_OPEN ||
111                     lastFoldUpdate == FOLD_UPDATE_FINISH_HALF_OPEN)
112 
113     private val isTransitionInProgress: Boolean
114         get() =
115             lastFoldUpdate == FOLD_UPDATE_START_OPENING ||
116                 lastFoldUpdate == FOLD_UPDATE_START_CLOSING
117 
onHingeAnglenull118     private fun onHingeAngle(angle: Float) {
119         assertInProgressThread()
120         if (DEBUG) {
121             Log.d(
122                 TAG,
123                 "Hinge angle: $angle, " +
124                     "lastHingeAngle: $lastHingeAngle, " +
125                     "lastHingeAngleBeforeTransition: $lastHingeAngleBeforeTransition"
126             )
127         }
128 
129         val currentDirection =
130             if (angle < lastHingeAngle) FOLD_UPDATE_START_CLOSING else FOLD_UPDATE_START_OPENING
131         if (isTransitionInProgress && currentDirection != lastFoldUpdate) {
132             lastHingeAngleBeforeTransition = lastHingeAngle
133         }
134 
135         val isClosing = angle < lastHingeAngleBeforeTransition
136         val transitionUpdate =
137             if (isClosing) FOLD_UPDATE_START_CLOSING else FOLD_UPDATE_START_OPENING
138         val angleChangeSurpassedThreshold =
139             Math.abs(angle - lastHingeAngleBeforeTransition) > HINGE_ANGLE_CHANGE_THRESHOLD_DEGREES
140         val isFullyOpened = FULLY_OPEN_DEGREES - angle < FULLY_OPEN_THRESHOLD_DEGREES
141         val eventNotAlreadyDispatched = lastFoldUpdate != transitionUpdate
142         val screenAvailableEventSent = isUnfoldHandled
143         val isOnLargeScreen = isOnLargeScreen()
144 
145         if (
146             angleChangeSurpassedThreshold && // Do not react immediately to small changes in angle
147                 eventNotAlreadyDispatched && // we haven't sent transition event already
148                 !isFullyOpened && // do not send transition event if we are in fully opened hinge
149                 // angle range as closing threshold could overlap this range
150                 screenAvailableEventSent && // do not send transition event if we are still in the
151                 // process of turning on the inner display
152                 isClosingThresholdMet(angle) && // hinge angle is below certain threshold.
153                 isOnLargeScreen // Avoids sending closing event when on small screen.
154         // Start event is sent regardless due to hall sensor.
155         ) {
156             notifyFoldUpdate(transitionUpdate, lastHingeAngle)
157         }
158 
159         if (isTransitionInProgress) {
160             if (isFullyOpened) {
161                 notifyFoldUpdate(FOLD_UPDATE_FINISH_FULL_OPEN, angle)
162                 cancelTimeout()
163             } else {
164                 // The timeout will trigger some constant time after the last angle update.
165                 rescheduleAbortAnimationTimeout()
166             }
167         }
168 
169         lastHingeAngle = angle
170         outputListeners.forEach { it.onHingeAngleUpdate(angle) }
171     }
172 
isClosingThresholdMetnull173     private fun isClosingThresholdMet(currentAngle: Float): Boolean {
174         val closingThreshold = getClosingThreshold()
175         return closingThreshold == null || currentAngle < closingThreshold
176     }
177 
178     /**
179      * Fold animation should be started only after the threshold returned here.
180      *
181      * This has been introduced because the fold animation might be distracting/unwanted on top of
182      * apps that support table-top/HALF_FOLDED mode. Only for launcher, there is no threshold.
183      */
getClosingThresholdnull184     private fun getClosingThreshold(): Int? {
185         val isHomeActivity = activityTypeProvider.isHomeActivity ?: return null
186         val isKeyguardVisible = unfoldKeyguardVisibilityProvider.isKeyguardVisible == true
187 
188         if (DEBUG) {
189             Log.d(TAG, "isHomeActivity=$isHomeActivity, isOnKeyguard=$isKeyguardVisible")
190         }
191 
192         return if (isHomeActivity || isKeyguardVisible) {
193             null
194         } else {
195             START_CLOSING_ON_APPS_THRESHOLD_DEGREES
196         }
197     }
198 
199     private inner class FoldStateListener : FoldProvider.FoldCallback {
onFoldUpdatednull200         override fun onFoldUpdated(isFolded: Boolean) {
201             assertInProgressThread()
202             this@DeviceFoldStateProvider.isFolded = isFolded
203             lastHingeAngle = FULLY_CLOSED_DEGREES
204 
205             if (isFolded) {
206                 hingeAngleProvider.stop()
207                 notifyFoldUpdate(FOLD_UPDATE_FINISH_CLOSED, lastHingeAngle)
208                 cancelTimeout()
209                 isUnfoldHandled = false
210             } else {
211                 notifyFoldUpdate(FOLD_UPDATE_START_OPENING, lastHingeAngle)
212                 rescheduleAbortAnimationTimeout()
213                 hingeAngleProvider.start()
214             }
215         }
216     }
217 
218     private inner class FoldRotationListener : RotationChangeProvider.RotationListener {
219         @WorkerThread
onRotationChangednull220         override fun onRotationChanged(newRotation: Int) {
221             assertInProgressThread()
222             if (isTransitionInProgress) cancelAnimation()
223         }
224     }
225 
notifyFoldUpdatenull226     private fun notifyFoldUpdate(@FoldStateProvider.FoldUpdate update: Int, angle: Float) {
227         if (DEBUG) {
228             Log.d(TAG, update.name())
229         }
230         val previouslyTransitioning = isTransitionInProgress
231 
232         outputListeners.forEach { it.onFoldUpdate(update) }
233         lastFoldUpdate = update
234 
235         if (previouslyTransitioning != isTransitionInProgress) {
236             lastHingeAngleBeforeTransition = angle
237         }
238     }
239 
rescheduleAbortAnimationTimeoutnull240     private fun rescheduleAbortAnimationTimeout() {
241         if (isTransitionInProgress) {
242             cancelTimeout()
243         }
244         progressHandler.postDelayed(timeoutRunnable, halfOpenedTimeoutMillis.toLong())
245     }
246 
cancelTimeoutnull247     private fun cancelTimeout() {
248         progressHandler.removeCallbacks(timeoutRunnable)
249     }
250 
cancelAnimationnull251     private fun cancelAnimation(): Unit =
252         notifyFoldUpdate(FOLD_UPDATE_FINISH_HALF_OPEN, lastHingeAngle)
253 
254     private inner class ScreenStatusListener : ScreenStatusProvider.ScreenListener {
255 
256         override fun onScreenTurnedOn() {
257             executeInProgressThread {
258                 // Trigger this event only if we are unfolded and this is the first screen
259                 // turned on event since unfold started. This prevents running the animation when
260                 // turning on the internal display using the power button.
261                 // Initially isUnfoldHandled is true so it will be reset to false *only* when we
262                 // receive 'folded' event. If SystemUI started when device is already folded it will
263                 // still receive 'folded' event on startup.
264                 if (!isFolded && !isUnfoldHandled) {
265                     outputListeners.forEach { it.onUnfoldedScreenAvailable() }
266                     isUnfoldHandled = true
267                 }
268             }
269         }
270 
271         override fun markScreenAsTurnedOn() {
272             executeInProgressThread {
273                 if (!isFolded) {
274                     isUnfoldHandled = true
275                 }
276             }
277         }
278 
279         override fun onScreenTurningOn() {
280             executeInProgressThread {
281                 isScreenOn = true
282                 updateHingeAngleProviderState()
283             }
284         }
285 
286         override fun onScreenTurningOff() {
287             executeInProgressThread {
288                 isScreenOn = false
289                 updateHingeAngleProviderState()
290             }
291         }
292 
293         /**
294          * Needed just for compatibility while not all data sources are providing data in the
295          * background.
296          *
297          * TODO(b/277879146): Remove once ScreeStatusProvider provides in the background.
298          */
299         private fun executeInProgressThread(f: () -> Unit) {
300             progressHandler.post { f() }
301         }
302     }
303 
isOnLargeScreennull304     private fun isOnLargeScreen(): Boolean {
305         return context.resources.configuration.smallestScreenWidthDp >
306             INNER_SCREEN_SMALLEST_SCREEN_WIDTH_THRESHOLD_DP
307     }
308 
309     /** While the screen is off or the device is folded, hinge angle updates are not needed. */
updateHingeAngleProviderStatenull310     private fun updateHingeAngleProviderState() {
311         assertInProgressThread()
312         if (isScreenOn && !isFolded) {
313             hingeAngleProvider.start()
314         } else {
315             hingeAngleProvider.stop()
316         }
317     }
318 
319     private inner class HingeAngleListener : Consumer<Float> {
acceptnull320         override fun accept(angle: Float) {
321             assertInProgressThread()
322             onHingeAngle(angle)
323         }
324     }
325 
assertInProgressThreadnull326     private fun assertInProgressThread() {
327         check(progressHandler.looper.isCurrentThread) {
328             val progressThread = progressHandler.looper.thread
329             val thisThread = Thread.currentThread()
330             """should be called from the progress thread.
331                 progressThread=$progressThread tid=${progressThread.id}
332                 Thread.currentThread()=$thisThread tid=${thisThread.id}"""
333                 .trimMargin()
334         }
335     }
336 
337     @AssistedFactory
338     interface Factory {
339         /** Creates a [DeviceFoldStateProvider] using the provided dependencies. */
createnull340         fun create(
341             hingeAngleProvider: HingeAngleProvider,
342             rotationChangeProvider: RotationChangeProvider,
343             progressHandler: Handler,
344         ): DeviceFoldStateProvider
345     }
346 }
347 
348 fun @receiver:FoldStateProvider.FoldUpdate Int.name() =
349     when (this) {
350         FOLD_UPDATE_START_OPENING -> "START_OPENING"
351         FOLD_UPDATE_START_CLOSING -> "START_CLOSING"
352         FOLD_UPDATE_FINISH_HALF_OPEN -> "FINISH_HALF_OPEN"
353         FOLD_UPDATE_FINISH_FULL_OPEN -> "FINISH_FULL_OPEN"
354         FOLD_UPDATE_FINISH_CLOSED -> "FINISH_CLOSED"
355         else -> "UNKNOWN"
356     }
357 
358 private const val TAG = "DeviceFoldProvider"
359 private val DEBUG = Log.isLoggable(TAG, Log.DEBUG)
360 
361 /** Threshold after which we consider the device fully unfolded. */
362 @VisibleForTesting const val FULLY_OPEN_THRESHOLD_DEGREES = 15f
363 
364 /** Threshold after which hinge angle updates are considered. This is to eliminate noise. */
365 @VisibleForTesting const val HINGE_ANGLE_CHANGE_THRESHOLD_DEGREES = 7.5f
366 
367 /** Fold animation on top of apps only when the angle exceeds this threshold. */
368 @VisibleForTesting const val START_CLOSING_ON_APPS_THRESHOLD_DEGREES = 60
369