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

Commit 19acb607 authored by Caitlin Shkuratov's avatar Caitlin Shkuratov
Browse files

[SB][Screen Chips] Add view models for share-to-app and cast-to-other.

The MediaProjectionChipInteractor is currently handling two different
kinds of chips (share-to-app and cast-to-other-device), and also has
some UI-specific logic like how to display a dialog when the chip is
tapped (which violates clean architecture principles).

This CL fixes both those issues by adding two new view models:
 - ShareToAppChipViewModel
 - CastToOtherDeviceViewModel

Each view model listens to MediaProjectionChipInteractor, and handles
the display for *only* their type of chip. This solves our clean
architecture problem and helps distinguish these two cases in code more
clearly.

Note that this does create a diamond in our flow graph:

                   ShareToAppVM
MediaProjIntr ---<               >-- OngoingActivityChipsVM
                   CastToOtherVM

I don't think this will be a problem because only one branch can ever be
active at once.

This will also be useful later, because the cast-to-other-device events
can come from two different sources (MediaProjection and MediaRouter).
Splitting cast-to-other-device into its own class up now will make it
easier to add in the MediaRouter datasource later.

Future CLs will add view models for the call chip and screen record
chip so the structure is the same for all chip types.

Bug: 332662551
Flag: com.android.systemui.status_bar_screen_sharing_chips
Test: verify share-to-app chip still works
Test: verify cast-to-other-device chip still works
Test: all tests in statusbar.chips
Change-Id: I1bd2cc89447b76421fb4126d054beaecc278a81e
parent 72b3afc1
Loading
Loading
Loading
Loading
+4 −3
Original line number Original line Diff line number Diff line
@@ -23,9 +23,9 @@ import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.res.R
import com.android.systemui.res.R
import com.android.systemui.statusbar.chips.domain.interactor.OngoingActivityChipInteractor
import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel
import com.android.systemui.statusbar.chips.domain.model.OngoingActivityChipModel
import com.android.systemui.statusbar.chips.ui.view.ChipBackgroundContainer
import com.android.systemui.statusbar.chips.ui.view.ChipBackgroundContainer
import com.android.systemui.statusbar.chips.ui.viewmodel.OngoingActivityChipViewModel
import com.android.systemui.statusbar.phone.ongoingcall.data.model.OngoingCallModel
import com.android.systemui.statusbar.phone.ongoingcall.data.model.OngoingCallModel
import com.android.systemui.statusbar.phone.ongoingcall.data.repository.OngoingCallRepository
import com.android.systemui.statusbar.phone.ongoingcall.data.repository.OngoingCallRepository
import com.android.systemui.util.time.SystemClock
import com.android.systemui.util.time.SystemClock
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.stateIn


/** Interactor for the ongoing phone call chip shown in the status bar. */
/** Interactor for the ongoing phone call chip shown in the status bar. */
// TODO(b/332662551): Convert this into a view model.
@SysUISingleton
@SysUISingleton
open class CallChipInteractor
open class CallChipInteractor
@Inject
@Inject
@@ -45,7 +46,7 @@ constructor(
    repository: OngoingCallRepository,
    repository: OngoingCallRepository,
    systemClock: SystemClock,
    systemClock: SystemClock,
    private val activityStarter: ActivityStarter,
    private val activityStarter: ActivityStarter,
) : OngoingActivityChipInteractor {
) : OngoingActivityChipViewModel {
    override val chip: StateFlow<OngoingActivityChipModel> =
    override val chip: StateFlow<OngoingActivityChipModel> =
        repository.ongoingCallState
        repository.ongoingCallState
            .map { state ->
            .map { state ->
+9 −8
Original line number Original line Diff line number Diff line
@@ -14,19 +14,20 @@
 * limitations under the License.
 * limitations under the License.
 */
 */


package com.android.systemui.statusbar.chips.mediaprojection.ui.view
package com.android.systemui.statusbar.chips.casttootherdevice.ui.view


import android.os.Bundle
import android.os.Bundle
import com.android.systemui.mediaprojection.data.model.MediaProjectionState
import com.android.systemui.res.R
import com.android.systemui.res.R
import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractor
import com.android.systemui.statusbar.chips.casttootherdevice.ui.viewmodel.CastToOtherDeviceChipViewModel.Companion.CAST_TO_OTHER_DEVICE_ICON
import com.android.systemui.statusbar.chips.mediaprojection.domain.model.ProjectionChipModel
import com.android.systemui.statusbar.chips.mediaprojection.ui.view.EndMediaProjectionDialogHelper
import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.statusbar.phone.SystemUIDialog


/** A dialog that lets the user stop an ongoing cast-screen-to-other-device event. */
/** A dialog that lets the user stop an ongoing cast-screen-to-other-device event. */
class EndCastToOtherDeviceDialogDelegate(
class EndCastToOtherDeviceDialogDelegate(
    private val endMediaProjectionDialogHelper: EndMediaProjectionDialogHelper,
    private val endMediaProjectionDialogHelper: EndMediaProjectionDialogHelper,
    private val interactor: MediaProjectionChipInteractor,
    private val stopAction: () -> Unit,
    private val state: MediaProjectionState.Projecting,
    private val state: ProjectionChipModel.Projecting,
) : SystemUIDialog.Delegate {
) : SystemUIDialog.Delegate {
    override fun createDialog(): SystemUIDialog {
    override fun createDialog(): SystemUIDialog {
        return endMediaProjectionDialogHelper.createDialog(this)
        return endMediaProjectionDialogHelper.createDialog(this)
@@ -34,11 +35,11 @@ class EndCastToOtherDeviceDialogDelegate(


    override fun beforeCreate(dialog: SystemUIDialog, savedInstanceState: Bundle?) {
    override fun beforeCreate(dialog: SystemUIDialog, savedInstanceState: Bundle?) {
        with(dialog) {
        with(dialog) {
            setIcon(MediaProjectionChipInteractor.CAST_TO_OTHER_DEVICE_ICON)
            setIcon(CAST_TO_OTHER_DEVICE_ICON)
            setTitle(R.string.cast_to_other_device_stop_dialog_title)
            setTitle(R.string.cast_to_other_device_stop_dialog_title)
            setMessage(
            setMessage(
                endMediaProjectionDialogHelper.getDialogMessage(
                endMediaProjectionDialogHelper.getDialogMessage(
                    state,
                    state.projectionState,
                    genericMessageResId = R.string.cast_to_other_device_stop_dialog_message,
                    genericMessageResId = R.string.cast_to_other_device_stop_dialog_message,
                    specificAppMessageResId =
                    specificAppMessageResId =
                        R.string.cast_to_other_device_stop_dialog_message_specific_app,
                        R.string.cast_to_other_device_stop_dialog_message_specific_app,
@@ -48,7 +49,7 @@ class EndCastToOtherDeviceDialogDelegate(
            // button is clicked anyway.
            // button is clicked anyway.
            setNegativeButton(R.string.close_dialog_button, /* onClick= */ null)
            setNegativeButton(R.string.close_dialog_button, /* onClick= */ null)
            setPositiveButton(R.string.cast_to_other_device_stop_dialog_button) { _, _ ->
            setPositiveButton(R.string.cast_to_other_device_stop_dialog_button) { _, _ ->
                interactor.stopProjecting()
                stopAction.invoke()
            }
            }
        }
        }
    }
    }
+107 −0
Original line number Original line 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.statusbar.chips.casttootherdevice.ui.viewmodel

import androidx.annotation.DrawableRes
import com.android.systemui.animation.DialogTransitionAnimator
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.res.R
import com.android.systemui.statusbar.chips.casttootherdevice.ui.view.EndCastToOtherDeviceDialogDelegate
import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractor
import com.android.systemui.statusbar.chips.mediaprojection.domain.model.ProjectionChipModel
import com.android.systemui.statusbar.chips.mediaprojection.ui.view.EndMediaProjectionDialogHelper
import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel
import com.android.systemui.statusbar.chips.ui.viewmodel.OngoingActivityChipViewModel
import com.android.systemui.statusbar.chips.ui.viewmodel.OngoingActivityChipViewModel.Companion.createDialogLaunchOnClickListener
import com.android.systemui.util.time.SystemClock
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

/**
 * View model for the cast-to-other-device chip, shown when sharing your phone screen content to a
 * different device. (Triggered from the Quick Settings Cast tile or from the Settings app.)
 */
@SysUISingleton
class CastToOtherDeviceChipViewModel
@Inject
constructor(
    @Application private val scope: CoroutineScope,
    private val mediaProjectionChipInteractor: MediaProjectionChipInteractor,
    private val systemClock: SystemClock,
    private val dialogTransitionAnimator: DialogTransitionAnimator,
    private val endMediaProjectionDialogHelper: EndMediaProjectionDialogHelper,
) : OngoingActivityChipViewModel {
    override val chip: StateFlow<OngoingActivityChipModel> =
        // TODO(b/342169876): The MediaProjection APIs are not invoked for certain
        // cast-to-other-device events, like audio-only casting. We should also listen to
        // MediaRouter APIs to cover all cast events.
        mediaProjectionChipInteractor.projection
            .map { projectionModel ->
                when (projectionModel) {
                    is ProjectionChipModel.NotProjecting -> OngoingActivityChipModel.Hidden
                    is ProjectionChipModel.Projecting -> {
                        if (projectionModel.type != ProjectionChipModel.Type.CAST_TO_OTHER_DEVICE) {
                            OngoingActivityChipModel.Hidden
                        } else {
                            createCastToOtherDeviceChip(projectionModel)
                        }
                    }
                }
            }
            .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Hidden)

    /** Stops the currently active projection. */
    private fun stopProjecting() {
        mediaProjectionChipInteractor.stopProjecting()
    }

    private fun createCastToOtherDeviceChip(
        state: ProjectionChipModel.Projecting,
    ): OngoingActivityChipModel.Shown {
        return OngoingActivityChipModel.Shown(
            icon =
                Icon.Resource(
                    CAST_TO_OTHER_DEVICE_ICON,
                    ContentDescription.Resource(R.string.accessibility_casting),
                ),
            // TODO(b/332662551): Maybe use a MediaProjection API to fetch this time.
            startTimeMs = systemClock.elapsedRealtime(),
            createDialogLaunchOnClickListener(
                createCastToOtherDeviceDialogDelegate(state),
                dialogTransitionAnimator,
            ),
        )
    }

    private fun createCastToOtherDeviceDialogDelegate(state: ProjectionChipModel.Projecting) =
        EndCastToOtherDeviceDialogDelegate(
            endMediaProjectionDialogHelper,
            stopAction = this::stopProjecting,
            state,
        )

    companion object {
        @DrawableRes val CAST_TO_OTHER_DEVICE_ICON = R.drawable.ic_cast_connected
    }
}
+17 −85
Original line number Original line Diff line number Diff line
@@ -17,23 +17,12 @@
package com.android.systemui.statusbar.chips.mediaprojection.domain.interactor
package com.android.systemui.statusbar.chips.mediaprojection.domain.interactor


import android.content.pm.PackageManager
import android.content.pm.PackageManager
import androidx.annotation.DrawableRes
import com.android.systemui.animation.DialogTransitionAnimator
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.mediaprojection.data.model.MediaProjectionState
import com.android.systemui.mediaprojection.data.model.MediaProjectionState
import com.android.systemui.mediaprojection.data.repository.MediaProjectionRepository
import com.android.systemui.mediaprojection.data.repository.MediaProjectionRepository
import com.android.systemui.res.R
import com.android.systemui.statusbar.chips.mediaprojection.domain.model.ProjectionChipModel
import com.android.systemui.statusbar.chips.domain.interactor.OngoingActivityChipInteractor
import com.android.systemui.statusbar.chips.domain.interactor.OngoingActivityChipInteractor.Companion.createDialogLaunchOnClickListener
import com.android.systemui.statusbar.chips.domain.model.OngoingActivityChipModel
import com.android.systemui.statusbar.chips.mediaprojection.ui.view.EndCastToOtherDeviceDialogDelegate
import com.android.systemui.statusbar.chips.mediaprojection.ui.view.EndMediaProjectionDialogHelper
import com.android.systemui.statusbar.chips.mediaprojection.ui.view.EndShareToAppDialogDelegate
import com.android.systemui.util.Utils
import com.android.systemui.util.Utils
import com.android.systemui.util.time.SystemClock
import javax.inject.Inject
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted
@@ -43,14 +32,11 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.launch


/**
/**
 * Interactor for media-projection-related chips in the status bar.
 * Interactor for media projection events, used to show chips in the status bar for share-to-app and
 *
 * cast-to-other-device events. See
 * There are two kinds of media projection events that will show chips in the status bar:
 * [com.android.systemui.statusbar.chips.sharetoapp.ui.viewmodel.ShareToAppChipViewModel] and
 * 1) Share-to-app: Sharing your phone screen content to another app on the same device. (Triggered
 * [com.android.systemui.statusbar.chips.casttootherdevice.ui.viewmodel.CastToOtherDeviceChipViewModel]
 *    from within each individual app.)
 * for more details on what those events are.
 * 2) Cast-to-other-device: Sharing your phone screen content to a different device. (Triggered from
 *    the Quick Settings Cast tile or from the Settings app.) This interactor handles both of those
 *    event types (though maybe not audio-only casting -- see b/342169876).
 */
 */
@SysUISingleton
@SysUISingleton
class MediaProjectionChipInteractor
class MediaProjectionChipInteractor
@@ -59,25 +45,24 @@ constructor(
    @Application private val scope: CoroutineScope,
    @Application private val scope: CoroutineScope,
    private val mediaProjectionRepository: MediaProjectionRepository,
    private val mediaProjectionRepository: MediaProjectionRepository,
    private val packageManager: PackageManager,
    private val packageManager: PackageManager,
    private val systemClock: SystemClock,
) {
    private val dialogTransitionAnimator: DialogTransitionAnimator,
    val projection: StateFlow<ProjectionChipModel> =
    private val endMediaProjectionDialogHelper: EndMediaProjectionDialogHelper,
) : OngoingActivityChipInteractor {
    override val chip: StateFlow<OngoingActivityChipModel> =
        mediaProjectionRepository.mediaProjectionState
        mediaProjectionRepository.mediaProjectionState
            .map { state ->
            .map { state ->
                when (state) {
                when (state) {
                    is MediaProjectionState.NotProjecting -> OngoingActivityChipModel.Hidden
                    is MediaProjectionState.NotProjecting -> ProjectionChipModel.NotProjecting
                    is MediaProjectionState.Projecting -> {
                    is MediaProjectionState.Projecting -> {
                        val type =
                            if (isProjectionToOtherDevice(state.hostPackage)) {
                            if (isProjectionToOtherDevice(state.hostPackage)) {
                            createCastToOtherDeviceChip(state)
                                ProjectionChipModel.Type.CAST_TO_OTHER_DEVICE
                            } else {
                            } else {
                            createShareToAppChip(state)
                                ProjectionChipModel.Type.SHARE_TO_APP
                            }
                            }
                        ProjectionChipModel.Projecting(type, state)
                    }
                    }
                }
                }
            }
            }
            .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Hidden)
            .stateIn(scope, SharingStarted.WhileSubscribed(), ProjectionChipModel.NotProjecting)


    /** Stops the currently active projection. */
    /** Stops the currently active projection. */
    fun stopProjecting() {
    fun stopProjecting() {
@@ -96,57 +81,4 @@ constructor(
        // marked as going to a different device, even if that isn't always true. See b/321078669.
        // marked as going to a different device, even if that isn't always true. See b/321078669.
        return Utils.isHeadlessRemoteDisplayProvider(packageManager, packageName)
        return Utils.isHeadlessRemoteDisplayProvider(packageManager, packageName)
    }
    }

    private fun createCastToOtherDeviceChip(
        state: MediaProjectionState.Projecting,
    ): OngoingActivityChipModel.Shown {
        return OngoingActivityChipModel.Shown(
            icon =
                Icon.Resource(
                    CAST_TO_OTHER_DEVICE_ICON,
                    ContentDescription.Resource(R.string.accessibility_casting)
                ),
            // TODO(b/332662551): Maybe use a MediaProjection API to fetch this time.
            startTimeMs = systemClock.elapsedRealtime(),
            createDialogLaunchOnClickListener(
                createCastToOtherDeviceDialogDelegate(state),
                dialogTransitionAnimator,
            ),
        )
    }

    private fun createCastToOtherDeviceDialogDelegate(state: MediaProjectionState.Projecting) =
        EndCastToOtherDeviceDialogDelegate(
            endMediaProjectionDialogHelper,
            this@MediaProjectionChipInteractor,
            state,
        )

    private fun createShareToAppChip(
        state: MediaProjectionState.Projecting,
    ): OngoingActivityChipModel.Shown {
        return OngoingActivityChipModel.Shown(
            // TODO(b/332662551): Use the right content description.
            icon = Icon.Resource(SHARE_TO_APP_ICON, contentDescription = null),
            // TODO(b/332662551): Maybe use a MediaProjection API to fetch this time.
            startTimeMs = systemClock.elapsedRealtime(),
            createDialogLaunchOnClickListener(
                createShareToAppDialogDelegate(state),
                dialogTransitionAnimator
            ),
        )
    }

    private fun createShareToAppDialogDelegate(state: MediaProjectionState.Projecting) =
        EndShareToAppDialogDelegate(
            endMediaProjectionDialogHelper,
            this@MediaProjectionChipInteractor,
            state,
        )

    companion object {
        // TODO(b/332662551): Use the right icon.
        @DrawableRes val SHARE_TO_APP_ICON = R.drawable.ic_screenshot_share
        @DrawableRes val CAST_TO_OTHER_DEVICE_ICON = R.drawable.ic_cast_connected
    }
}
}
+43 −0
Original line number Original line 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.statusbar.chips.mediaprojection.domain.model

import com.android.systemui.mediaprojection.data.model.MediaProjectionState

/**
 * Represents the state of media projection needed to show chips in the status bar. In particular,
 * also includes what type of projection is occurring.
 */
sealed class ProjectionChipModel {
    /** There is no media being projected. */
    data object NotProjecting : ProjectionChipModel()

    /** Media is currently being projected. */
    data class Projecting(
        val type: Type,
        val projectionState: MediaProjectionState.Projecting,
    ) : ProjectionChipModel()

    enum class Type {
        /**
         * This projection is sharing your phone screen content to another app on the same device.
         */
        SHARE_TO_APP,
        /** This projection is sharing your phone screen content to a different device. */
        CAST_TO_OTHER_DEVICE,
    }
}
Loading