diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/taskswitcher/MediaProjectionTaskSwitcherModule.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/taskswitcher/MediaProjectionTaskSwitcherModule.kt index 478e717bad61e..22ad07ebc3b16 100644 --- a/packages/SystemUI/src/com/android/systemui/mediaprojection/taskswitcher/MediaProjectionTaskSwitcherModule.kt +++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/taskswitcher/MediaProjectionTaskSwitcherModule.kt @@ -17,8 +17,8 @@ package com.android.systemui.mediaprojection.taskswitcher import com.android.systemui.mediaprojection.taskswitcher.data.repository.ActivityTaskManagerTasksRepository +import com.android.systemui.mediaprojection.taskswitcher.data.repository.MediaProjectionManagerRepository import com.android.systemui.mediaprojection.taskswitcher.data.repository.MediaProjectionRepository -import com.android.systemui.mediaprojection.taskswitcher.data.repository.NoOpMediaProjectionRepository import com.android.systemui.mediaprojection.taskswitcher.data.repository.TasksRepository import dagger.Binds import dagger.Module @@ -26,7 +26,7 @@ import dagger.Module @Module interface MediaProjectionTaskSwitcherModule { - @Binds fun mediaRepository(impl: NoOpMediaProjectionRepository): MediaProjectionRepository + @Binds fun mediaRepository(impl: MediaProjectionManagerRepository): MediaProjectionRepository @Binds fun tasksRepository(impl: ActivityTaskManagerTasksRepository): TasksRepository } diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/MediaProjectionManagerRepository.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/MediaProjectionManagerRepository.kt new file mode 100644 index 0000000000000..38d4e698f2d93 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/MediaProjectionManagerRepository.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2023 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.media.projection.MediaProjectionInfo +import android.media.projection.MediaProjectionManager +import android.os.Handler +import android.util.Log +import android.view.ContentRecordingSession +import android.view.ContentRecordingSession.RECORD_CONTENT_DISPLAY +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.mediaprojection.taskswitcher.data.model.MediaProjectionState +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch + +@SysUISingleton +class MediaProjectionManagerRepository +@Inject +constructor( + private val mediaProjectionManager: MediaProjectionManager, + @Main private val handler: Handler, + @Application private val applicationScope: CoroutineScope, + private val tasksRepository: TasksRepository, +) : MediaProjectionRepository { + + override val mediaProjectionState: Flow = + conflatedCallbackFlow { + val callback = + object : MediaProjectionManager.Callback() { + override fun onStart(info: MediaProjectionInfo?) { + Log.d(TAG, "MediaProjectionManager.Callback#onStart") + trySendWithFailureLogging(MediaProjectionState.NotProjecting, TAG) + } + + override fun onStop(info: MediaProjectionInfo?) { + Log.d(TAG, "MediaProjectionManager.Callback#onStop") + trySendWithFailureLogging(MediaProjectionState.NotProjecting, TAG) + } + + override fun onRecordingSessionSet( + info: MediaProjectionInfo, + session: ContentRecordingSession? + ) { + Log.d(TAG, "MediaProjectionManager.Callback#onSessionStarted: $session") + launch { trySendWithFailureLogging(stateForSession(session), TAG) } + } + } + mediaProjectionManager.addCallback(callback, handler) + awaitClose { mediaProjectionManager.removeCallback(callback) } + } + .shareIn(scope = applicationScope, started = SharingStarted.Lazily, replay = 1) + + private suspend fun stateForSession(session: ContentRecordingSession?): MediaProjectionState { + if (session == null) { + return MediaProjectionState.NotProjecting + } + if (session.contentToRecord == RECORD_CONTENT_DISPLAY || session.tokenToRecord == null) { + return MediaProjectionState.EntireScreen + } + val matchingTask = + tasksRepository.findRunningTaskFromWindowContainerToken(session.tokenToRecord) + ?: return MediaProjectionState.EntireScreen + return MediaProjectionState.SingleTask(matchingTask) + } + + companion object { + private const val TAG = "MediaProjectionMngrRepo" + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/FakeTasksRepository.kt b/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/FakeTasksRepository.kt new file mode 100644 index 0000000000000..593e3893fb2a2 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/FakeTasksRepository.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2023 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.RunningTaskInfo +import android.content.Intent +import android.os.IBinder +import android.window.IWindowContainerToken +import android.window.WindowContainerToken +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeTasksRepository : TasksRepository { + + private val _foregroundTask = MutableStateFlow(DEFAULT_TASK) + + override val foregroundTask: Flow = _foregroundTask.asStateFlow() + + private val runningTasks = mutableListOf(DEFAULT_TASK) + + override suspend fun findRunningTaskFromWindowContainerToken( + windowContainerToken: IBinder + ): RunningTaskInfo? = runningTasks.firstOrNull { it.token.asBinder() == windowContainerToken } + + fun addRunningTask(task: RunningTaskInfo) { + runningTasks.add(task) + } + + fun moveTaskToForeground(task: RunningTaskInfo) { + _foregroundTask.value = task + } + + companion object { + val DEFAULT_TASK = createTask(taskId = -1) + val LAUNCHER_INTENT: Intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME) + + fun createTask( + taskId: Int, + token: WindowContainerToken = createToken(), + baseIntent: Intent = Intent() + ) = + RunningTaskInfo().apply { + this.taskId = taskId + this.token = token + this.baseIntent = baseIntent + } + + fun createToken(): WindowContainerToken { + val realToken = object : IWindowContainerToken.Stub() {} + return WindowContainerToken(realToken) + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/MediaProjectionManagerRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/MediaProjectionManagerRepositoryTest.kt new file mode 100644 index 0000000000000..2b074655bb027 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/taskswitcher/data/repository/MediaProjectionManagerRepositoryTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2023 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.media.projection.MediaProjectionInfo +import android.media.projection.MediaProjectionManager +import android.os.Binder +import android.os.Handler +import android.os.UserHandle +import android.testing.AndroidTestingRunner +import android.view.ContentRecordingSession +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.mediaprojection.taskswitcher.data.model.MediaProjectionState +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidTestingRunner::class) +@SmallTest +class MediaProjectionManagerRepositoryTest : SysuiTestCase() { + + private val mediaProjectionManager = mock() + + private val dispatcher = StandardTestDispatcher() + private val testScope = TestScope(dispatcher) + private val tasksRepo = FakeTasksRepository() + + private lateinit var callback: MediaProjectionManager.Callback + private lateinit var repo: MediaProjectionManagerRepository + + @Before + fun setUp() { + whenever(mediaProjectionManager.addCallback(any(), any())).thenAnswer { + callback = it.arguments[0] as MediaProjectionManager.Callback + return@thenAnswer Unit + } + repo = + MediaProjectionManagerRepository( + mediaProjectionManager = mediaProjectionManager, + handler = Handler.getMain(), + applicationScope = testScope.backgroundScope, + tasksRepository = tasksRepo + ) + } + + @Test + fun mediaProjectionState_onStart_emitsNotProjecting() = + testScope.runTest { + val state by collectLastValue(repo.mediaProjectionState) + runCurrent() + + callback.onStart(TEST_MEDIA_INFO) + + assertThat(state).isEqualTo(MediaProjectionState.NotProjecting) + } + + @Test + fun mediaProjectionState_onStop_emitsNotProjecting() = + testScope.runTest { + val state by collectLastValue(repo.mediaProjectionState) + runCurrent() + + callback.onStop(TEST_MEDIA_INFO) + + assertThat(state).isEqualTo(MediaProjectionState.NotProjecting) + } + + @Test + fun mediaProjectionState_onSessionSet_sessionNull_emitsNotProjecting() = + testScope.runTest { + val state by collectLastValue(repo.mediaProjectionState) + runCurrent() + + callback.onRecordingSessionSet(TEST_MEDIA_INFO, /* session= */ null) + + assertThat(state).isEqualTo(MediaProjectionState.NotProjecting) + } + + @Test + fun mediaProjectionState_onSessionSet_contentToRecordDisplay_emitsEntireScreen() = + testScope.runTest { + val state by collectLastValue(repo.mediaProjectionState) + runCurrent() + + val session = ContentRecordingSession.createDisplaySession(/* displayToMirror= */ 123) + callback.onRecordingSessionSet(TEST_MEDIA_INFO, session) + + assertThat(state).isEqualTo(MediaProjectionState.EntireScreen) + } + + @Test + fun mediaProjectionState_onSessionSet_tokenNull_emitsEntireScreen() = + testScope.runTest { + val state by collectLastValue(repo.mediaProjectionState) + runCurrent() + + val session = + ContentRecordingSession.createTaskSession(/* taskWindowContainerToken= */ null) + callback.onRecordingSessionSet(TEST_MEDIA_INFO, session) + + assertThat(state).isEqualTo(MediaProjectionState.EntireScreen) + } + + @Test + fun mediaProjectionState_sessionSet_taskWithToken_noMatchingRunningTask_emitsEntireScreen() = + testScope.runTest { + val state by collectLastValue(repo.mediaProjectionState) + runCurrent() + + val taskWindowContainerToken = Binder() + val session = ContentRecordingSession.createTaskSession(taskWindowContainerToken) + callback.onRecordingSessionSet(TEST_MEDIA_INFO, session) + + assertThat(state).isEqualTo(MediaProjectionState.EntireScreen) + } + + @Test + fun mediaProjectionState_sessionSet_taskWithToken_matchingRunningTask_emitsSingleTask() = + testScope.runTest { + val token = FakeTasksRepository.createToken() + val task = FakeTasksRepository.createTask(taskId = 1, token = token) + tasksRepo.addRunningTask(task) + val state by collectLastValue(repo.mediaProjectionState) + runCurrent() + + val session = ContentRecordingSession.createTaskSession(token.asBinder()) + callback.onRecordingSessionSet(TEST_MEDIA_INFO, session) + + assertThat(state).isEqualTo(MediaProjectionState.SingleTask(task)) + } + + companion object { + val TEST_MEDIA_INFO = + MediaProjectionInfo(/* packageName= */ "com.test.package", UserHandle.CURRENT) + } +}