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

Commit 8a735c3f authored by Ioana Alexandru's avatar Ioana Alexandru
Browse files

Open modes settings on tile long press

Also fixed the animation for the settings button, now it expands
correctly from the dialog.

Bug: 346519570
Test: unit tests + manually verified that the transitions look good
Flag: android.app.modes_ui

Change-Id: Ia402a3d69f6177f71c12e3c907c16b78a5fe545d
parent 5c4f2bb3
Loading
Loading
Loading
Loading
+35 −31
Original line number Diff line number Diff line
@@ -21,64 +21,68 @@ import android.provider.Settings
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.qs.tiles.base.actions.FakeQSTileIntentUserInputHandler
import com.android.systemui.animation.Expandable
import com.android.systemui.qs.tiles.base.actions.QSTileIntentUserInputHandlerSubject
import com.android.systemui.qs.tiles.base.actions.qsTileIntentUserInputHandler
import com.android.systemui.qs.tiles.base.interactor.QSTileInputTestKtx
import com.android.systemui.qs.tiles.impl.modes.domain.model.ModesTileModel
import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.statusbar.policy.ui.dialog.ModesDialogDelegate
import com.google.common.truth.Truth
import kotlin.coroutines.EmptyCoroutineContext
import com.android.systemui.statusbar.policy.ui.dialog.mockModesDialogDelegate
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@SmallTest
@RunWith(AndroidJUnit4::class)
@EnableFlags(android.app.Flags.FLAG_MODES_UI)
class ModesTileUserActionInteractorTest : SysuiTestCase() {
    private val inputHandler = FakeQSTileIntentUserInputHandler()
    private val kosmos = testKosmos()
    private val inputHandler = kosmos.qsTileIntentUserInputHandler
    private val mockDialogDelegate = kosmos.mockModesDialogDelegate

    @Mock private lateinit var dialogTransitionAnimator: DialogTransitionAnimator
    @Mock private lateinit var dialogDelegate: ModesDialogDelegate
    @Mock private lateinit var mockDialog: SystemUIDialog
    private val underTest =
        ModesTileUserActionInteractor(
            inputHandler,
            mockDialogDelegate,
        )

    private lateinit var underTest: ModesTileUserActionInteractor
    @Test
    fun handleClick_active() = runTest {
        val expandable = mock<Expandable>()
        underTest.handleInput(
            QSTileInputTestKtx.click(data = ModesTileModel(true), expandable = expandable))

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)
        verify(mockDialogDelegate).showDialog(eq(expandable))
    }

        whenever(dialogDelegate.createDialog()).thenReturn(mockDialog)
    @Test
    fun handleClick_inactive() = runTest {
        val expandable = mock<Expandable>()
        underTest.handleInput(
            QSTileInputTestKtx.click(data = ModesTileModel(false), expandable = expandable))

        underTest =
            ModesTileUserActionInteractor(
                EmptyCoroutineContext,
                inputHandler,
                dialogTransitionAnimator,
                dialogDelegate,
            )
        verify(mockDialogDelegate).showDialog(eq(expandable))
    }

    @Test
    fun handleClick() = runTest {
        underTest.handleInput(QSTileInputTestKtx.click(ModesTileModel(false)))
    fun handleLongClick_active() = runTest {
        underTest.handleInput(QSTileInputTestKtx.longClick(ModesTileModel(true)))

        verify(mockDialog).show()
        QSTileIntentUserInputHandlerSubject.assertThat(inputHandler).handledOneIntentInput {
            assertThat(it.intent.action).isEqualTo(Settings.ACTION_ZEN_MODE_SETTINGS)
        }
    }

    @Test
    fun handleLongClick() = runTest {
    fun handleLongClick_inactive() = runTest {
        underTest.handleInput(QSTileInputTestKtx.longClick(ModesTileModel(false)))

        QSTileIntentUserInputHandlerSubject.assertThat(inputHandler).handledOneIntentInput {
            Truth.assertThat(it.intent.action).isEqualTo(Settings.ACTION_ZEN_MODE_SETTINGS)
            assertThat(it.intent.action).isEqualTo(Settings.ACTION_ZEN_MODE_SETTINGS)
        }
    }
}
+124 −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.
 */

@file:OptIn(ExperimentalCoroutinesApi::class)

package com.android.systemui.statusbar.policy.ui.dialog

import android.app.Dialog
import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.animation.ActivityTransitionAnimator
import com.android.systemui.animation.mockActivityTransitionAnimatorController
import com.android.systemui.animation.mockDialogTransitionAnimator
import com.android.systemui.kosmos.applicationCoroutineScope
import com.android.systemui.kosmos.mainCoroutineContext
import com.android.systemui.kosmos.testScope
import com.android.systemui.plugins.activityStarter
import com.android.systemui.runOnMainThreadAndWaitForIdleSync
import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.statusbar.phone.systemUIDialogFactory
import com.android.systemui.statusbar.policy.ui.dialog.viewmodel.modesDialogViewModel
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@SmallTest
@RunWith(AndroidJUnit4::class)
class ModesDialogDelegateTest : SysuiTestCase() {
    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val activityStarter = kosmos.activityStarter
    private val mockDialogTransitionAnimator = kosmos.mockDialogTransitionAnimator
    private val mockAnimationController = kosmos.mockActivityTransitionAnimatorController
    private lateinit var underTest: ModesDialogDelegate

    @Before
    fun setup() {
        whenever(
                mockDialogTransitionAnimator.createActivityTransitionController(
                    any<SystemUIDialog>(),
                    eq(null)
                )
            )
            .thenReturn(mockAnimationController)

        underTest =
            ModesDialogDelegate(
                kosmos.systemUIDialogFactory,
                mockDialogTransitionAnimator,
                activityStarter,
                { kosmos.modesDialogViewModel },
                kosmos.mainCoroutineContext,
            )
    }

    @Test
    fun launchFromDialog_whenDialogNotOpen() {
        val intent: Intent = mock()

        runOnMainThreadAndWaitForIdleSync { underTest.launchFromDialog(intent) }

        verify(activityStarter)
            .startActivity(eq(intent), eq(true), eq<ActivityTransitionAnimator.Controller?>(null))
    }

    @Test
    fun launchFromDialog_whenDialogOpen() =
        testScope.runTest {
            val intent: Intent = mock()
            lateinit var dialog: Dialog

            runOnMainThreadAndWaitForIdleSync {
                kosmos.applicationCoroutineScope.launch { dialog = underTest.showDialog() }
                runCurrent()
                underTest.launchFromDialog(intent)
            }

            verify(mockDialogTransitionAnimator)
                .createActivityTransitionController(any<Dialog>(), eq(null))
            verify(activityStarter).startActivity(eq(intent), eq(true), eq(mockAnimationController))

            runOnMainThreadAndWaitForIdleSync { dialog.dismiss() }
        }

    @Test
    fun dismiss_clearsDialogReference() {
        val dialog = runOnMainThreadAndWaitForIdleSync { underTest.createDialog() }

        assertThat(underTest.currentDialog).isEqualTo(dialog)

        runOnMainThreadAndWaitForIdleSync {
            dialog.show()
            dialog.dismiss()
        }

        assertThat(underTest.currentDialog).isNull()
    }
}
+67 −5
Original line number Diff line number Diff line
@@ -18,6 +18,8 @@

package com.android.systemui.statusbar.policy.ui.dialog.viewmodel

import android.content.Intent
import android.provider.Settings
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.settingslib.notification.modes.TestModeBuilder
@@ -27,6 +29,7 @@ import com.android.systemui.kosmos.testDispatcher
import com.android.systemui.kosmos.testScope
import com.android.systemui.statusbar.policy.data.repository.fakeZenModeRepository
import com.android.systemui.statusbar.policy.domain.interactor.zenModeInteractor
import com.android.systemui.statusbar.policy.ui.dialog.mockModesDialogDelegate
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -34,16 +37,21 @@ import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.clearInvocations
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.verify

@SmallTest
@RunWith(AndroidJUnit4::class)
class ModesDialogViewModelTest : SysuiTestCase() {
    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    val repository = kosmos.fakeZenModeRepository
    val interactor = kosmos.zenModeInteractor
    private val repository = kosmos.fakeZenModeRepository
    private val interactor = kosmos.zenModeInteractor
    private val mockDialogDelegate = kosmos.mockModesDialogDelegate

    val underTest = ModesDialogViewModel(context, interactor, kosmos.testDispatcher)
    private val underTest =
        ModesDialogViewModel(context, interactor, kosmos.testDispatcher, mockDialogDelegate)

    @Test
    fun tiles_filtersOutDisabledModes() =
@@ -64,7 +72,8 @@ class ModesDialogViewModelTest : SysuiTestCase() {
                        .setEnabled(false)
                        .setManualInvocationAllowed(true)
                        .build(),
                ))
                )
            )
            runCurrent()

            assertThat(tiles?.size).isEqualTo(2)
@@ -108,7 +117,8 @@ class ModesDialogViewModelTest : SysuiTestCase() {
                        .setActive(false)
                        .setManualInvocationAllowed(false)
                        .build(),
                ))
                )
            )
            runCurrent()

            assertThat(tiles?.size).isEqualTo(3)
@@ -161,4 +171,56 @@ class ModesDialogViewModelTest : SysuiTestCase() {

            assertThat(tiles?.first()?.enabled).isFalse()
        }

    @Test
    fun onLongClick_launchesIntent() =
        testScope.runTest {
            val tiles by collectLastValue(underTest.tiles)
            val intentCaptor = argumentCaptor<Intent>()

            val modeId = "id"
            repository.addModes(
                listOf(
                    TestModeBuilder()
                        .setId(modeId)
                        .setId("A")
                        .setActive(true)
                        .setManualInvocationAllowed(true)
                        .build(),
                    TestModeBuilder()
                        .setId(modeId)
                        .setId("B")
                        .setActive(false)
                        .setManualInvocationAllowed(true)
                        .build(),
                )
            )
            runCurrent()

            assertThat(tiles?.size).isEqualTo(2)

            // Trigger onLongClick for A
            tiles?.first()?.onLongClick?.let { it() }
            runCurrent()

            // Check that it launched the correct intent
            verify(mockDialogDelegate).launchFromDialog(intentCaptor.capture())
            var intent = intentCaptor.lastValue
            assertThat(intent.action).isEqualTo(Settings.ACTION_AUTOMATIC_ZEN_RULE_SETTINGS)
            assertThat(intent.extras?.getString(Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID))
                .isEqualTo("A")

            clearInvocations(mockDialogDelegate)

            // Trigger onLongClick for B
            tiles?.last()?.onLongClick?.let { it() }
            runCurrent()

            // Check that it launched the correct intent
            verify(mockDialogDelegate).launchFromDialog(intentCaptor.capture())
            intent = intentCaptor.lastValue
            assertThat(intent.action).isEqualTo(Settings.ACTION_AUTOMATIC_ZEN_RULE_SETTINGS)
            assertThat(intent.extras?.getString(Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID))
                .isEqualTo("B")
        }
}
+7 −28
Original line number Diff line number Diff line
@@ -16,14 +16,10 @@

package com.android.systemui.qs.tiles.impl.modes.domain.interactor

//noinspection CleanArchitectureDependencyViolation: dialog needs to be opened on click
import android.content.Intent
import android.provider.Settings
import com.android.internal.jank.InteractionJankMonitor
import com.android.systemui.animation.DialogCuj
import com.android.systemui.animation.DialogTransitionAnimator
import com.android.systemui.animation.Expandable
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.qs.tiles.base.actions.QSTileIntentUserInputHandler
import com.android.systemui.qs.tiles.base.interactor.QSTileInput
import com.android.systemui.qs.tiles.base.interactor.QSTileUserActionInteractor
@@ -31,15 +27,13 @@ import com.android.systemui.qs.tiles.impl.modes.domain.model.ModesTileModel
import com.android.systemui.qs.tiles.viewmodel.QSTileUserAction
import com.android.systemui.statusbar.policy.ui.dialog.ModesDialogDelegate
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.withContext

@SysUISingleton
class ModesTileUserActionInteractor
@Inject
constructor(
    @Main private val coroutineContext: CoroutineContext,
    private val qsTileIntentUserActionHandler: QSTileIntentUserInputHandler,
    private val dialogTransitionAnimator: DialogTransitionAnimator,
    private val qsTileIntentUserInputHandler: QSTileIntentUserInputHandler,
    // TODO(b/353896370): The domain layer should not have to depend on the UI layer.
    private val dialogDelegate: ModesDialogDelegate,
) : QSTileUserActionInteractor<ModesTileModel> {
    val longClickIntent = Intent(Settings.ACTION_ZEN_MODE_SETTINGS)
@@ -51,29 +45,14 @@ constructor(
                    handleClick(action.expandable)
                }
                is QSTileUserAction.LongClick -> {
                    qsTileIntentUserActionHandler.handle(action.expandable, longClickIntent)
                    qsTileIntentUserInputHandler.handle(action.expandable, longClickIntent)
                }
            }
        }
    }

    suspend fun handleClick(expandable: Expandable?) {
        // Show a dialog with the list of modes to configure. Dialogs shown by the
        // DialogTransitionAnimator must be created and shown on the main thread, so we post it to
        // the UI handler.
        withContext(coroutineContext) {
            val dialog = dialogDelegate.createDialog()

            expandable
                ?.dialogTransitionController(
                    DialogCuj(InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN, INTERACTION_JANK_TAG)
                )
                ?.let { controller -> dialogTransitionAnimator.show(dialog, controller) }
                ?: dialog.show()
        }
    }

    companion object {
        private const val INTERACTION_JANK_TAG = "configure_priority_modes"
        // Show a dialog with the list of modes to configure.
        dialogDelegate.showDialog(expandable)
    }
}
+120 −32
Original line number Diff line number Diff line
@@ -18,45 +18,97 @@ package com.android.systemui.statusbar.policy.ui.dialog

import android.content.Intent
import android.provider.Settings
import android.util.Log
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.android.compose.PlatformButton
import com.android.compose.PlatformOutlinedButton
import com.android.internal.annotations.VisibleForTesting
import com.android.internal.jank.InteractionJankMonitor
import com.android.systemui.animation.DialogCuj
import com.android.systemui.animation.DialogTransitionAnimator
import com.android.systemui.animation.Expandable
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.dialog.ui.composable.AlertDialogContent
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.res.R
import com.android.systemui.statusbar.phone.ComponentSystemUIDialog
import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.statusbar.phone.SystemUIDialogFactory
import com.android.systemui.statusbar.phone.create
import com.android.systemui.statusbar.policy.ui.dialog.composable.ModeTileGrid
import com.android.systemui.statusbar.policy.ui.dialog.viewmodel.ModesDialogViewModel
import com.android.systemui.util.Assert
import javax.inject.Inject
import javax.inject.Provider
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.withContext

@SysUISingleton
class ModesDialogDelegate
@Inject
constructor(
    private val sysuiDialogFactory: SystemUIDialogFactory,
    private val dialogTransitionAnimator: DialogTransitionAnimator,
    private val activityStarter: ActivityStarter,
    private val viewModel: ModesDialogViewModel,
    // Using a provider to avoid a circular dependency.
    private val viewModel: Provider<ModesDialogViewModel>,
    @Main private val mainCoroutineContext: CoroutineContext,
) : SystemUIDialog.Delegate {
    // NOTE: This should only be accessed/written from the main thread.
    @VisibleForTesting var currentDialog: ComponentSystemUIDialog? = null

    override fun createDialog(): SystemUIDialog {
        return sysuiDialogFactory.create { dialog ->
        Assert.isMainThread()
        if (currentDialog != null) {
            Log.w(TAG, "Dialog is already open, dismissing it and creating a new one.")
            currentDialog?.dismiss()
        }

        currentDialog = sysuiDialogFactory.create() { ModesDialogContent(it) }
        currentDialog
            ?.lifecycle
            ?.addObserver(
                object : DefaultLifecycleObserver {
                    override fun onStop(owner: LifecycleOwner) {
                        Assert.isMainThread()
                        currentDialog = null
                    }
                }
            )

        return currentDialog!!
    }

    @Composable
    private fun ModesDialogContent(dialog: SystemUIDialog) {
        AlertDialogContent(
            title = { Text(stringResource(R.string.zen_modes_dialog_title)) },
                content = { ModeTileGrid(viewModel) },
            content = { ModeTileGrid(viewModel.get()) },
            neutralButton = {
                    PlatformOutlinedButton(
                        onClick = {
                            val animationController =
                                dialogTransitionAnimator.createActivityTransitionController(
                                    dialog.getButton(SystemUIDialog.BUTTON_NEUTRAL)
                PlatformOutlinedButton(onClick = { openSettings(dialog) }) {
                    Text(stringResource(R.string.zen_modes_dialog_settings))
                }
            },
            positiveButton = {
                PlatformButton(onClick = { dialog.dismiss() }) {
                    Text(stringResource(R.string.zen_modes_dialog_done))
                }
            },
        )
    }

    private fun openSettings(dialog: SystemUIDialog) {
        val animationController =
            dialogTransitionAnimator.createActivityTransitionController(dialog)
        if (animationController == null) {
                                // The controller will take care of dismissing for us after the
                                // animation, but let's make sure we dismiss the dialog if we don't
                                // animate it.
            // The controller will take care of dismissing for us after
            // the animation, but let's make sure we dismiss the dialog
            // if we don't animate it.
            dialog.dismiss()
        }
        activityStarter.startActivity(
@@ -65,20 +117,56 @@ constructor(
            animationController
        )
    }
                    ) {
                        Text(stringResource(R.string.zen_modes_dialog_settings))

    suspend fun showDialog(expandable: Expandable? = null): SystemUIDialog {
        // Dialogs shown by the DialogTransitionAnimator must be created and shown on the main
        // thread, so we post it to the UI handler.
        withContext(mainCoroutineContext) {
            // Create the dialog if necessary
            if (currentDialog == null) {
                createDialog()
            }
                },
                positiveButton = {
                    PlatformButton(onClick = { dialog.dismiss() }) {
                        Text(stringResource(R.string.zen_modes_dialog_done))

            expandable
                ?.dialogTransitionController(
                    DialogCuj(InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN, INTERACTION_JANK_TAG)
                )
                ?.let { controller -> dialogTransitionAnimator.show(currentDialog!!, controller) }
                ?: currentDialog!!.show()
        }
                },

        return currentDialog!!
    }

    /**
     * Launches the [intent] by animating from the dialog. If the dialog is not showing, just
     * launches it normally without animating.
     */
    fun launchFromDialog(intent: Intent) {
        Assert.isMainThread()
        if (currentDialog == null) {
            Log.w(
                TAG,
                "Cannot launch from dialog, the dialog is not present. " +
                    "Will launch activity without animating."
            )
        }

        val animationController =
            currentDialog?.let { dialogTransitionAnimator.createActivityTransitionController(it) }
        if (animationController == null) {
            currentDialog?.dismiss()
        }
        activityStarter.startActivity(
            intent,
            true, /* dismissShade */
            animationController,
        )
    }

    companion object {
        private const val TAG = "ModesDialogDelegate"
        private val ZEN_MODE_SETTINGS_INTENT = Intent(Settings.ACTION_ZEN_MODE_SETTINGS)
        private const val INTERACTION_JANK_TAG = "configure_priority_modes"
    }
}
Loading