diff options
18 files changed, 704 insertions, 84 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModel.kt index 11ccdff687a1..59fd0ca4513e 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModel.kt @@ -57,7 +57,7 @@ constructor( interactor.ongoingCallState .map { state -> when (state) { - is OngoingCallModel.NoCall -> OngoingActivityChipModel.Hidden + is OngoingCallModel.NoCall -> OngoingActivityChipModel.Hidden() is OngoingCallModel.InCall -> { // This block mimics OngoingCallController#updateChip. if (state.startTimeMs <= 0L) { @@ -82,7 +82,7 @@ constructor( } } } - .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Hidden) + .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Hidden()) private fun getOnClickListener(state: OngoingCallModel.InCall): View.OnClickListener? { if (state.intent == null) { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/casttootherdevice/ui/viewmodel/CastToOtherDeviceChipViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/casttootherdevice/ui/viewmodel/CastToOtherDeviceChipViewModel.kt index 66a61290f87a..d9b0504308f8 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/casttootherdevice/ui/viewmodel/CastToOtherDeviceChipViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/casttootherdevice/ui/viewmodel/CastToOtherDeviceChipViewModel.kt @@ -38,6 +38,7 @@ import com.android.systemui.statusbar.chips.mediaprojection.domain.model.Project import com.android.systemui.statusbar.chips.mediaprojection.ui.view.EndMediaProjectionDialogHelper import com.android.systemui.statusbar.chips.ui.model.ColorsModel import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel +import com.android.systemui.statusbar.chips.ui.viewmodel.ChipTransitionHelper 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 @@ -78,18 +79,18 @@ constructor( mediaProjectionChipInteractor.projection .map { projectionModel -> when (projectionModel) { - is ProjectionChipModel.NotProjecting -> OngoingActivityChipModel.Hidden + is ProjectionChipModel.NotProjecting -> OngoingActivityChipModel.Hidden() is ProjectionChipModel.Projecting -> { if (projectionModel.type != ProjectionChipModel.Type.CAST_TO_OTHER_DEVICE) { - OngoingActivityChipModel.Hidden + OngoingActivityChipModel.Hidden() } else { createCastScreenToOtherDeviceChip(projectionModel) } } } } - // See b/347726238. - .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Hidden) + // See b/347726238 for [SharingStarted.Lazily] reasoning. + .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Hidden()) /** * The cast chip to show, based only on MediaRouter API events. @@ -113,7 +114,7 @@ constructor( mediaRouterChipInteractor.mediaRouterCastingState .map { routerModel -> when (routerModel) { - is MediaRouterCastModel.DoingNothing -> OngoingActivityChipModel.Hidden + is MediaRouterCastModel.DoingNothing -> OngoingActivityChipModel.Hidden() is MediaRouterCastModel.Casting -> { // A consequence of b/269975671 is that MediaRouter will mark a device as // casting before casting has actually started. To alleviate this bug a bit, @@ -127,9 +128,9 @@ constructor( } } } - .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Hidden) + .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Hidden()) - override val chip: StateFlow<OngoingActivityChipModel> = + private val internalChip: StateFlow<OngoingActivityChipModel> = combine(projectionChip, routerChip) { projection, router -> logger.log( TAG, @@ -163,17 +164,24 @@ constructor( router } } - .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Hidden) + .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Hidden()) + + private val hideChipDuringDialogTransitionHelper = ChipTransitionHelper(scope) + + override val chip: StateFlow<OngoingActivityChipModel> = + hideChipDuringDialogTransitionHelper.createChipFlow(internalChip) /** Stops the currently active projection. */ - private fun stopProjecting() { - logger.log(TAG, LogLevel.INFO, {}, { "Stop casting requested (projection)" }) + private fun stopProjectingFromDialog() { + logger.log(TAG, LogLevel.INFO, {}, { "Stop casting requested from dialog (projection)" }) + hideChipDuringDialogTransitionHelper.onActivityStoppedFromDialog() mediaProjectionChipInteractor.stopProjecting() } /** Stops the currently active media route. */ - private fun stopMediaRouterCasting() { - logger.log(TAG, LogLevel.INFO, {}, { "Stop casting requested (router)" }) + private fun stopMediaRouterCastingFromDialog() { + logger.log(TAG, LogLevel.INFO, {}, { "Stop casting requested from dialog (router)" }) + hideChipDuringDialogTransitionHelper.onActivityStoppedFromDialog() mediaRouterChipInteractor.stopCasting() } @@ -230,7 +238,7 @@ constructor( EndCastScreenToOtherDeviceDialogDelegate( endMediaProjectionDialogHelper, context, - stopAction = this::stopProjecting, + stopAction = this::stopProjectingFromDialog, state, ) @@ -239,7 +247,7 @@ constructor( endMediaProjectionDialogHelper, context, deviceName, - stopAction = this::stopMediaRouterCasting, + stopAction = this::stopMediaRouterCastingFromDialog, ) companion object { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModel.kt index faebed44d7d2..fcf3de42eb32 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModel.kt @@ -35,8 +35,10 @@ import com.android.systemui.statusbar.chips.mediaprojection.ui.view.EndMediaProj import com.android.systemui.statusbar.chips.screenrecord.domain.interactor.ScreenRecordChipInteractor import com.android.systemui.statusbar.chips.screenrecord.domain.model.ScreenRecordChipModel import com.android.systemui.statusbar.chips.screenrecord.ui.view.EndScreenRecordingDialogDelegate +import com.android.systemui.statusbar.chips.sharetoapp.ui.viewmodel.ShareToAppChipViewModel import com.android.systemui.statusbar.chips.ui.model.ColorsModel import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel +import com.android.systemui.statusbar.chips.ui.viewmodel.ChipTransitionHelper 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 @@ -55,16 +57,18 @@ constructor( @Application private val scope: CoroutineScope, private val context: Context, private val interactor: ScreenRecordChipInteractor, + private val shareToAppChipViewModel: ShareToAppChipViewModel, private val systemClock: SystemClock, private val endMediaProjectionDialogHelper: EndMediaProjectionDialogHelper, private val dialogTransitionAnimator: DialogTransitionAnimator, @StatusBarChipsLog private val logger: LogBuffer, ) : OngoingActivityChipViewModel { - override val chip: StateFlow<OngoingActivityChipModel> = + + private val internalChip = interactor.screenRecordState .map { state -> when (state) { - is ScreenRecordChipModel.DoingNothing -> OngoingActivityChipModel.Hidden + is ScreenRecordChipModel.DoingNothing -> OngoingActivityChipModel.Hidden() is ScreenRecordChipModel.Starting -> { OngoingActivityChipModel.Shown.Countdown( colors = ColorsModel.Red, @@ -96,8 +100,13 @@ constructor( } } } - // See b/347726238. - .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Hidden) + // See b/347726238 for [SharingStarted.Lazily] reasoning. + .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Hidden()) + + private val chipTransitionHelper = ChipTransitionHelper(scope) + + override val chip: StateFlow<OngoingActivityChipModel> = + chipTransitionHelper.createChipFlow(internalChip) private fun createDelegate( recordedTask: ActivityManager.RunningTaskInfo? @@ -105,13 +114,15 @@ constructor( return EndScreenRecordingDialogDelegate( endMediaProjectionDialogHelper, context, - stopAction = this::stopRecording, + stopAction = this::stopRecordingFromDialog, recordedTask, ) } - private fun stopRecording() { - logger.log(TAG, LogLevel.INFO, {}, { "Stop recording requested" }) + private fun stopRecordingFromDialog() { + logger.log(TAG, LogLevel.INFO, {}, { "Stop recording requested from dialog" }) + chipTransitionHelper.onActivityStoppedFromDialog() + shareToAppChipViewModel.onRecordingStoppedFromDialog() interactor.stopRecording() } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/sharetoapp/ui/viewmodel/ShareToAppChipViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/sharetoapp/ui/viewmodel/ShareToAppChipViewModel.kt index 63b4625c4014..85973fca4326 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/sharetoapp/ui/viewmodel/ShareToAppChipViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/sharetoapp/ui/viewmodel/ShareToAppChipViewModel.kt @@ -35,6 +35,7 @@ import com.android.systemui.statusbar.chips.mediaprojection.ui.view.EndMediaProj import com.android.systemui.statusbar.chips.sharetoapp.ui.view.EndShareToAppDialogDelegate import com.android.systemui.statusbar.chips.ui.model.ColorsModel import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel +import com.android.systemui.statusbar.chips.ui.viewmodel.ChipTransitionHelper 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 @@ -61,26 +62,46 @@ constructor( private val dialogTransitionAnimator: DialogTransitionAnimator, @StatusBarChipsLog private val logger: LogBuffer, ) : OngoingActivityChipViewModel { - override val chip: StateFlow<OngoingActivityChipModel> = + private val internalChip = mediaProjectionChipInteractor.projection .map { projectionModel -> when (projectionModel) { - is ProjectionChipModel.NotProjecting -> OngoingActivityChipModel.Hidden + is ProjectionChipModel.NotProjecting -> OngoingActivityChipModel.Hidden() is ProjectionChipModel.Projecting -> { if (projectionModel.type != ProjectionChipModel.Type.SHARE_TO_APP) { - OngoingActivityChipModel.Hidden + OngoingActivityChipModel.Hidden() } else { createShareToAppChip(projectionModel) } } } } - // See b/347726238. - .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Hidden) + // See b/347726238 for [SharingStarted.Lazily] reasoning. + .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Hidden()) + + private val chipTransitionHelper = ChipTransitionHelper(scope) + + override val chip: StateFlow<OngoingActivityChipModel> = + chipTransitionHelper.createChipFlow(internalChip) + + /** + * Notifies this class that the user just stopped a screen recording from the dialog that's + * shown when you tap the recording chip. + */ + fun onRecordingStoppedFromDialog() { + // When a screen recording is active, share-to-app is also active (screen recording is just + // a special case of share-to-app, where the specific app receiving the share is System UI). + // When a screen recording is stopped, we immediately hide the screen recording chip in + // [com.android.systemui.statusbar.chips.screenrecord.ui.viewmodel.ScreenRecordChipViewModel]. + // We *also* need to immediately hide the share-to-app chip so it doesn't briefly show. + // See b/350891338. + chipTransitionHelper.onActivityStoppedFromDialog() + } /** Stops the currently active projection. */ - private fun stopProjecting() { - logger.log(TAG, LogLevel.INFO, {}, { "Stop sharing requested" }) + private fun stopProjectingFromDialog() { + logger.log(TAG, LogLevel.INFO, {}, { "Stop sharing requested from dialog" }) + chipTransitionHelper.onActivityStoppedFromDialog() mediaProjectionChipInteractor.stopProjecting() } @@ -113,7 +134,7 @@ constructor( EndShareToAppDialogDelegate( endMediaProjectionDialogHelper, context, - stopAction = this::stopProjecting, + stopAction = this::stopProjectingFromDialog, state, ) diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt index 40f86f924cd5..17cf60bf2dc5 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt @@ -24,9 +24,15 @@ sealed class OngoingActivityChipModel { /** Condensed name representing the model, used for logs. */ abstract val logName: String - /** This chip shouldn't be shown. */ - data object Hidden : OngoingActivityChipModel() { - override val logName = "Hidden" + /** + * This chip shouldn't be shown. + * + * @property shouldAnimate true if the transition from [Shown] to [Hidden] should be animated, + * and false if that transition should *not* be animated (i.e. the chip view should + * immediately disappear). + */ + data class Hidden(val shouldAnimate: Boolean = true) : OngoingActivityChipModel() { + override val logName = "Hidden(anim=$shouldAnimate)" } /** This chip should be shown with the given information. */ diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChipTransitionHelper.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChipTransitionHelper.kt new file mode 100644 index 000000000000..92e72c29519a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChipTransitionHelper.kt @@ -0,0 +1,97 @@ +/* + * 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.ui.viewmodel + +import android.annotation.SuppressLint +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +/** + * A class that can help [OngoingActivityChipViewModel] instances with various transition states. + * + * For now, this class's only functionality is immediately hiding the chip if the user has tapped an + * activity chip and then clicked "Stop" on the resulting dialog. There's a bit of a delay between + * when the user clicks "Stop" and when the system services notify SysUI that the activity has + * indeed stopped. We don't want the chip to briefly show for a few frames during that delay, so + * this class helps us immediately hide the chip as soon as the user clicks "Stop" in the dialog. + * See b/353249803#comment4. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChipTransitionHelper(@Application private val scope: CoroutineScope) { + /** A flow that emits each time the user has clicked "Stop" on the dialog. */ + @SuppressLint("SharedFlowCreation") + private val activityStoppedFromDialogEvent = MutableSharedFlow<Unit>() + + /** True if the user recently stopped the activity from the dialog. */ + private val wasActivityRecentlyStoppedFromDialog: Flow<Boolean> = + activityStoppedFromDialogEvent + .transformLatest { + // Give system services 500ms to stop the activity and notify SysUI. Once more than + // 500ms has elapsed, we should go back to using the current system service + // information as the source of truth. + emit(true) + delay(500) + emit(false) + } + // Use stateIn so that the flow created in [createChipFlow] is guaranteed to + // emit. (`combine`s require that all input flows have emitted.) + .stateIn(scope, SharingStarted.Lazily, false) + + /** + * Notifies this class that the user just clicked "Stop" on the stop dialog that's shown when + * the chip is tapped. + * + * Call this method in order to immediately hide the chip. + */ + fun onActivityStoppedFromDialog() { + // Because this event causes UI changes, make sure it's launched on the main thread scope. + scope.launch { activityStoppedFromDialogEvent.emit(Unit) } + } + + /** + * Creates a flow that will forcibly hide the chip if the user recently stopped the activity + * (see [onActivityStoppedFromDialog]). In general, this flow just uses value in [chip]. + */ + fun createChipFlow(chip: Flow<OngoingActivityChipModel>): StateFlow<OngoingActivityChipModel> { + return combine( + chip, + wasActivityRecentlyStoppedFromDialog, + ) { chipModel, activityRecentlyStopped -> + if (activityRecentlyStopped) { + // There's a bit of a delay between when the user stops an activity via + // SysUI and when the system services notify SysUI that the activity has + // indeed stopped. Prevent the chip from showing during this delay by + // immediately hiding it without any animation. + OngoingActivityChipModel.Hidden(shouldAnimate = false) + } else { + chipModel + } + } + .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Hidden()) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsViewModel.kt index 15c348ed2f67..b0d897def53f 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsViewModel.kt @@ -26,11 +26,14 @@ import com.android.systemui.statusbar.chips.casttootherdevice.ui.viewmodel.CastT import com.android.systemui.statusbar.chips.screenrecord.ui.viewmodel.ScreenRecordChipViewModel import com.android.systemui.statusbar.chips.sharetoapp.ui.viewmodel.ShareToAppChipViewModel import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel +import com.android.systemui.util.kotlin.pairwise import javax.inject.Inject import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn /** @@ -50,49 +53,132 @@ constructor( callChipViewModel: CallChipViewModel, @StatusBarChipsLog private val logger: LogBuffer, ) { + private enum class ChipType { + ScreenRecord, + ShareToApp, + CastToOtherDevice, + Call, + } + + /** Model that helps us internally track the various chip states from each of the types. */ + private sealed interface InternalChipModel { + /** + * Represents that we've internally decided to show the chip with type [type] with the given + * [model] information. + */ + data class Shown(val type: ChipType, val model: OngoingActivityChipModel.Shown) : + InternalChipModel + + /** + * Represents that all chip types would like to be hidden. Each value specifies *how* that + * chip type should get hidden. + */ + data class Hidden( + val screenRecord: OngoingActivityChipModel.Hidden, + val shareToApp: OngoingActivityChipModel.Hidden, + val castToOtherDevice: OngoingActivityChipModel.Hidden, + val call: OngoingActivityChipModel.Hidden, + ) : InternalChipModel + } + + private val internalChip: Flow<InternalChipModel> = + combine( + screenRecordChipViewModel.chip, + shareToAppChipViewModel.chip, + castToOtherDeviceChipViewModel.chip, + callChipViewModel.chip, + ) { screenRecord, shareToApp, castToOtherDevice, call -> + logger.log( + TAG, + LogLevel.INFO, + { + str1 = screenRecord.logName + str2 = shareToApp.logName + str3 = castToOtherDevice.logName + }, + { "Chips: ScreenRecord=$str1 > ShareToApp=$str2 > CastToOther=$str3..." }, + ) + logger.log(TAG, LogLevel.INFO, { str1 = call.logName }, { "... > Call=$str1" }) + // This `when` statement shows the priority order of the chips. + when { + // Screen recording also activates the media projection APIs, so whenever the + // screen recording chip is active, the media projection chip would also be + // active. We want the screen-recording-specific chip shown in this case, so we + // give the screen recording chip priority. See b/296461748. + screenRecord is OngoingActivityChipModel.Shown -> + InternalChipModel.Shown(ChipType.ScreenRecord, screenRecord) + shareToApp is OngoingActivityChipModel.Shown -> + InternalChipModel.Shown(ChipType.ShareToApp, shareToApp) + castToOtherDevice is OngoingActivityChipModel.Shown -> + InternalChipModel.Shown(ChipType.CastToOtherDevice, castToOtherDevice) + call is OngoingActivityChipModel.Shown -> + InternalChipModel.Shown(ChipType.Call, call) + else -> { + // We should only get here if all chip types are hidden + check(screenRecord is OngoingActivityChipModel.Hidden) + check(shareToApp is OngoingActivityChipModel.Hidden) + check(castToOtherDevice is OngoingActivityChipModel.Hidden) + check(call is OngoingActivityChipModel.Hidden) + InternalChipModel.Hidden( + screenRecord = screenRecord, + shareToApp = shareToApp, + castToOtherDevice = castToOtherDevice, + call = call, + ) + } + } + } + /** * A flow modeling the chip that should be shown in the status bar after accounting for possibly - * multiple ongoing activities. + * multiple ongoing activities and animation requirements. * * [com.android.systemui.statusbar.phone.fragment.CollapsedStatusBarFragment] is responsible for * actually displaying the chip. */ val chip: StateFlow<OngoingActivityChipModel> = - combine( - screenRecordChipViewModel.chip, - shareToAppChipViewModel.chip, - castToOtherDeviceChipViewModel.chip, - callChipViewModel.chip, - ) { screenRecord, shareToApp, castToOtherDevice, call -> - logger.log( - TAG, - LogLevel.INFO, - { - str1 = screenRecord.logName - str2 = shareToApp.logName - str3 = castToOtherDevice.logName - }, - { "Chips: ScreenRecord=$str1 > ShareToApp=$str2 > CastToOther=$str3..." }, - ) - logger.log(TAG, LogLevel.INFO, { str1 = call.logName }, { "... > Call=$str1" }) - // This `when` statement shows the priority order of the chips - when { - // Screen recording also activates the media projection APIs, so whenever the - // screen recording chip is active, the media projection chip would also be - // active. We want the screen-recording-specific chip shown in this case, so we - // give the screen recording chip priority. See b/296461748. - screenRecord is OngoingActivityChipModel.Shown -> screenRecord - shareToApp is OngoingActivityChipModel.Shown -> shareToApp - castToOtherDevice is OngoingActivityChipModel.Shown -> castToOtherDevice - else -> call + internalChip + .pairwise(initialValue = DEFAULT_INTERNAL_HIDDEN_MODEL) + .map { (old, new) -> + if (old is InternalChipModel.Shown && new is InternalChipModel.Hidden) { + // If we're transitioning from showing the chip to hiding the chip, different + // chips require different animation behaviors. For example, the screen share + // chips shouldn't animate if the user stopped the screen share from the dialog + // (see b/353249803#comment4), but the call chip should always animate. + // + // This `when` block makes sure that when we're transitioning from Shown to + // Hidden, we check what chip type was previously showing and we use that chip + // type's hide animation behavior. + when (old.type) { + ChipType.ScreenRecord -> new.screenRecord + ChipType.ShareToApp -> new.shareToApp + ChipType.CastToOtherDevice -> new.castToOtherDevice + ChipType.Call -> new.call + } + } else if (new is InternalChipModel.Shown) { + // If we have a chip to show, always show it. + new.model + } else { + // In the Hidden -> Hidden transition, it shouldn't matter which hidden model we + // choose because no animation should happen regardless. + OngoingActivityChipModel.Hidden() } } // Some of the chips could have timers in them and we don't want the start time // for those timers to get reset for any reason. So, as soon as any subscriber has - // requested the chip information, we need to maintain it forever. See b/347726238. - .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Hidden) + // requested the chip information, we maintain it forever by using + // [SharingStarted.Lazily]. See b/347726238. + .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Hidden()) companion object { private const val TAG = "ChipsViewModel" + + private val DEFAULT_INTERNAL_HIDDEN_MODEL = + InternalChipModel.Hidden( + screenRecord = OngoingActivityChipModel.Hidden(), + shareToApp = OngoingActivityChipModel.Hidden(), + castToOtherDevice = OngoingActivityChipModel.Hidden(), + call = OngoingActivityChipModel.Hidden(), + ) } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragment.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragment.java index aced0be4cc46..0320a7ae103b 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragment.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragment.java @@ -528,9 +528,10 @@ public class CollapsedStatusBarFragment extends Fragment implements CommandQueue } @Override - public void onOngoingActivityStatusChanged(boolean hasOngoingActivity) { + public void onOngoingActivityStatusChanged( + boolean hasOngoingActivity, boolean shouldAnimate) { mHasOngoingActivity = hasOngoingActivity; - updateStatusBarVisibilities(/* animate= */ true); + updateStatusBarVisibilities(shouldAnimate); } @Override diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/binder/CollapsedStatusBarViewBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/binder/CollapsedStatusBarViewBinder.kt index ae1898bc479c..4c97854bb5c9 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/binder/CollapsedStatusBarViewBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/binder/CollapsedStatusBarViewBinder.kt @@ -122,7 +122,8 @@ class CollapsedStatusBarViewBinderImpl @Inject constructor() : CollapsedStatusBa // Notify listeners listener.onOngoingActivityStatusChanged( - hasOngoingActivity = true + hasOngoingActivity = true, + shouldAnimate = true, ) } is OngoingActivityChipModel.Hidden -> { @@ -130,7 +131,8 @@ class CollapsedStatusBarViewBinderImpl @Inject constructor() : CollapsedStatusBa // b/192243808 and [Chronometer.start]. chipTimeView.stop() listener.onOngoingActivityStatusChanged( - hasOngoingActivity = false + hasOngoingActivity = false, + shouldAnimate = chipModel.shouldAnimate, ) } } @@ -266,8 +268,13 @@ interface StatusBarVisibilityChangeListener { /** Called when a transition from lockscreen to dream has started. */ fun onTransitionFromLockscreenToDreamStarted() - /** Called when the status of the ongoing activity chip (active or not active) has changed. */ - fun onOngoingActivityStatusChanged(hasOngoingActivity: Boolean) + /** + * Called when the status of the ongoing activity chip (active or not active) has changed. + * + * @param shouldAnimate true if the chip should animate in/out, and false if the chip should + * immediately appear/disappear. + */ + fun onOngoingActivityStatusChanged(hasOngoingActivity: Boolean, shouldAnimate: Boolean) /** * Called when the scene state has changed such that the home status bar is newly allowed or no diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/casttootherdevice/ui/viewmodel/CastToOtherDeviceChipViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/casttootherdevice/ui/viewmodel/CastToOtherDeviceChipViewModelTest.kt index 2a48f1d4e18e..02764f8a15fd 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/casttootherdevice/ui/viewmodel/CastToOtherDeviceChipViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/casttootherdevice/ui/viewmodel/CastToOtherDeviceChipViewModelTest.kt @@ -16,6 +16,7 @@ package com.android.systemui.statusbar.chips.casttootherdevice.ui.viewmodel +import android.content.DialogInterface import android.view.View import androidx.test.filters.SmallTest import com.android.internal.jank.Cuj @@ -41,6 +42,7 @@ import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.Me import com.android.systemui.statusbar.chips.ui.model.ColorsModel 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.OngoingActivityChipsViewModelTest.Companion.getStopActionFromDialog import com.android.systemui.statusbar.phone.SystemUIDialog import com.android.systemui.statusbar.phone.mockSystemUIDialogFactory import com.android.systemui.statusbar.policy.CastDevice @@ -210,6 +212,63 @@ class CastToOtherDeviceChipViewModelTest : SysuiTestCase() { } @Test + fun chip_projectionStoppedFromDialog_chipImmediatelyHidden() = + testScope.runTest { + val latest by collectLastValue(underTest.chip) + + mediaProjectionRepo.mediaProjectionState.value = + MediaProjectionState.Projecting.EntireScreen(CAST_TO_OTHER_DEVICES_PACKAGE) + + assertThat(latest).isInstanceOf(OngoingActivityChipModel.Shown::class.java) + + // WHEN the stop action on the dialog is clicked + val dialogStopAction = + getStopActionFromDialog(latest, chipView, mockScreenCastDialog, kosmos) + dialogStopAction.onClick(mock<DialogInterface>(), 0) + + // THEN the chip is immediately hidden... + assertThat(latest).isInstanceOf(OngoingActivityChipModel.Hidden::class.java) + // ...even though the repo still says it's projecting + assertThat(mediaProjectionRepo.mediaProjectionState.value) + .isInstanceOf(MediaProjectionState.Projecting::class.java) + + // AND we specify no animation + assertThat((latest as OngoingActivityChipModel.Hidden).shouldAnimate).isFalse() + } + + @Test + fun chip_routeStoppedFromDialog_chipImmediatelyHidden() = + testScope.runTest { + val latest by collectLastValue(underTest.chip) + + mediaRouterRepo.castDevices.value = + listOf( + CastDevice( + state = CastDevice.CastState.Connected, + id = "id", + name = "name", + description = "desc", + origin = CastDevice.CastOrigin.MediaRouter, + ) + ) + + assertThat(latest).isInstanceOf(OngoingActivityChipModel.Shown::class.java) + + // WHEN the stop action on the dialog is clicked + val dialogStopAction = + getStopActionFromDialog(latest, chipView, mockGenericCastDialog, kosmos) + dialogStopAction.onClick(mock<DialogInterface>(), 0) + + // THEN the chip is immediately hidden... + assertThat(latest).isInstanceOf(OngoingActivityChipModel.Hidden::class.java) + // ...even though the repo still says it's projecting + assertThat(mediaRouterRepo.castDevices.value).isNotEmpty() + + // AND we specify no animation + assertThat((latest as OngoingActivityChipModel.Hidden).shouldAnimate).isFalse() + } + + @Test fun chip_colorsAreRed() = testScope.runTest { val latest by collectLastValue(underTest.chip) diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModelTest.kt index e5a34a18dd56..b4a37ee1a55e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModelTest.kt @@ -16,6 +16,7 @@ package com.android.systemui.statusbar.chips.screenrecord.ui.viewmodel +import android.content.DialogInterface import android.view.View import androidx.test.filters.SmallTest import com.android.internal.jank.Cuj @@ -33,10 +34,13 @@ import com.android.systemui.mediaprojection.taskswitcher.FakeActivityTaskManager import com.android.systemui.res.R import com.android.systemui.screenrecord.data.model.ScreenRecordModel import com.android.systemui.screenrecord.data.repository.screenRecordRepository +import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractorTest.Companion.setUpPackageManagerForMediaProjection import com.android.systemui.statusbar.chips.screenrecord.ui.view.EndScreenRecordingDialogDelegate +import com.android.systemui.statusbar.chips.sharetoapp.ui.viewmodel.shareToAppChipViewModel import com.android.systemui.statusbar.chips.ui.model.ColorsModel 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.OngoingActivityChipsViewModelTest.Companion.getStopActionFromDialog import com.android.systemui.statusbar.phone.SystemUIDialog import com.android.systemui.statusbar.phone.mockSystemUIDialogFactory import com.android.systemui.util.time.fakeSystemClock @@ -75,6 +79,7 @@ class ScreenRecordChipViewModelTest : SysuiTestCase() { @Before fun setUp() { + setUpPackageManagerForMediaProjection(kosmos) whenever(kosmos.mockSystemUIDialogFactory.create(any<EndScreenRecordingDialogDelegate>())) .thenReturn(mockSystemUIDialog) } @@ -149,6 +154,40 @@ class ScreenRecordChipViewModelTest : SysuiTestCase() { } @Test + fun chip_recordingStoppedFromDialog_screenRecordAndShareToAppChipImmediatelyHidden() = + testScope.runTest { + val latest by collectLastValue(underTest.chip) + val latestShareToApp by collectLastValue(kosmos.shareToAppChipViewModel.chip) + + // On real devices, when screen recording is active then share-to-app is also active + // because screen record is just a special case of share-to-app where the app receiving + // the share is SysUI + screenRecordRepo.screenRecordState.value = ScreenRecordModel.Recording + mediaProjectionRepo.mediaProjectionState.value = + MediaProjectionState.Projecting.EntireScreen("fake.package") + + assertThat(latest).isInstanceOf(OngoingActivityChipModel.Shown::class.java) + assertThat(latestShareToApp).isInstanceOf(OngoingActivityChipModel.Shown::class.java) + + // WHEN the stop action on the dialog is clicked + val dialogStopAction = + getStopActionFromDialog(latest, chipView, mockSystemUIDialog, kosmos) + dialogStopAction.onClick(mock<DialogInterface>(), 0) + + // THEN both the screen record chip and the share-to-app chip are immediately hidden... + assertThat(latest).isInstanceOf(OngoingActivityChipModel.Hidden::class.java) + assertThat(latestShareToApp).isInstanceOf(OngoingActivityChipModel.Hidden::class.java) + // ...even though the repos still say it's recording + assertThat(screenRecordRepo.screenRecordState.value) + .isEqualTo(ScreenRecordModel.Recording) + assertThat(mediaProjectionRepo.mediaProjectionState.value) + .isInstanceOf(MediaProjectionState.Projecting::class.java) + + // AND we specify no animation + assertThat((latest as OngoingActivityChipModel.Hidden).shouldAnimate).isFalse() + } + + @Test fun chip_startingState_colorsAreRed() = testScope.runTest { val latest by collectLastValue(underTest.chip) diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/sharetoapp/ui/viewmodel/ShareToAppChipViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/sharetoapp/ui/viewmodel/ShareToAppChipViewModelTest.kt index 7d0df6f885cb..2658679dee08 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/sharetoapp/ui/viewmodel/ShareToAppChipViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/sharetoapp/ui/viewmodel/ShareToAppChipViewModelTest.kt @@ -16,6 +16,7 @@ package com.android.systemui.statusbar.chips.sharetoapp.ui.viewmodel +import android.content.DialogInterface import android.view.View import androidx.test.filters.SmallTest import com.android.internal.jank.Cuj @@ -38,6 +39,7 @@ import com.android.systemui.statusbar.chips.sharetoapp.ui.view.EndShareToAppDial import com.android.systemui.statusbar.chips.ui.model.ColorsModel 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.OngoingActivityChipsViewModelTest.Companion.getStopActionFromDialog import com.android.systemui.statusbar.phone.SystemUIDialog import com.android.systemui.statusbar.phone.mockSystemUIDialogFactory import com.android.systemui.util.time.fakeSystemClock @@ -151,6 +153,31 @@ class ShareToAppChipViewModelTest : SysuiTestCase() { } @Test + fun chip_shareStoppedFromDialog_chipImmediatelyHidden() = + testScope.runTest { + val latest by collectLastValue(underTest.chip) + + mediaProjectionRepo.mediaProjectionState.value = + MediaProjectionState.Projecting.EntireScreen(NORMAL_PACKAGE) + + assertThat(latest).isInstanceOf(OngoingActivityChipModel.Shown::class.java) + + // WHEN the stop action on the dialog is clicked + val dialogStopAction = + getStopActionFromDialog(latest, chipView, mockShareDialog, kosmos) + dialogStopAction.onClick(mock<DialogInterface>(), 0) + + // THEN the chip is immediately hidden... + assertThat(latest).isInstanceOf(OngoingActivityChipModel.Hidden::class.java) + // ...even though the repo still says it's projecting + assertThat(mediaProjectionRepo.mediaProjectionState.value) + .isInstanceOf(MediaProjectionState.Projecting::class.java) + + // AND we specify no animation + assertThat((latest as OngoingActivityChipModel.Hidden).shouldAnimate).isFalse() + } + + @Test fun chip_colorsAreRed() = testScope.runTest { val latest by collectLastValue(underTest.chip) diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChipTransitionHelperTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChipTransitionHelperTest.kt new file mode 100644 index 000000000000..b9049e8f76b6 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChipTransitionHelperTest.kt @@ -0,0 +1,154 @@ +/* + * 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.ui.viewmodel + +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.applicationCoroutineScope +import com.android.systemui.kosmos.testScope +import com.android.systemui.res.R +import com.android.systemui.statusbar.chips.ui.model.ColorsModel +import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +@SmallTest +@OptIn(ExperimentalCoroutinesApi::class) +class ChipTransitionHelperTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val testScope = kosmos.testScope + + @Test + fun createChipFlow_typicallyFollowsInputFlow() = + testScope.runTest { + val underTest = ChipTransitionHelper(kosmos.applicationCoroutineScope) + val inputChipFlow = + MutableStateFlow<OngoingActivityChipModel>(OngoingActivityChipModel.Hidden()) + val latest by collectLastValue(underTest.createChipFlow(inputChipFlow)) + + val newChip = + OngoingActivityChipModel.Shown.Timer( + icon = Icon.Resource(R.drawable.ic_cake, contentDescription = null), + colors = ColorsModel.Themed, + startTimeMs = 100L, + onClickListener = null, + ) + + inputChipFlow.value = newChip + + assertThat(latest).isEqualTo(newChip) + + val newerChip = + OngoingActivityChipModel.Shown.IconOnly( + icon = Icon.Resource(R.drawable.ic_hotspot, contentDescription = null), + colors = ColorsModel.Themed, + onClickListener = null, + ) + + inputChipFlow.value = newerChip + + assertThat(latest).isEqualTo(newerChip) + } + + @Test + fun activityStopped_chipHiddenWithoutAnimationFor500ms() = + testScope.runTest { + val underTest = ChipTransitionHelper(kosmos.applicationCoroutineScope) + val inputChipFlow = + MutableStateFlow<OngoingActivityChipModel>(OngoingActivityChipModel.Hidden()) + val latest by collectLastValue(underTest.createChipFlow(inputChipFlow)) + + val shownChip = + OngoingActivityChipModel.Shown.Timer( + icon = Icon.Resource(R.drawable.ic_cake, contentDescription = null), + colors = ColorsModel.Themed, + startTimeMs = 100L, + onClickListener = null, + ) + + inputChipFlow.value = shownChip + + assertThat(latest).isEqualTo(shownChip) + + // WHEN #onActivityStopped is invoked + underTest.onActivityStoppedFromDialog() + runCurrent() + + // THEN the chip is hidden and has no animation + assertThat(latest).isEqualTo(OngoingActivityChipModel.Hidden(shouldAnimate = false)) + + // WHEN only 250ms have elapsed + advanceTimeBy(250) + + // THEN the chip is still hidden + assertThat(latest).isEqualTo(OngoingActivityChipModel.Hidden(shouldAnimate = false)) + + // WHEN over 500ms have elapsed + advanceTimeBy(251) + + // THEN the chip returns to the original input flow value + assertThat(latest).isEqualTo(shownChip) + } + + @Test + fun activityStopped_stoppedAgainBefore500ms_chipReshownAfterSecond500ms() = + testScope.runTest { + val underTest = ChipTransitionHelper(kosmos.applicationCoroutineScope) + val inputChipFlow = + MutableStateFlow<OngoingActivityChipModel>(OngoingActivityChipModel.Hidden()) + val latest by collectLastValue(underTest.createChipFlow(inputChipFlow)) + + val shownChip = + OngoingActivityChipModel.Shown.Timer( + icon = Icon.Resource(R.drawable.ic_cake, contentDescription = null), + colors = ColorsModel.Themed, + startTimeMs = 100L, + onClickListener = null, + ) + + inputChipFlow.value = shownChip + + assertThat(latest).isEqualTo(shownChip) + + // WHEN #onActivityStopped is invoked + underTest.onActivityStoppedFromDialog() + runCurrent() + + // THEN the chip is hidden and has no animation + assertThat(latest).isEqualTo(OngoingActivityChipModel.Hidden(shouldAnimate = false)) + + // WHEN 250ms have elapsed, get another stop event + advanceTimeBy(250) + underTest.onActivityStoppedFromDialog() + runCurrent() + + // THEN the chip is still hidden for another 500ms afterwards + assertThat(latest).isEqualTo(OngoingActivityChipModel.Hidden(shouldAnimate = false)) + advanceTimeBy(499) + assertThat(latest).isEqualTo(OngoingActivityChipModel.Hidden(shouldAnimate = false)) + advanceTimeBy(2) + assertThat(latest).isEqualTo(shownChip) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsViewModelTest.kt index b1a8d0beab34..ee249f0f8a2c 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsViewModelTest.kt @@ -16,6 +16,10 @@ package com.android.systemui.statusbar.chips.ui.viewmodel +import android.content.DialogInterface +import android.content.packageManager +import android.content.pm.PackageManager +import android.view.View import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase @@ -33,10 +37,14 @@ import com.android.systemui.screenrecord.data.repository.screenRecordRepository import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractorTest.Companion.NORMAL_PACKAGE import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractorTest.Companion.setUpPackageManagerForMediaProjection import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel +import com.android.systemui.statusbar.chips.ui.view.ChipBackgroundContainer +import com.android.systemui.statusbar.phone.SystemUIDialog +import com.android.systemui.statusbar.phone.mockSystemUIDialogFactory import com.android.systemui.statusbar.phone.ongoingcall.data.repository.ongoingCallRepository import com.android.systemui.statusbar.phone.ongoingcall.shared.model.OngoingCallModel import com.android.systemui.util.time.fakeSystemClock import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.runCurrent @@ -44,9 +52,17 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @SmallTest @RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) class OngoingActivityChipsViewModelTest : SysuiTestCase() { private val kosmos = Kosmos().also { it.testCase = this } private val testScope = kosmos.testScope @@ -56,6 +72,18 @@ class OngoingActivityChipsViewModelTest : SysuiTestCase() { private val mediaProjectionState = kosmos.fakeMediaProjectionRepository.mediaProjectionState private val callRepo = kosmos.ongoingCallRepository + private val mockSystemUIDialog = mock<SystemUIDialog>() + private val chipBackgroundView = mock<ChipBackgroundContainer>() + private val chipView = + mock<View>().apply { + whenever( + this.requireViewById<ChipBackgroundContainer>( + R.id.ongoing_activity_chip_background + ) + ) + .thenReturn(chipBackgroundView) + } + private val underTest = kosmos.ongoingActivityChipsViewModel @Before @@ -72,7 +100,7 @@ class OngoingActivityChipsViewModelTest : SysuiTestCase() { val latest by collectLastValue(underTest.chip) - assertThat(latest).isEqualTo(OngoingActivityChipModel.Hidden) + assertThat(latest).isInstanceOf(OngoingActivityChipModel.Hidden::class.java) } @Test @@ -230,7 +258,81 @@ class OngoingActivityChipsViewModelTest : SysuiTestCase() { job2.cancel() } + @Test + fun chip_screenRecordStoppedViaDialog_chipHiddenWithoutAnimation() = + testScope.runTest { + screenRecordState.value = ScreenRecordModel.Recording + mediaProjectionState.value = MediaProjectionState.NotProjecting + callRepo.setOngoingCallState(OngoingCallModel.NoCall) + + val latest by collectLastValue(underTest.chip) + + assertIsScreenRecordChip(latest) + + // WHEN screen record gets stopped via dialog + val dialogStopAction = + getStopActionFromDialog(latest, chipView, mockSystemUIDialog, kosmos) + dialogStopAction.onClick(mock<DialogInterface>(), 0) + + // THEN the chip is immediately hidden with no animation + assertThat(latest).isEqualTo(OngoingActivityChipModel.Hidden(shouldAnimate = false)) + } + + @Test + fun chip_projectionStoppedViaDialog_chipHiddenWithoutAnimation() = + testScope.runTest { + mediaProjectionState.value = + MediaProjectionState.Projecting.EntireScreen(NORMAL_PACKAGE) + screenRecordState.value = ScreenRecordModel.DoingNothing + callRepo.setOngoingCallState(OngoingCallModel.NoCall) + + val latest by collectLastValue(underTest.chip) + + assertIsShareToAppChip(latest) + + // WHEN media projection gets stopped via dialog + val dialogStopAction = + getStopActionFromDialog(latest, chipView, mockSystemUIDialog, kosmos) + dialogStopAction.onClick(mock<DialogInterface>(), 0) + + // THEN the chip is immediately hidden with no animation + assertThat(latest).isEqualTo(OngoingActivityChipModel.Hidden(shouldAnimate = false)) + } + companion object { + /** + * Assuming that the click listener in [latest] opens a dialog, this fetches the action + * associated with the positive button, which we assume is the "Stop sharing" action. + */ + fun getStopActionFromDialog( + latest: OngoingActivityChipModel?, + chipView: View, + dialog: SystemUIDialog, + kosmos: Kosmos + ): DialogInterface.OnClickListener { + // Capture the action that would get invoked when the user clicks "Stop" on the dialog + lateinit var dialogStopAction: DialogInterface.OnClickListener + Mockito.doAnswer { + val delegate = it.arguments[0] as SystemUIDialog.Delegate + delegate.beforeCreate(dialog, /* savedInstanceState= */ null) + + val stopActionCaptor = argumentCaptor<DialogInterface.OnClickListener>() + verify(dialog).setPositiveButton(any(), stopActionCaptor.capture()) + dialogStopAction = stopActionCaptor.firstValue + + return@doAnswer dialog + } + .whenever(kosmos.mockSystemUIDialogFactory) + .create(any<SystemUIDialog.Delegate>()) + whenever(kosmos.packageManager.getApplicationInfo(eq(NORMAL_PACKAGE), any<Int>())) + .thenThrow(PackageManager.NameNotFoundException()) + // Click the chip so that we open the dialog and we fill in [dialogStopAction] + val clickListener = ((latest as OngoingActivityChipModel.Shown).onClickListener) + clickListener!!.onClick(chipView) + + return dialogStopAction + } + fun assertIsScreenRecordChip(latest: OngoingActivityChipModel?) { assertThat(latest).isInstanceOf(OngoingActivityChipModel.Shown::class.java) val icon = (latest as OngoingActivityChipModel.Shown).icon diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragmentTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragmentTest.java index 01540e7584a3..58ad83546e01 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragmentTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragmentTest.java @@ -536,7 +536,7 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest { // WHEN there's *no* ongoing activity via new callback mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ false); + /* hasOngoingActivity= */ false, /* shouldAnimate= */ false); // THEN the old callback value is used, so the view is shown assertEquals(View.VISIBLE, @@ -548,7 +548,7 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest { // WHEN there *is* an ongoing activity via new callback mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ true); + /* hasOngoingActivity= */ true, /* shouldAnimate= */ false); // THEN the old callback value is used, so the view is hidden assertEquals(View.GONE, @@ -565,7 +565,7 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest { // listener, but I'm unable to get the fragment to get attached so that the binder starts // listening to flows. mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ false); + /* hasOngoingActivity= */ false, /* shouldAnimate= */ false); assertEquals(View.GONE, mFragment.getView().findViewById(R.id.ongoing_activity_chip).getVisibility()); @@ -577,7 +577,7 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest { resumeAndGetFragment(); mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ true); + /* hasOngoingActivity= */ true, /* shouldAnimate= */ false); assertEquals(View.VISIBLE, mFragment.getView().findViewById(R.id.ongoing_activity_chip).getVisibility()); @@ -590,7 +590,7 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest { CollapsedStatusBarFragment fragment = resumeAndGetFragment(); mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ true); + /* hasOngoingActivity= */ true, /* shouldAnimate= */ false); fragment.disable(DEFAULT_DISPLAY, StatusBarManager.DISABLE_NOTIFICATION_ICONS, 0, false); @@ -605,7 +605,7 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest { CollapsedStatusBarFragment fragment = resumeAndGetFragment(); mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ true); + /* hasOngoingActivity= */ true, /* shouldAnimate= */ false); when(mHeadsUpAppearanceController.shouldBeVisible()).thenReturn(true); fragment.disable(DEFAULT_DISPLAY, 0, 0, false); @@ -621,14 +621,14 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest { // Ongoing activity started mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ true); + /* hasOngoingActivity= */ true, /* shouldAnimate= */ false); assertEquals(View.VISIBLE, mFragment.getView().findViewById(R.id.ongoing_activity_chip).getVisibility()); // Ongoing activity ended mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ false); + /* hasOngoingActivity= */ false, /* shouldAnimate= */ false); assertEquals(View.GONE, mFragment.getView().findViewById(R.id.ongoing_activity_chip).getVisibility()); @@ -643,7 +643,7 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest { // Ongoing call started mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ true); + /* hasOngoingActivity= */ true, /* shouldAnimate= */ false); // Notification area is hidden without delay assertEquals(0f, getNotificationAreaView().getAlpha(), 0.01); @@ -661,7 +661,7 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest { // WHEN there's *no* ongoing activity via new callback mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ false); + /* hasOngoingActivity= */ false, /* shouldAnimate= */ false); // THEN the new callback value is used, so the view is hidden assertEquals(View.GONE, @@ -673,7 +673,7 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest { // WHEN there *is* an ongoing activity via new callback mCollapsedStatusBarViewBinder.getListener().onOngoingActivityStatusChanged( - /* hasOngoingActivity= */ true); + /* hasOngoingActivity= */ true, /* shouldAnimate= */ false); // THEN the new callback value is used, so the view is shown assertEquals(View.VISIBLE, diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/shared/ui/viewmodel/CollapsedStatusBarViewModelImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/shared/ui/viewmodel/CollapsedStatusBarViewModelImplTest.kt index 94159bcebf47..60750cf96e67 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/shared/ui/viewmodel/CollapsedStatusBarViewModelImplTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/shared/ui/viewmodel/CollapsedStatusBarViewModelImplTest.kt @@ -425,7 +425,7 @@ class CollapsedStatusBarViewModelImplTest : SysuiTestCase() { kosmos.screenRecordRepository.screenRecordState.value = ScreenRecordModel.DoingNothing - assertThat(latest).isEqualTo(OngoingActivityChipModel.Hidden) + assertThat(latest).isInstanceOf(OngoingActivityChipModel.Hidden::class.java) kosmos.fakeMediaProjectionRepository.mediaProjectionState.value = MediaProjectionState.Projecting.EntireScreen(NORMAL_PACKAGE) diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/shared/ui/viewmodel/FakeCollapsedStatusBarViewModel.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/shared/ui/viewmodel/FakeCollapsedStatusBarViewModel.kt index d3f11253fc09..cefdf7e43fae 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/shared/ui/viewmodel/FakeCollapsedStatusBarViewModel.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/shared/ui/viewmodel/FakeCollapsedStatusBarViewModel.kt @@ -29,7 +29,7 @@ class FakeCollapsedStatusBarViewModel : CollapsedStatusBarViewModel { override val transitionFromLockscreenToDreamStartedEvent = MutableSharedFlow<Unit>() override val ongoingActivityChip: MutableStateFlow<OngoingActivityChipModel> = - MutableStateFlow(OngoingActivityChipModel.Hidden) + MutableStateFlow(OngoingActivityChipModel.Hidden()) override val isHomeStatusBarAllowedByScene = MutableStateFlow(false) diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModelKosmos.kt index 7e59e5fa8e18..c2a6f7d91eb0 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModelKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/screenrecord/ui/viewmodel/ScreenRecordChipViewModelKosmos.kt @@ -22,6 +22,7 @@ import com.android.systemui.kosmos.Kosmos import com.android.systemui.kosmos.applicationCoroutineScope import com.android.systemui.statusbar.chips.mediaprojection.ui.view.endMediaProjectionDialogHelper import com.android.systemui.statusbar.chips.screenrecord.domain.interactor.screenRecordChipInteractor +import com.android.systemui.statusbar.chips.sharetoapp.ui.viewmodel.shareToAppChipViewModel import com.android.systemui.statusbar.chips.statusBarChipsLogger import com.android.systemui.util.time.fakeSystemClock @@ -31,6 +32,7 @@ val Kosmos.screenRecordChipViewModel: ScreenRecordChipViewModel by scope = applicationCoroutineScope, context = applicationContext, interactor = screenRecordChipInteractor, + shareToAppChipViewModel = shareToAppChipViewModel, endMediaProjectionDialogHelper = endMediaProjectionDialogHelper, dialogTransitionAnimator = mockDialogTransitionAnimator, systemClock = fakeSystemClock, |