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

Commit 76a10167 authored by Caitlin Shkuratov's avatar Caitlin Shkuratov
Browse files

[SB][Screen Chips] Always maintain media projection callback order.

This fixes b/352483752. In this bug, an #onRecordingSessionSet event
came in, and then an #onStop event came in very soon after. Because
onRecordingSessionSet has to do some background work before emitting but
onStop doesn't, the repository was actually emitting the stopped event
*first* and the session event *second*, even though the callback events
came in the opposite order.

This CL instead has all callback events immediately emit, and then uses
a `mapLatest` to perform that extra background work for
onRecordingSessionSet. The `mapLatest` will ensure that if an onStop
event comes in while we're still performing background work for
onRecordingSessionSet, that background work will get cancelled and only
the stop event will be emitted.

Fixes: 352483752
Bug: 332662551

Flag: com.android.systemui.status_bar_screen_sharing_chips
Flag: com.android.systemui.pss_task_switcher

(Note on flag stanzas: This repository is used by two features, each
guarded by one of those flags)

Test: atest MediaProjectionManagerRepositoryTest (verified new test
failed before changes, and passes after)

Change-Id: I15d09beec744515c09a49122d4ece37cc4007ca9
parent 5df43f2f
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()
}