Loading packages/SystemUI/src/com/android/systemui/mediaprojection/data/repository/MediaProjectionManagerRepository.kt +45 −6 Original line number Diff line number Diff line Loading @@ -36,14 +36,16 @@ import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @SysUISingleton @OptIn(ExperimentalCoroutinesApi::class) class MediaProjectionManagerRepository @Inject constructor( Loading Loading @@ -76,12 +78,12 @@ constructor( object : MediaProjectionManager.Callback() { override fun onStart(info: MediaProjectionInfo?) { Log.d(TAG, "MediaProjectionManager.Callback#onStart") trySendWithFailureLogging(MediaProjectionState.NotProjecting, TAG) trySendWithFailureLogging(CallbackEvent.OnStart, TAG) } override fun onStop(info: MediaProjectionInfo?) { Log.d(TAG, "MediaProjectionManager.Callback#onStop") trySendWithFailureLogging(MediaProjectionState.NotProjecting, TAG) trySendWithFailureLogging(CallbackEvent.OnStop, TAG) } override fun onRecordingSessionSet( Loading @@ -89,14 +91,36 @@ constructor( session: ContentRecordingSession? ) { Log.d(TAG, "MediaProjectionManager.Callback#onSessionStarted: $session") launch { trySendWithFailureLogging(stateForSession(info, session), TAG) } trySendWithFailureLogging( CallbackEvent.OnRecordingSessionSet(info, session), TAG, ) } } mediaProjectionManager.addCallback(callback, handler) awaitClose { mediaProjectionManager.removeCallback(callback) } } // When we get an #onRecordingSessionSet event, we need to do some work in the // background before emitting the right state value. But when we get an #onStop // event, we immediately know what state value to emit. // // Without `mapLatest`, this could be a problem if an #onRecordingSessionSet event // comes in and then an #onStop event comes in shortly afterwards (b/352483752): // 1. #onRecordingSessionSet -> start some work in the background // 2. #onStop -> immediately emit "Not Projecting" // 3. onRecordingSessionSet work finishes -> emit "Projecting" // // At step 3, we *shouldn't* emit "Projecting" because #onStop was the last callback // event we received, so we should be "Not Projecting". This `mapLatest` ensures // that if an #onStop event comes in, we cancel any ongoing work for // #onRecordingSessionSet and we don't emit "Projecting". .mapLatest { when (it) { is CallbackEvent.OnStart, is CallbackEvent.OnStop -> MediaProjectionState.NotProjecting is CallbackEvent.OnRecordingSessionSet -> stateForSession(it.info, it.session) } } .stateIn( scope = applicationScope, started = SharingStarted.Lazily, Loading Loading @@ -129,6 +153,21 @@ constructor( return MediaProjectionState.Projecting.SingleTask(hostPackage, hostDeviceName, matchingTask) } /** * Translates [MediaProjectionManager.Callback] events into objects so that we always maintain * the correct callback ordering. */ sealed interface CallbackEvent { data object OnStart : CallbackEvent data object OnStop : CallbackEvent data class OnRecordingSessionSet( val info: MediaProjectionInfo, val session: ContentRecordingSession?, ) : CallbackEvent } companion object { private const val TAG = "MediaProjectionMngrRepo" } Loading packages/SystemUI/tests/src/com/android/systemui/mediaprojection/data/repository/MediaProjectionManagerRepositoryTest.kt +48 −0 Original line number Diff line number Diff line Loading @@ -19,6 +19,7 @@ package com.android.systemui.mediaprojection.data.repository import android.hardware.display.displayManager import android.media.projection.MediaProjectionInfo import android.os.Binder import android.os.Handler import android.os.UserHandle import android.view.ContentRecordingSession import android.view.Display Loading @@ -26,11 +27,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue import com.android.systemui.kosmos.applicationCoroutineScope import com.android.systemui.kosmos.testDispatcher import com.android.systemui.kosmos.testScope import com.android.systemui.mediaprojection.data.model.MediaProjectionState import com.android.systemui.mediaprojection.taskswitcher.FakeActivityTaskManager.Companion.createTask import com.android.systemui.mediaprojection.taskswitcher.FakeActivityTaskManager.Companion.createToken import com.android.systemui.mediaprojection.taskswitcher.FakeMediaProjectionManager.Companion.createDisplaySession import com.android.systemui.mediaprojection.taskswitcher.data.repository.FakeTasksRepository import com.android.systemui.mediaprojection.taskswitcher.fakeActivityTaskManager import com.android.systemui.mediaprojection.taskswitcher.fakeMediaProjectionManager import com.android.systemui.mediaprojection.taskswitcher.taskSwitcherKosmos Loading Loading @@ -253,6 +257,50 @@ class MediaProjectionManagerRepositoryTest : SysuiTestCase() { .isNull() } /** Regression test for b/352483752. */ @Test fun mediaProjectionState_sessionStartedThenImmediatelyStopped_emitsOnlyNotProjecting() = testScope.runTest { val fakeTasksRepo = FakeTasksRepository() val repoWithTimingControl = MediaProjectionManagerRepository( // fakeTasksRepo lets us have control over when the background dispatcher // finishes fetching the tasks info. tasksRepository = fakeTasksRepo, mediaProjectionManager = fakeMediaProjectionManager.mediaProjectionManager, displayManager = displayManager, handler = Handler.getMain(), applicationScope = kosmos.applicationCoroutineScope, backgroundDispatcher = kosmos.testDispatcher, mediaProjectionServiceHelper = fakeMediaProjectionManager.helper, ) val state by collectLastValue(repoWithTimingControl.mediaProjectionState) val token = createToken() val task = createTask(taskId = 1, token = token) // Dispatch a session using a task session so that MediaProjectionManagerRepository // has to ask TasksRepository for the tasks info. fakeMediaProjectionManager.dispatchOnSessionSet( session = ContentRecordingSession.createTaskSession(token.asBinder()) ) // FakeTasksRepository is set up to not return the tasks info until the test manually // calls [FakeTasksRepository#setRunningTaskResult]. At this point, // MediaProjectionManagerRepository is waiting for the tasks info and hasn't emitted // anything yet. // Before the tasks info comes back, dispatch a stop event. fakeMediaProjectionManager.dispatchOnStop() // Then let the tasks info come back. fakeTasksRepo.setRunningTaskResult(task) // Verify that MediaProjectionManagerRepository threw away the tasks info because // a newer callback event (#onStop) occurred. assertThat(state).isEqualTo(MediaProjectionState.NotProjecting) } @Test fun stopProjecting_invokesManager() = testScope.runTest { Loading packages/SystemUI/tests/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/FakeTasksRepository.kt 0 → 100644 +46 −0 Original line number Diff line number Diff line /* * Copyright (C) 2024 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.mediaprojection.taskswitcher.data.repository import android.app.ActivityManager import android.os.IBinder import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow /** * Fake tasks repository that gives us fine-grained control over when the result of * [findRunningTaskFromWindowContainerToken] gets emitted. */ class FakeTasksRepository : TasksRepository { override suspend fun launchRecentTask(taskInfo: ActivityManager.RunningTaskInfo) {} private val findRunningTaskResult: CompletableDeferred<ActivityManager.RunningTaskInfo?> = CompletableDeferred() override suspend fun findRunningTaskFromWindowContainerToken( windowContainerToken: IBinder ): ActivityManager.RunningTaskInfo? { return findRunningTaskResult.await() } fun setRunningTaskResult(task: ActivityManager.RunningTaskInfo?) { findRunningTaskResult.complete(task) } override val foregroundTask: Flow<ActivityManager.RunningTaskInfo> = emptyFlow() } Loading
packages/SystemUI/src/com/android/systemui/mediaprojection/data/repository/MediaProjectionManagerRepository.kt +45 −6 Original line number Diff line number Diff line Loading @@ -36,14 +36,16 @@ import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @SysUISingleton @OptIn(ExperimentalCoroutinesApi::class) class MediaProjectionManagerRepository @Inject constructor( Loading Loading @@ -76,12 +78,12 @@ constructor( object : MediaProjectionManager.Callback() { override fun onStart(info: MediaProjectionInfo?) { Log.d(TAG, "MediaProjectionManager.Callback#onStart") trySendWithFailureLogging(MediaProjectionState.NotProjecting, TAG) trySendWithFailureLogging(CallbackEvent.OnStart, TAG) } override fun onStop(info: MediaProjectionInfo?) { Log.d(TAG, "MediaProjectionManager.Callback#onStop") trySendWithFailureLogging(MediaProjectionState.NotProjecting, TAG) trySendWithFailureLogging(CallbackEvent.OnStop, TAG) } override fun onRecordingSessionSet( Loading @@ -89,14 +91,36 @@ constructor( session: ContentRecordingSession? ) { Log.d(TAG, "MediaProjectionManager.Callback#onSessionStarted: $session") launch { trySendWithFailureLogging(stateForSession(info, session), TAG) } trySendWithFailureLogging( CallbackEvent.OnRecordingSessionSet(info, session), TAG, ) } } mediaProjectionManager.addCallback(callback, handler) awaitClose { mediaProjectionManager.removeCallback(callback) } } // When we get an #onRecordingSessionSet event, we need to do some work in the // background before emitting the right state value. But when we get an #onStop // event, we immediately know what state value to emit. // // Without `mapLatest`, this could be a problem if an #onRecordingSessionSet event // comes in and then an #onStop event comes in shortly afterwards (b/352483752): // 1. #onRecordingSessionSet -> start some work in the background // 2. #onStop -> immediately emit "Not Projecting" // 3. onRecordingSessionSet work finishes -> emit "Projecting" // // At step 3, we *shouldn't* emit "Projecting" because #onStop was the last callback // event we received, so we should be "Not Projecting". This `mapLatest` ensures // that if an #onStop event comes in, we cancel any ongoing work for // #onRecordingSessionSet and we don't emit "Projecting". .mapLatest { when (it) { is CallbackEvent.OnStart, is CallbackEvent.OnStop -> MediaProjectionState.NotProjecting is CallbackEvent.OnRecordingSessionSet -> stateForSession(it.info, it.session) } } .stateIn( scope = applicationScope, started = SharingStarted.Lazily, Loading Loading @@ -129,6 +153,21 @@ constructor( return MediaProjectionState.Projecting.SingleTask(hostPackage, hostDeviceName, matchingTask) } /** * Translates [MediaProjectionManager.Callback] events into objects so that we always maintain * the correct callback ordering. */ sealed interface CallbackEvent { data object OnStart : CallbackEvent data object OnStop : CallbackEvent data class OnRecordingSessionSet( val info: MediaProjectionInfo, val session: ContentRecordingSession?, ) : CallbackEvent } companion object { private const val TAG = "MediaProjectionMngrRepo" } Loading
packages/SystemUI/tests/src/com/android/systemui/mediaprojection/data/repository/MediaProjectionManagerRepositoryTest.kt +48 −0 Original line number Diff line number Diff line Loading @@ -19,6 +19,7 @@ package com.android.systemui.mediaprojection.data.repository import android.hardware.display.displayManager import android.media.projection.MediaProjectionInfo import android.os.Binder import android.os.Handler import android.os.UserHandle import android.view.ContentRecordingSession import android.view.Display Loading @@ -26,11 +27,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue import com.android.systemui.kosmos.applicationCoroutineScope import com.android.systemui.kosmos.testDispatcher import com.android.systemui.kosmos.testScope import com.android.systemui.mediaprojection.data.model.MediaProjectionState import com.android.systemui.mediaprojection.taskswitcher.FakeActivityTaskManager.Companion.createTask import com.android.systemui.mediaprojection.taskswitcher.FakeActivityTaskManager.Companion.createToken import com.android.systemui.mediaprojection.taskswitcher.FakeMediaProjectionManager.Companion.createDisplaySession import com.android.systemui.mediaprojection.taskswitcher.data.repository.FakeTasksRepository import com.android.systemui.mediaprojection.taskswitcher.fakeActivityTaskManager import com.android.systemui.mediaprojection.taskswitcher.fakeMediaProjectionManager import com.android.systemui.mediaprojection.taskswitcher.taskSwitcherKosmos Loading Loading @@ -253,6 +257,50 @@ class MediaProjectionManagerRepositoryTest : SysuiTestCase() { .isNull() } /** Regression test for b/352483752. */ @Test fun mediaProjectionState_sessionStartedThenImmediatelyStopped_emitsOnlyNotProjecting() = testScope.runTest { val fakeTasksRepo = FakeTasksRepository() val repoWithTimingControl = MediaProjectionManagerRepository( // fakeTasksRepo lets us have control over when the background dispatcher // finishes fetching the tasks info. tasksRepository = fakeTasksRepo, mediaProjectionManager = fakeMediaProjectionManager.mediaProjectionManager, displayManager = displayManager, handler = Handler.getMain(), applicationScope = kosmos.applicationCoroutineScope, backgroundDispatcher = kosmos.testDispatcher, mediaProjectionServiceHelper = fakeMediaProjectionManager.helper, ) val state by collectLastValue(repoWithTimingControl.mediaProjectionState) val token = createToken() val task = createTask(taskId = 1, token = token) // Dispatch a session using a task session so that MediaProjectionManagerRepository // has to ask TasksRepository for the tasks info. fakeMediaProjectionManager.dispatchOnSessionSet( session = ContentRecordingSession.createTaskSession(token.asBinder()) ) // FakeTasksRepository is set up to not return the tasks info until the test manually // calls [FakeTasksRepository#setRunningTaskResult]. At this point, // MediaProjectionManagerRepository is waiting for the tasks info and hasn't emitted // anything yet. // Before the tasks info comes back, dispatch a stop event. fakeMediaProjectionManager.dispatchOnStop() // Then let the tasks info come back. fakeTasksRepo.setRunningTaskResult(task) // Verify that MediaProjectionManagerRepository threw away the tasks info because // a newer callback event (#onStop) occurred. assertThat(state).isEqualTo(MediaProjectionState.NotProjecting) } @Test fun stopProjecting_invokesManager() = testScope.runTest { Loading
packages/SystemUI/tests/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/FakeTasksRepository.kt 0 → 100644 +46 −0 Original line number Diff line number Diff line /* * Copyright (C) 2024 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.mediaprojection.taskswitcher.data.repository import android.app.ActivityManager import android.os.IBinder import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow /** * Fake tasks repository that gives us fine-grained control over when the result of * [findRunningTaskFromWindowContainerToken] gets emitted. */ class FakeTasksRepository : TasksRepository { override suspend fun launchRecentTask(taskInfo: ActivityManager.RunningTaskInfo) {} private val findRunningTaskResult: CompletableDeferred<ActivityManager.RunningTaskInfo?> = CompletableDeferred() override suspend fun findRunningTaskFromWindowContainerToken( windowContainerToken: IBinder ): ActivityManager.RunningTaskInfo? { return findRunningTaskResult.await() } fun setRunningTaskResult(task: ActivityManager.RunningTaskInfo?) { findRunningTaskResult.complete(task) } override val foregroundTask: Flow<ActivityManager.RunningTaskInfo> = emptyFlow() }