diff --git a/Android.bp b/Android.bp index 150bdaf84a1..c0a4c7c24f2 100644 --- a/Android.bp +++ b/Android.bp @@ -139,6 +139,7 @@ android_library { "aconfig_settings_flags", "aconfig_settingslib_flags", "android.app.flags-aconfig", + "android.app.supervision.flags-aconfig", "android.provider.flags-aconfig", "android.security.flags-aconfig", "android.view.contentprotection.flags-aconfig", diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 8f5699c7572..90119a9a57b 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -2817,6 +2817,15 @@ + + + + + + + Allow all sites + + Enter supervision PIN %1$s animation diff --git a/src/com/android/settings/supervision/ConfirmSupervisionCredentialsActivity.kt b/src/com/android/settings/supervision/ConfirmSupervisionCredentialsActivity.kt new file mode 100644 index 00000000000..b459f5383b5 --- /dev/null +++ b/src/com/android/settings/supervision/ConfirmSupervisionCredentialsActivity.kt @@ -0,0 +1,101 @@ +/* + * 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.supervision + +import android.Manifest.permission.USE_BIOMETRIC +import android.app.Activity +import android.content.pm.PackageManager +import android.hardware.biometrics.BiometricManager +import android.hardware.biometrics.BiometricPrompt +import android.hardware.biometrics.BiometricPrompt.AuthenticationCallback +import android.os.Bundle +import android.os.CancellationSignal +import android.util.Log +import androidx.annotation.RequiresPermission +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.android.settings.R + +/** + * Activity for confirming supervision credentials using device credential authentication. + * + * This activity displays an authentication prompt to the user, requiring them to authenticate using + * their device credentials (PIN, pattern, or password). It is specifically designed for verifying + * credentials for supervision purposes. + * + * It returns `Activity.RESULT_OK` if authentication succeeds, and `Activity.RESULT_CANCELED` if + * authentication fails or is canceled by the user. + * + * Usage: + * 1. Start this activity using `startActivityForResult()`. + * 2. Handle the result in `onActivityResult()`. + * + * Permissions: + * - Requires `android.permission.USE_BIOMETRIC`. + */ +class ConfirmSupervisionCredentialsActivity : FragmentActivity() { + private val mAuthenticationCallback = + object : AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + Log.w(TAG, "onAuthenticationError(errorCode=$errorCode, errString=$errString)") + setResult(Activity.RESULT_CANCELED) + finish() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { + setResult(Activity.RESULT_OK) + finish() + } + + override fun onAuthenticationFailed() { + setResult(Activity.RESULT_CANCELED) + finish() + } + } + + @RequiresPermission(USE_BIOMETRIC) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // TODO(b/392961554): Check if caller is the SYSTEM_SUPERVISION role holder. Call + // RoleManager#getRoleHolders(SYSTEM_SUPERVISION) and check if getCallingPackage() is in the + // list. + if (checkCallingOrSelfPermission(USE_BIOMETRIC) == PackageManager.PERMISSION_GRANTED) { + showBiometricPrompt() + } + } + + @RequiresPermission(USE_BIOMETRIC) + fun showBiometricPrompt() { + // TODO(b/392961554): adapts to new user profile type to trigger PIN verification dialog. + val biometricPrompt = + BiometricPrompt.Builder(this) + .setTitle(getString(R.string.supervision_full_screen_pin_verification_title)) + .setConfirmationRequired(true) + .setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL) + .build() + biometricPrompt.authenticate( + CancellationSignal(), + ContextCompat.getMainExecutor(this), + mAuthenticationCallback, + ) + } + + companion object { + // TODO(b/392961554): remove this tag and use shared tag after http://ag/31997167 is + // submitted. + const val TAG = "SupervisionSettings" + } +} diff --git a/src/com/android/settings/supervision/SupervisionDashboardScreen.kt b/src/com/android/settings/supervision/SupervisionDashboardScreen.kt index 86f77f726b6..674c0f3a7fb 100644 --- a/src/com/android/settings/supervision/SupervisionDashboardScreen.kt +++ b/src/com/android/settings/supervision/SupervisionDashboardScreen.kt @@ -56,7 +56,7 @@ class SupervisionDashboardScreen : PreferenceScreenCreator { override fun getPreferenceHierarchy(context: Context) = preferenceHierarchy(context, this) { - +SupervisionMainSwitchPreference() + +SupervisionMainSwitchPreference(context) +TitlelessPreferenceGroup(SUPERVISION_DYNAMIC_GROUP_1) += { +SupervisionWebContentFiltersScreen.KEY } diff --git a/src/com/android/settings/supervision/SupervisionMainSwitchPreference.kt b/src/com/android/settings/supervision/SupervisionMainSwitchPreference.kt index 5a84137ecb6..88afc55147c 100644 --- a/src/com/android/settings/supervision/SupervisionMainSwitchPreference.kt +++ b/src/com/android/settings/supervision/SupervisionMainSwitchPreference.kt @@ -15,8 +15,10 @@ */ package com.android.settings.supervision +import android.app.Activity import android.app.supervision.SupervisionManager import android.content.Context +import android.content.Intent import androidx.preference.Preference import com.android.settings.R import com.android.settingslib.datastore.KeyValueStore @@ -32,19 +34,22 @@ import com.android.settingslib.preference.MainSwitchPreferenceBinding import com.android.settingslib.preference.forEachRecursively /** Main toggle to enable or disable device supervision. */ -class SupervisionMainSwitchPreference : +class SupervisionMainSwitchPreference(context: Context) : MainSwitchPreference(KEY, R.string.device_supervision_switch_title), PreferenceSummaryProvider, MainSwitchPreferenceBinding, Preference.OnPreferenceChangeListener, PreferenceLifecycleProvider { + private val supervisionMainSwitchStorage = SupervisionMainSwitchStorage(context) + private lateinit var lifeCycleContext: PreferenceLifecycleContext + // TODO(b/383568136): Make presence of summary conditional on whether PIN // has been set up before or not. override fun getSummary(context: Context): CharSequence? = context.getString(R.string.device_supervision_switch_no_pin_summary) - override fun storage(context: Context): KeyValueStore = SupervisionMainSwitchStorage(context) + override fun storage(context: Context): KeyValueStore = supervisionMainSwitchStorage override fun getReadPermit(context: Context, callingPid: Int, callingUid: Int) = ReadWritePermit.DISALLOW @@ -55,26 +60,49 @@ class SupervisionMainSwitchPreference : override val sensitivityLevel: Int get() = SensitivityLevel.HIGH_SENSITIVITY + override fun onCreate(context: PreferenceLifecycleContext) { + lifeCycleContext = context + } + + override fun onResume(context: PreferenceLifecycleContext) { + updateDependentPreferencesEnabledState( + context.findPreference(KEY), + supervisionMainSwitchStorage.getBoolean(KEY)!!, + ) + } + + override fun onActivityResult( + context: PreferenceLifecycleContext, + requestCode: Int, + resultCode: Int, + data: Intent?, + ): Boolean { + if (resultCode == Activity.RESULT_OK) { + val mainSwitchPreference = + context.requirePreference(KEY) + val newValue = !supervisionMainSwitchStorage.getBoolean(KEY)!! + mainSwitchPreference.setChecked(newValue) + updateDependentPreferencesEnabledState(mainSwitchPreference, newValue) + } + + return true + } + override fun bind(preference: Preference, metadata: PreferenceMetadata) { super.bind(preference, metadata) preference.onPreferenceChangeListener = this } - override fun onResume(context: PreferenceLifecycleContext) { - val currentValue = storage(context.applicationContext)?.getBoolean(key) ?: false - - updateDependentPreferencesEnabledState( - context.findPreference(KEY), - currentValue, - ) - } - override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { if (newValue !is Boolean) return true - updateDependentPreferencesEnabledState(preference, newValue) - - return true + val intent = Intent(lifeCycleContext, ConfirmSupervisionCredentialsActivity::class.java) + lifeCycleContext.startActivityForResult( + intent, + REQUEST_CODE_CONFIRM_SUPERVISION_CREDENTIALS, + null, + ) + return false } private fun updateDependentPreferencesEnabledState( @@ -83,9 +111,8 @@ class SupervisionMainSwitchPreference : ) { preference?.parent?.forEachRecursively { if ( - it.parent?.key?.toString() == - SupervisionDashboardScreen.SUPERVISION_DYNAMIC_GROUP_1 || - it.key?.toString() == SupervisionPinManagementScreen.KEY + it.parent?.key == SupervisionDashboardScreen.SUPERVISION_DYNAMIC_GROUP_1 || + it.key == SupervisionPinManagementScreen.KEY ) { it.isEnabled = isChecked } @@ -103,7 +130,6 @@ class SupervisionMainSwitchPreference : as T override fun setValue(key: String, valueType: Class, value: T?) { - // TODO(b/392694561): add PIN protection to main toggle. if (key == KEY && value is Boolean) { val supervisionManager = context.getSystemService(SupervisionManager::class.java) supervisionManager?.setSupervisionEnabled(value) @@ -113,5 +139,6 @@ class SupervisionMainSwitchPreference : companion object { const val KEY = "device_supervision_switch" + const val REQUEST_CODE_CONFIRM_SUPERVISION_CREDENTIALS = 0 } } diff --git a/tests/robotests/src/com/android/settings/supervision/SupervisionDashboardScreenTest.kt b/tests/robotests/src/com/android/settings/supervision/SupervisionDashboardScreenTest.kt index d5fa2972513..bf579d9fd89 100644 --- a/tests/robotests/src/com/android/settings/supervision/SupervisionDashboardScreenTest.kt +++ b/tests/robotests/src/com/android/settings/supervision/SupervisionDashboardScreenTest.kt @@ -15,6 +15,7 @@ */ package com.android.settings.supervision +import android.app.Activity import android.app.supervision.flags.Flags import android.content.Context import android.platform.test.annotations.DisableFlags @@ -24,6 +25,7 @@ import androidx.fragment.app.testing.FragmentScenario import androidx.preference.Preference import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settings.supervision.SupervisionMainSwitchPreference.Companion.REQUEST_CODE_CONFIRM_SUPERVISION_CREDENTIALS import com.android.settingslib.widget.MainSwitchPreference import com.google.common.truth.Truth.assertThat import org.junit.Rule @@ -57,7 +59,7 @@ class SupervisionDashboardScreenTest { @Test @EnableFlags(Flags.FLAG_ENABLE_SUPERVISION_SETTINGS_SCREEN) - fun toggleMainSwitch_disablesChildPreferences() { + fun toggleMainSwitch_pinVerificationSucceeded_enablesChildPreferences() { FragmentScenario.launchInContainer(preferenceScreenCreator.fragmentClass()).onFragment { fragment -> val mainSwitchPreference = @@ -68,8 +70,38 @@ class SupervisionDashboardScreenTest { assertThat(childPreference.isEnabled).isFalse() mainSwitchPreference.performClick() + // Pretend the PIN verification succeeded. + fragment.onActivityResult( + requestCode = REQUEST_CODE_CONFIRM_SUPERVISION_CREDENTIALS, + resultCode = Activity.RESULT_OK, + data = null, + ) assertThat(childPreference.isEnabled).isTrue() } } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_SUPERVISION_SETTINGS_SCREEN) + fun toggleMainSwitch_pinVerificationFailed_childPreferencesRemainDisabled() { + FragmentScenario.launchInContainer(preferenceScreenCreator.fragmentClass()).onFragment { + fragment -> + val mainSwitchPreference = + fragment.findPreference(SupervisionMainSwitchPreference.KEY)!! + val childPreference = + fragment.findPreference(SupervisionPinManagementScreen.KEY)!! + + assertThat(childPreference.isEnabled).isFalse() + + mainSwitchPreference.performClick() + // Pretend the PIN verification failed. + fragment.onActivityResult( + requestCode = REQUEST_CODE_CONFIRM_SUPERVISION_CREDENTIALS, + resultCode = Activity.RESULT_CANCELED, + data = null, + ) + + assertThat(childPreference.isEnabled).isFalse() + } + } } diff --git a/tests/robotests/src/com/android/settings/supervision/SupervisionMainSwitchPreferenceTest.kt b/tests/robotests/src/com/android/settings/supervision/SupervisionMainSwitchPreferenceTest.kt index 8b15c29b869..c7c393d9f9e 100644 --- a/tests/robotests/src/com/android/settings/supervision/SupervisionMainSwitchPreferenceTest.kt +++ b/tests/robotests/src/com/android/settings/supervision/SupervisionMainSwitchPreferenceTest.kt @@ -15,25 +15,33 @@ */ package com.android.settings.supervision +import android.app.Activity import android.app.supervision.SupervisionManager import android.content.Context import android.content.ContextWrapper +import android.content.Intent +import androidx.preference.Preference import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settings.supervision.SupervisionMainSwitchPreference.Companion.REQUEST_CODE_CONFIRM_SUPERVISION_CREDENTIALS +import com.android.settingslib.metadata.PreferenceLifecycleContext import com.android.settingslib.preference.createAndBindWidget import com.android.settingslib.widget.MainSwitchPreference import com.google.common.truth.Truth.assertThat +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.stub import org.mockito.kotlin.verify @RunWith(AndroidJUnit4::class) class SupervisionMainSwitchPreferenceTest { - private val preference = SupervisionMainSwitchPreference() - + private val mockLifeCycleContext = mock() private val mockSupervisionManager = mock() private val appContext: Context = ApplicationProvider.getApplicationContext() @@ -46,6 +54,13 @@ class SupervisionMainSwitchPreferenceTest { } } + private val preference = SupervisionMainSwitchPreference(context) + + @Before + fun setUp() { + preference.onCreate(mockLifeCycleContext) + } + @Test fun checked_supervisionEnabled_returnTrue() { setSupervisionEnabled(true) @@ -61,7 +76,7 @@ class SupervisionMainSwitchPreferenceTest { } @Test - fun toggleOn() { + fun toggleOn_triggersPinVerification() { setSupervisionEnabled(false) val widget = getMainSwitchPreference() @@ -69,26 +84,90 @@ class SupervisionMainSwitchPreferenceTest { widget.performClick() + verifyConfirmSupervisionCredentialsActivityStarted() + assertThat(widget.isChecked).isFalse() + verify(mockSupervisionManager, never()).setSupervisionEnabled(false) + } + + @Test + fun toggleOn_pinVerificationSucceeded_supervisionEnabled() { + setSupervisionEnabled(false) + val widget = getMainSwitchPreference() + + assertThat(widget.isChecked).isFalse() + + preference.onActivityResult( + mockLifeCycleContext, + REQUEST_CODE_CONFIRM_SUPERVISION_CREDENTIALS, + Activity.RESULT_OK, + null, + ) + assertThat(widget.isChecked).isTrue() verify(mockSupervisionManager).setSupervisionEnabled(true) } @Test - fun toggleOff() { + fun toggleOff_pinVerificationSucceeded_supervisionDisabled() { setSupervisionEnabled(true) val widget = getMainSwitchPreference() assertThat(widget.isChecked).isTrue() - widget.performClick() + preference.onActivityResult( + mockLifeCycleContext, + REQUEST_CODE_CONFIRM_SUPERVISION_CREDENTIALS, + Activity.RESULT_OK, + null, + ) assertThat(widget.isChecked).isFalse() verify(mockSupervisionManager).setSupervisionEnabled(false) } - private fun getMainSwitchPreference(): MainSwitchPreference = - preference.createAndBindWidget(context) + @Test + fun toggleOff_pinVerificationFailed_supervisionNotEnabled() { + setSupervisionEnabled(true) + val widget = getMainSwitchPreference() + + assertThat(widget.isChecked).isTrue() + + preference.onActivityResult( + mockLifeCycleContext, + REQUEST_CODE_CONFIRM_SUPERVISION_CREDENTIALS, + Activity.RESULT_CANCELED, + null, + ) + + assertThat(widget.isChecked).isTrue() + verify(mockSupervisionManager, never()).setSupervisionEnabled(true) + } private fun setSupervisionEnabled(enabled: Boolean) = mockSupervisionManager.stub { on { isSupervisionEnabled } doReturn enabled } + + private fun getMainSwitchPreference(): MainSwitchPreference { + val widget: MainSwitchPreference = preference.createAndBindWidget(context) + + mockLifeCycleContext.stub { + on { findPreference(SupervisionMainSwitchPreference.KEY) } doReturn widget + on { + requirePreference(SupervisionMainSwitchPreference.KEY) + } doReturn widget + } + return widget + } + + private fun verifyConfirmSupervisionCredentialsActivityStarted() { + val intentCaptor = argumentCaptor() + verify(mockLifeCycleContext) + .startActivityForResult( + intentCaptor.capture(), + eq(REQUEST_CODE_CONFIRM_SUPERVISION_CREDENTIALS), + eq(null), + ) + assertThat(intentCaptor.allValues.size).isEqualTo(1) + assertThat(intentCaptor.firstValue.component?.className) + .isEqualTo(ConfirmSupervisionCredentialsActivity::class.java.name) + } }