blob: e8038fd7dfa65292441a8576aa1413a8f2c7932f [file] [log] [blame]
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.unfold.updates
import android.os.Handler
import android.util.Log
import androidx.annotation.FloatRange
import androidx.annotation.VisibleForTesting
import androidx.core.util.Consumer
import com.android.systemui.unfold.config.UnfoldTransitionConfig
import com.android.systemui.unfold.dagger.UnfoldMain
import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdate
import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdatesListener
import com.android.systemui.unfold.updates.hinge.FULLY_CLOSED_DEGREES
import com.android.systemui.unfold.updates.hinge.FULLY_OPEN_DEGREES
import com.android.systemui.unfold.updates.hinge.HingeAngleProvider
import com.android.systemui.unfold.updates.screen.ScreenStatusProvider
import com.android.systemui.unfold.util.CurrentActivityTypeProvider
import java.util.concurrent.Executor
import javax.inject.Inject
class DeviceFoldStateProvider
@Inject
constructor(
config: UnfoldTransitionConfig,
private val hingeAngleProvider: HingeAngleProvider,
private val screenStatusProvider: ScreenStatusProvider,
private val foldProvider: FoldProvider,
private val activityTypeProvider: CurrentActivityTypeProvider,
@UnfoldMain private val mainExecutor: Executor,
@UnfoldMain private val handler: Handler
) : FoldStateProvider {
private val outputListeners: MutableList<FoldUpdatesListener> = mutableListOf()
@FoldUpdate
private var lastFoldUpdate: Int? = null
@FloatRange(from = 0.0, to = 180.0)
private var lastHingeAngle: Float = 0f
private val hingeAngleListener = HingeAngleListener()
private val screenListener = ScreenStatusListener()
private val foldStateListener = FoldStateListener()
private val timeoutRunnable = TimeoutRunnable()
/**
* Time after which [FOLD_UPDATE_FINISH_HALF_OPEN] is emitted following a
* [FOLD_UPDATE_START_CLOSING] or [FOLD_UPDATE_START_OPENING] event, if an end state is not
* reached.
*/
private val halfOpenedTimeoutMillis: Int = config.halfFoldedTimeoutMillis
private var isFolded = false
private var isUnfoldHandled = true
override fun start() {
foldProvider.registerCallback(foldStateListener, mainExecutor)
screenStatusProvider.addCallback(screenListener)
hingeAngleProvider.addCallback(hingeAngleListener)
}
override fun stop() {
screenStatusProvider.removeCallback(screenListener)
foldProvider.unregisterCallback(foldStateListener)
hingeAngleProvider.removeCallback(hingeAngleListener)
hingeAngleProvider.stop()
}
override fun addCallback(listener: FoldUpdatesListener) {
outputListeners.add(listener)
}
override fun removeCallback(listener: FoldUpdatesListener) {
outputListeners.remove(listener)
}
override val isFinishedOpening: Boolean
get() = !isFolded &&
(lastFoldUpdate == FOLD_UPDATE_FINISH_FULL_OPEN ||
lastFoldUpdate == FOLD_UPDATE_FINISH_HALF_OPEN)
private val isTransitionInProgress: Boolean
get() =
lastFoldUpdate == FOLD_UPDATE_START_OPENING ||
lastFoldUpdate == FOLD_UPDATE_START_CLOSING
private fun onHingeAngle(angle: Float) {
if (DEBUG) {
Log.d(TAG, "Hinge angle: $angle, lastHingeAngle: $lastHingeAngle")
}
val isClosing = angle < lastHingeAngle
val closingThreshold = getClosingThreshold()
val closingThresholdMet = closingThreshold == null || angle < closingThreshold
val isFullyOpened = FULLY_OPEN_DEGREES - angle < FULLY_OPEN_THRESHOLD_DEGREES
val closingEventDispatched = lastFoldUpdate == FOLD_UPDATE_START_CLOSING
if (isClosing && closingThresholdMet && !closingEventDispatched && !isFullyOpened) {
notifyFoldUpdate(FOLD_UPDATE_START_CLOSING)
}
if (isTransitionInProgress) {
if (isFullyOpened) {
notifyFoldUpdate(FOLD_UPDATE_FINISH_FULL_OPEN)
cancelTimeout()
} else {
// The timeout will trigger some constant time after the last angle update.
rescheduleAbortAnimationTimeout()
}
}
lastHingeAngle = angle
outputListeners.forEach { it.onHingeAngleUpdate(angle) }
}
/**
* Fold animation should be started only after the threshold returned here.
*
* This has been introduced because the fold animation might be distracting/unwanted on top of
* apps that support table-top/HALF_FOLDED mode. Only for launcher, there is no threshold.
*/
private fun getClosingThreshold(): Int? {
val isHomeActivity = activityTypeProvider.isHomeActivity ?: return null
if (DEBUG) {
Log.d(TAG, "isHomeActivity=$isHomeActivity")
}
return if (isHomeActivity) {
null
} else {
START_CLOSING_ON_APPS_THRESHOLD_DEGREES
}
}
private inner class FoldStateListener : FoldProvider.FoldCallback {
override fun onFoldUpdated(isFolded: Boolean) {
this@DeviceFoldStateProvider.isFolded = isFolded
lastHingeAngle = FULLY_CLOSED_DEGREES
if (isFolded) {
hingeAngleProvider.stop()
notifyFoldUpdate(FOLD_UPDATE_FINISH_CLOSED)
cancelTimeout()
isUnfoldHandled = false
} else {
notifyFoldUpdate(FOLD_UPDATE_START_OPENING)
rescheduleAbortAnimationTimeout()
hingeAngleProvider.start()
}
}
}
private fun notifyFoldUpdate(@FoldUpdate update: Int) {
if (DEBUG) {
Log.d(TAG, stateToString(update))
}
outputListeners.forEach { it.onFoldUpdate(update) }
lastFoldUpdate = update
}
private fun rescheduleAbortAnimationTimeout() {
if (isTransitionInProgress) {
cancelTimeout()
}
handler.postDelayed(timeoutRunnable, halfOpenedTimeoutMillis.toLong())
}
private fun cancelTimeout() {
handler.removeCallbacks(timeoutRunnable)
}
private inner class ScreenStatusListener : ScreenStatusProvider.ScreenListener {
override fun onScreenTurnedOn() {
// Trigger this event only if we are unfolded and this is the first screen
// turned on event since unfold started. This prevents running the animation when
// turning on the internal display using the power button.
// Initially isUnfoldHandled is true so it will be reset to false *only* when we
// receive 'folded' event. If SystemUI started when device is already folded it will
// still receive 'folded' event on startup.
if (!isFolded && !isUnfoldHandled) {
outputListeners.forEach { it.onFoldUpdate(FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE) }
isUnfoldHandled = true
}
}
}
private inner class HingeAngleListener : Consumer<Float> {
override fun accept(angle: Float) {
onHingeAngle(angle)
}
}
private inner class TimeoutRunnable : Runnable {
override fun run() {
notifyFoldUpdate(FOLD_UPDATE_FINISH_HALF_OPEN)
}
}
}
private fun stateToString(@FoldUpdate update: Int): String {
return when (update) {
FOLD_UPDATE_START_OPENING -> "START_OPENING"
FOLD_UPDATE_START_CLOSING -> "START_CLOSING"
FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE -> "UNFOLDED_SCREEN_AVAILABLE"
FOLD_UPDATE_FINISH_HALF_OPEN -> "FINISH_HALF_OPEN"
FOLD_UPDATE_FINISH_FULL_OPEN -> "FINISH_FULL_OPEN"
FOLD_UPDATE_FINISH_CLOSED -> "FINISH_CLOSED"
else -> "UNKNOWN"
}
}
private const val TAG = "DeviceFoldProvider"
private const val DEBUG = false
/** Threshold after which we consider the device fully unfolded. */
@VisibleForTesting
const val FULLY_OPEN_THRESHOLD_DEGREES = 15f
/** Fold animation on top of apps only when the angle exceeds this threshold. */
@VisibleForTesting
const val START_CLOSING_ON_APPS_THRESHOLD_DEGREES = 60