Partial Screen Sharing: Task Switcher - Domain Layer implementation

Implements the domain layer:
- Defines model/state
- Implements the interactor that is responsible for knowing whether a
  task switch has happened, in the context of task projection
- Implements fake repositories to be used in unit tests

Bug: 286201261
Test: TaskSwitchInteractorTest.kt
Change-Id: I46ff1214c97a52e63893bc728c2fa191b08b7c6e
This commit is contained in:
Christian Göllner
2023-06-21 16:48:19 +02:00
committed by Chris Göllner
parent 92f65330d2
commit 6f22f3af64
6 changed files with 382 additions and 67 deletions

View File

@@ -0,0 +1,85 @@
/*
* 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.domain.interactor
import android.app.TaskInfo
import android.content.Intent
import android.util.Log
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.mediaprojection.taskswitcher.data.model.MediaProjectionState
import com.android.systemui.mediaprojection.taskswitcher.data.repository.MediaProjectionRepository
import com.android.systemui.mediaprojection.taskswitcher.data.repository.TasksRepository
import com.android.systemui.mediaprojection.taskswitcher.domain.model.TaskSwitchState
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
/** Interactor with logic related to task switching in the context of media projection. */
@OptIn(ExperimentalCoroutinesApi::class)
@SysUISingleton
class TaskSwitchInteractor
@Inject
constructor(
mediaProjectionRepository: MediaProjectionRepository,
private val tasksRepository: TasksRepository,
) {
/**
* Emits a stream of changes to the state of task switching, in the context of media projection.
*/
val taskSwitchChanges: Flow<TaskSwitchState> =
mediaProjectionRepository.mediaProjectionState.flatMapLatest { projectionState ->
Log.d(TAG, "MediaProjectionState -> $projectionState")
when (projectionState) {
is MediaProjectionState.SingleTask -> {
val projectedTask = projectionState.task
tasksRepository.foregroundTask.map { foregroundTask ->
if (hasForegroundTaskSwitched(projectedTask, foregroundTask)) {
TaskSwitchState.TaskSwitched(projectedTask, foregroundTask)
} else {
TaskSwitchState.TaskUnchanged
}
}
}
is MediaProjectionState.EntireScreen,
is MediaProjectionState.NotProjecting -> {
flowOf(TaskSwitchState.NotProjectingTask)
}
}
}
/**
* Returns whether tasks have been switched.
*
* Always returns `false` when launcher is in the foreground. The reason is that when going to
* recents to switch apps, launcher becomes the new foreground task, and we don't want to show
* the notification then.
*/
private fun hasForegroundTaskSwitched(projectedTask: TaskInfo, foregroundTask: TaskInfo) =
projectedTask.taskId != foregroundTask.taskId && !foregroundTask.isLauncher
private val TaskInfo.isLauncher
get() =
baseIntent.hasCategory(Intent.CATEGORY_HOME) && baseIntent.action == Intent.ACTION_MAIN
companion object {
private const val TAG = "TaskSwitchInteractor"
}
}

View File

@@ -0,0 +1,30 @@
/*
* 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.domain.model
import android.app.TaskInfo
/** Represents tha state of task switching in the context of single task media projection. */
sealed interface TaskSwitchState {
/** Currently no task is being projected. */
object NotProjectingTask : TaskSwitchState
/** The foreground task is the same as the task that is currently being projected. */
object TaskUnchanged : TaskSwitchState
/** The foreground task is a different one to the task it currently being projected. */
data class TaskSwitched(val projectedTask: TaskInfo, val foregroundTask: TaskInfo) :
TaskSwitchState
}

View File

@@ -16,67 +16,41 @@
package com.android.systemui.mediaprojection.taskswitcher.data.repository
import android.app.ActivityManager.RunningTaskInfo
import android.app.ActivityTaskManager
import android.app.TaskStackListener
import android.os.Binder
import android.testing.AndroidTestingRunner
import android.window.IWindowContainerToken
import android.window.WindowContainerToken
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.whenever
import com.android.systemui.mediaprojection.taskswitcher.data.repository.FakeActivityTaskManager.Companion.createTask
import com.android.systemui.mediaprojection.taskswitcher.data.repository.FakeActivityTaskManager.Companion.createToken
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.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.MockitoAnnotations
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidTestingRunner::class)
@SmallTest
class ActivityTaskManagerTasksRepositoryTest : SysuiTestCase() {
@Mock private lateinit var activityTaskManager: ActivityTaskManager
private val fakeActivityTaskManager = FakeActivityTaskManager()
private val dispatcher = StandardTestDispatcher()
private val dispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(dispatcher)
private lateinit var repo: ActivityTaskManagerTasksRepository
private lateinit var taskStackListener: TaskStackListener
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
whenever(activityTaskManager.registerTaskStackListener(any())).thenAnswer {
taskStackListener = it.arguments[0] as TaskStackListener
return@thenAnswer Unit
}
repo =
ActivityTaskManagerTasksRepository(
activityTaskManager,
applicationScope = testScope.backgroundScope,
backgroundDispatcher = dispatcher
)
}
private val repo =
ActivityTaskManagerTasksRepository(
activityTaskManager = fakeActivityTaskManager.activityTaskManager,
applicationScope = testScope.backgroundScope,
backgroundDispatcher = dispatcher
)
@Test
fun findRunningTaskFromWindowContainerToken_noMatch_returnsNull() {
whenever(activityTaskManager.getTasks(Integer.MAX_VALUE))
.thenReturn(
listOf(
createTaskInfo(newTaskId = 1, windowContainerToken = createToken()),
createTaskInfo(newTaskId = 2, windowContainerToken = createToken())
)
)
fakeActivityTaskManager.addRunningTasks(createTask(taskId = 1), createTask(taskId = 2))
testScope.runTest {
val matchingTask =
@@ -89,15 +63,12 @@ class ActivityTaskManagerTasksRepositoryTest : SysuiTestCase() {
@Test
fun findRunningTaskFromWindowContainerToken_matchingToken_returnsTaskInfo() {
val expectedToken = createToken()
val expectedTask = createTaskInfo(newTaskId = 1, windowContainerToken = expectedToken)
val expectedTask = createTask(taskId = 1, token = expectedToken)
whenever(activityTaskManager.getTasks(Integer.MAX_VALUE))
.thenReturn(
listOf(
createTaskInfo(newTaskId = 2, windowContainerToken = createToken()),
expectedTask
)
)
fakeActivityTaskManager.addRunningTasks(
createTask(taskId = 2),
expectedTask,
)
testScope.runTest {
val actualTask =
@@ -113,15 +84,14 @@ class ActivityTaskManagerTasksRepositoryTest : SysuiTestCase() {
fun foregroundTask_returnsStreamOfTasksMovedToFront() =
testScope.runTest {
val foregroundTask by collectLastValue(repo.foregroundTask)
runCurrent()
taskStackListener.onTaskMovedToFront(createTaskInfo(newTaskId = 1))
fakeActivityTaskManager.moveTaskToForeground(createTask(taskId = 1))
assertThat(foregroundTask?.taskId).isEqualTo(1)
taskStackListener.onTaskMovedToFront(createTaskInfo(newTaskId = 2))
fakeActivityTaskManager.moveTaskToForeground(createTask(taskId = 2))
assertThat(foregroundTask?.taskId).isEqualTo(2)
taskStackListener.onTaskMovedToFront(createTaskInfo(newTaskId = 3))
fakeActivityTaskManager.moveTaskToForeground(createTask(taskId = 3))
assertThat(foregroundTask?.taskId).isEqualTo(3)
}
@@ -129,26 +99,10 @@ class ActivityTaskManagerTasksRepositoryTest : SysuiTestCase() {
fun foregroundTask_lastValueIsCached() =
testScope.runTest {
val foregroundTaskA by collectLastValue(repo.foregroundTask)
runCurrent()
taskStackListener.onTaskMovedToFront(createTaskInfo(newTaskId = 1))
fakeActivityTaskManager.moveTaskToForeground(createTask(taskId = 1))
assertThat(foregroundTaskA?.taskId).isEqualTo(1)
val foregroundTaskB by collectLastValue(repo.foregroundTask)
assertThat(foregroundTaskB?.taskId).isEqualTo(1)
}
private fun createToken(): WindowContainerToken {
val realToken = object : IWindowContainerToken.Stub() {}
return WindowContainerToken(realToken)
}
private fun createTaskInfo(
windowContainerToken: WindowContainerToken = createToken(),
newTaskId: Int,
): RunningTaskInfo {
return RunningTaskInfo().apply {
token = windowContainerToken
taskId = newTaskId
}
}
}

View File

@@ -0,0 +1,77 @@
/*
* 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.app.ActivityTaskManager
import android.app.TaskStackListener
import android.content.Intent
import android.window.IWindowContainerToken
import android.window.WindowContainerToken
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
class FakeActivityTaskManager {
private val runningTasks = mutableListOf<RunningTaskInfo>()
private val taskTaskListeners = mutableListOf<TaskStackListener>()
val activityTaskManager = mock<ActivityTaskManager>()
init {
whenever(activityTaskManager.registerTaskStackListener(any())).thenAnswer {
taskTaskListeners += it.arguments[0] as TaskStackListener
return@thenAnswer Unit
}
whenever(activityTaskManager.unregisterTaskStackListener(any())).thenAnswer {
taskTaskListeners -= it.arguments[0] as TaskStackListener
return@thenAnswer Unit
}
whenever(activityTaskManager.getTasks(any())).thenAnswer {
val maxNumTasks = it.arguments[0] as Int
return@thenAnswer runningTasks.take(maxNumTasks)
}
}
fun moveTaskToForeground(task: RunningTaskInfo) {
taskTaskListeners.forEach { it.onTaskMovedToFront(task) }
}
fun addRunningTasks(vararg tasks: RunningTaskInfo) {
runningTasks += tasks
}
companion object {
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)
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* 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.TaskInfo
import com.android.systemui.mediaprojection.taskswitcher.data.model.MediaProjectionState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeMediaProjectionRepository : MediaProjectionRepository {
private val state = MutableStateFlow<MediaProjectionState>(MediaProjectionState.NotProjecting)
fun switchProjectedTask(newTask: TaskInfo) {
state.value = MediaProjectionState.SingleTask(newTask)
}
override val mediaProjectionState: Flow<MediaProjectionState> = state.asStateFlow()
fun projectEntireScreen() {
state.value = MediaProjectionState.EntireScreen
}
fun stopProjecting() {
state.value = MediaProjectionState.NotProjecting
}
}

View File

@@ -0,0 +1,127 @@
/*
* 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.domain.interactor
import android.content.Intent
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.mediaprojection.taskswitcher.data.repository.ActivityTaskManagerTasksRepository
import com.android.systemui.mediaprojection.taskswitcher.data.repository.FakeActivityTaskManager
import com.android.systemui.mediaprojection.taskswitcher.data.repository.FakeActivityTaskManager.Companion.createTask
import com.android.systemui.mediaprojection.taskswitcher.data.repository.FakeMediaProjectionRepository
import com.android.systemui.mediaprojection.taskswitcher.domain.model.TaskSwitchState
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidTestingRunner::class)
@SmallTest
class TaskSwitchInteractorTest : SysuiTestCase() {
private val dispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(dispatcher)
private val fakeActivityTaskManager = FakeActivityTaskManager()
private val mediaRepo = FakeMediaProjectionRepository()
private val tasksRepo =
ActivityTaskManagerTasksRepository(
activityTaskManager = fakeActivityTaskManager.activityTaskManager,
applicationScope = testScope.backgroundScope,
backgroundDispatcher = dispatcher
)
private val interactor = TaskSwitchInteractor(mediaRepo, tasksRepo)
@Test
fun taskSwitchChanges_notProjecting_foregroundTaskChange_emitsNotProjectingTask() =
testScope.runTest {
mediaRepo.stopProjecting()
val taskSwitchState by collectLastValue(interactor.taskSwitchChanges)
fakeActivityTaskManager.moveTaskToForeground(createTask(taskId = 1))
assertThat(taskSwitchState).isEqualTo(TaskSwitchState.NotProjectingTask)
}
@Test
fun taskSwitchChanges_projectingScreen_foregroundTaskChange_emitsNotProjectingTask() =
testScope.runTest {
mediaRepo.projectEntireScreen()
val taskSwitchState by collectLastValue(interactor.taskSwitchChanges)
fakeActivityTaskManager.moveTaskToForeground(createTask(taskId = 1))
assertThat(taskSwitchState).isEqualTo(TaskSwitchState.NotProjectingTask)
}
@Test
fun taskSwitchChanges_projectingTask_foregroundTaskDifferent_emitsTaskChanged() =
testScope.runTest {
val projectedTask = createTask(taskId = 0)
val foregroundTask = createTask(taskId = 1)
mediaRepo.switchProjectedTask(projectedTask)
val taskSwitchState by collectLastValue(interactor.taskSwitchChanges)
fakeActivityTaskManager.moveTaskToForeground(foregroundTask)
assertThat(taskSwitchState)
.isEqualTo(
TaskSwitchState.TaskSwitched(
projectedTask = projectedTask,
foregroundTask = foregroundTask
)
)
}
@Test
fun taskSwitchChanges_projectingTask_foregroundTaskLauncher_emitsTaskUnchanged() =
testScope.runTest {
val projectedTask = createTask(taskId = 0)
val foregroundTask = createTask(taskId = 1, baseIntent = LAUNCHER_INTENT)
mediaRepo.switchProjectedTask(projectedTask)
val taskSwitchState by collectLastValue(interactor.taskSwitchChanges)
fakeActivityTaskManager.moveTaskToForeground(foregroundTask)
assertThat(taskSwitchState).isEqualTo(TaskSwitchState.TaskUnchanged)
}
@Test
fun taskSwitchChanges_projectingTask_foregroundTaskSame_emitsTaskUnchanged() =
testScope.runTest {
val projectedTask = createTask(taskId = 0)
val foregroundTask = createTask(taskId = 0)
mediaRepo.switchProjectedTask(projectedTask)
val taskSwitchState by collectLastValue(interactor.taskSwitchChanges)
fakeActivityTaskManager.moveTaskToForeground(foregroundTask)
assertThat(taskSwitchState).isEqualTo(TaskSwitchState.TaskUnchanged)
}
companion object {
private val LAUNCHER_INTENT: Intent =
Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
}
}