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

Commit 18dd1701 authored by Chun-Ku Lin's avatar Chun-Ku Lin
Browse files

Create DialogFragment for handling changing cursor following mode

The dialog was created inside of
MagnificationCursorFollowingModePreferenceController.

Moving them outside of the controller makes it:
- Easier to read
- PreferenceController is only responsible for displaying the preference
- Easier to test dialogs

Bug: 406052931
Test: atest
Test: manually by changing the code locally to show the dialog
Flag: EXEMPT no real usage yet
Change-Id: I2b5c6449183d82f9504fa05632b740950744a1ee
parent c5c145af
Loading
Loading
Loading
Loading
+165 −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.settings.accessibility.detail.screenmagnification.dialogs

import android.app.Dialog
import android.app.settings.SettingsEnums
import android.content.DialogInterface
import android.os.Bundle
import android.provider.Settings
import android.provider.Settings.Secure.AccessibilityMagnificationCursorFollowingMode
import android.util.Log
import android.view.LayoutInflater
import android.widget.AdapterView
import android.widget.TextView
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import com.android.settings.R
import com.android.settings.accessibility.AccessibilityDialogUtils
import com.android.settings.accessibility.ItemInfoArrayAdapter
import com.android.settings.core.instrumentation.InstrumentedDialogFragment

/** Displays options of how Magnification follows your cursor */
class CursorFollowingModeChooser : InstrumentedDialogFragment() {
    private lateinit var requestKey: String
    private lateinit var modeInfos: List<CursorFollowingModeInfo>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        requestKey = requireArguments().getString(ARG_REQUEST_KEY, "")
        modeInfos =
            listOf(
                CursorFollowingModeInfo(
                    title =
                        getText(R.string.accessibility_magnification_cursor_following_continuous),
                    mode =
                        Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CONTINUOUS,
                ),
                CursorFollowingModeInfo(
                    title = getText(R.string.accessibility_magnification_cursor_following_center),
                    mode = Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER,
                ),
                CursorFollowingModeInfo(
                    title = getText(R.string.accessibility_magnification_cursor_following_edge),
                    mode = Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE,
                ),
            )
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val listView =
            AccessibilityDialogUtils.createSingleChoiceListView(
                requireContext(),
                modeInfos,
                /* itemListener= */ null,
            )

        val headerView =
            LayoutInflater.from(requireContext())
                .inflate(R.layout.accessibility_dialog_header, listView, /* attachToRoot= */ false)
        headerView
            .requireViewById<TextView>(R.id.accessibility_dialog_header_text_view)
            .setText(R.string.accessibility_magnification_cursor_following_header)
        listView.addHeaderView(headerView, /* data= */ null, /* isSelectable= */ false)

        if (savedInstanceState == null) {
            // Sets up initial selected item
            val selectedMode =
                Settings.Secure.getInt(
                    requireContext().contentResolver,
                    SETTING_KEY,
                    Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CONTINUOUS,
                )
            val selectedIndex =
                listView.adapter.run {
                    for (index in 0 until count) {
                        val modeInfo = getItem(index) as? CursorFollowingModeInfo
                        if (modeInfo?.mode == selectedMode) {
                            return@run index
                        }
                    }
                    AdapterView.INVALID_POSITION
                }
            if (selectedIndex != AdapterView.INVALID_POSITION) {
                listView.setItemChecked(selectedIndex, true)
            }
        }

        return AccessibilityDialogUtils.createCustomDialog(
            requireContext(),
            getText(R.string.accessibility_magnification_cursor_following_title),
            listView,
            getText(R.string.save),
            DialogInterface.OnClickListener { _, _ ->
                val selectedModeInfo: CursorFollowingModeInfo? =
                    listView.checkedItemPosition.let {
                        if (it == AdapterView.INVALID_POSITION) {
                            null
                        } else {
                            listView.adapter.getItem(it) as? CursorFollowingModeInfo
                        }
                    }

                confirmSelection(selectedModeInfo)
            },
            getText(R.string.cancel),
            /* negativeListener= */ null,
        )
    }

    private fun confirmSelection(modeInfo: CursorFollowingModeInfo?) {
        if (modeInfo == null) {
            Log.w(TAG, "Selected positive button with INVALID_POSITION index")
            return
        }

        Settings.Secure.putInt(requireContext().contentResolver, SETTING_KEY, modeInfo.mode)
        setFragmentResult(requestKey, Bundle().apply { putInt(RESULT, modeInfo.mode) })
    }

    override fun getMetricsCategory(): Int {
        return SettingsEnums.DIALOG_MAGNIFICATION_CURSOR_FOLLOWING
    }

    companion object {
        private const val TAG = "CursorFollowingModeChooser"
        private const val SETTING_KEY =
            Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE
        internal const val ARG_REQUEST_KEY = "requestKey"
        internal const val RESULT = "selectedMode"

        @JvmStatic
        fun showDialog(fragmentManager: FragmentManager, requestKey: String) {
            val bundle = Bundle().apply { putString(ARG_REQUEST_KEY, requestKey) }
            CursorFollowingModeChooser().apply {
                arguments = bundle
                show(fragmentManager, /* tag= */ CursorFollowingModeChooser::class.simpleName)
            }
        }

        @JvmStatic
        @AccessibilityMagnificationCursorFollowingMode
        fun getCheckedModeFromResult(bundle: Bundle): Int {
            return bundle.getInt(RESULT)
        }
    }
}

class CursorFollowingModeInfo(
    title: CharSequence,
    @AccessibilityMagnificationCursorFollowingMode val mode: Int,
) : ItemInfoArrayAdapter.ItemInfo(title, /* summary= */ null, /* drawableId= */ null)
+315 −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.settings.accessibility.detail.screenmagnification.dialogs

import android.app.Dialog
import android.app.settings.SettingsEnums
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.provider.Settings
import android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER
import android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CONTINUOUS
import android.provider.Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE
import android.provider.Settings.Secure.AccessibilityMagnificationCursorFollowingMode
import android.widget.AdapterView
import android.widget.ListView
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentResultListener
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragment
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ApplicationProvider
import com.android.settings.R
import com.android.settings.accessibility.detail.screenmagnification.dialogs.CursorFollowingModeChooser.Companion.getCheckedModeFromResult
import com.google.common.truth.Truth.assertThat
import com.google.testing.junit.testparameterinjector.TestParameters
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.junit.MockitoJUnit
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.robolectric.RobolectricTestParameterInjector
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowDialog
import org.robolectric.shadows.ShadowListView
import org.robolectric.shadows.ShadowLooper

private const val SETTING_KEY = Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE

/** Tests for [CursorFollowingModeChooser] */
@RunWith(RobolectricTestParameterInjector::class)
class CursorFollowingModeChooserTest {
    @get:Rule val mockito = MockitoJUnit.rule()
    private val requestKey = "requestFromTest"
    private val context: Context = ApplicationProvider.getApplicationContext()
    private lateinit var fragmentScenario: FragmentScenario<Fragment>
    private lateinit var fragment: Fragment
    @Mock private lateinit var mockFragResultListener: FragmentResultListener
    @Captor lateinit var responseCaptor: ArgumentCaptor<Bundle>

    @Before
    fun setUp() {
        fragmentScenario = launchFragment(themeResId = androidx.appcompat.R.style.Theme_AppCompat)
        fragmentScenario.onFragment { frag ->
            fragment = frag
            fragment.childFragmentManager.setFragmentResultListener(
                requestKey,
                fragment,
                mockFragResultListener,
            )
        }
    }

    @After
    fun cleanUp() {
        fragmentScenario.close()
    }

    @Test
    fun launchDialog_verifyTitle() {
        val alertDialog = shadowOf(launchDialog())

        assertThat(alertDialog.title.toString())
            .isEqualTo(
                context.getString(R.string.accessibility_magnification_cursor_following_title)
            )
    }

    @Test
    fun launchDialog_verifySummary() {
        val alertDialog = launchDialog()
        val listView: ShadowListView = shadowOf(getListViewInDialog(alertDialog))

        assertThat(listView.headerViews.count()).isEqualTo(1)
        val summaryView: TextView =
            listView.headerViews[0].requireViewById(R.id.accessibility_dialog_header_text_view)
        assertThat(summaryView.text.toString())
            .isEqualTo(
                context.getString(R.string.accessibility_magnification_cursor_following_header)
            )
    }

    @Test
    fun launchDialog_verifyListAdapterData() {
        val alertDialog = launchDialog()
        val listView: ListView = getListViewInDialog(alertDialog)

        val adapter = listView.adapter
        val headerCounts = listView.headerViewsCount
        val optionsCount = 3

        assertThat(adapter.count).isEqualTo(headerCounts + optionsCount)

        val modeContinuous = adapter.getItem(headerCounts) as CursorFollowingModeInfo
        val modeCenter = adapter.getItem(headerCounts + 1) as CursorFollowingModeInfo
        val modeEdge = adapter.getItem(headerCounts + 2) as CursorFollowingModeInfo

        assertContinuousModeInfo(modeContinuous)
        assertCenterModeInfo(modeCenter)
        assertEdgeModeInfo(modeEdge)
    }

    private fun assertContinuousModeInfo(info: CursorFollowingModeInfo) {
        assertThat(info.mTitle.toString())
            .isEqualTo(
                context.getString(R.string.accessibility_magnification_cursor_following_continuous)
            )
        assertThat(info.mode)
            .isEqualTo(ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CONTINUOUS)
    }

    private fun assertCenterModeInfo(info: CursorFollowingModeInfo) {
        assertThat(info.mTitle.toString())
            .isEqualTo(
                context.getString(R.string.accessibility_magnification_cursor_following_center)
            )
        assertThat(info.mode).isEqualTo(ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER)
    }

    private fun assertEdgeModeInfo(info: CursorFollowingModeInfo) {
        assertThat(info.mTitle.toString())
            .isEqualTo(
                context.getString(R.string.accessibility_magnification_cursor_following_edge)
            )
        assertThat(info.mode).isEqualTo(ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE)
    }

    @Test
    fun launchDialog_verifyButtonsText() {
        val alertDialog = launchDialog() as AlertDialog
        val positiveBtn = alertDialog.getButton(DialogInterface.BUTTON_POSITIVE)
        val negativeBtn = alertDialog.getButton(DialogInterface.BUTTON_NEGATIVE)

        assertThat(positiveBtn.text.toString()).isEqualTo(context.getString(R.string.save))
        assertThat(negativeBtn.text.toString()).isEqualTo(context.getString(R.string.cancel))
    }

    @Test
    @TestParameters(
        customName = "continuous",
        value = ["{initialMode: ${ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CONTINUOUS}}"],
    )
    @TestParameters(
        customName = "center",
        value = ["{initialMode: ${ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER}}"],
    )
    @TestParameters(
        customName = "edge",
        value = ["{initialMode: ${ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE}}"],
    )
    fun launchDialog_verifyInitialModeSetCorrectly(
        @AccessibilityMagnificationCursorFollowingMode initialMode: Int
    ) {
        val dialog = launchDialogAndChecked(
            initialMode = initialMode,
            checkedMode = null,
        )
        val listView = getListViewInDialog(dialog)

        assertThat(getCheckedMode(listView)).isEqualTo(initialMode)
    }

    @Test
    fun configurationChange_checkedItemUiPersists() {
        launchDialogAndChecked(
            initialMode = ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER,
            checkedMode = ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE,
        )
        ShadowDialog.reset()

        // configuration change
        fragmentScenario.recreate().moveToState(Lifecycle.State.RESUMED)
        val dialog = ShadowDialog.getLatestDialog()
        val listView = getListViewInDialog(dialog)

        assertThat(getCheckedMode(listView))
            .isEqualTo(ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE)
    }

    @Test
    fun configurationChange_magnificationModeSettingUnchanged() {
        launchDialogAndChecked(
            initialMode = ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER,
            checkedMode = ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE,
        )

        // configuration change
        fragmentScenario.recreate().moveToState(Lifecycle.State.RESUMED)

        assertThat(Settings.Secure.getInt(context.contentResolver, SETTING_KEY))
            .isEqualTo(ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER)
    }

    @Test
    fun checkModeAndSave_modeSettingUpdates() {
        launchDialogAndChecked(
            initialMode = ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER,
            checkedMode = ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE,
        )

        val alertDialog = ShadowDialog.getLatestDialog() as AlertDialog
        alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick()
        ShadowLooper.runUiThreadTasksIncludingDelayedTasks()

        assertThat(Settings.Secure.getInt(context.contentResolver, SETTING_KEY))
            .isEqualTo(ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE)
        verify(mockFragResultListener).onFragmentResult(eq(requestKey), responseCaptor.capture())
        val response = responseCaptor.value
        assertThat(getCheckedModeFromResult(response))
            .isEqualTo(ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE)
    }

    @Test
    fun checkModeAndCancel_modeSettingUnchanged() {
        launchDialogAndChecked(
            initialMode = ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER,
            checkedMode = ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE,
        )

        val alertDialog = ShadowDialog.getLatestDialog() as AlertDialog
        alertDialog.getButton(DialogInterface.BUTTON_NEGATIVE).performClick()
        ShadowLooper.runUiThreadTasksIncludingDelayedTasks()

        assertThat(Settings.Secure.getInt(context.contentResolver, SETTING_KEY))
            .isEqualTo(ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER)
        verify(mockFragResultListener, never()).onFragmentResult(eq(requestKey), any())
    }

    @Test
    fun getMetricsCategory() {
        assertThat(CursorFollowingModeChooser().metricsCategory)
            .isEqualTo(SettingsEnums.DIALOG_MAGNIFICATION_CURSOR_FOLLOWING)
    }

    private fun launchDialog(): Dialog {
        return launchDialogAndChecked(
            initialMode = ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CONTINUOUS,
            checkedMode = null,
        )
    }

    private fun launchDialogAndChecked(
        @AccessibilityMagnificationCursorFollowingMode initialMode: Int,
        @AccessibilityMagnificationCursorFollowingMode checkedMode: Int?,
    ): Dialog {
        Settings.Secure.putInt(context.contentResolver, SETTING_KEY, initialMode)
        CursorFollowingModeChooser.showDialog(
            fragmentManager = fragment.childFragmentManager,
            requestKey = requestKey,
        )
        ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
        var dialog = ShadowDialog.getLatestDialog()
        var listView = getListViewInDialog(dialog)
        val adapter = listView.adapter

        if (checkedMode != null) {
            for (i in 0 until listView.count) {
                if ((adapter.getItem(i) as? CursorFollowingModeInfo)?.mode == checkedMode) {
                    listView.setItemChecked(i, true)
                    break
                }
            }
        }

        return dialog
    }

    private fun getListViewInDialog(dialog: Dialog): ListView {
        return dialog.requireViewById<ListView>(android.R.id.list)
    }

    @AccessibilityMagnificationCursorFollowingMode
    private fun getCheckedMode(listView: ListView): Int? {
        val checkedPosition = listView.checkedItemPosition
        return if (checkedPosition != AdapterView.INVALID_POSITION) {
            (listView.adapter.getItem(checkedPosition) as? CursorFollowingModeInfo)?.mode
        } else {
            null
        }
    }
}