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

Commit 37412ee9 authored by Daniel Akinola's avatar Daniel Akinola
Browse files

[4/n] Add new display connection dialog + settings preference

Adding new dialog which shows up on display connection. Allows users to choose between desktop or mirroring. Users can also choose to have their choice remembered using a checkbox. Each time afterwards, the remembered choice will start up by default. Users can go back to having the dialog showing, or change their saved choice, using a new setting in the external display prefence fragment.

Bug: 413620089
Test: MirroringConfirmationDialogDelegateTest
Test: ExternalDisplayConnectionDialogDelegateTest
Flag: com.android.window.flags.enable_updated_display_connection_dialog
Change-Id: I3e74cf4fe195582adbfe9ce66c62e214ae0fed7b
parent ebddd8fc
Loading
Loading
Loading
Loading
+190 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.display.ui.view

import android.app.Dialog
import android.graphics.Insets
import android.graphics.Rect
import android.platform.test.annotations.RequiresFlagsEnabled
import android.testing.TestableLooper
import android.view.LayoutInflater
import android.view.View
import android.view.Window
import android.view.WindowInsets
import android.view.WindowInsetsAnimation
import android.widget.CompoundButton
import androidx.core.view.marginBottom
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.app.animation.Interpolators
import com.android.server.display.feature.flags.Flags.FLAG_ENABLE_DISPLAY_CONTENT_MODE_MANAGEMENT
import com.android.systemui.SysuiTestCase
import com.android.systemui.res.R
import com.android.window.flags.Flags.FLAG_ENABLE_UPDATED_DISPLAY_CONNECTION_DIALOG
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.kotlin.any
import org.mockito.kotlin.capture
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever

@SmallTest
@RequiresFlagsEnabled(
    FLAG_ENABLE_DISPLAY_CONTENT_MODE_MANAGEMENT,
    FLAG_ENABLE_UPDATED_DISPLAY_CONNECTION_DIALOG,
)
@RunWith(AndroidJUnit4::class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
class ExternalDisplayConnectionDialogDelegateTest : SysuiTestCase() {

    private val rememberChoiceCallback = mock<CompoundButton.OnCheckedChangeListener>()
    private val onStartDesktopCallback = mock<View.OnClickListener>()
    private val onStartMirroringCallback = mock<View.OnClickListener>()
    private val onCancelCallback = mock<View.OnClickListener>()
    private val windowInsetsAnimationCallbackCaptor =
        ArgumentCaptor.forClass(WindowInsetsAnimation.Callback::class.java)
    private val dialog: Dialog = mock()
    private val window: Window = mock()
    private val windowDecorView: View = mock()

    private lateinit var inflatedView: View
    private lateinit var underTest: ExternalDisplayConnectionDialogDelegate

    @Before
    fun setUp() {
        inflatedView =
            LayoutInflater.from(context).inflate(R.layout.connected_display_dialog_2, null, false)

        whenever(dialog.window).thenReturn(window)
        whenever(window.decorView).thenReturn(windowDecorView)
        whenever(dialog.requireViewById<View>(any())).thenAnswer { invocation ->
            val viewId = invocation.getArgument<Int>(0)
            inflatedView.requireViewById(viewId)
        }

        underTest =
            ExternalDisplayConnectionDialogDelegate(
                context = context,
                showConcurrentDisplayInfo = false,
                rememberChoiceCheckBoxListener = rememberChoiceCallback,
                onStartDesktopClickListener = onStartDesktopCallback,
                onStartMirroringClickListener = onStartMirroringCallback,
                onCancelClickListener = onCancelCallback,
                insetsProvider = { Insets.of(Rect()) },
            )
    }

    @Test
    fun startDesktopButton_clicked_callsCorrectCallback() {
        underTest.onCreate(dialog, null)

        dialog.requireViewById<View>(R.id.start_desktop_mode).callOnClick()

        verify(onStartDesktopCallback).onClick(any())
        verify(onCancelCallback, never()).onClick(any())
    }

    @Test
    fun startMirroringButton_clicked_callsCorrectCallback() {
        underTest.onCreate(dialog, null)

        dialog.requireViewById<View>(R.id.start_mirroring).callOnClick()

        verify(onStartMirroringCallback).onClick(any())
        verify(onCancelCallback, never()).onClick(any())
    }

    @Test
    fun cancelButton_clicked_callsCorrectCallback() {
        underTest.onCreate(dialog, null)

        dialog.requireViewById<View>(R.id.cancel).callOnClick()

        verify(onCancelCallback).onClick(any())
        verify(onStartMirroringCallback, never()).onClick(any())
        verify(onStartDesktopCallback, never()).onClick(any())
    }

    @Test
    fun onCancel_afterEnablingMirroring_cancelCallbackNotCalled() {
        underTest.onCreate(dialog, null)
        dialog.requireViewById<View>(R.id.start_mirroring).callOnClick()

        underTest.onStop(dialog)

        verify(onCancelCallback, never()).onClick(any())
        verify(onStartMirroringCallback).onClick(any())
    }

    @Test
    fun onCancel_afterEnablingDesktopMode_cancelCallbackNotCalled() {
        underTest.onCreate(dialog, null)
        dialog.requireViewById<View>(R.id.start_desktop_mode).callOnClick()

        underTest.onStop(dialog)

        verify(onCancelCallback, never()).onClick(any())
        verify(onStartDesktopCallback).onClick(any())
    }

    @Test
    fun onInsetsChanged_navBarInsets_updatesBottomMargin() {
        underTest.onCreate(dialog, null)
        underTest.onStart(dialog)

        val insets = buildInsets(WindowInsets.Type.navigationBars(), TEST_BOTTOM_INSETS)

        triggerInsetsChanged(WindowInsets.Type.navigationBars(), insets)

        assertThat(dialog.requireViewById<View>(R.id.cancel).marginBottom)
            .isEqualTo(TEST_BOTTOM_INSETS)
    }

    @Test
    fun onInsetsChanged_otherType_doesNotUpdateBottomPadding() {
        underTest.onCreate(dialog, null)
        underTest.onStart(dialog)

        val insets = buildInsets(WindowInsets.Type.ime(), TEST_BOTTOM_INSETS)
        triggerInsetsChanged(WindowInsets.Type.ime(), insets)

        assertThat(dialog.requireViewById<View>(R.id.cancel).marginBottom)
            .isNotEqualTo(TEST_BOTTOM_INSETS)
    }

    private fun buildInsets(@WindowInsets.Type.InsetsType type: Int, bottom: Int): WindowInsets {
        return WindowInsets.Builder().setInsets(type, Insets.of(0, 0, 0, bottom)).build()
    }

    private fun triggerInsetsChanged(type: Int, insets: WindowInsets) {
        verify(windowDecorView)
            .setWindowInsetsAnimationCallback(capture(windowInsetsAnimationCallbackCaptor))
        windowInsetsAnimationCallbackCaptor.value.onProgress(
            insets,
            listOf(WindowInsetsAnimation(type, Interpolators.INSTANT, 0)),
        )
    }

    private companion object {
        const val TEST_BOTTOM_INSETS = 1000 // arbitrarily high number
    }
}
+4 −1
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ package com.android.systemui.display.ui.view

import android.app.Dialog
import android.graphics.Insets
import android.platform.test.annotations.RequiresFlagsDisabled
import android.testing.TestableLooper
import android.view.LayoutInflater
import android.view.View
@@ -33,6 +34,7 @@ import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.capture
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import com.android.window.flags.Flags.FLAG_ENABLE_UPDATED_DISPLAY_CONNECTION_DIALOG
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
@@ -42,6 +44,7 @@ import org.mockito.Mockito.never
import org.mockito.Mockito.verify

@SmallTest
@RequiresFlagsDisabled(FLAG_ENABLE_UPDATED_DISPLAY_CONNECTION_DIALOG)
@RunWith(AndroidJUnit4::class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
class MirroringConfirmationDialogDelegateTest : SysuiTestCase() {
@@ -157,7 +160,7 @@ class MirroringConfirmationDialogDelegateTest : SysuiTestCase() {
            .setWindowInsetsAnimationCallback(capture(windowInsetsAnimationCallbackCaptor))
        windowInsetsAnimationCallbackCaptor.value.onProgress(
            insets,
            listOf(WindowInsetsAnimation(type, Interpolators.INSTANT, 0))
            listOf(WindowInsetsAnimation(type, Interpolators.INSTANT, 0)),
        )
    }

+109 −0
Original line number Diff line number Diff line
<!--
  Copyright (C) 2025 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.
  -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
    android:id="@+id/cd_bottom_sheet"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/connected_display_dialog_bg"
    android:scrollbars="none">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:orientation="vertical"
        android:paddingHorizontal="@dimen/dialog_side_padding"
        android:paddingTop="@dimen/dialog_top_padding">

        <ImageView
            android:id="@+id/connected_display_dialog_icon"
            android:layout_width="@dimen/connected_display_dialog_logo_size"
            android:layout_height="@dimen/connected_display_dialog_logo_size"
            android:background="@drawable/circular_background"
            android:backgroundTint="@androidprv:color/materialColorSecondary"
            android:importantForAccessibility="no"
            android:padding="6dp"
            android:src="@drawable/stat_sys_connected_display"
            android:tint="@androidprv:color/materialColorOnSecondary" />

        <TextView
            android:id="@+id/connected_display_dialog_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_marginBottom="4dp"
            android:gravity="center"
            android:text="@string/connected_display_dialog_title"
            android:textAppearance="@style/TextAppearance.Dialog.Title" />

        <CheckBox
            android:id="@+id/save_connection_preference"
            android:paddingStart="8dp"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginVertical="8dp"
            android:text="@string/remember_choice"
            android:textAppearance="@style/TextAppearance.Dialog.Body"
            android:textSize="18sp" />

        <TextView
            android:id="@+id/dual_display_warning"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginVertical="4dp"
            android:gravity="center"
            android:text="@string/connected_display_dialog_dual_display_stop_warning"
            android:textAppearance="@style/TextAppearance.Dialog.Body"
            android:visibility="gone" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:orientation="vertical">

            <Button
                android:id="@+id/start_desktop_mode"
                style="@style/Widget.Dialog.Button"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:minHeight="48dp"
                android:text="@string/start_desktop_mode"
                android:textSize="18sp" />

            <Button
                android:id="@+id/start_mirroring"
                style="@style/Widget.Dialog.Button.BorderButton"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:minHeight="48dp"
                android:text="@string/start_mirroring"
                android:textSize="18sp" />

            <Button
                android:id="@+id/cancel"
                style="@style/Widget.Dialog.Button.BorderButton"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:background="@drawable/btn_borderless_rect"
                android:layout_marginBottom="@dimen/dialog_bottom_padding"
                android:minHeight="48dp"
                android:text="@string/cancel"
                android:textSize="18sp" />
        </LinearLayout>
    </LinearLayout>
</ScrollView>
 No newline at end of file
+1 −0
Original line number Diff line number Diff line
@@ -1597,6 +1597,7 @@

    <!-- Connected display dialog -->
    <dimen name="connected_display_dialog_logo_size">48dp</dimen>
    <dimen name="connected_display_dialog_landscape_width">412dp</dimen>

    <!-- Keyguard user switcher -->
    <dimen name="kg_user_switcher_text_size">16sp</dimen>
+11 −0
Original line number Diff line number Diff line
@@ -3955,8 +3955,19 @@
    <!--- Label of the dismiss button of the dialog appearing when an external display is connected [CHAR LIMIT=NONE]-->
    <string name="dismiss_dialog">Dismiss</string>

    <!--- Title of the dialog appearing when an external display is connected, asking whether to start desktop mode or mirroring [CHAR LIMIT=NONE]-->
    <string name="connected_display_dialog_title">Connect to external display</string>
    <!--- Label of the "don't ask me again" checkbox of the dialog appearing when an external display is connected [CHAR LIMIT=NONE]-->
    <string name="remember_choice">Don\'t ask me again</string>
    <!--- Label of the "enable display for desktop" button of the dialog appearing when an external display is connected [CHAR LIMIT=NONE]-->
    <string name="start_desktop_mode">Desktop</string>
    <!--- Label of the "enable display for mirroring" button of the dialog appearing when an external display is connected [CHAR LIMIT=NONE]-->
    <string name="start_mirroring">Mirror</string>
    <!--- Content description of the connected display status bar icon that appears every time a display is connected [CHAR LIMIT=NONE]-->
    <string name="connected_display_icon_desc">Display connected</string>
    <!--- Text for toast that appears every time an extended display is connected (skipping connection dialog) [CHAR LIMIT=NONE]-->
    <string name="connected_display_extended_mode_text">Connected to external display</string>


    <!-- Title of the privacy dialog, shown for active / recent app usage of some phone sensors [CHAR LIMIT=30] -->
    <string name="privacy_dialog_title">Microphone, Camera &amp; Location</string>
Loading