Loading src/com/android/settings/supervision/ConfirmSupervisionCredentialsActivity.kt +19 −7 Original line number Diff line number Diff line Loading @@ -61,7 +61,8 @@ import com.android.settingslib.supervision.SupervisionLog.TAG */ class ConfirmSupervisionCredentialsActivity : FragmentActivity() { private val mAuthenticationCallback = @VisibleForTesting val mAuthenticationCallback = object : AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { Log.w(TAG, "onAuthenticationError(errorCode=$errorCode, errString=$errString)") Loading @@ -70,6 +71,11 @@ class ConfirmSupervisionCredentialsActivity : FragmentActivity() { } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { val authController = SupervisionAuthController.getInstance( this@ConfirmSupervisionCredentialsActivity ) authController.startSession(taskId) setResult(RESULT_OK) finish() } Loading Loading @@ -117,13 +123,19 @@ class ConfirmSupervisionCredentialsActivity : FragmentActivity() { return } val authController = SupervisionAuthController.getInstance(this) if (!authController.isSessionActive(taskId)) { val activityManager = getSystemService(ActivityManager::class.java) if (!activityManager.startProfile(supervisingUser)) { errorHandler("Unable to start supervising user, cannot verify credentials.") return } showBiometricPrompt(supervisingUser.identifier) } else { Log.i(TAG, "Bypassing authentication due to active session") setResult(RESULT_OK) finish() } } @RequiresPermission(allOf = [USE_BIOMETRIC_INTERNAL, SET_BIOMETRIC_DIALOG_ADVANCED]) Loading src/com/android/settings/supervision/SupervisionAuthController.kt 0 → 100644 +102 −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.supervision import android.app.ActivityManager import android.app.ActivityTaskManager import android.app.TaskStackListener import android.content.Context import androidx.annotation.VisibleForTesting import javax.annotation.concurrent.GuardedBy /** * Manages a supervision authentication session. * * After the supervising profile is authenticated, the user should not need to authenticate again * unless they leave the Settings app. This session should persist across interactions with native * settings and those injected from the separate supervision APK. * * Activities responsible for authentication should start a session only after the user has been * successfully authenticated, and the session will be automatically invalidated when the task that * started the session stops running or goes into the background. */ class SupervisionAuthController private constructor(appContext: Context) { private val activityManager = appContext.getSystemService(ActivityManager::class.java) @GuardedBy("this") private var currentTaskId: Int? = null @VisibleForTesting val mTaskStackListener: TaskStackListener = object : TaskStackListener() { override fun onTaskStackChanged() { synchronized(this) { if (currentTaskId != null && !isCurrentTaskFocused()) { invalidateSession() } } } } init { ActivityTaskManager.getInstance().registerTaskStackListener(mTaskStackListener) } /** * Starts an auth session, indicating that a parent has been authenticated on the current task. */ fun startSession(taskId: Int) { synchronized(this) { currentTaskId = taskId } } /** Returns whether an auth session is currently active on this task. */ fun isSessionActive(taskId: Int): Boolean { synchronized(this) { return currentTaskId == taskId } } /** * Invalidates the current session. This should only be done if a task with an active session * goes into the background. */ @GuardedBy("this") private fun invalidateSession() { currentTaskId = null } /** Whether the task with a currently active auth session is running and focused. */ @GuardedBy("this") private fun isCurrentTaskFocused(): Boolean { if (currentTaskId == null) return false val appTasks = activityManager.appTasks ?: emptyList() val task = appTasks.find { it.taskInfo.taskId == currentTaskId } if (task == null) return false return task.taskInfo.isRunning && task.taskInfo.isFocused } companion object { @Volatile @VisibleForTesting var sInstance: SupervisionAuthController? = null fun getInstance(context: Context): SupervisionAuthController { return sInstance ?: synchronized(this) { sInstance ?: SupervisionAuthController(context.applicationContext).also { sInstance = it } } } } } tests/robotests/src/com/android/settings/supervision/ConfirmSupervisionCredentialsActivityTest.kt +26 −10 Original line number Diff line number Diff line Loading @@ -31,7 +31,6 @@ import android.os.Process import android.os.UserHandle import android.os.UserManager import android.os.UserManager.USER_TYPE_PROFILE_SUPERVISING import android.os.UserManager.USER_TYPE_PROFILE_TEST import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.settings.R Loading Loading @@ -93,6 +92,7 @@ class ConfirmSupervisionCredentialsActivityTest { setSystemService(Context.SUPERVISION_SERVICE, mockSupervisionManager) setSystemService(Context.USER_SERVICE, mockUserManager) } SupervisionAuthController.sInstance = null } @Test Loading Loading @@ -139,6 +139,20 @@ class ConfirmSupervisionCredentialsActivityTest { assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED) } @Test fun onCreate_authSessionActive_finishWithResultOK() { ShadowRoleManager.addRoleHolder(ROLE_SYSTEM_SUPERVISION, callingPackage, currentUser) mockUserManager.stub { on { users } doReturn listOf(SUPERVISING_USER_INFO) } mockActivityManager.stub { on { startProfile(any()) } doReturn true } shadowKeyguardManager.setIsDeviceSecure(SUPERVISING_USER_ID, true) SupervisionAuthController.getInstance(context).startSession(mActivity.taskId) mActivityController.setup() assertThat(mActivity.isFinishing).isTrue() assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK) } @Test @Config(sdk = [Build.VERSION_CODES.BAKLAVA]) fun onCreate_callerIsSystemUid_doesNotFinish() { Loading Loading @@ -211,6 +225,17 @@ class ConfirmSupervisionCredentialsActivityTest { assertThat(biometricPrompt.contentView).isNull() } fun onAuthenticationSucceeded_startsAuthSession_returnsResultOK() { mockUserManager.stub { on { users } doReturn listOf(SUPERVISING_USER_INFO) } shadowKeyguardManager.setIsDeviceSecure(SUPERVISING_USER_ID, true) mActivity.mAuthenticationCallback.onAuthenticationSucceeded(null) assertThat(SupervisionAuthController.getInstance(context).isSessionActive(mActivity.taskId)) .isTrue() assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK) } private companion object { const val SUPERVISING_USER_ID = 5 val SUPERVISING_USER_INFO = Loading @@ -221,14 +246,5 @@ class ConfirmSupervisionCredentialsActivityTest { /* flags */ 0, USER_TYPE_PROFILE_SUPERVISING, ) const val TESTING_USER_ID = 6 val TESTING_USER_INFO = UserInfo( TESTING_USER_ID, /* name */ "testing", /* iconPath */ "", /* flags */ 0, USER_TYPE_PROFILE_TEST, ) } } tests/robotests/src/com/android/settings/supervision/SupervisionAuthControllerTest.kt 0 → 100644 +99 −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.supervision import android.app.ActivityManager import android.content.ContextWrapper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import kotlin.test.Test import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.stub @RunWith(AndroidJUnit4::class) class SupervisionAuthControllerTest { private val mockActivityManager = mock<ActivityManager>() private val context = object : ContextWrapper(ApplicationProvider.getApplicationContext()) { override fun getSystemService(name: String): Any = when (name) { getSystemServiceName(ActivityManager::class.java) -> mockActivityManager else -> super.getSystemService(name) } } @Before fun setUp() { SupervisionAuthController.sInstance = null } @Test fun initially_sessionIsNotActive() { val mockTask = mock<ActivityManager.AppTask>().stub { on { taskInfo } doReturn FOCUSED_TASK_INFO } mockActivityManager.stub { on { appTasks } doReturn listOf(mockTask) } val authController = SupervisionAuthController.getInstance(context) assertThat(authController.isSessionActive(TASK_ID)).isFalse() } @Test fun startSession_sessionIsActive() { val mockTask = mock<ActivityManager.AppTask>().stub { on { taskInfo } doReturn FOCUSED_TASK_INFO } mockActivityManager.stub { on { appTasks } doReturn listOf(mockTask) } val authController = SupervisionAuthController.getInstance(context) authController.startSession(TASK_ID) assertThat(authController.isSessionActive(TASK_ID)).isTrue() } @Test fun taskLosesFocus_sessionInvalidated() { val mockTask = mock<ActivityManager.AppTask>().stub { on { taskInfo } doReturn FOCUSED_TASK_INFO } mockActivityManager.stub { on { appTasks } doReturn listOf(mockTask) } val authController = SupervisionAuthController.getInstance(context) authController.startSession(TASK_ID) assertThat(authController.isSessionActive(TASK_ID)).isTrue() mockTask.stub { on { taskInfo } doReturn NOT_FOCUSED_TASK_INFO } authController.mTaskStackListener.onTaskStackChanged() assertThat(authController.isSessionActive(TASK_ID)).isFalse() } private companion object { const val TASK_ID = 100 val FOCUSED_TASK_INFO = ActivityManager.RecentTaskInfo().apply { taskId = TASK_ID isRunning = true isFocused = true } val NOT_FOCUSED_TASK_INFO = ActivityManager.RecentTaskInfo().apply { taskId = TASK_ID isRunning = true isFocused = false } } } Loading
src/com/android/settings/supervision/ConfirmSupervisionCredentialsActivity.kt +19 −7 Original line number Diff line number Diff line Loading @@ -61,7 +61,8 @@ import com.android.settingslib.supervision.SupervisionLog.TAG */ class ConfirmSupervisionCredentialsActivity : FragmentActivity() { private val mAuthenticationCallback = @VisibleForTesting val mAuthenticationCallback = object : AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { Log.w(TAG, "onAuthenticationError(errorCode=$errorCode, errString=$errString)") Loading @@ -70,6 +71,11 @@ class ConfirmSupervisionCredentialsActivity : FragmentActivity() { } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { val authController = SupervisionAuthController.getInstance( this@ConfirmSupervisionCredentialsActivity ) authController.startSession(taskId) setResult(RESULT_OK) finish() } Loading Loading @@ -117,13 +123,19 @@ class ConfirmSupervisionCredentialsActivity : FragmentActivity() { return } val authController = SupervisionAuthController.getInstance(this) if (!authController.isSessionActive(taskId)) { val activityManager = getSystemService(ActivityManager::class.java) if (!activityManager.startProfile(supervisingUser)) { errorHandler("Unable to start supervising user, cannot verify credentials.") return } showBiometricPrompt(supervisingUser.identifier) } else { Log.i(TAG, "Bypassing authentication due to active session") setResult(RESULT_OK) finish() } } @RequiresPermission(allOf = [USE_BIOMETRIC_INTERNAL, SET_BIOMETRIC_DIALOG_ADVANCED]) Loading
src/com/android/settings/supervision/SupervisionAuthController.kt 0 → 100644 +102 −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.supervision import android.app.ActivityManager import android.app.ActivityTaskManager import android.app.TaskStackListener import android.content.Context import androidx.annotation.VisibleForTesting import javax.annotation.concurrent.GuardedBy /** * Manages a supervision authentication session. * * After the supervising profile is authenticated, the user should not need to authenticate again * unless they leave the Settings app. This session should persist across interactions with native * settings and those injected from the separate supervision APK. * * Activities responsible for authentication should start a session only after the user has been * successfully authenticated, and the session will be automatically invalidated when the task that * started the session stops running or goes into the background. */ class SupervisionAuthController private constructor(appContext: Context) { private val activityManager = appContext.getSystemService(ActivityManager::class.java) @GuardedBy("this") private var currentTaskId: Int? = null @VisibleForTesting val mTaskStackListener: TaskStackListener = object : TaskStackListener() { override fun onTaskStackChanged() { synchronized(this) { if (currentTaskId != null && !isCurrentTaskFocused()) { invalidateSession() } } } } init { ActivityTaskManager.getInstance().registerTaskStackListener(mTaskStackListener) } /** * Starts an auth session, indicating that a parent has been authenticated on the current task. */ fun startSession(taskId: Int) { synchronized(this) { currentTaskId = taskId } } /** Returns whether an auth session is currently active on this task. */ fun isSessionActive(taskId: Int): Boolean { synchronized(this) { return currentTaskId == taskId } } /** * Invalidates the current session. This should only be done if a task with an active session * goes into the background. */ @GuardedBy("this") private fun invalidateSession() { currentTaskId = null } /** Whether the task with a currently active auth session is running and focused. */ @GuardedBy("this") private fun isCurrentTaskFocused(): Boolean { if (currentTaskId == null) return false val appTasks = activityManager.appTasks ?: emptyList() val task = appTasks.find { it.taskInfo.taskId == currentTaskId } if (task == null) return false return task.taskInfo.isRunning && task.taskInfo.isFocused } companion object { @Volatile @VisibleForTesting var sInstance: SupervisionAuthController? = null fun getInstance(context: Context): SupervisionAuthController { return sInstance ?: synchronized(this) { sInstance ?: SupervisionAuthController(context.applicationContext).also { sInstance = it } } } } }
tests/robotests/src/com/android/settings/supervision/ConfirmSupervisionCredentialsActivityTest.kt +26 −10 Original line number Diff line number Diff line Loading @@ -31,7 +31,6 @@ import android.os.Process import android.os.UserHandle import android.os.UserManager import android.os.UserManager.USER_TYPE_PROFILE_SUPERVISING import android.os.UserManager.USER_TYPE_PROFILE_TEST import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.settings.R Loading Loading @@ -93,6 +92,7 @@ class ConfirmSupervisionCredentialsActivityTest { setSystemService(Context.SUPERVISION_SERVICE, mockSupervisionManager) setSystemService(Context.USER_SERVICE, mockUserManager) } SupervisionAuthController.sInstance = null } @Test Loading Loading @@ -139,6 +139,20 @@ class ConfirmSupervisionCredentialsActivityTest { assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED) } @Test fun onCreate_authSessionActive_finishWithResultOK() { ShadowRoleManager.addRoleHolder(ROLE_SYSTEM_SUPERVISION, callingPackage, currentUser) mockUserManager.stub { on { users } doReturn listOf(SUPERVISING_USER_INFO) } mockActivityManager.stub { on { startProfile(any()) } doReturn true } shadowKeyguardManager.setIsDeviceSecure(SUPERVISING_USER_ID, true) SupervisionAuthController.getInstance(context).startSession(mActivity.taskId) mActivityController.setup() assertThat(mActivity.isFinishing).isTrue() assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK) } @Test @Config(sdk = [Build.VERSION_CODES.BAKLAVA]) fun onCreate_callerIsSystemUid_doesNotFinish() { Loading Loading @@ -211,6 +225,17 @@ class ConfirmSupervisionCredentialsActivityTest { assertThat(biometricPrompt.contentView).isNull() } fun onAuthenticationSucceeded_startsAuthSession_returnsResultOK() { mockUserManager.stub { on { users } doReturn listOf(SUPERVISING_USER_INFO) } shadowKeyguardManager.setIsDeviceSecure(SUPERVISING_USER_ID, true) mActivity.mAuthenticationCallback.onAuthenticationSucceeded(null) assertThat(SupervisionAuthController.getInstance(context).isSessionActive(mActivity.taskId)) .isTrue() assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK) } private companion object { const val SUPERVISING_USER_ID = 5 val SUPERVISING_USER_INFO = Loading @@ -221,14 +246,5 @@ class ConfirmSupervisionCredentialsActivityTest { /* flags */ 0, USER_TYPE_PROFILE_SUPERVISING, ) const val TESTING_USER_ID = 6 val TESTING_USER_INFO = UserInfo( TESTING_USER_ID, /* name */ "testing", /* iconPath */ "", /* flags */ 0, USER_TYPE_PROFILE_TEST, ) } }
tests/robotests/src/com/android/settings/supervision/SupervisionAuthControllerTest.kt 0 → 100644 +99 −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.supervision import android.app.ActivityManager import android.content.ContextWrapper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import kotlin.test.Test import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.stub @RunWith(AndroidJUnit4::class) class SupervisionAuthControllerTest { private val mockActivityManager = mock<ActivityManager>() private val context = object : ContextWrapper(ApplicationProvider.getApplicationContext()) { override fun getSystemService(name: String): Any = when (name) { getSystemServiceName(ActivityManager::class.java) -> mockActivityManager else -> super.getSystemService(name) } } @Before fun setUp() { SupervisionAuthController.sInstance = null } @Test fun initially_sessionIsNotActive() { val mockTask = mock<ActivityManager.AppTask>().stub { on { taskInfo } doReturn FOCUSED_TASK_INFO } mockActivityManager.stub { on { appTasks } doReturn listOf(mockTask) } val authController = SupervisionAuthController.getInstance(context) assertThat(authController.isSessionActive(TASK_ID)).isFalse() } @Test fun startSession_sessionIsActive() { val mockTask = mock<ActivityManager.AppTask>().stub { on { taskInfo } doReturn FOCUSED_TASK_INFO } mockActivityManager.stub { on { appTasks } doReturn listOf(mockTask) } val authController = SupervisionAuthController.getInstance(context) authController.startSession(TASK_ID) assertThat(authController.isSessionActive(TASK_ID)).isTrue() } @Test fun taskLosesFocus_sessionInvalidated() { val mockTask = mock<ActivityManager.AppTask>().stub { on { taskInfo } doReturn FOCUSED_TASK_INFO } mockActivityManager.stub { on { appTasks } doReturn listOf(mockTask) } val authController = SupervisionAuthController.getInstance(context) authController.startSession(TASK_ID) assertThat(authController.isSessionActive(TASK_ID)).isTrue() mockTask.stub { on { taskInfo } doReturn NOT_FOCUSED_TASK_INFO } authController.mTaskStackListener.onTaskStackChanged() assertThat(authController.isSessionActive(TASK_ID)).isFalse() } private companion object { const val TASK_ID = 100 val FOCUSED_TASK_INFO = ActivityManager.RecentTaskInfo().apply { taskId = TASK_ID isRunning = true isFocused = true } val NOT_FOCUSED_TASK_INFO = ActivityManager.RecentTaskInfo().apply { taskId = TASK_ID isRunning = true isFocused = false } } }