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

Commit 2578043b authored by Chun-Ku Lin's avatar Chun-Ku Lin
Browse files

Make service warning and disable service dialog into DialogFragment

Also, add convenience method for AccessibilityServiceInfo

Bug: 406052931
Test: atest com.android.settings.accessibility
Flag: EXEMPT no real usage on the newly created classes
Change-Id: I14ae7f84282bb6627dd9f6247b2fda9a0b26dead
parent c5c145af
Loading
Loading
Loading
Loading
+51 −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.extensions

import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.text.BidiFormatter
import android.view.accessibility.AccessibilityManager
import com.android.settings.accessibility.AccessibilityStatsLogUtils
import com.android.settingslib.accessibility.AccessibilityUtils

fun AccessibilityServiceInfo.getFeatureName(context: Context): CharSequence {
    val locale = context.resources.configuration.getLocales().get(0)
    return resolveInfo?.loadLabel(context.packageManager)?.let {
        BidiFormatter.getInstance(locale).unicodeWrap(it)
    } ?: ""
}

fun AccessibilityServiceInfo.isServiceWarningRequired(context: Context): Boolean {
    val a11yManager: AccessibilityManager? =
        context.getSystemService(AccessibilityManager::class.java)
    return if (a11yManager != null) {
        return a11yManager.isAccessibilityServiceWarningRequired(this)
    } else {
        return true
    }
}

fun AccessibilityServiceInfo.targetSdkIsAtLeast(sdkVersionCode: Int): Boolean {
    val targetSdk = resolveInfo?.serviceInfo?.applicationInfo?.targetSdkVersion ?: 0
    return targetSdk >= sdkVersionCode
}

fun AccessibilityServiceInfo.useService(context: Context, enabled: Boolean) {
    AccessibilityStatsLogUtils.logAccessibilityServiceEnabled(componentName, enabled)
    AccessibilityUtils.setAccessibilityServiceState(context, componentName, enabled)
}
+179 −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.shared.dialogs

import android.accessibilityservice.AccessibilityServiceInfo
import android.app.Dialog
import android.app.settings.SettingsEnums
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import com.android.internal.accessibility.dialog.AccessibilityServiceWarning
import com.android.settings.accessibility.AccessibilityDialogUtils.DialogEnums
import com.android.settings.accessibility.data.AccessibilityRepositoryProvider
import com.android.settings.core.instrumentation.InstrumentedDialogFragment

class AccessibilityServiceWarningDialogFragment : InstrumentedDialogFragment() {
    private var source: Int = 0
    private lateinit var requestKey: String
    private lateinit var serviceInfo: AccessibilityServiceInfo

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val componentName =
            ComponentName.unflattenFromString(
                requireArguments().getString(ARG_FEATURE_COMPONENT_NAME) ?: ""
            )
        val serviceInfo =
            componentName?.let {
                AccessibilityRepositoryProvider.get(requireContext())
                    .getAccessibilityServiceInfo(it)
            }
        if (serviceInfo == null) {
            dismiss()
        } else {
            this.serviceInfo = serviceInfo
        }

        source = requireArguments().getInt(ARG_SOURCE)
        requestKey = requireArguments().getString(ARG_REQUEST_KEY, "")
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val context: Context = requireContext()
        val allowListener =
            object : View.OnClickListener {
                override fun onClick(v: View?) {
                    setFragmentResult(
                        requestKey,
                        Bundle().apply {
                            putString(RESULT_STATUS, RESULT_STATUS_ALLOW)
                            putInt(RESULT_DIALOG_CONTEXT, source)
                        },
                    )
                    dismiss()
                }
            }
        val denyListener =
            object : View.OnClickListener {
                override fun onClick(v: View?) {
                    setFragmentResult(
                        requestKey,
                        Bundle().apply {
                            putString(RESULT_STATUS, RESULT_STATUS_DENY)
                            putInt(RESULT_DIALOG_CONTEXT, source)
                        },
                    )
                    dismiss()
                }
            }
        val uninstallListener =
            object : View.OnClickListener {
                override fun onClick(v: View?) {
                    uninstallAccessibilityService()
                }
            }

        val alertDialog =
            AlertDialog.Builder(context)
                .setView(
                    AccessibilityServiceWarning.createAccessibilityServiceWarningDialogContentView(
                        context,
                        serviceInfo,
                        allowListener,
                        denyListener,
                        uninstallListener,
                    )
                )
                .setCancelable(true)
                .create()
        alertDialog.window?.run {
            val params = attributes
            params.privateFlags =
                params.privateFlags or
                    WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS
            attributes = params
        }
        return alertDialog
    }

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

    private fun uninstallAccessibilityService() {
        val appInfo = serviceInfo.resolveInfo.serviceInfo.applicationInfo
        val packageUri = ("package:${appInfo.packageName}").toUri()
        val uninstallIntent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, packageUri)
        startActivity(uninstallIntent)
        dismiss()
    }

    companion object {
        private const val ARG_FEATURE_COMPONENT_NAME = "serviceComponentName"
        private const val ARG_SOURCE = "source"
        private const val ARG_REQUEST_KEY = "requestKey"

        @VisibleForTesting const val RESULT_STATUS = "status"
        const val RESULT_STATUS_ALLOW = "allow"
        const val RESULT_STATUS_DENY = "deny"
        @VisibleForTesting const val RESULT_DIALOG_CONTEXT = "dialogContext"

        @JvmStatic
        fun showDialog(
            fragmentManager: FragmentManager,
            componentName: ComponentName,
            @DialogEnums source: Int,
            requestKey: String,
        ) {
            val bundle =
                Bundle().apply {
                    putString(ARG_FEATURE_COMPONENT_NAME, componentName.flattenToString())
                    putInt(ARG_SOURCE, source)
                    putString(ARG_REQUEST_KEY, requestKey)
                }

            AccessibilityServiceWarningDialogFragment().apply {
                arguments = bundle
                show(
                    fragmentManager,
                    /* tag= */ AccessibilityServiceWarningDialogFragment::class.simpleName,
                )
            }
        }

        /** Returns [RESULT_STATUS_ALLOW] or [RESULT_STATUS_DENY] */
        @JvmStatic
        fun getResultStatus(bundle: Bundle): String? {
            return bundle.getString(RESULT_STATUS)
        }

        /** Returns the source dialog enum used when showing the dialog */
        @JvmStatic
        fun getResultDialogContext(bundle: Bundle): Int {
            return bundle.getInt(RESULT_DIALOG_CONTEXT)
        }
    }
}
+107 −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.shared.dialogs

import android.accessibilityservice.AccessibilityServiceInfo
import android.app.Dialog
import android.app.settings.SettingsEnums.DIALOG_ACCESSIBILITY_SERVICE_DISABLE
import android.content.ComponentName
import android.content.DialogInterface
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager
import com.android.settings.R
import com.android.settings.accessibility.data.AccessibilityRepositoryProvider
import com.android.settings.accessibility.extensions.getFeatureName
import com.android.settings.accessibility.extensions.useService
import com.android.settings.core.instrumentation.InstrumentedDialogFragment

private const val ARG_FEATURE_COMPONENT_NAME = "serviceComponentName"

class DisableAccessibilityServiceDialogFragment : InstrumentedDialogFragment() {
    private lateinit var serviceInfo: AccessibilityServiceInfo

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val componentName =
            ComponentName.unflattenFromString(
                requireArguments().getString(ARG_FEATURE_COMPONENT_NAME) ?: ""
            )
        val serviceInfo =
            componentName?.let {
                AccessibilityRepositoryProvider.get(requireContext())
                    .getAccessibilityServiceInfo(it)
            }
        if (serviceInfo == null) {
            dismiss()
        } else {
            this.serviceInfo = serviceInfo
        }
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val context = requireContext()

        val listener =
            object : DialogInterface.OnClickListener {
                override fun onClick(dialog: DialogInterface?, which: Int) {
                    when (which) {
                        DialogInterface.BUTTON_POSITIVE -> {
                            serviceInfo.useService(requireContext(), enabled = false)
                        }
                        DialogInterface.BUTTON_NEGATIVE -> {
                            // Do nothing.
                        }
                    }
                }
            }

        return AlertDialog.Builder(context)
            .setTitle(
                context.getString(
                    R.string.disable_service_title,
                    serviceInfo.getFeatureName(context),
                )
            )
            .setCancelable(true)
            .setPositiveButton(R.string.accessibility_dialog_button_stop, listener)
            .setNegativeButton(R.string.accessibility_dialog_button_cancel, listener)
            .create()
    }

    override fun getMetricsCategory(): Int {
        return DIALOG_ACCESSIBILITY_SERVICE_DISABLE
    }

    companion object {
        @JvmStatic
        fun showDialog(fragmentManager: FragmentManager, componentName: ComponentName) {
            val bundle =
                Bundle().apply {
                    putString(ARG_FEATURE_COMPONENT_NAME, componentName.flattenToString())
                }

            DisableAccessibilityServiceDialogFragment().apply {
                arguments = bundle
                show(
                    fragmentManager,
                    /* tag= */ DisableAccessibilityServiceDialogFragment::class.simpleName,
                )
            }
        }
    }
}
+125 −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.extensions

import android.content.ComponentName
import android.content.Context
import android.os.UserHandle
import android.view.accessibility.AccessibilityManager
import androidx.test.core.app.ApplicationProvider
import com.android.settings.testutils.AccessibilityTestUtils
import com.android.settings.testutils.shadow.ShadowAccessibilityManager
import com.android.settingslib.accessibility.AccessibilityUtils
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.shadow.api.Shadow

/** Tests for AccessibilityServiceInfo extension functions */
@RunWith(RobolectricTestRunner::class)
class AccessibilityServiceInfoExtTest {

    private val context: Context = ApplicationProvider.getApplicationContext()
    private val shadowAccessibilityManager: ShadowAccessibilityManager =
        Shadow.extract(context.getSystemService(AccessibilityManager::class.java))
    private val fakeComponentName = ComponentName("FakePackage", "StandardA11yService")

    @Test
    fun getFeatureName() {
        val serviceInfo =
            AccessibilityTestUtils.createAccessibilityServiceInfo(
                context,
                fakeComponentName,
                /* isAlwaysOnService= */ false,
            )

        assertThat(serviceInfo.getFeatureName(context).toString()).isEqualTo("StandardA11yService")
    }

    @Test
    fun isServiceWarningRequired_required_returnTrue() {
        val serviceInfo =
            AccessibilityTestUtils.createAccessibilityServiceInfo(
                context,
                fakeComponentName,
                /* isAlwaysOnService= */ false,
            )

        assertThat(serviceInfo.isServiceWarningRequired(context)).isTrue()
    }

    @Test
    fun isServiceWarningRequired_notRequired_returnFalse() {
        val serviceInfo =
            AccessibilityTestUtils.createAccessibilityServiceInfo(
                context,
                fakeComponentName,
                /* isAlwaysOnService= */ false,
            )
        shadowAccessibilityManager.setAccessibilityServiceWarningExempted(fakeComponentName)

        assertThat(serviceInfo.isServiceWarningRequired(context)).isFalse()
    }

    @Test
    fun targetSdkIsAtLeast() {
        val serviceInfo =
            AccessibilityTestUtils.createAccessibilityServiceInfo(
                context,
                fakeComponentName,
                /* isAlwaysOnService= */ false,
            )
        val targetSdk = serviceInfo.resolveInfo.serviceInfo.applicationInfo.targetSdkVersion

        assertThat(serviceInfo.targetSdkIsAtLeast(targetSdk + 1)).isFalse()
        assertThat(serviceInfo.targetSdkIsAtLeast(targetSdk)).isTrue()
        assertThat(serviceInfo.targetSdkIsAtLeast(targetSdk - 1)).isTrue()
    }

    @Test
    fun useService_turnOn_enabledServiceSettingContainsService() {
        val serviceInfo =
            AccessibilityTestUtils.createAccessibilityServiceInfo(
                context,
                fakeComponentName,
                /* isAlwaysOnService= */ false,
            )
        serviceInfo.useService(context, enabled = true)

        assertThat(
                AccessibilityUtils.getEnabledServicesFromSettings(context, UserHandle.myUserId())
            )
            .contains(fakeComponentName)
    }

    @Test
    fun useService_turnOff_enabledServiceSettingDoesNotService() {
        val serviceInfo =
            AccessibilityTestUtils.createAccessibilityServiceInfo(
                context,
                fakeComponentName,
                /* isAlwaysOnService= */ false,
            )
        serviceInfo.useService(context, enabled = false)

        assertThat(
                AccessibilityUtils.getEnabledServicesFromSettings(context, UserHandle.myUserId())
            )
            .doesNotContain(fakeComponentName)
    }
}
+171 −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.shared.dialogs

import android.app.settings.SettingsEnums
import android.content.ComponentName
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.os.Bundle
import android.view.accessibility.AccessibilityManager
import android.widget.Button
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentResultListener
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragment
import androidx.test.core.app.ApplicationProvider
import com.android.settings.accessibility.AccessibilityDialogUtils.DialogEnums
import com.android.settings.accessibility.data.AccessibilityRepositoryProvider
import com.android.settings.accessibility.shared.dialogs.AccessibilityServiceWarningDialogFragment.Companion.RESULT_STATUS_ALLOW
import com.android.settings.accessibility.shared.dialogs.AccessibilityServiceWarningDialogFragment.Companion.RESULT_STATUS_DENY
import com.android.settings.accessibility.shared.dialogs.AccessibilityServiceWarningDialogFragment.Companion.getResultDialogContext
import com.android.settings.accessibility.shared.dialogs.AccessibilityServiceWarningDialogFragment.Companion.getResultStatus
import com.android.settings.testutils.AccessibilityTestUtils
import com.android.settings.testutils.shadow.ShadowAccessibilityManager
import com.google.common.truth.Truth.assertThat
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.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.shadow.api.Shadow
import org.robolectric.shadows.ShadowDialog
import org.robolectric.shadows.ShadowLooper

/** Tests for [AccessibilityServiceWarningDialogFragment] */
@RunWith(RobolectricTestRunner::class)
class AccessibilityServiceWarningDialogFragmentTest {
    @get:Rule val mockito = MockitoJUnit.rule()
    private val requestKey = "requestFromTest"
    private val sourceDialogEnum = DialogEnums.ENABLE_WARNING_FROM_TOGGLE
    private val context: Context = ApplicationProvider.getApplicationContext()
    private val shadowAccessibilityManager: ShadowAccessibilityManager =
        Shadow.extract(context.getSystemService(AccessibilityManager::class.java))
    private val fakeComponentName = ComponentName("FakePackage", "StandardA11yService")
    private val serviceInfo =
        AccessibilityTestUtils.createAccessibilityServiceInfo(
            context,
            fakeComponentName,
            /* isAlwaysOnService= */ false,
        )
    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() {
        AccessibilityRepositoryProvider.resetInstanceForTesting()
        shadowAccessibilityManager.setInstalledAccessibilityServiceList(listOf(serviceInfo))
        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 clickAllowButton_replyAllow() {
        getPermissionAllowButton(launchDialog())!!.performClick()

        verify(mockFragResultListener).onFragmentResult(eq(requestKey), responseCaptor.capture())
        val response = responseCaptor.value
        assertThat(getResultStatus(response)).isEqualTo(RESULT_STATUS_ALLOW)
        assertThat(getResultDialogContext(response)).isEqualTo(sourceDialogEnum)
    }

    @Test
    fun clickDeny_replyDeny() {
        getPermissionDenyButton(launchDialog())!!.performClick()

        verify(mockFragResultListener).onFragmentResult(eq(requestKey), responseCaptor.capture())
        val response = responseCaptor.value
        assertThat(getResultStatus(response)).isEqualTo(RESULT_STATUS_DENY)
        assertThat(getResultDialogContext(response)).isEqualTo(sourceDialogEnum)
    }

    @Test
    fun clickUninstallButton_sendUninstallRequest() {
        val alertDialog = launchDialog()
        getUninstallAppButton(alertDialog)!!.performClick()

        verify(mockFragResultListener, never()).onFragmentResult(any(), any())
        val intent =
            Shadows.shadowOf(alertDialog.context as ContextWrapper).peekNextStartedActivity()

        assertThat(intent.action).isEqualTo(Intent.ACTION_UNINSTALL_PACKAGE)
        assertThat(intent.data).isEqualTo(("package:${fakeComponentName.packageName}").toUri())
    }

    @Test
    fun getMetricsCategory() {
        assertThat(AccessibilityServiceWarningDialogFragment().metricsCategory)
            .isEqualTo(SettingsEnums.DIALOG_ACCESSIBILITY_SERVICE_ENABLE)
    }

    private fun launchDialog(): AlertDialog {
        AccessibilityServiceWarningDialogFragment.showDialog(
            fragmentManager = fragment.childFragmentManager,
            componentName = fakeComponentName,
            source = sourceDialogEnum,
            requestKey = requestKey,
        )
        ShadowLooper.runUiThreadTasksIncludingDelayedTasks()

        return ShadowDialog.getLatestDialog() as AlertDialog
    }

    private fun getPermissionAllowButton(alertDialog: AlertDialog): Button? {
        return alertDialog.findViewById(
            com.android.internal.R.id.accessibility_permission_enable_allow_button
        )
    }

    private fun getPermissionDenyButton(alertDialog: AlertDialog): Button? {
        return alertDialog.findViewById(
            com.android.internal.R.id.accessibility_permission_enable_deny_button
        )
    }

    private fun getUninstallAppButton(alertDialog: AlertDialog): Button? {
        return alertDialog.findViewById(
            com.android.internal.R.id.accessibility_permission_enable_uninstall_button
        )
    }
}
Loading