Loading packages/SystemUI/multivalentTests/src/com/android/systemui/unfold/DisplaySwitchLatencyTrackerTest.kt +39 −14 Original line number Original line Diff line number Diff line Loading @@ -47,13 +47,14 @@ import com.android.systemui.power.shared.model.ScreenPowerState.SCREEN_ON import com.android.systemui.shared.system.SysUiStatsLog import com.android.systemui.shared.system.SysUiStatsLog import com.android.systemui.statusbar.policy.FakeConfigurationController import com.android.systemui.statusbar.policy.FakeConfigurationController import com.android.systemui.testKosmos import com.android.systemui.testKosmos import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.COOL_DOWN_DURATION import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.FOLDABLE_DEVICE_STATE_CLOSED import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.FOLDABLE_DEVICE_STATE_CLOSED import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.FOLDABLE_DEVICE_STATE_HALF_OPEN import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.FOLDABLE_DEVICE_STATE_HALF_OPEN import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.SCREEN_EVENT_TIMEOUT import com.android.systemui.unfold.DisplaySwitchLatencyTracker.DisplaySwitchLatencyEvent import com.android.systemui.unfold.DisplaySwitchLatencyTracker.DisplaySwitchLatencyEvent import com.android.systemui.unfold.data.repository.ScreenTimeoutPolicyRepository import com.android.systemui.unfold.data.repository.ScreenTimeoutPolicyRepository import com.android.systemui.unfold.data.repository.UnfoldTransitionRepositoryImpl import com.android.systemui.unfold.data.repository.UnfoldTransitionRepositoryImpl import com.android.systemui.unfold.domain.interactor.FoldableDisplaySwitchTrackingInteractor import com.android.systemui.unfold.domain.interactor.FoldableDisplaySwitchTrackingInteractor.Companion.COOL_DOWN_DURATION import com.android.systemui.unfold.domain.interactor.FoldableDisplaySwitchTrackingInteractor.Companion.SCREEN_EVENT_TIMEOUT import com.android.systemui.unfold.domain.interactor.UnfoldTransitionInteractor import com.android.systemui.unfold.domain.interactor.UnfoldTransitionInteractor import com.android.systemui.unfoldedDeviceState import com.android.systemui.unfoldedDeviceState import com.android.systemui.util.animation.data.repository.fakeAnimationStatusRepository import com.android.systemui.util.animation.data.repository.fakeAnimationStatusRepository Loading @@ -62,7 +63,6 @@ import com.android.systemui.util.time.FakeSystemClock import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat import java.util.Optional import java.util.Optional import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.asExecutor import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.TestDispatcher Loading Loading @@ -93,7 +93,7 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { private val kosmos = testKosmos() private val kosmos = testKosmos() private val mockContext = mock<Context>() private val mockContext = mock<Context>() private val resources = mock<Resources>() private val resources = mock<Resources>() private val foldStateRepository = kosmos.fakeDeviceStateRepository private val deviceStateRepository = kosmos.fakeDeviceStateRepository private val powerInteractor = PowerInteractorFactory.create().powerInteractor private val powerInteractor = PowerInteractorFactory.create().powerInteractor private val animationStatusRepository = kosmos.fakeAnimationStatusRepository private val animationStatusRepository = kosmos.fakeAnimationStatusRepository private val keyguardInteractor = mock<KeyguardInteractor>() private val keyguardInteractor = mock<KeyguardInteractor>() Loading Loading @@ -143,20 +143,29 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { animationStatusRepository.onAnimationStatusChanged(true) animationStatusRepository.onAnimationStatusChanged(true) powerInteractor.setAwakeForTest() powerInteractor.setAwakeForTest() powerInteractor.setScreenPowerState(SCREEN_ON) powerInteractor.setScreenPowerState(SCREEN_ON) val displaySwitchInteractor = FoldableDisplaySwitchTrackingInteractor( deviceStateRepository, powerInteractor, unfoldTransitionInteractor, animationStatusRepository, keyguardInteractor, systemClock, testScope.backgroundScope, ) displaySwitchInteractor.start() displaySwitchLatencyTracker = displaySwitchLatencyTracker = DisplaySwitchLatencyTracker( DisplaySwitchLatencyTracker( mockContext, mockContext, foldStateRepository, powerInteractor, powerInteractor, screenTimeoutPolicyRepository, screenTimeoutPolicyRepository, unfoldTransitionInteractor, animationStatusRepository, keyguardInteractor, keyguardInteractor, testDispatcher.asExecutor(), testScope.backgroundScope, testScope.backgroundScope, displaySwitchLatencyLogger, displaySwitchLatencyLogger, systemClock, systemClock, deviceStateManager, deviceStateManager, displaySwitchInteractor, latencyTracker, latencyTracker, ) ) } } Loading Loading @@ -196,20 +205,28 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { UnfoldTransitionRepositoryImpl(Optional.empty()), UnfoldTransitionRepositoryImpl(Optional.empty()), configurationInteractor, configurationInteractor, ) ) val displaySwitchInteractor = FoldableDisplaySwitchTrackingInteractor( deviceStateRepository, powerInteractor, unfoldTransitionInteractorWithEmptyProgressProvider, animationStatusRepository, keyguardInteractor, systemClock, testScope.backgroundScope, ) displaySwitchInteractor.start() displaySwitchLatencyTracker = displaySwitchLatencyTracker = DisplaySwitchLatencyTracker( DisplaySwitchLatencyTracker( mockContext, mockContext, foldStateRepository, powerInteractor, powerInteractor, screenTimeoutPolicyRepository, screenTimeoutPolicyRepository, unfoldTransitionInteractorWithEmptyProgressProvider, animationStatusRepository, keyguardInteractor, keyguardInteractor, testDispatcher.asExecutor(), testScope.backgroundScope, testScope.backgroundScope, displaySwitchLatencyLogger, displaySwitchLatencyLogger, systemClock, systemClock, deviceStateManager, deviceStateManager, displaySwitchInteractor, latencyTracker, latencyTracker, ) ) Loading Loading @@ -467,13 +484,14 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { } } @Test @Test fun displaySwitchInterrupted_cancelsTrackingWhenNewDeviceStateEmitted() { fun displaySwitchInterrupted_newDeviceState_trackingNotSent() { testScope.runTest { testScope.runTest { startInFoldedState(displaySwitchLatencyTracker) startInFoldedState(displaySwitchLatencyTracker) startUnfolding() startUnfolding() startFolding() startFolding() finishFolding() finishFolding() waitForCorruptedStateToPass() verify(latencyTracker).onActionCancel(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker).onActionCancel(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker, never()).onActionEnd(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker, never()).onActionEnd(ACTION_SWITCH_DISPLAY_UNFOLD) Loading @@ -491,6 +509,7 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { startFolding() startFolding() startUnfolding() startUnfolding() finishUnfolding() finishUnfolding() waitForCorruptedStateToPass() verify(latencyTracker).onActionCancel(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker).onActionCancel(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker, never()).onActionEnd(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker, never()).onActionEnd(ACTION_SWITCH_DISPLAY_UNFOLD) Loading Loading @@ -621,6 +640,7 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { startInFoldedState(displaySwitchLatencyTracker) startInFoldedState(displaySwitchLatencyTracker) startUnfolding() startUnfolding() systemClock.advanceTime(SCREEN_EVENT_TIMEOUT.inWholeMilliseconds) advanceTimeBy(SCREEN_EVENT_TIMEOUT + 10.milliseconds) advanceTimeBy(SCREEN_EVENT_TIMEOUT + 10.milliseconds) finishUnfolding() finishUnfolding() Loading Loading @@ -747,7 +767,12 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { runCurrent() runCurrent() } } private fun TestScope.waitForCorruptedStateToPass() { // extra buffer time so corrupted state is finished advanceTimeBy(COOL_DOWN_DURATION.plus(10.milliseconds)) } private suspend fun setDeviceState(state: DeviceState) { private suspend fun setDeviceState(state: DeviceState) { foldStateRepository.emit(state) deviceStateRepository.emit(state) } } } } packages/SystemUI/shared/src/com/android/systemui/unfold/system/SystemUnfoldSharedModule.kt +25 −12 Original line number Original line Diff line number Diff line Loading @@ -18,6 +18,7 @@ import android.os.Handler import android.os.HandlerThread import android.os.HandlerThread import android.os.Looper import android.os.Looper import android.os.Process import android.os.Process import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.dagger.qualifiers.UiBackground import com.android.systemui.dagger.qualifiers.UiBackground import com.android.systemui.unfold.config.ResourceUnfoldTransitionConfig import com.android.systemui.unfold.config.ResourceUnfoldTransitionConfig Loading @@ -25,6 +26,7 @@ import com.android.systemui.unfold.config.UnfoldTransitionConfig import com.android.systemui.unfold.dagger.UnfoldBg import com.android.systemui.unfold.dagger.UnfoldBg import com.android.systemui.unfold.dagger.UnfoldMain import com.android.systemui.unfold.dagger.UnfoldMain import com.android.systemui.unfold.dagger.UnfoldSingleThreadBg import com.android.systemui.unfold.dagger.UnfoldSingleThreadBg import com.android.systemui.unfold.dagger.UnfoldTracking import com.android.systemui.unfold.updates.FoldProvider import com.android.systemui.unfold.updates.FoldProvider import com.android.systemui.unfold.util.CurrentActivityTypeProvider import com.android.systemui.unfold.util.CurrentActivityTypeProvider import dagger.Binds import dagger.Binds Loading @@ -33,7 +35,10 @@ import dagger.Provides import java.util.concurrent.Executor import java.util.concurrent.Executor import javax.inject.Singleton import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.android.asCoroutineDispatcher import kotlinx.coroutines.android.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.plus /** /** * Dagger module with system-only dependencies for the unfold animation. The code that is used to * Dagger module with system-only dependencies for the unfold animation. The code that is used to Loading @@ -45,25 +50,20 @@ import kotlinx.coroutines.android.asCoroutineDispatcher abstract class SystemUnfoldSharedModule { abstract class SystemUnfoldSharedModule { @Binds @Binds abstract fun activityTypeProvider(executor: ActivityManagerActivityTypeProvider): abstract fun activityTypeProvider( CurrentActivityTypeProvider executor: ActivityManagerActivityTypeProvider ): CurrentActivityTypeProvider @Binds @Binds abstract fun config(config: ResourceUnfoldTransitionConfig): UnfoldTransitionConfig abstract fun config(config: ResourceUnfoldTransitionConfig): UnfoldTransitionConfig @Binds @Binds abstract fun foldState(provider: DeviceStateManagerFoldProvider): FoldProvider abstract fun foldState(provider: DeviceStateManagerFoldProvider): FoldProvider @Binds @Binds abstract fun deviceStateRepository(provider: DeviceStateRepositoryImpl): DeviceStateRepository abstract fun deviceStateRepository(provider: DeviceStateRepositoryImpl): DeviceStateRepository @Binds @Binds @UnfoldMain abstract fun mainExecutor(@Main executor: Executor): Executor @UnfoldMain abstract fun mainExecutor(@Main executor: Executor): Executor @Binds @Binds @UnfoldMain abstract fun mainHandler(@Main handler: Handler): Handler @UnfoldMain abstract fun mainHandler(@Main handler: Handler): Handler @Binds @Binds @UnfoldSingleThreadBg @UnfoldSingleThreadBg Loading Loading @@ -92,5 +92,18 @@ abstract class SystemUnfoldSharedModule { .apply { start() } .apply { start() } .looper .looper } } @Provides @UnfoldTracking @Singleton fun unfoldTrackingContext( @UnfoldSingleThreadBg singleThreadBgExecutor: Executor, @Application applicationScope: CoroutineScope, ): CoroutineScope { // tracking depends on being executed on a single thread so when changing it, ensure all // consumers are not accessing shared state val backgroundDispatcher = singleThreadBgExecutor.asCoroutineDispatcher() return applicationScope + backgroundDispatcher } } } } } packages/SystemUI/src/com/android/systemui/unfold/DisplaySwitchLatencyTracker.kt +132 −201 File changed.Preview size limit exceeded, changes collapsed. Show changes packages/SystemUI/src/com/android/systemui/unfold/SysUIUnfoldModule.kt +10 −5 Original line number Original line Diff line number Diff line Loading @@ -23,6 +23,8 @@ import com.android.systemui.shade.NotificationPanelUnfoldAnimationController import com.android.systemui.statusbar.phone.StatusBarMoveFromCenterAnimationController import com.android.systemui.statusbar.phone.StatusBarMoveFromCenterAnimationController import com.android.systemui.unfold.dagger.NaturalRotation import com.android.systemui.unfold.dagger.NaturalRotation import com.android.systemui.unfold.dagger.UnfoldBg import com.android.systemui.unfold.dagger.UnfoldBg import com.android.systemui.unfold.domain.interactor.DisplaySwitchTrackingInteractor import com.android.systemui.unfold.domain.interactor.FoldableDisplaySwitchTrackingInteractor import com.android.systemui.unfold.util.NaturalRotationUnfoldProgressProvider import com.android.systemui.unfold.util.NaturalRotationUnfoldProgressProvider import com.android.systemui.unfold.util.ScopedUnfoldTransitionProgressProvider import com.android.systemui.unfold.util.ScopedUnfoldTransitionProgressProvider import com.android.systemui.unfold.util.UnfoldKeyguardVisibilityManager import com.android.systemui.unfold.util.UnfoldKeyguardVisibilityManager Loading Loading @@ -56,9 +58,7 @@ import javax.inject.Scope @Module(subcomponents = [SysUIUnfoldComponent::class]) @Module(subcomponents = [SysUIUnfoldComponent::class]) class SysUIUnfoldModule { class SysUIUnfoldModule { /** /** Qualifier for dependencies bound in [com.android.systemui.unfold.SysUIUnfoldModule] */ * Qualifier for dependencies bound in [com.android.systemui.unfold.SysUIUnfoldModule] */ @Qualifier @Qualifier @MustBeDocumented @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME) Loading @@ -72,7 +72,7 @@ class SysUIUnfoldModule { rotationProvider: Optional<NaturalRotationUnfoldProgressProvider>, rotationProvider: Optional<NaturalRotationUnfoldProgressProvider>, @Named(UNFOLD_STATUS_BAR) scopedProvider: Optional<ScopedUnfoldTransitionProgressProvider>, @Named(UNFOLD_STATUS_BAR) scopedProvider: Optional<ScopedUnfoldTransitionProgressProvider>, @UnfoldBg bgProvider: Optional<UnfoldTransitionProgressProvider>, @UnfoldBg bgProvider: Optional<UnfoldTransitionProgressProvider>, factory: SysUIUnfoldComponent.Factory factory: SysUIUnfoldComponent.Factory, ): Optional<SysUIUnfoldComponent> { ): Optional<SysUIUnfoldComponent> { val p1 = provider.getOrNull() val p1 = provider.getOrNull() val p2 = rotationProvider.getOrNull() val p2 = rotationProvider.getOrNull() Loading @@ -92,6 +92,11 @@ interface SysUIUnfoldStartableModule { @IntoMap @IntoMap @ClassKey(UnfoldInitializationStartable::class) @ClassKey(UnfoldInitializationStartable::class) fun bindsUnfoldInitializationStartable(impl: UnfoldInitializationStartable): CoreStartable fun bindsUnfoldInitializationStartable(impl: UnfoldInitializationStartable): CoreStartable @Binds @IntoMap @ClassKey(DisplaySwitchTrackingInteractor::class) fun bindsDisplaySwitchTracker(impl: FoldableDisplaySwitchTrackingInteractor): CoreStartable } } @Module @Module Loading Loading @@ -128,7 +133,7 @@ interface SysUIUnfoldComponent { @BindsInstance p1: UnfoldTransitionProgressProvider, @BindsInstance p1: UnfoldTransitionProgressProvider, @BindsInstance p2: NaturalRotationUnfoldProgressProvider, @BindsInstance p2: NaturalRotationUnfoldProgressProvider, @BindsInstance p3: ScopedUnfoldTransitionProgressProvider, @BindsInstance p3: ScopedUnfoldTransitionProgressProvider, @BindsInstance @UnfoldBg p4: UnfoldTransitionProgressProvider @BindsInstance @UnfoldBg p4: UnfoldTransitionProgressProvider, ): SysUIUnfoldComponent ): SysUIUnfoldComponent } } Loading packages/SystemUI/src/com/android/systemui/unfold/domain/interactor/FoldableDisplaySwitchTrackingInteractor.kt 0 → 100644 +238 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2025 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.domain.interactor import android.os.Build import android.util.Log import androidx.annotation.VisibleForTesting import com.android.app.tracing.TraceUtils.traceAsync import com.android.app.tracing.instantForTrack import com.android.systemui.CoreStartable import com.android.systemui.dagger.SysUISingleton import com.android.systemui.display.data.repository.DeviceStateRepository import com.android.systemui.display.data.repository.DeviceStateRepository.DeviceState import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor import com.android.systemui.power.domain.interactor.PowerInteractor import com.android.systemui.power.shared.model.ScreenPowerState import com.android.systemui.power.shared.model.WakefulnessModel import com.android.systemui.power.shared.model.WakefulnessState import com.android.systemui.unfold.dagger.UnfoldTracking import com.android.systemui.unfold.data.repository.UnfoldTransitionStatus.TransitionStarted import com.android.systemui.unfold.domain.interactor.DisplaySwitchState.Corrupted import com.android.systemui.unfold.domain.interactor.DisplaySwitchState.Idle import com.android.systemui.util.animation.data.repository.AnimationStatusRepository import com.android.systemui.util.kotlin.pairwise import com.android.systemui.util.kotlin.race import com.android.systemui.util.time.SystemClock import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout interface DisplaySwitchTrackingInteractor { /** * Emits latest [DisplaySwitchState] with a guarantee it doesn't emit the same class of state * twice in a row. */ val displaySwitchState: StateFlow<DisplaySwitchState> } sealed interface DisplaySwitchState { val newDeviceState: DeviceState /** * Displays are in a stable state aka not in the process of switching. If we couldn't track * display switch properly because end event never arrived within [SCREEN_EVENT_TIMEOUT], * [timedOut] is set to true. */ data class Idle(override val newDeviceState: DeviceState, val timedOut: Boolean = false) : DisplaySwitchState /** Displays are currently switching. This state can only come directly after [Idle] state. */ data class Switching(override val newDeviceState: DeviceState) : DisplaySwitchState /** * Switching displays happened multiple times before [Idle] state could settle. This state will * hold until no new display switch related events are sent within [COOL_DOWN_DURATION] window. * This event can only happen directly after [Switching] state and is always directly followed * by [Idle] state. */ data class Corrupted(override val newDeviceState: DeviceState) : DisplaySwitchState /** Missing data about device state, should happen only when class is initialized. */ data object Unknown : DisplaySwitchState { override val newDeviceState: DeviceState = DeviceState.UNKNOWN } } /** * Contains data about current state of display switch on foldable device which can be one of * [DisplaySwitchState]. [displaySwitchState] emits only states that we're sure are legit changes, * compared to super quick toggling between the states caused by Hall sensor misfiring or user doing * something very creative. Those kind of changes cause [DisplaySwitchState.CORRUPTED] to be * emitted. * * You need to call [start] for [displaySwitchState] to start emitting state changes. */ @SysUISingleton class FoldableDisplaySwitchTrackingInteractor @Inject constructor( private val deviceStateRepository: DeviceStateRepository, private val powerInteractor: PowerInteractor, private val unfoldTransitionInteractor: UnfoldTransitionInteractor, private val animationStatusRepository: AnimationStatusRepository, private val keyguardInteractor: KeyguardInteractor, private val systemClock: SystemClock, @UnfoldTracking private val scope: CoroutineScope, ) : DisplaySwitchTrackingInteractor, CoreStartable { private val _displaySwitchState = MutableStateFlow<DisplaySwitchState>(DisplaySwitchState.Unknown) override val displaySwitchState: StateFlow<DisplaySwitchState> = _displaySwitchState private val displaySwitchStarted = deviceStateRepository.state.pairwise().filter { // React only when the foldable device is // folding(UNFOLDED/HALF_FOLDED -> FOLDED) or unfolding(FOLDED -> HALF_FOLD/UNFOLDED) foldableDeviceState -> foldableDeviceState.previousValue == DeviceState.FOLDED || foldableDeviceState.newValue == DeviceState.FOLDED } private val startOrEndEvent: Flow<Any> = merge(displaySwitchStarted, anyEndEventFlow()) private var isCoolingDown = false override fun start() { scope.launch { _displaySwitchState.value = Idle(deviceStateRepository.state.first { it != DeviceState.UNKNOWN }) displaySwitchStarted.collectLatest { (previousState, newState) -> if (isCoolingDown) return@collectLatest log { "received previousState=$previousState, newState=$newState" } try { _displaySwitchState.value = DisplaySwitchState.Switching(newState) withTimeout(SCREEN_EVENT_TIMEOUT) { traceAsync(TAG, "displaySwitch") { waitForDisplaySwitch(newState) } _displaySwitchState.value = Idle(deviceStateRepository.state.value) } } catch (e: TimeoutCancellationException) { log { "tracking timed out" } _displaySwitchState.value = Idle(newState, timedOut = true) } catch (e: CancellationException) { log { "new state interrupted, entering cool down" } _displaySwitchState.value = Corrupted(newState) startCoolDown() } } } } private inline fun log(msg: () -> String) { if (DEBUG) Log.d(TAG, msg()) } @OptIn(FlowPreview::class) private fun CoroutineScope.startCoolDown() { if (isCoolingDown) return isCoolingDown = true launch { val startTime = systemClock.elapsedRealtime() try { startOrEndEvent.timeout(COOL_DOWN_DURATION).collect {} } catch (e: TimeoutCancellationException) { val totalCooldownTime = systemClock.elapsedRealtime() - startTime instantForTrack(TAG) { "cool down finished, lasted $totalCooldownTime ms" } _displaySwitchState.value = Idle(deviceStateRepository.state.value) isCoolingDown = false } } } private suspend fun waitForDisplaySwitch(toFoldableDeviceState: DeviceState) { val isTransitionEnabled = unfoldTransitionInteractor.isAvailable && animationStatusRepository.areAnimationsEnabled().first() if (shouldWaitForTransitionStart(toFoldableDeviceState, isTransitionEnabled)) { traceAsync(TAG, "waitForTransitionStart()") { unfoldTransitionInteractor.waitForTransitionStart() } } else { race({ waitForScreenTurnedOn() }, { waitForGoToSleepWithScreenOff() }) } } private fun anyEndEventFlow(): Flow<Any> { val unfoldStatus = unfoldTransitionInteractor.unfoldTransitionStatus.filter { it is TransitionStarted } // dropping first emission as we're only interested in new emissions, not current state val screenOn = powerInteractor.screenPowerState.drop(1).filter { it == ScreenPowerState.SCREEN_ON } val goToSleep = powerInteractor.detailedWakefulness.drop(1).filter { sleepWithScreenOff(it) } return merge(screenOn, goToSleep, unfoldStatus) } private fun shouldWaitForTransitionStart( toFoldableDeviceState: DeviceState, isTransitionEnabled: Boolean, ): Boolean = (toFoldableDeviceState != DeviceState.FOLDED && isTransitionEnabled) private suspend fun waitForScreenTurnedOn() { traceAsync(TAG, "waitForScreenTurnedOn()") { // dropping first as it's stateFlow and will always emit latest value but we're // only interested in new states powerInteractor.screenPowerState .drop(1) .filter { it == ScreenPowerState.SCREEN_ON } .first() } } private suspend fun waitForGoToSleepWithScreenOff() { traceAsync(TAG, "waitForGoToSleepWithScreenOff()") { powerInteractor.detailedWakefulness.filter { sleepWithScreenOff(it) }.first() } } private fun sleepWithScreenOff(model: WakefulnessModel) = model.internalWakefulnessState == WakefulnessState.ASLEEP && !keyguardInteractor.isAodAvailable.value companion object { private const val TAG = "FoldableDisplaySwitch" private val DEBUG = Build.IS_DEBUGGABLE @VisibleForTesting val COOL_DOWN_DURATION = 2.seconds @VisibleForTesting val SCREEN_EVENT_TIMEOUT = 15.seconds } } Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/unfold/DisplaySwitchLatencyTrackerTest.kt +39 −14 Original line number Original line Diff line number Diff line Loading @@ -47,13 +47,14 @@ import com.android.systemui.power.shared.model.ScreenPowerState.SCREEN_ON import com.android.systemui.shared.system.SysUiStatsLog import com.android.systemui.shared.system.SysUiStatsLog import com.android.systemui.statusbar.policy.FakeConfigurationController import com.android.systemui.statusbar.policy.FakeConfigurationController import com.android.systemui.testKosmos import com.android.systemui.testKosmos import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.COOL_DOWN_DURATION import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.FOLDABLE_DEVICE_STATE_CLOSED import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.FOLDABLE_DEVICE_STATE_CLOSED import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.FOLDABLE_DEVICE_STATE_HALF_OPEN import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.FOLDABLE_DEVICE_STATE_HALF_OPEN import com.android.systemui.unfold.DisplaySwitchLatencyTracker.Companion.SCREEN_EVENT_TIMEOUT import com.android.systemui.unfold.DisplaySwitchLatencyTracker.DisplaySwitchLatencyEvent import com.android.systemui.unfold.DisplaySwitchLatencyTracker.DisplaySwitchLatencyEvent import com.android.systemui.unfold.data.repository.ScreenTimeoutPolicyRepository import com.android.systemui.unfold.data.repository.ScreenTimeoutPolicyRepository import com.android.systemui.unfold.data.repository.UnfoldTransitionRepositoryImpl import com.android.systemui.unfold.data.repository.UnfoldTransitionRepositoryImpl import com.android.systemui.unfold.domain.interactor.FoldableDisplaySwitchTrackingInteractor import com.android.systemui.unfold.domain.interactor.FoldableDisplaySwitchTrackingInteractor.Companion.COOL_DOWN_DURATION import com.android.systemui.unfold.domain.interactor.FoldableDisplaySwitchTrackingInteractor.Companion.SCREEN_EVENT_TIMEOUT import com.android.systemui.unfold.domain.interactor.UnfoldTransitionInteractor import com.android.systemui.unfold.domain.interactor.UnfoldTransitionInteractor import com.android.systemui.unfoldedDeviceState import com.android.systemui.unfoldedDeviceState import com.android.systemui.util.animation.data.repository.fakeAnimationStatusRepository import com.android.systemui.util.animation.data.repository.fakeAnimationStatusRepository Loading @@ -62,7 +63,6 @@ import com.android.systemui.util.time.FakeSystemClock import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat import java.util.Optional import java.util.Optional import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.asExecutor import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.TestDispatcher Loading Loading @@ -93,7 +93,7 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { private val kosmos = testKosmos() private val kosmos = testKosmos() private val mockContext = mock<Context>() private val mockContext = mock<Context>() private val resources = mock<Resources>() private val resources = mock<Resources>() private val foldStateRepository = kosmos.fakeDeviceStateRepository private val deviceStateRepository = kosmos.fakeDeviceStateRepository private val powerInteractor = PowerInteractorFactory.create().powerInteractor private val powerInteractor = PowerInteractorFactory.create().powerInteractor private val animationStatusRepository = kosmos.fakeAnimationStatusRepository private val animationStatusRepository = kosmos.fakeAnimationStatusRepository private val keyguardInteractor = mock<KeyguardInteractor>() private val keyguardInteractor = mock<KeyguardInteractor>() Loading Loading @@ -143,20 +143,29 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { animationStatusRepository.onAnimationStatusChanged(true) animationStatusRepository.onAnimationStatusChanged(true) powerInteractor.setAwakeForTest() powerInteractor.setAwakeForTest() powerInteractor.setScreenPowerState(SCREEN_ON) powerInteractor.setScreenPowerState(SCREEN_ON) val displaySwitchInteractor = FoldableDisplaySwitchTrackingInteractor( deviceStateRepository, powerInteractor, unfoldTransitionInteractor, animationStatusRepository, keyguardInteractor, systemClock, testScope.backgroundScope, ) displaySwitchInteractor.start() displaySwitchLatencyTracker = displaySwitchLatencyTracker = DisplaySwitchLatencyTracker( DisplaySwitchLatencyTracker( mockContext, mockContext, foldStateRepository, powerInteractor, powerInteractor, screenTimeoutPolicyRepository, screenTimeoutPolicyRepository, unfoldTransitionInteractor, animationStatusRepository, keyguardInteractor, keyguardInteractor, testDispatcher.asExecutor(), testScope.backgroundScope, testScope.backgroundScope, displaySwitchLatencyLogger, displaySwitchLatencyLogger, systemClock, systemClock, deviceStateManager, deviceStateManager, displaySwitchInteractor, latencyTracker, latencyTracker, ) ) } } Loading Loading @@ -196,20 +205,28 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { UnfoldTransitionRepositoryImpl(Optional.empty()), UnfoldTransitionRepositoryImpl(Optional.empty()), configurationInteractor, configurationInteractor, ) ) val displaySwitchInteractor = FoldableDisplaySwitchTrackingInteractor( deviceStateRepository, powerInteractor, unfoldTransitionInteractorWithEmptyProgressProvider, animationStatusRepository, keyguardInteractor, systemClock, testScope.backgroundScope, ) displaySwitchInteractor.start() displaySwitchLatencyTracker = displaySwitchLatencyTracker = DisplaySwitchLatencyTracker( DisplaySwitchLatencyTracker( mockContext, mockContext, foldStateRepository, powerInteractor, powerInteractor, screenTimeoutPolicyRepository, screenTimeoutPolicyRepository, unfoldTransitionInteractorWithEmptyProgressProvider, animationStatusRepository, keyguardInteractor, keyguardInteractor, testDispatcher.asExecutor(), testScope.backgroundScope, testScope.backgroundScope, displaySwitchLatencyLogger, displaySwitchLatencyLogger, systemClock, systemClock, deviceStateManager, deviceStateManager, displaySwitchInteractor, latencyTracker, latencyTracker, ) ) Loading Loading @@ -467,13 +484,14 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { } } @Test @Test fun displaySwitchInterrupted_cancelsTrackingWhenNewDeviceStateEmitted() { fun displaySwitchInterrupted_newDeviceState_trackingNotSent() { testScope.runTest { testScope.runTest { startInFoldedState(displaySwitchLatencyTracker) startInFoldedState(displaySwitchLatencyTracker) startUnfolding() startUnfolding() startFolding() startFolding() finishFolding() finishFolding() waitForCorruptedStateToPass() verify(latencyTracker).onActionCancel(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker).onActionCancel(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker, never()).onActionEnd(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker, never()).onActionEnd(ACTION_SWITCH_DISPLAY_UNFOLD) Loading @@ -491,6 +509,7 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { startFolding() startFolding() startUnfolding() startUnfolding() finishUnfolding() finishUnfolding() waitForCorruptedStateToPass() verify(latencyTracker).onActionCancel(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker).onActionCancel(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker, never()).onActionEnd(ACTION_SWITCH_DISPLAY_UNFOLD) verify(latencyTracker, never()).onActionEnd(ACTION_SWITCH_DISPLAY_UNFOLD) Loading Loading @@ -621,6 +640,7 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { startInFoldedState(displaySwitchLatencyTracker) startInFoldedState(displaySwitchLatencyTracker) startUnfolding() startUnfolding() systemClock.advanceTime(SCREEN_EVENT_TIMEOUT.inWholeMilliseconds) advanceTimeBy(SCREEN_EVENT_TIMEOUT + 10.milliseconds) advanceTimeBy(SCREEN_EVENT_TIMEOUT + 10.milliseconds) finishUnfolding() finishUnfolding() Loading Loading @@ -747,7 +767,12 @@ class DisplaySwitchLatencyTrackerTest : SysuiTestCase() { runCurrent() runCurrent() } } private fun TestScope.waitForCorruptedStateToPass() { // extra buffer time so corrupted state is finished advanceTimeBy(COOL_DOWN_DURATION.plus(10.milliseconds)) } private suspend fun setDeviceState(state: DeviceState) { private suspend fun setDeviceState(state: DeviceState) { foldStateRepository.emit(state) deviceStateRepository.emit(state) } } } }
packages/SystemUI/shared/src/com/android/systemui/unfold/system/SystemUnfoldSharedModule.kt +25 −12 Original line number Original line Diff line number Diff line Loading @@ -18,6 +18,7 @@ import android.os.Handler import android.os.HandlerThread import android.os.HandlerThread import android.os.Looper import android.os.Looper import android.os.Process import android.os.Process import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.dagger.qualifiers.UiBackground import com.android.systemui.dagger.qualifiers.UiBackground import com.android.systemui.unfold.config.ResourceUnfoldTransitionConfig import com.android.systemui.unfold.config.ResourceUnfoldTransitionConfig Loading @@ -25,6 +26,7 @@ import com.android.systemui.unfold.config.UnfoldTransitionConfig import com.android.systemui.unfold.dagger.UnfoldBg import com.android.systemui.unfold.dagger.UnfoldBg import com.android.systemui.unfold.dagger.UnfoldMain import com.android.systemui.unfold.dagger.UnfoldMain import com.android.systemui.unfold.dagger.UnfoldSingleThreadBg import com.android.systemui.unfold.dagger.UnfoldSingleThreadBg import com.android.systemui.unfold.dagger.UnfoldTracking import com.android.systemui.unfold.updates.FoldProvider import com.android.systemui.unfold.updates.FoldProvider import com.android.systemui.unfold.util.CurrentActivityTypeProvider import com.android.systemui.unfold.util.CurrentActivityTypeProvider import dagger.Binds import dagger.Binds Loading @@ -33,7 +35,10 @@ import dagger.Provides import java.util.concurrent.Executor import java.util.concurrent.Executor import javax.inject.Singleton import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.android.asCoroutineDispatcher import kotlinx.coroutines.android.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.plus /** /** * Dagger module with system-only dependencies for the unfold animation. The code that is used to * Dagger module with system-only dependencies for the unfold animation. The code that is used to Loading @@ -45,25 +50,20 @@ import kotlinx.coroutines.android.asCoroutineDispatcher abstract class SystemUnfoldSharedModule { abstract class SystemUnfoldSharedModule { @Binds @Binds abstract fun activityTypeProvider(executor: ActivityManagerActivityTypeProvider): abstract fun activityTypeProvider( CurrentActivityTypeProvider executor: ActivityManagerActivityTypeProvider ): CurrentActivityTypeProvider @Binds @Binds abstract fun config(config: ResourceUnfoldTransitionConfig): UnfoldTransitionConfig abstract fun config(config: ResourceUnfoldTransitionConfig): UnfoldTransitionConfig @Binds @Binds abstract fun foldState(provider: DeviceStateManagerFoldProvider): FoldProvider abstract fun foldState(provider: DeviceStateManagerFoldProvider): FoldProvider @Binds @Binds abstract fun deviceStateRepository(provider: DeviceStateRepositoryImpl): DeviceStateRepository abstract fun deviceStateRepository(provider: DeviceStateRepositoryImpl): DeviceStateRepository @Binds @Binds @UnfoldMain abstract fun mainExecutor(@Main executor: Executor): Executor @UnfoldMain abstract fun mainExecutor(@Main executor: Executor): Executor @Binds @Binds @UnfoldMain abstract fun mainHandler(@Main handler: Handler): Handler @UnfoldMain abstract fun mainHandler(@Main handler: Handler): Handler @Binds @Binds @UnfoldSingleThreadBg @UnfoldSingleThreadBg Loading Loading @@ -92,5 +92,18 @@ abstract class SystemUnfoldSharedModule { .apply { start() } .apply { start() } .looper .looper } } @Provides @UnfoldTracking @Singleton fun unfoldTrackingContext( @UnfoldSingleThreadBg singleThreadBgExecutor: Executor, @Application applicationScope: CoroutineScope, ): CoroutineScope { // tracking depends on being executed on a single thread so when changing it, ensure all // consumers are not accessing shared state val backgroundDispatcher = singleThreadBgExecutor.asCoroutineDispatcher() return applicationScope + backgroundDispatcher } } } } }
packages/SystemUI/src/com/android/systemui/unfold/DisplaySwitchLatencyTracker.kt +132 −201 File changed.Preview size limit exceeded, changes collapsed. Show changes
packages/SystemUI/src/com/android/systemui/unfold/SysUIUnfoldModule.kt +10 −5 Original line number Original line Diff line number Diff line Loading @@ -23,6 +23,8 @@ import com.android.systemui.shade.NotificationPanelUnfoldAnimationController import com.android.systemui.statusbar.phone.StatusBarMoveFromCenterAnimationController import com.android.systemui.statusbar.phone.StatusBarMoveFromCenterAnimationController import com.android.systemui.unfold.dagger.NaturalRotation import com.android.systemui.unfold.dagger.NaturalRotation import com.android.systemui.unfold.dagger.UnfoldBg import com.android.systemui.unfold.dagger.UnfoldBg import com.android.systemui.unfold.domain.interactor.DisplaySwitchTrackingInteractor import com.android.systemui.unfold.domain.interactor.FoldableDisplaySwitchTrackingInteractor import com.android.systemui.unfold.util.NaturalRotationUnfoldProgressProvider import com.android.systemui.unfold.util.NaturalRotationUnfoldProgressProvider import com.android.systemui.unfold.util.ScopedUnfoldTransitionProgressProvider import com.android.systemui.unfold.util.ScopedUnfoldTransitionProgressProvider import com.android.systemui.unfold.util.UnfoldKeyguardVisibilityManager import com.android.systemui.unfold.util.UnfoldKeyguardVisibilityManager Loading Loading @@ -56,9 +58,7 @@ import javax.inject.Scope @Module(subcomponents = [SysUIUnfoldComponent::class]) @Module(subcomponents = [SysUIUnfoldComponent::class]) class SysUIUnfoldModule { class SysUIUnfoldModule { /** /** Qualifier for dependencies bound in [com.android.systemui.unfold.SysUIUnfoldModule] */ * Qualifier for dependencies bound in [com.android.systemui.unfold.SysUIUnfoldModule] */ @Qualifier @Qualifier @MustBeDocumented @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME) Loading @@ -72,7 +72,7 @@ class SysUIUnfoldModule { rotationProvider: Optional<NaturalRotationUnfoldProgressProvider>, rotationProvider: Optional<NaturalRotationUnfoldProgressProvider>, @Named(UNFOLD_STATUS_BAR) scopedProvider: Optional<ScopedUnfoldTransitionProgressProvider>, @Named(UNFOLD_STATUS_BAR) scopedProvider: Optional<ScopedUnfoldTransitionProgressProvider>, @UnfoldBg bgProvider: Optional<UnfoldTransitionProgressProvider>, @UnfoldBg bgProvider: Optional<UnfoldTransitionProgressProvider>, factory: SysUIUnfoldComponent.Factory factory: SysUIUnfoldComponent.Factory, ): Optional<SysUIUnfoldComponent> { ): Optional<SysUIUnfoldComponent> { val p1 = provider.getOrNull() val p1 = provider.getOrNull() val p2 = rotationProvider.getOrNull() val p2 = rotationProvider.getOrNull() Loading @@ -92,6 +92,11 @@ interface SysUIUnfoldStartableModule { @IntoMap @IntoMap @ClassKey(UnfoldInitializationStartable::class) @ClassKey(UnfoldInitializationStartable::class) fun bindsUnfoldInitializationStartable(impl: UnfoldInitializationStartable): CoreStartable fun bindsUnfoldInitializationStartable(impl: UnfoldInitializationStartable): CoreStartable @Binds @IntoMap @ClassKey(DisplaySwitchTrackingInteractor::class) fun bindsDisplaySwitchTracker(impl: FoldableDisplaySwitchTrackingInteractor): CoreStartable } } @Module @Module Loading Loading @@ -128,7 +133,7 @@ interface SysUIUnfoldComponent { @BindsInstance p1: UnfoldTransitionProgressProvider, @BindsInstance p1: UnfoldTransitionProgressProvider, @BindsInstance p2: NaturalRotationUnfoldProgressProvider, @BindsInstance p2: NaturalRotationUnfoldProgressProvider, @BindsInstance p3: ScopedUnfoldTransitionProgressProvider, @BindsInstance p3: ScopedUnfoldTransitionProgressProvider, @BindsInstance @UnfoldBg p4: UnfoldTransitionProgressProvider @BindsInstance @UnfoldBg p4: UnfoldTransitionProgressProvider, ): SysUIUnfoldComponent ): SysUIUnfoldComponent } } Loading
packages/SystemUI/src/com/android/systemui/unfold/domain/interactor/FoldableDisplaySwitchTrackingInteractor.kt 0 → 100644 +238 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2025 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.domain.interactor import android.os.Build import android.util.Log import androidx.annotation.VisibleForTesting import com.android.app.tracing.TraceUtils.traceAsync import com.android.app.tracing.instantForTrack import com.android.systemui.CoreStartable import com.android.systemui.dagger.SysUISingleton import com.android.systemui.display.data.repository.DeviceStateRepository import com.android.systemui.display.data.repository.DeviceStateRepository.DeviceState import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor import com.android.systemui.power.domain.interactor.PowerInteractor import com.android.systemui.power.shared.model.ScreenPowerState import com.android.systemui.power.shared.model.WakefulnessModel import com.android.systemui.power.shared.model.WakefulnessState import com.android.systemui.unfold.dagger.UnfoldTracking import com.android.systemui.unfold.data.repository.UnfoldTransitionStatus.TransitionStarted import com.android.systemui.unfold.domain.interactor.DisplaySwitchState.Corrupted import com.android.systemui.unfold.domain.interactor.DisplaySwitchState.Idle import com.android.systemui.util.animation.data.repository.AnimationStatusRepository import com.android.systemui.util.kotlin.pairwise import com.android.systemui.util.kotlin.race import com.android.systemui.util.time.SystemClock import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout interface DisplaySwitchTrackingInteractor { /** * Emits latest [DisplaySwitchState] with a guarantee it doesn't emit the same class of state * twice in a row. */ val displaySwitchState: StateFlow<DisplaySwitchState> } sealed interface DisplaySwitchState { val newDeviceState: DeviceState /** * Displays are in a stable state aka not in the process of switching. If we couldn't track * display switch properly because end event never arrived within [SCREEN_EVENT_TIMEOUT], * [timedOut] is set to true. */ data class Idle(override val newDeviceState: DeviceState, val timedOut: Boolean = false) : DisplaySwitchState /** Displays are currently switching. This state can only come directly after [Idle] state. */ data class Switching(override val newDeviceState: DeviceState) : DisplaySwitchState /** * Switching displays happened multiple times before [Idle] state could settle. This state will * hold until no new display switch related events are sent within [COOL_DOWN_DURATION] window. * This event can only happen directly after [Switching] state and is always directly followed * by [Idle] state. */ data class Corrupted(override val newDeviceState: DeviceState) : DisplaySwitchState /** Missing data about device state, should happen only when class is initialized. */ data object Unknown : DisplaySwitchState { override val newDeviceState: DeviceState = DeviceState.UNKNOWN } } /** * Contains data about current state of display switch on foldable device which can be one of * [DisplaySwitchState]. [displaySwitchState] emits only states that we're sure are legit changes, * compared to super quick toggling between the states caused by Hall sensor misfiring or user doing * something very creative. Those kind of changes cause [DisplaySwitchState.CORRUPTED] to be * emitted. * * You need to call [start] for [displaySwitchState] to start emitting state changes. */ @SysUISingleton class FoldableDisplaySwitchTrackingInteractor @Inject constructor( private val deviceStateRepository: DeviceStateRepository, private val powerInteractor: PowerInteractor, private val unfoldTransitionInteractor: UnfoldTransitionInteractor, private val animationStatusRepository: AnimationStatusRepository, private val keyguardInteractor: KeyguardInteractor, private val systemClock: SystemClock, @UnfoldTracking private val scope: CoroutineScope, ) : DisplaySwitchTrackingInteractor, CoreStartable { private val _displaySwitchState = MutableStateFlow<DisplaySwitchState>(DisplaySwitchState.Unknown) override val displaySwitchState: StateFlow<DisplaySwitchState> = _displaySwitchState private val displaySwitchStarted = deviceStateRepository.state.pairwise().filter { // React only when the foldable device is // folding(UNFOLDED/HALF_FOLDED -> FOLDED) or unfolding(FOLDED -> HALF_FOLD/UNFOLDED) foldableDeviceState -> foldableDeviceState.previousValue == DeviceState.FOLDED || foldableDeviceState.newValue == DeviceState.FOLDED } private val startOrEndEvent: Flow<Any> = merge(displaySwitchStarted, anyEndEventFlow()) private var isCoolingDown = false override fun start() { scope.launch { _displaySwitchState.value = Idle(deviceStateRepository.state.first { it != DeviceState.UNKNOWN }) displaySwitchStarted.collectLatest { (previousState, newState) -> if (isCoolingDown) return@collectLatest log { "received previousState=$previousState, newState=$newState" } try { _displaySwitchState.value = DisplaySwitchState.Switching(newState) withTimeout(SCREEN_EVENT_TIMEOUT) { traceAsync(TAG, "displaySwitch") { waitForDisplaySwitch(newState) } _displaySwitchState.value = Idle(deviceStateRepository.state.value) } } catch (e: TimeoutCancellationException) { log { "tracking timed out" } _displaySwitchState.value = Idle(newState, timedOut = true) } catch (e: CancellationException) { log { "new state interrupted, entering cool down" } _displaySwitchState.value = Corrupted(newState) startCoolDown() } } } } private inline fun log(msg: () -> String) { if (DEBUG) Log.d(TAG, msg()) } @OptIn(FlowPreview::class) private fun CoroutineScope.startCoolDown() { if (isCoolingDown) return isCoolingDown = true launch { val startTime = systemClock.elapsedRealtime() try { startOrEndEvent.timeout(COOL_DOWN_DURATION).collect {} } catch (e: TimeoutCancellationException) { val totalCooldownTime = systemClock.elapsedRealtime() - startTime instantForTrack(TAG) { "cool down finished, lasted $totalCooldownTime ms" } _displaySwitchState.value = Idle(deviceStateRepository.state.value) isCoolingDown = false } } } private suspend fun waitForDisplaySwitch(toFoldableDeviceState: DeviceState) { val isTransitionEnabled = unfoldTransitionInteractor.isAvailable && animationStatusRepository.areAnimationsEnabled().first() if (shouldWaitForTransitionStart(toFoldableDeviceState, isTransitionEnabled)) { traceAsync(TAG, "waitForTransitionStart()") { unfoldTransitionInteractor.waitForTransitionStart() } } else { race({ waitForScreenTurnedOn() }, { waitForGoToSleepWithScreenOff() }) } } private fun anyEndEventFlow(): Flow<Any> { val unfoldStatus = unfoldTransitionInteractor.unfoldTransitionStatus.filter { it is TransitionStarted } // dropping first emission as we're only interested in new emissions, not current state val screenOn = powerInteractor.screenPowerState.drop(1).filter { it == ScreenPowerState.SCREEN_ON } val goToSleep = powerInteractor.detailedWakefulness.drop(1).filter { sleepWithScreenOff(it) } return merge(screenOn, goToSleep, unfoldStatus) } private fun shouldWaitForTransitionStart( toFoldableDeviceState: DeviceState, isTransitionEnabled: Boolean, ): Boolean = (toFoldableDeviceState != DeviceState.FOLDED && isTransitionEnabled) private suspend fun waitForScreenTurnedOn() { traceAsync(TAG, "waitForScreenTurnedOn()") { // dropping first as it's stateFlow and will always emit latest value but we're // only interested in new states powerInteractor.screenPowerState .drop(1) .filter { it == ScreenPowerState.SCREEN_ON } .first() } } private suspend fun waitForGoToSleepWithScreenOff() { traceAsync(TAG, "waitForGoToSleepWithScreenOff()") { powerInteractor.detailedWakefulness.filter { sleepWithScreenOff(it) }.first() } } private fun sleepWithScreenOff(model: WakefulnessModel) = model.internalWakefulnessState == WakefulnessState.ASLEEP && !keyguardInteractor.isAodAvailable.value companion object { private const val TAG = "FoldableDisplaySwitch" private val DEBUG = Build.IS_DEBUGGABLE @VisibleForTesting val COOL_DOWN_DURATION = 2.seconds @VisibleForTesting val SCREEN_EVENT_TIMEOUT = 15.seconds } }