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