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