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 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.plugins.ActivityStarter
import com.android.systemui.res.R
import com.android.systemui.statusbar.chips.domain.interactor.OngoingActivityChipInteractor
import com.android.systemui.statusbar.chips.domain.model.OngoingActivityChipModel
import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel
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.repository.OngoingCallRepository
import com.android.systemui.util.time.SystemClock
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

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

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

    override fun beforeCreate(dialog: SystemUIDialog, savedInstanceState: Bundle?) {
        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)
            setMessage(
                endMediaProjectionDialogHelper.getDialogMessage(
                    state,
                    state.projectionState,
                    genericMessageResId = R.string.cast_to_other_device_stop_dialog_message,
                    specificAppMessageResId =
                        R.string.cast_to_other_device_stop_dialog_message_specific_app,
@@ -48,7 +49,7 @@ class EndCastToOtherDeviceDialogDelegate(
            // button is clicked anyway.
            setNegativeButton(R.string.close_dialog_button, /* onClick= */ null)
            setPositiveButton(R.string.cast_to_other_device_stop_dialog_button) { _, _ ->
                interactor.stopProjecting()
                stopAction.invoke()
            }
        }
    }
+107 −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.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 Diff line number Diff line
@@ -17,23 +17,12 @@
package com.android.systemui.statusbar.chips.mediaprojection.domain.interactor

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.qualifiers.Application
import com.android.systemui.mediaprojection.data.model.MediaProjectionState
import com.android.systemui.mediaprojection.data.repository.MediaProjectionRepository
import com.android.systemui.res.R
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.statusbar.chips.mediaprojection.domain.model.ProjectionChipModel
import com.android.systemui.util.Utils
import com.android.systemui.util.time.SystemClock
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
@@ -43,14 +32,11 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

/**
 * Interactor for media-projection-related chips in the status bar.
 *
 * There are two kinds of media projection events that will show chips in the status bar:
 * 1) Share-to-app: Sharing your phone screen content to another app on the same device. (Triggered
 *    from within each individual app.)
 * 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).
 * Interactor for media projection events, used to show chips in the status bar for share-to-app and
 * cast-to-other-device events. See
 * [com.android.systemui.statusbar.chips.sharetoapp.ui.viewmodel.ShareToAppChipViewModel] and
 * [com.android.systemui.statusbar.chips.casttootherdevice.ui.viewmodel.CastToOtherDeviceChipViewModel]
 * for more details on what those events are.
 */
@SysUISingleton
class MediaProjectionChipInteractor
@@ -59,25 +45,24 @@ constructor(
    @Application private val scope: CoroutineScope,
    private val mediaProjectionRepository: MediaProjectionRepository,
    private val packageManager: PackageManager,
    private val systemClock: SystemClock,
    private val dialogTransitionAnimator: DialogTransitionAnimator,
    private val endMediaProjectionDialogHelper: EndMediaProjectionDialogHelper,
) : OngoingActivityChipInteractor {
    override val chip: StateFlow<OngoingActivityChipModel> =
) {
    val projection: StateFlow<ProjectionChipModel> =
        mediaProjectionRepository.mediaProjectionState
            .map { state ->
                when (state) {
                    is MediaProjectionState.NotProjecting -> OngoingActivityChipModel.Hidden
                    is MediaProjectionState.NotProjecting -> ProjectionChipModel.NotProjecting
                    is MediaProjectionState.Projecting -> {
                        val type =
                            if (isProjectionToOtherDevice(state.hostPackage)) {
                            createCastToOtherDeviceChip(state)
                                ProjectionChipModel.Type.CAST_TO_OTHER_DEVICE
                            } 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. */
    fun stopProjecting() {
@@ -96,57 +81,4 @@ constructor(
        // marked as going to a different device, even if that isn't always true. See b/321078669.
        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 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