diff options
12 files changed, 553 insertions, 32 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalAutoOpenInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalAutoOpenInteractorTest.kt index 95334b5aaf09..856a62e3f5a7 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalAutoOpenInteractorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalAutoOpenInteractorTest.kt @@ -25,9 +25,10 @@ import com.android.systemui.common.data.repository.fake import com.android.systemui.communal.data.model.FEATURE_AUTO_OPEN import com.android.systemui.communal.data.model.FEATURE_MANUAL_OPEN import com.android.systemui.communal.data.model.SuppressionReason +import com.android.systemui.communal.posturing.data.model.PositionState import com.android.systemui.communal.posturing.data.repository.fake import com.android.systemui.communal.posturing.data.repository.posturingRepository -import com.android.systemui.communal.posturing.shared.model.PosturedState +import com.android.systemui.communal.posturing.domain.interactor.advanceTimeBySlidingWindowAndRun import com.android.systemui.dock.DockManager import com.android.systemui.dock.fakeDockManager import com.android.systemui.kosmos.Kosmos @@ -127,7 +128,12 @@ class CommunalAutoOpenInteractorTest : SysuiTestCase() { ) batteryRepository.fake.setDevicePluggedIn(true) - posturingRepository.fake.setPosturedState(PosturedState.NotPostured) + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.NotPostured(confidence = 1f), + ) + ) assertThat(shouldAutoOpen).isFalse() assertThat(suppressionReason) @@ -135,7 +141,13 @@ class CommunalAutoOpenInteractorTest : SysuiTestCase() { SuppressionReason.ReasonWhenToAutoShow(FEATURE_AUTO_OPEN or FEATURE_MANUAL_OPEN) ) - posturingRepository.fake.setPosturedState(PosturedState.Postured(1f)) + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.Postured(confidence = 1f), + ) + ) + advanceTimeBySlidingWindowAndRun() assertThat(shouldAutoOpen).isTrue() assertThat(suppressionReason).isNull() } @@ -153,7 +165,7 @@ class CommunalAutoOpenInteractorTest : SysuiTestCase() { ) batteryRepository.fake.setDevicePluggedIn(true) - posturingRepository.fake.setPosturedState(PosturedState.Postured(1f)) + posturingRepository.fake.emitPositionState(PositionState()) fakeDockManager.setIsDocked(true) assertThat(shouldAutoOpen).isFalse() diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractorTest.kt index 0df8834618d5..b4708d97c4c3 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractorTest.kt @@ -16,22 +16,39 @@ package com.android.systemui.communal.posturing.domain.interactor +import android.hardware.Sensor +import android.hardware.TriggerEventListener +import android.platform.test.annotations.EnableFlags +import android.service.dreams.Flags.FLAG_ALLOW_DREAM_WHEN_POSTURED import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase +import com.android.systemui.communal.posturing.data.model.PositionState import com.android.systemui.communal.posturing.data.repository.fake import com.android.systemui.communal.posturing.data.repository.posturingRepository import com.android.systemui.communal.posturing.shared.model.PosturedState +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.advanceTimeBy import com.android.systemui.kosmos.collectLastValue +import com.android.systemui.kosmos.runCurrent import com.android.systemui.kosmos.runTest import com.android.systemui.kosmos.useUnconfinedTestDispatcher import com.android.systemui.testKosmos +import com.android.systemui.util.sensors.asyncSensorManager import com.google.common.truth.Truth.assertThat +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub @SmallTest @RunWith(AndroidJUnit4::class) +@EnableFlags(FLAG_ALLOW_DREAM_WHEN_POSTURED) class PosturingInteractorTest : SysuiTestCase() { private val kosmos = testKosmos().useUnconfinedTestDispatcher() @@ -44,8 +61,166 @@ class PosturingInteractorTest : SysuiTestCase() { val postured by collectLastValue(underTest.postured) assertThat(postured).isFalse() - posturingRepository.fake.setPosturedState(PosturedState.Postured(1f)) + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.Postured(confidence = 1f), + ) + ) + + advanceTimeBySlidingWindowAndRun() + assertThat(postured).isTrue() + } + + @Test + fun testLowConfidenceOrientation() = + kosmos.runTest { + val postured by collectLastValue(underTest.postured) + assertThat(postured).isFalse() + + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.Postured(confidence = 0.2f), + ) + ) + + advanceTimeBySlidingWindowAndRun() + assertThat(postured).isFalse() + } + + @Test + fun testLowConfidenceStationary() = + kosmos.runTest { + val postured by collectLastValue(underTest.postured) + assertThat(postured).isFalse() + + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.Postured(confidence = 0.2f), + ) + ) + + advanceTimeBySlidingWindowAndRun() + assertThat(postured).isFalse() + } + + @Test + fun testSlidingWindow() = + kosmos.runTest { + val postured by collectLastValue(underTest.postured) + assertThat(postured).isFalse() + + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.Postured(confidence = 0.2f), + ) + ) + + advanceTimeBy(PosturingInteractor.SLIDING_WINDOW_DURATION / 2) + runCurrent() + assertThat(postured).isFalse() + + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.Postured(confidence = 1f), + ) + ) + assertThat(postured).isFalse() + advanceTimeBy(PosturingInteractor.SLIDING_WINDOW_DURATION / 2) + runCurrent() + + // The 0.2 confidence will have fallen out of the sliding window, and we should now flip + // to true. + assertThat(postured).isTrue() + + advanceTimeBy(9999.hours) + // We should remain postured if no other updates are received. + assertThat(postured).isTrue() + } + + @Test + fun testLiftGesture_afterSlidingWindow() = + kosmos.runTest { + val triggerSensor = stubSensorManager() + val sensor = asyncSensorManager.getDefaultSensor(Sensor.TYPE_PICK_UP_GESTURE)!! + + val postured by collectLastValue(underTest.postured) + assertThat(postured).isFalse() + + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.Postured(confidence = 1f), + ) + ) + + advanceTimeBySlidingWindowAndRun() + assertThat(postured).isTrue() + + // If we detect a lift gesture, we should transition back to not postured. + triggerSensor(sensor) + assertThat(postured).isFalse() + + advanceTimeBy(9999.hours) + assertThat(postured).isFalse() + } + + @Test + fun testLiftGesture_overridesSlidingWindow() = + kosmos.runTest { + val triggerSensor = stubSensorManager() + val sensor = asyncSensorManager.getDefaultSensor(Sensor.TYPE_PICK_UP_GESTURE)!! + + val postured by collectLastValue(underTest.postured) + assertThat(postured).isFalse() + + // Add multiple stationary + postured events to the sliding window. + repeat(100) { + advanceTimeBy(1.milliseconds) + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.Postured(confidence = 1f), + ) + ) + } + + assertThat(postured).isTrue() + + // If we detect a lift gesture, we should transition back to not postured immediately. + triggerSensor(sensor) + assertThat(postured).isFalse() + } + + @Test + fun testSignificantMotion_afterSlidingWindow() = + kosmos.runTest { + val triggerSensor = stubSensorManager() + val sensor = asyncSensorManager.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION)!! + + val postured by collectLastValue(underTest.postured) + assertThat(postured).isFalse() + + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.Postured(confidence = 1f), + ) + ) + + advanceTimeBySlidingWindowAndRun() assertThat(postured).isTrue() + + // If we detect motion, we should transition back to not postured. + triggerSensor(sensor) + assertThat(postured).isFalse() + + advanceTimeBy(9999.hours) + assertThat(postured).isFalse() } @Test @@ -55,12 +230,51 @@ class PosturingInteractorTest : SysuiTestCase() { assertThat(postured).isFalse() underTest.setValueForDebug(PosturedState.NotPostured) - posturingRepository.fake.setPosturedState(PosturedState.Postured(1f)) + posturingRepository.fake.emitPositionState( + PositionState( + stationary = PositionState.StationaryState.Stationary(confidence = 1f), + orientation = PositionState.OrientationState.Postured(confidence = 1f), + ) + ) // Repository value is overridden by debug value assertThat(postured).isFalse() underTest.setValueForDebug(PosturedState.Unknown) + + advanceTimeBySlidingWindowAndRun() assertThat(postured).isTrue() } + + private fun Kosmos.stubSensorManager(): (sensor: Sensor) -> Unit { + val callbacks = mutableMapOf<Sensor, List<TriggerEventListener>>() + val pickupSensor = mock<Sensor>() + val motionSensor = mock<Sensor>() + + asyncSensorManager.stub { + on { getDefaultSensor(Sensor.TYPE_PICK_UP_GESTURE) } doReturn pickupSensor + on { getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION) } doReturn motionSensor + on { requestTriggerSensor(any(), any()) } doAnswer + { + val callback = it.arguments[0] as TriggerEventListener + val sensor = it.arguments[1] as Sensor + callbacks[sensor] = callbacks.getOrElse(sensor) { emptyList() } + callback + true + } + on { cancelTriggerSensor(any(), any()) } doAnswer + { + val callback = it.arguments[0] as TriggerEventListener + val sensor = it.arguments[1] as Sensor + callbacks[sensor] = callbacks.getOrElse(sensor) { emptyList() } - callback + true + } + } + + return { sensor: Sensor -> + val list = callbacks.getOrElse(sensor) { emptyList() } + // Simulate a trigger sensor which unregisters callbacks after triggering. + callbacks[sensor] = emptyList() + list.forEach { it.onTrigger(mock()) } + } + } } diff --git a/packages/SystemUI/src/com/android/systemui/communal/DevicePosturingListener.kt b/packages/SystemUI/src/com/android/systemui/communal/DevicePosturingListener.kt index af8a5fa23ccb..8bfec0a5dac2 100644 --- a/packages/SystemUI/src/com/android/systemui/communal/DevicePosturingListener.kt +++ b/packages/SystemUI/src/com/android/systemui/communal/DevicePosturingListener.kt @@ -21,8 +21,11 @@ import android.app.DreamManager import android.service.dreams.Flags.allowDreamWhenPostured import com.android.app.tracing.coroutines.launchInTraced import com.android.systemui.CoreStartable +import com.android.systemui.common.domain.interactor.BatteryInteractor +import com.android.systemui.communal.domain.interactor.CommunalSettingsInteractor import com.android.systemui.communal.posturing.domain.interactor.PosturingInteractor import com.android.systemui.communal.posturing.shared.model.PosturedState +import com.android.systemui.communal.shared.model.WhenToDream import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.log.dagger.CommunalTableLog @@ -30,10 +33,14 @@ import com.android.systemui.log.table.TableLogBuffer import com.android.systemui.log.table.logDiffsForTable import com.android.systemui.statusbar.commandline.Command import com.android.systemui.statusbar.commandline.CommandRegistry +import com.android.systemui.util.kotlin.BooleanFlowOperators.allOf +import com.android.systemui.utils.coroutines.flow.flatMapLatestConflated import java.io.PrintWriter import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @SysUISingleton @@ -42,19 +49,36 @@ class DevicePosturingListener constructor( private val commandRegistry: CommandRegistry, private val dreamManager: DreamManager, - private val interactor: PosturingInteractor, + private val posturingInteractor: PosturingInteractor, + communalSettingsInteractor: CommunalSettingsInteractor, + batteryInteractor: BatteryInteractor, @Background private val bgScope: CoroutineScope, @CommunalTableLog private val tableLogBuffer: TableLogBuffer, ) : CoreStartable { private val command = DevicePosturingCommand() + // Only subscribe to posturing if applicable to avoid running the posturing CHRE nanoapp + // if posturing signal is not needed. + private val postured = + allOf( + batteryInteractor.isDevicePluggedIn, + communalSettingsInteractor.whenToDream.map { it == WhenToDream.WHILE_POSTURED }, + ) + .flatMapLatestConflated { shouldListen -> + if (shouldListen) { + posturingInteractor.postured + } else { + flowOf(false) + } + } + @SuppressLint("MissingPermission") override fun start() { if (!allowDreamWhenPostured()) { return } - interactor.postured + postured .distinctUntilChanged() .logDiffsForTable( tableLogBuffer = tableLogBuffer, @@ -78,7 +102,7 @@ constructor( val state = when (arg.lowercase()) { - "true" -> PosturedState.Postured(confidence = 1f) + "true" -> PosturedState.Postured "false" -> PosturedState.NotPostured "clear" -> PosturedState.Unknown else -> { @@ -87,7 +111,7 @@ constructor( null } } - state?.let { interactor.setValueForDebug(it) } + state?.let { posturingInteractor.setValueForDebug(it) } } override fun help(pw: PrintWriter) { diff --git a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalAutoOpenInteractor.kt b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalAutoOpenInteractor.kt index 20bfabdc5fb9..678a5e2f6a1c 100644 --- a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalAutoOpenInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalAutoOpenInteractor.kt @@ -57,7 +57,15 @@ constructor( allOf(batteryInteractor.isDevicePluggedIn, dockManager.retrieveIsDocked()) } WhenToStartHub.WHILE_CHARGING_AND_POSTURED -> { - allOf(batteryInteractor.isDevicePluggedIn, posturingInteractor.postured) + // Only listen to posturing if applicable to avoid running the posturing + // CHRE nanoapp when not needed. + batteryInteractor.isDevicePluggedIn.flatMapLatestConflated { isCharging -> + if (isCharging) { + posturingInteractor.postured + } else { + flowOf(false) + } + } } WhenToStartHub.NEVER -> flowOf(false) } diff --git a/packages/SystemUI/src/com/android/systemui/communal/posturing/data/model/PositionState.kt b/packages/SystemUI/src/com/android/systemui/communal/posturing/data/model/PositionState.kt new file mode 100644 index 000000000000..21b8dd785f53 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/posturing/data/model/PositionState.kt @@ -0,0 +1,48 @@ +/* + * 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.communal.posturing.data.model + +import androidx.annotation.FloatRange + +data class PositionState( + val stationary: StationaryState = StationaryState.Unknown, + val orientation: OrientationState = OrientationState.Unknown, +) { + sealed interface StationaryState { + @get:FloatRange(from = 0.0, to = 1.0) val confidence: Float + + data object Unknown : StationaryState { + override val confidence: Float = 0f + } + + data class Stationary(override val confidence: Float) : StationaryState + + data class NotStationary(override val confidence: Float) : StationaryState + } + + sealed interface OrientationState { + @get:FloatRange(from = 0.0, to = 1.0) val confidence: Float + + data object Unknown : OrientationState { + override val confidence: Float = 0f + } + + data class Postured(override val confidence: Float) : OrientationState + + data class NotPostured(override val confidence: Float) : OrientationState + } +} diff --git a/packages/SystemUI/src/com/android/systemui/communal/posturing/data/repository/NoOpPosturingRepository.kt b/packages/SystemUI/src/com/android/systemui/communal/posturing/data/repository/NoOpPosturingRepository.kt index c5f357f556ca..d826685886d9 100644 --- a/packages/SystemUI/src/com/android/systemui/communal/posturing/data/repository/NoOpPosturingRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/communal/posturing/data/repository/NoOpPosturingRepository.kt @@ -16,7 +16,7 @@ package com.android.systemui.communal.posturing.data.repository -import com.android.systemui.communal.posturing.shared.model.PosturedState +import com.android.systemui.communal.posturing.data.model.PositionState import com.android.systemui.dagger.SysUISingleton import javax.inject.Inject import kotlinx.coroutines.flow.Flow @@ -25,6 +25,6 @@ import kotlinx.coroutines.flow.asStateFlow @SysUISingleton class NoOpPosturingRepository @Inject constructor() : PosturingRepository { - override val posturedState: Flow<PosturedState> = - MutableStateFlow(PosturedState.Unknown).asStateFlow() + override val positionState: Flow<PositionState> = + MutableStateFlow(PositionState()).asStateFlow() } diff --git a/packages/SystemUI/src/com/android/systemui/communal/posturing/data/repository/PosturingRepository.kt b/packages/SystemUI/src/com/android/systemui/communal/posturing/data/repository/PosturingRepository.kt index dae1a47f5be0..4de0a1e21d35 100644 --- a/packages/SystemUI/src/com/android/systemui/communal/posturing/data/repository/PosturingRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/communal/posturing/data/repository/PosturingRepository.kt @@ -16,7 +16,7 @@ package com.android.systemui.communal.posturing.data.repository -import com.android.systemui.communal.posturing.shared.model.PosturedState +import com.android.systemui.communal.posturing.data.model.PositionState import kotlinx.coroutines.flow.Flow /** @@ -25,5 +25,5 @@ import kotlinx.coroutines.flow.Flow */ interface PosturingRepository { /** Whether the device is currently stationary and upright. */ - val posturedState: Flow<PosturedState> + val positionState: Flow<PositionState> } diff --git a/packages/SystemUI/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractor.kt b/packages/SystemUI/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractor.kt index cd81dea9cad1..e487590d87d7 100644 --- a/packages/SystemUI/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractor.kt @@ -16,26 +16,211 @@ package com.android.systemui.communal.posturing.domain.interactor +import android.annotation.SuppressLint +import android.hardware.Sensor +import android.hardware.TriggerEvent +import android.hardware.TriggerEventListener +import android.service.dreams.Flags.allowDreamWhenPostured +import com.android.systemui.communal.posturing.data.model.PositionState import com.android.systemui.communal.posturing.data.repository.PosturingRepository import com.android.systemui.communal.posturing.shared.model.PosturedState import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.log.LogBuffer +import com.android.systemui.log.core.Logger +import com.android.systemui.log.dagger.CommunalLog +import com.android.systemui.util.kotlin.BooleanFlowOperators.allOf +import com.android.systemui.util.kotlin.slidingWindow +import com.android.systemui.util.sensors.AsyncSensorManager +import com.android.systemui.util.time.SystemClock +import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn @SysUISingleton -class PosturingInteractor @Inject constructor(repository: PosturingRepository) { - private val debugPostured = MutableStateFlow<PosturedState>(PosturedState.Unknown) +class PosturingInteractor +@Inject +constructor( + repository: PosturingRepository, + private val asyncSensorManager: AsyncSensorManager, + @Application private val applicationScope: CoroutineScope, + @Background private val bgDispatcher: CoroutineDispatcher, + @CommunalLog private val logBuffer: LogBuffer, + clock: SystemClock, +) { + private val logger = Logger(logBuffer, TAG) - val postured: Flow<Boolean> = - combine(repository.posturedState, debugPostured) { postured, debugValue -> - debugValue.asBoolean() ?: postured.asBoolean() ?: false - } + private val debugPostured = MutableStateFlow<PosturedState>(PosturedState.Unknown) fun setValueForDebug(value: PosturedState) { debugPostured.value = value } + + /** + * Detects whether or not the device is stationary, applying a sliding window smoothing + * algorithm. + */ + private val stationarySmoothed: Flow<Boolean> = + merge( + observeTriggerSensor(Sensor.TYPE_PICK_UP_GESTURE) + // If pickup detected, avoid triggering posturing at all within the sliding + // window by emitting a negative infinity value. + .map { Float.NEGATIVE_INFINITY } + .onEach { logger.i("pickup gesture detected") }, + observeTriggerSensor(Sensor.TYPE_SIGNIFICANT_MOTION) + // If motion detected, avoid triggering posturing at all within the sliding + // window by emitting a negative infinity value. + .map { Float.NEGATIVE_INFINITY } + .onEach { logger.i("significant motion detected") }, + repository.positionState + .map { it.stationary } + .filterNot { it is PositionState.StationaryState.Unknown } + .map { stationaryState -> + if (stationaryState is PositionState.StationaryState.Stationary) { + stationaryState.confidence + } else { + // If not stationary, then we should effectively disable posturing by + // emitting the lowest possible confidence. + Float.NEGATIVE_INFINITY + } + }, + ) + .slidingWindow(SLIDING_WINDOW_DURATION, clock) + .filterNot { it.isEmpty() } + .map { window -> + val avgStationaryConfidence = window.average() + logger.i({ "stationary confidence: $double1 | window: $str1" }) { + str1 = window.formatWindowForDebugging() + double1 = avgStationaryConfidence + } + avgStationaryConfidence > CONFIDENCE_THRESHOLD + } + + /** + * Detects whether or not the device is in an upright orientation, applying a sliding window + * smoothing algorithm. + */ + private val orientationSmoothed: Flow<Boolean> = + repository.positionState + .map { it.orientation } + .filterNot { it is PositionState.OrientationState.Unknown } + .map { orientationState -> + if (orientationState is PositionState.OrientationState.Postured) { + orientationState.confidence + } else { + // If not postured, then we should effectively disable posturing by + // emitting the lowest possible confidence. + Float.NEGATIVE_INFINITY + } + } + .slidingWindow(SLIDING_WINDOW_DURATION, clock) + .filterNot { it.isEmpty() } + .map { window -> + val avgOrientationConfidence = window.average() + logger.i({ "orientation confidence: $double1 | window: $str1" }) { + str1 = window.formatWindowForDebugging() + double1 = avgOrientationConfidence + } + avgOrientationConfidence > CONFIDENCE_THRESHOLD + } + + /** + * Posturing is composed of the device being stationary and in the correct orientation. If both + * conditions are met, then consider it postured. + */ + private val posturedSmoothed: Flow<PosturedState> = + allOf(stationarySmoothed, orientationSmoothed) + .map { postured -> + if (postured) { + PosturedState.Postured + } else { + PosturedState.NotPostured + } + } + .flowOn(bgDispatcher) + .stateIn( + scope = applicationScope, + // Avoid losing the smoothing history if the user plug/unplugs rapidly. + started = + SharingStarted.WhileSubscribed( + stopTimeoutMillis = STOP_TIMEOUT_AFTER_UNSUBSCRIBE.inWholeMilliseconds, + replayExpirationMillis = 0, + ), + initialValue = PosturedState.Unknown, + ) + + /** + * Whether the device is postured. + * + * NOTE: Due to smoothing, this signal may be delayed to ensure we have a stable reading before + * being considered postured. + */ + val postured: Flow<Boolean> by lazy { + if (allowDreamWhenPostured()) { + combine(posturedSmoothed, debugPostured) { postured, debugValue -> + debugValue.asBoolean() ?: postured.asBoolean() ?: false + } + } else { + MutableStateFlow(false) + } + } + + /** + * Helper for observing a trigger sensor, which automatically unregisters itself after it + * executes once. + */ + private fun observeTriggerSensor(type: Int): Flow<Unit> = conflatedCallbackFlow { + val sensor = asyncSensorManager.getDefaultSensor(type) + val isRegistered = AtomicBoolean(false) + + fun registerCallbackInternal(callback: TriggerEventListener) { + if (isRegistered.compareAndSet(false, true)) { + asyncSensorManager.requestTriggerSensor(callback, sensor) + } + } + + val callback = + object : TriggerEventListener() { + override fun onTrigger(event: TriggerEvent) { + trySend(Unit) + if (isRegistered.getAndSet(false)) { + registerCallbackInternal(this) + } + } + } + + if (sensor != null) { + registerCallbackInternal(callback) + } + + awaitClose { + if (isRegistered.getAndSet(false)) { + asyncSensorManager.cancelTriggerSensor(callback, sensor) + } + } + } + + companion object { + const val TAG = "PosturingInteractor" + val SLIDING_WINDOW_DURATION = 10.seconds + const val CONFIDENCE_THRESHOLD = 0.8f + val STOP_TIMEOUT_AFTER_UNSUBSCRIBE = 5.seconds + } } fun PosturedState.asBoolean(): Boolean? { @@ -45,3 +230,8 @@ fun PosturedState.asBoolean(): Boolean? { PosturedState.Unknown -> null } } + +@SuppressLint("DefaultLocale") +fun List<Float>.formatWindowForDebugging(): String { + return joinToString(prefix = "[", postfix = "]") { String.format("%.2f", it) } +} diff --git a/packages/SystemUI/src/com/android/systemui/communal/posturing/shared/model/PosturedState.kt b/packages/SystemUI/src/com/android/systemui/communal/posturing/shared/model/PosturedState.kt index 431ca67315eb..c71cf14c4b52 100644 --- a/packages/SystemUI/src/com/android/systemui/communal/posturing/shared/model/PosturedState.kt +++ b/packages/SystemUI/src/com/android/systemui/communal/posturing/shared/model/PosturedState.kt @@ -18,7 +18,7 @@ package com.android.systemui.communal.posturing.shared.model sealed interface PosturedState { /** Represents postured state */ - data class Postured(val confidence: Float) : PosturedState + data object Postured : PosturedState /** Represents unknown/uninitialized state */ data object Unknown : PosturedState diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/posturing/data/repository/FakePosturingRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/posturing/data/repository/FakePosturingRepository.kt index 8a597a61ee78..47b1bf52d8e2 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/posturing/data/repository/FakePosturingRepository.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/posturing/data/repository/FakePosturingRepository.kt @@ -16,18 +16,18 @@ package com.android.systemui.communal.posturing.data.repository -import com.android.systemui.communal.posturing.shared.model.PosturedState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import com.android.systemui.communal.posturing.data.model.PositionState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow class FakePosturingRepository : PosturingRepository { - private val _postured = MutableStateFlow<PosturedState>(PosturedState.Unknown) + private val _postured = MutableSharedFlow<PositionState>() - override val posturedState: StateFlow<PosturedState> = _postured.asStateFlow() + override val positionState: Flow<PositionState> = _postured.asSharedFlow() - fun setPosturedState(state: PosturedState) { - _postured.value = state + suspend fun emitPositionState(state: PositionState) { + _postured.emit(state) } } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractorKosmos.kt index 53c9c6440c69..792346ebce59 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractorKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/posturing/domain/interactor/PosturingInteractorKosmos.kt @@ -18,6 +18,27 @@ package com.android.systemui.communal.posturing.domain.interactor import com.android.systemui.communal.posturing.data.repository.posturingRepository import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.advanceTimeBy +import com.android.systemui.kosmos.applicationCoroutineScope +import com.android.systemui.kosmos.runCurrent +import com.android.systemui.kosmos.testDispatcher +import com.android.systemui.log.logcatLogBuffer +import com.android.systemui.util.sensors.asyncSensorManager +import com.android.systemui.util.time.systemClock val Kosmos.posturingInteractor by - Kosmos.Fixture<PosturingInteractor> { PosturingInteractor(repository = posturingRepository) } + Kosmos.Fixture<PosturingInteractor> { + PosturingInteractor( + repository = posturingRepository, + asyncSensorManager = asyncSensorManager, + applicationScope = applicationCoroutineScope, + bgDispatcher = testDispatcher, + logBuffer = logcatLogBuffer("PosturingInteractor"), + clock = systemClock, + ) + } + +fun Kosmos.advanceTimeBySlidingWindowAndRun() { + advanceTimeBy(PosturingInteractor.SLIDING_WINDOW_DURATION) + runCurrent() +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/GeneralKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/GeneralKosmos.kt index 044332981bf8..ae28022e096e 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/GeneralKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/GeneralKosmos.kt @@ -25,6 +25,7 @@ import com.android.systemui.coroutines.collectLastValue import com.android.systemui.coroutines.collectValues import com.android.systemui.kosmos.Kosmos.Fixture import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -32,6 +33,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import org.mockito.kotlin.verify @@ -72,6 +74,8 @@ fun Kosmos.runTest(testBody: suspend Kosmos.() -> Unit) = let { kosmos -> fun Kosmos.runCurrent() = testScope.runCurrent() +fun Kosmos.advanceTimeBy(duration: Duration) = testScope.advanceTimeBy(duration) + fun <T> Kosmos.collectLastValue(flow: Flow<T>) = testScope.collectLastValue(flow) fun <T> Kosmos.collectValues(flow: Flow<T>): FlowValue<List<T>> = testScope.collectValues(flow) |