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

Commit 552be2b7 authored by Behnam Heydarshahi's avatar Behnam Heydarshahi Committed by Android (Google) Code Review
Browse files

Merge "Migrate ScreenRecord Tile" into main

parents e475d138 e3ea339c
Loading
Loading
Loading
Loading
+149 −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.qs.tiles.impl.screenrecord.domain.interactor

import android.os.UserHandle
import android.platform.test.annotations.EnabledOnRavenwood
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.testScope
import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger
import com.android.systemui.qs.tiles.impl.screenrecord.domain.model.ScreenRecordTileModel
import com.android.systemui.screenrecord.RecordingController
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.verify

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@EnabledOnRavenwood
@RunWith(AndroidJUnit4::class)
class ScreenRecordTileDataInteractorTest : SysuiTestCase() {
    private val kosmos = Kosmos()
    private val testScope = kosmos.testScope
    private val controller = mock<RecordingController>()
    private val underTest: ScreenRecordTileDataInteractor =
        ScreenRecordTileDataInteractor(testScope.testScheduler, controller)

    private val isRecording = ScreenRecordTileModel.Recording
    private val isDoingNothing = ScreenRecordTileModel.DoingNothing
    private val isStarting0 = ScreenRecordTileModel.Starting(0)

    @Test
    fun isAvailable_returnsTrue() = runTest {
        val availability by collectLastValue(underTest.availability(TEST_USER))

        assertThat(availability).isTrue()
    }

    @Test
    fun dataMatchesController() =
        testScope.runTest {
            whenever(controller.isRecording).thenReturn(false)
            whenever(controller.isStarting).thenReturn(false)

            val callbackCaptor = argumentCaptor<RecordingController.RecordingStateChangeCallback>()

            val lastModel by
                collectLastValue(
                    underTest.tileData(TEST_USER, flowOf(DataUpdateTrigger.InitialRequest))
                )
            runCurrent()

            verify(controller).addCallback(callbackCaptor.capture())
            val callback = callbackCaptor.value

            assertThat(lastModel).isEqualTo(isDoingNothing)

            val expectedModelStartingIn1 = ScreenRecordTileModel.Starting(1)
            callback.onCountdown(1)
            assertThat(lastModel).isEqualTo(expectedModelStartingIn1)

            val expectedModelStartingIn0 = isStarting0
            callback.onCountdown(0)
            assertThat(lastModel).isEqualTo(expectedModelStartingIn0)

            callback.onCountdownEnd()
            assertThat(lastModel).isEqualTo(isDoingNothing)

            callback.onRecordingStart()
            assertThat(lastModel).isEqualTo(isRecording)

            callback.onRecordingEnd()
            assertThat(lastModel).isEqualTo(isDoingNothing)
        }

    @Test
    fun data_whenRecording_matchesController() =
        testScope.runTest {
            whenever(controller.isRecording).thenReturn(true)
            whenever(controller.isStarting).thenReturn(false)

            val lastModel by
                collectLastValue(
                    underTest.tileData(TEST_USER, flowOf(DataUpdateTrigger.InitialRequest))
                )
            runCurrent()

            assertThat(lastModel).isEqualTo(isRecording)
        }

    @Test
    fun data_whenStarting_matchesController() =
        testScope.runTest {
            whenever(controller.isRecording).thenReturn(false)
            whenever(controller.isStarting).thenReturn(true)

            val lastModel by
                collectLastValue(
                    underTest.tileData(TEST_USER, flowOf(DataUpdateTrigger.InitialRequest))
                )
            runCurrent()

            assertThat(lastModel).isEqualTo(isStarting0)
        }

    @Test
    fun data_whenRecordingAndStarting_matchesControllerRecording() =
        testScope.runTest {
            whenever(controller.isRecording).thenReturn(true)
            whenever(controller.isStarting).thenReturn(true)

            val lastModel by
                collectLastValue(
                    underTest.tileData(TEST_USER, flowOf(DataUpdateTrigger.InitialRequest))
                )
            runCurrent()

            assertThat(lastModel).isEqualTo(isRecording)
        }

    private companion object {
        val TEST_USER = UserHandle.of(1)!!
    }
}
+163 −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.qs.tiles.impl.screenrecord.domain.interactor

import android.app.Dialog
import android.os.UserHandle
import android.view.View
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.animation.DialogTransitionAnimator
import com.android.systemui.animation.dialogTransitionAnimator
import com.android.systemui.flags.featureFlagsClassic
import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository
import com.android.systemui.keyguard.domain.interactor.keyguardInteractor
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.testScope
import com.android.systemui.mediaprojection.MediaProjectionMetricsLogger
import com.android.systemui.plugins.ActivityStarter.OnDismissAction
import com.android.systemui.plugins.activityStarter
import com.android.systemui.qs.pipeline.domain.interactor.PanelInteractor
import com.android.systemui.qs.tiles.base.interactor.QSTileInputTestKtx
import com.android.systemui.qs.tiles.impl.screenrecord.domain.model.ScreenRecordTileModel
import com.android.systemui.screenrecord.RecordingController
import com.android.systemui.statusbar.phone.KeyguardDismissUtil
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.verify

@SmallTest
@RunWith(AndroidJUnit4::class)
class ScreenRecordTileUserActionInteractorTest : SysuiTestCase() {
    private val kosmos = Kosmos()
    private val testScope = kosmos.testScope
    private val keyguardInteractor = kosmos.keyguardInteractor
    private val dialogTransitionAnimator = mock<DialogTransitionAnimator>()
    private val featureFlags = kosmos.featureFlagsClassic
    private val activityStarter = kosmos.activityStarter
    private val keyguardDismissUtil = mock<KeyguardDismissUtil>()
    private val panelInteractor = mock<PanelInteractor>()
    private val dialog = mock<Dialog>()
    private val recordingController =
        mock<RecordingController> {
            whenever(
                    createScreenRecordDialog(
                        eq(context),
                        eq(featureFlags),
                        eq(dialogTransitionAnimator),
                        eq(activityStarter),
                        any()
                    )
                )
                .thenReturn(dialog)
        }

    private val underTest =
        ScreenRecordTileUserActionInteractor(
            context,
            testScope.testScheduler,
            testScope.testScheduler,
            recordingController,
            keyguardInteractor,
            keyguardDismissUtil,
            dialogTransitionAnimator,
            panelInteractor,
            mock<MediaProjectionMetricsLogger>(),
            featureFlags,
            activityStarter,
        )

    @Test
    fun handleClick_whenStarting_cancelCountdown() = runTest {
        val startingModel = ScreenRecordTileModel.Starting(0)

        underTest.handleInput(QSTileInputTestKtx.click(startingModel))

        verify(recordingController).cancelCountdown()
    }

    @Test
    fun handleClick_whenRecording_stopRecording() = runTest {
        val recordingModel = ScreenRecordTileModel.Recording

        underTest.handleInput(QSTileInputTestKtx.click(recordingModel))

        verify(recordingController).stopRecording()
    }

    @Test
    fun handleClick_whenDoingNothing_createDialogDismissPanelShowDialog() = runTest {
        val recordingModel = ScreenRecordTileModel.DoingNothing

        underTest.handleInput(QSTileInputTestKtx.click(recordingModel))
        val onStartRecordingClickedCaptor = argumentCaptor<Runnable>()
        verify(recordingController)
            .createScreenRecordDialog(
                eq(context),
                eq(featureFlags),
                eq(dialogTransitionAnimator),
                eq(activityStarter),
                onStartRecordingClickedCaptor.capture()
            )

        val onDismissActionCaptor = argumentCaptor<OnDismissAction>()
        verify(keyguardDismissUtil)
            .executeWhenUnlocked(onDismissActionCaptor.capture(), eq(false), eq(true))
        onDismissActionCaptor.value.onDismiss()
        verify(dialog).show() // because the view was null

        // When starting the recording, we collapse the shade and disable the dialog animation.
        onStartRecordingClickedCaptor.value.run()
        verify(dialogTransitionAnimator).disableAllCurrentDialogsExitAnimations()
        verify(panelInteractor).collapsePanels()
    }

    /**
     * When the input view is not null and keyguard is not showing, dialog should animate and show
     */
    @Test
    fun handleClickFromView_whenDoingNothing_whenKeyguardNotShowing_showDialogFromView() = runTest {
        val view = mock<View>()
        kosmos.fakeKeyguardRepository.setKeyguardShowing(false)

        val recordingModel = ScreenRecordTileModel.DoingNothing

        underTest.handleInput(QSTileInputTestKtx.click(recordingModel, UserHandle.CURRENT, view))
        val onStartRecordingClickedCaptor = argumentCaptor<Runnable>()
        verify(recordingController)
            .createScreenRecordDialog(
                eq(context),
                eq(featureFlags),
                eq(dialogTransitionAnimator),
                eq(activityStarter),
                onStartRecordingClickedCaptor.capture()
            )

        val onDismissActionCaptor = argumentCaptor<OnDismissAction>()
        verify(keyguardDismissUtil)
            .executeWhenUnlocked(onDismissActionCaptor.capture(), eq(false), eq(true))
        onDismissActionCaptor.value.onDismiss()
        verify(dialogTransitionAnimator).showFromView(eq(dialog), eq(view), any(), eq(true))
    }
}
+128 −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.qs.tiles.impl.screenrecord.ui

import android.graphics.drawable.TestStubDrawable
import android.text.TextUtils
import android.widget.Switch
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.qs.tiles.impl.custom.QSTileStateSubject
import com.android.systemui.qs.tiles.impl.screenrecord.domain.model.ScreenRecordTileModel
import com.android.systemui.qs.tiles.impl.screenrecord.domain.ui.ScreenRecordTileMapper
import com.android.systemui.qs.tiles.impl.screenrecord.qsScreenRecordTileConfig
import com.android.systemui.qs.tiles.viewmodel.QSTileState
import com.android.systemui.res.R
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class ScreenRecordTileMapperTest : SysuiTestCase() {
    private val kosmos = Kosmos()
    private val config = kosmos.qsScreenRecordTileConfig

    private lateinit var mapper: ScreenRecordTileMapper

    @Before
    fun setup() {
        mapper =
            ScreenRecordTileMapper(
                context.orCreateTestableResources
                    .apply {
                        addOverride(R.drawable.qs_screen_record_icon_on, TestStubDrawable())
                        addOverride(R.drawable.qs_screen_record_icon_off, TestStubDrawable())
                    }
                    .resources,
                context.theme
            )
    }

    @Test
    fun activeStateMatchesRecordingDataModel() {
        val inputModel = ScreenRecordTileModel.Recording

        val outputState = mapper.map(config, inputModel)

        val expectedState =
            createScreenRecordTileState(
                QSTileState.ActivationState.ACTIVE,
                R.drawable.qs_screen_record_icon_on,
                context.getString(R.string.quick_settings_screen_record_stop),
            )
        QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState)
    }

    @Test
    fun activeStateMatchesStartingDataModel() {
        val timeLeft = 0L
        val inputModel = ScreenRecordTileModel.Starting(timeLeft)

        val outputState = mapper.map(config, inputModel)

        val expectedState =
            createScreenRecordTileState(
                QSTileState.ActivationState.ACTIVE,
                R.drawable.qs_screen_record_icon_on,
                String.format("%d...", timeLeft)
            )
        QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState)
    }

    @Test
    fun inactiveStateMatchesDisabledDataModel() {
        val inputModel = ScreenRecordTileModel.DoingNothing

        val outputState = mapper.map(config, inputModel)

        val expectedState =
            createScreenRecordTileState(
                QSTileState.ActivationState.INACTIVE,
                R.drawable.qs_screen_record_icon_off,
                context.getString(R.string.quick_settings_screen_record_start),
            )
        QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState)
    }

    private fun createScreenRecordTileState(
        activationState: QSTileState.ActivationState,
        iconRes: Int,
        secondaryLabel: String,
    ): QSTileState {
        val label = context.getString(R.string.quick_settings_screen_record_label)

        return QSTileState(
            { Icon.Loaded(context.getDrawable(iconRes)!!, null) },
            label,
            activationState,
            secondaryLabel,
            setOf(QSTileState.UserAction.CLICK),
            if (TextUtils.isEmpty(secondaryLabel)) label
            else TextUtils.concat(label, ", ", secondaryLabel),
            null,
            if (activationState == QSTileState.ActivationState.INACTIVE)
                QSTileState.SideViewIcon.Chevron
            else QSTileState.SideViewIcon.None,
            QSTileState.EnabledState.ENABLED,
            Switch::class.qualifiedName
        )
    }
}
+86 −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.qs.tiles.impl.screenrecord.domain.interactor

import android.os.UserHandle
import com.android.systemui.common.coroutine.ConflatedCallbackFlow
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger
import com.android.systemui.qs.tiles.base.interactor.QSTileDataInteractor
import com.android.systemui.qs.tiles.impl.screenrecord.domain.model.ScreenRecordTileModel
import com.android.systemui.screenrecord.RecordingController
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart

/** Observes screen record state changes providing the [ScreenRecordTileModel]. */
class ScreenRecordTileDataInteractor
@Inject
constructor(
    @Background private val bgCoroutineContext: CoroutineContext,
    private val recordingController: RecordingController,
) : QSTileDataInteractor<ScreenRecordTileModel> {

    override fun tileData(
        user: UserHandle,
        triggers: Flow<DataUpdateTrigger>
    ): Flow<ScreenRecordTileModel> =
        ConflatedCallbackFlow.conflatedCallbackFlow {
                val callback =
                    object : RecordingController.RecordingStateChangeCallback {
                        override fun onRecordingStart() {
                            trySend(ScreenRecordTileModel.Recording)
                        }
                        override fun onRecordingEnd() {
                            trySend(ScreenRecordTileModel.DoingNothing)
                        }
                        override fun onCountdown(millisUntilFinished: Long) {
                            trySend(ScreenRecordTileModel.Starting(millisUntilFinished))
                        }
                        override fun onCountdownEnd() {
                            if (
                                !recordingController.isRecording && !recordingController.isStarting
                            ) {
                                // The tile was in Starting state and got canceled before recording
                                trySend(ScreenRecordTileModel.DoingNothing)
                            }
                        }
                    }
                recordingController.addCallback(callback)
                awaitClose { recordingController.removeCallback(callback) }
            }
            .onStart { emit(generateModel()) }
            .distinctUntilChanged()
            .flowOn(bgCoroutineContext)

    override fun availability(user: UserHandle): Flow<Boolean> = flowOf(true)

    private fun generateModel(): ScreenRecordTileModel {
        if (recordingController.isRecording) {
            return ScreenRecordTileModel.Recording
        } else if (recordingController.isStarting) {
            return ScreenRecordTileModel.Starting(0)
        } else {
            return ScreenRecordTileModel.DoingNothing
        }
    }
}
+135 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading