Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 4921308e authored by Caitlin Shkuratov's avatar Caitlin Shkuratov Committed by Android (Google) Code Review
Browse files

Merge "[SB][Screen Chips] Always maintain media projection callback order." into main

parents de9439d7 76a10167
Loading
Loading
Loading
Loading
+45 −6
Original line number Diff line number Diff line
@@ -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(
@@ -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(
@@ -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,
@@ -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"
    }
+48 −0
Original line number Diff line number Diff line
@@ -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
@@ -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
@@ -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 {
+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()
}