From 16e4359e9a09e8c7e4a6714f43944a76b5ac84e8 Mon Sep 17 00:00:00 2001 From: Datawav <222291538+Datawav@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:29:22 +0000 Subject: [PATCH] SystemUI: authenticate before disabling airplane mode --- .../android/ext/settings/ExtSettings.java | 4 + core/java/android/provider/Settings.java | 5 + .../android/providers/settings/global.proto | 3 + .../settings/SettingsProtoDumpUtil.java | 3 + .../systemui/qs/tiles/AirplaneModeTileTest.kt | 66 ++++++ ...irplaneModeTileUserActionInteractorTest.kt | 42 +++- ...irplaneModeAuthenticationInteractorTest.kt | 220 ++++++++++++++++++ packages/SystemUI/res/values/strings.xml | 3 + .../systemui/qs/tiles/AirplaneModeTile.java | 31 ++- .../InternetDetailsContentController.java | 22 +- .../AirplaneModeTileUserActionInteractor.kt | 7 +- .../AirplaneModeAuthenticationInteractor.kt | 167 +++++++++++++ .../InternetDetailsContentControllerTest.java | 48 +++- 13 files changed, 614 insertions(+), 7 deletions(-) create mode 100644 packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/airplane/domain/interactor/AirplaneModeAuthenticationInteractorTest.kt create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/pipeline/airplane/domain/interactor/AirplaneModeAuthenticationInteractor.kt diff --git a/core/java/android/ext/settings/ExtSettings.java b/core/java/android/ext/settings/ExtSettings.java index 821f3d2384ce2..7017dbabd3427 100644 --- a/core/java/android/ext/settings/ExtSettings.java +++ b/core/java/android/ext/settings/ExtSettings.java @@ -100,6 +100,10 @@ public class ExtSettings { public static final BoolSetting DISALLOW_DELAYED_LOCKING_ON_USER_STOP = new BoolSetting( Setting.Scope.PER_USER, Settings.Secure.DISALLOW_DELAYED_LOCKING_ON_USER_STOP, false); + public static final BoolSetting REQUIRE_AUTHENTICATION_TO_DISABLE_AIRPLANE_MODE = + new BoolSetting(Setting.Scope.GLOBAL, + Settings.Global.REQUIRE_AUTHENTICATION_TO_DISABLE_AIRPLANE_MODE, false); + private ExtSettings() {} public static Function defaultBool(@BoolRes int res) { diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java index bcf423537bb66..5e3a48cccf203 100644 --- a/core/java/android/provider/Settings.java +++ b/core/java/android/provider/Settings.java @@ -14509,6 +14509,11 @@ public static final class Global extends NameValueTable { @Protected(read = KnownSystemPackage.SYSTEM_UI, readWrite = KnownSystemPackage.SETTINGS) public static final String BATTERY_CHARGE_LIMIT = "battery_charge_limit"; + /** @hide */ + @Protected(read = KnownSystemPackage.SYSTEM_UI, readWrite = KnownSystemPackage.SETTINGS) + public static final String REQUIRE_AUTHENTICATION_TO_DISABLE_AIRPLANE_MODE = + "require_authentication_to_disable_airplane_mode"; + /** @hide */ @Protected(restrictReads = false, readWrite = {KnownSystemPackage.SETTINGS, KnownSystemPackage.SETUP_WIZARD}) diff --git a/core/proto/android/providers/settings/global.proto b/core/proto/android/providers/settings/global.proto index 3065b96126f64..ec253268116a5 100644 --- a/core/proto/android/providers/settings/global.proto +++ b/core/proto/android/providers/settings/global.proto @@ -44,6 +44,9 @@ message GlobalSettingsProto { // are included in the comma-separated list. optional SettingProto radios = 2 [ (android.privacy).dest = DEST_AUTOMATIC ]; optional SettingProto toggleable_radios = 3 [ (android.privacy).dest = DEST_AUTOMATIC ]; + optional SettingProto require_authentication_to_disable_airplane_mode = 4 [ + (android.privacy).dest = DEST_AUTOMATIC + ]; } optional AirplaneMode airplane_mode = 5; diff --git a/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java b/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java index 6a6d1c26e98dd..bdfe5c52d3d6e 100644 --- a/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java +++ b/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java @@ -201,6 +201,9 @@ private static void dumpProtoGlobalSettingsLocked( dumpSetting(s, p, Settings.Global.AIRPLANE_MODE_TOGGLEABLE_RADIOS, GlobalSettingsProto.AirplaneMode.TOGGLEABLE_RADIOS); + dumpSetting(s, p, + Settings.Global.REQUIRE_AUTHENTICATION_TO_DISABLE_AIRPLANE_MODE, + GlobalSettingsProto.AirplaneMode.REQUIRE_AUTHENTICATION_TO_DISABLE_AIRPLANE_MODE); p.end(airplaneModeToken); dumpSetting(s, p, diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/AirplaneModeTileTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/AirplaneModeTileTest.kt index c9a9c96dcb927..8bdd3a6e93b09 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/AirplaneModeTileTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/AirplaneModeTileTest.kt @@ -17,7 +17,9 @@ package com.android.systemui.qs.tiles import android.net.ConnectivityManager +import android.os.CancellationSignal import android.os.Handler +import android.provider.Settings.Global import android.testing.TestableLooper import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest @@ -28,6 +30,7 @@ import com.android.systemui.classifier.FalsingManagerFake import com.android.systemui.plugins.ActivityStarter import com.android.systemui.plugins.qs.QSTile import com.android.systemui.plugins.statusbar.StatusBarStateController +import com.android.systemui.statusbar.pipeline.airplane.domain.interactor.AirplaneModeAuthenticationInteractor import com.android.systemui.qs.QSHost import com.android.systemui.qs.QsEventLogger import com.android.systemui.qs.logging.QSLogger @@ -63,6 +66,7 @@ class AirplaneModeTileTest : SysuiTestCase() { @Mock private lateinit var mLazyConnectivityManager: Lazy @Mock private lateinit var mConnectivityManager: ConnectivityManager @Mock private lateinit var mGlobalSettings: GlobalSettings + @Mock private lateinit var mAuthenticationInteractor: AirplaneModeAuthenticationInteractor @Mock private lateinit var mUserTracker: UserTracker @Mock private lateinit var mUiEventLogger: QsEventLogger private lateinit var mTestableLooper: TestableLooper @@ -92,6 +96,7 @@ class AirplaneModeTileTest : SysuiTestCase() { mLazyConnectivityManager, mGlobalSettings, mUserTracker, + mAuthenticationInteractor, ) } @@ -129,6 +134,67 @@ class AirplaneModeTileTest : SysuiTestCase() { verify(mConnectivityManager, times(0)).setAirplaneMode(any()) } + @Test + fun handleClick_toDisable_runsAfterAuthentication() { + mTile.state.value = true + Mockito.`when`(mGlobalSettings.getInt(Global.AIRPLANE_MODE_ON, 0)).thenReturn(1) + Mockito.doAnswer { invocation -> + invocation.getArgument(0).run() + null + } + .`when`(mAuthenticationInteractor) + .runAfterAuthentication(any()) + + mTile.handleClick(null) + mTestableLooper.processAllMessages() + + verify(mAuthenticationInteractor).runAfterAuthentication(any()) + verify(mConnectivityManager).setAirplaneMode(false) + } + + @Test + fun handleClick_authenticatedAfterAirplaneModeAlreadyDisabled_doesNotWriteAgain() { + mTile.state.value = true + Mockito.`when`(mGlobalSettings.getInt(Global.AIRPLANE_MODE_ON, 0)).thenReturn(0) + Mockito.doAnswer { invocation -> + invocation.getArgument(0).run() + null + } + .`when`(mAuthenticationInteractor) + .runAfterAuthentication(any()) + + mTile.handleClick(null) + mTestableLooper.processAllMessages() + + verify(mConnectivityManager, times(0)).setAirplaneMode(false) + } + + @Test + fun handleClick_toEnable_doesNotAuthenticate() { + mTile.state.value = false + + mTile.handleClick(null) + mTestableLooper.processAllMessages() + + verify(mAuthenticationInteractor, times(0)).runAfterAuthentication(any()) + verify(mConnectivityManager).setAirplaneMode(true) + } + + @Test + fun destroy_withAuthenticationPending_cancelsPrompt() { + val signal = Mockito.mock(CancellationSignal::class.java) + mTile.state.value = true + Mockito.`when`(mGlobalSettings.getInt(Global.AIRPLANE_MODE_ON, 0)).thenReturn(1) + Mockito.`when`(mAuthenticationInteractor.runAfterAuthentication(any())).thenReturn(signal) + + mTile.handleClick(null) + mTestableLooper.processAllMessages() + mTile.destroy() + mTestableLooper.processAllMessages() + + verify(mAuthenticationInteractor).cancelAuthentication(signal) + } + private fun createExpectedIcon(resId: Int): QSTile.Icon { return DrawableIconWithRes(mContext.getDrawable(resId), resId) } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/airplane/domain/interactor/AirplaneModeTileUserActionInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/airplane/domain/interactor/AirplaneModeTileUserActionInteractorTest.kt index d3d38aae50b35..8f6f0ff74f844 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/airplane/domain/interactor/AirplaneModeTileUserActionInteractorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/airplane/domain/interactor/AirplaneModeTileUserActionInteractorTest.kt @@ -26,6 +26,7 @@ import com.android.systemui.qs.tiles.base.domain.actions.QSTileIntentUserInputHa import com.android.systemui.qs.tiles.base.domain.model.QSTileInputTestKtx.click import com.android.systemui.qs.tiles.base.domain.model.QSTileInputTestKtx.longClick import com.android.systemui.qs.tiles.impl.airplane.domain.model.AirplaneModeTileModel +import com.android.systemui.statusbar.pipeline.airplane.domain.interactor.AirplaneModeAuthenticationInteractor import com.android.systemui.statusbar.pipeline.airplane.data.repository.airplaneModeRepository import com.android.systemui.statusbar.pipeline.airplane.domain.interactor.airplaneModeInteractor import com.android.systemui.statusbar.pipeline.mobile.data.repository.fakeMobileConnectionsRepository @@ -34,6 +35,10 @@ import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @SmallTest @RunWith(AndroidJUnit4::class) @@ -42,9 +47,14 @@ class AirplaneModeTileUserActionInteractorTest : SysuiTestCase() { private val airplaneModeRepository = kosmos.airplaneModeRepository private val inputHandler = FakeQSTileIntentUserInputHandler() + private val authenticationInteractor = mock() private val underTest = - AirplaneModeTileUserActionInteractor(kosmos.airplaneModeInteractor, inputHandler) + AirplaneModeTileUserActionInteractor( + kosmos.airplaneModeInteractor, + authenticationInteractor, + inputHandler, + ) @Test fun handleClickInEcmMode() = runTest { @@ -73,6 +83,36 @@ class AirplaneModeTileUserActionInteractorTest : SysuiTestCase() { assertThat(airplaneModeRepository.isAirplaneMode.value).isTrue() } + @Test + fun handleClickToDisable_authenticationSucceeds_disablesAirplaneMode() = runTest { + airplaneModeRepository.setIsAirplaneMode(true) + whenever(authenticationInteractor.authenticateIfRequired()).thenReturn(true) + + underTest.handleInput(click(AirplaneModeTileModel(true))) + + assertThat(airplaneModeRepository.isAirplaneMode.value).isFalse() + } + + @Test + fun handleClickToDisable_authenticationCancelled_keepsAirplaneModeEnabled() = runTest { + airplaneModeRepository.setIsAirplaneMode(true) + whenever(authenticationInteractor.authenticateIfRequired()).thenReturn(false) + + underTest.handleInput(click(AirplaneModeTileModel(true))) + + assertThat(airplaneModeRepository.isAirplaneMode.value).isTrue() + } + + @Test + fun handleClickToEnable_doesNotAuthenticate() = runTest { + airplaneModeRepository.setIsAirplaneMode(false) + + underTest.handleInput(click(AirplaneModeTileModel(false))) + + verify(authenticationInteractor, never()).authenticateIfRequired() + assertThat(airplaneModeRepository.isAirplaneMode.value).isTrue() + } + @Test fun handleLongClick() = runTest { underTest.handleInput(longClick(AirplaneModeTileModel(false))) diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/airplane/domain/interactor/AirplaneModeAuthenticationInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/airplane/domain/interactor/AirplaneModeAuthenticationInteractorTest.kt new file mode 100644 index 0000000000000..d4fb366441628 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/airplane/domain/interactor/AirplaneModeAuthenticationInteractorTest.kt @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2026 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.statusbar.pipeline.airplane.domain.interactor + +import android.app.KeyguardManager +import android.content.Context +import android.hardware.biometrics.BiometricPrompt +import android.os.CancellationSignal +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.Executor +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@SmallTest +@RunWith(AndroidJUnit4::class) +class AirplaneModeAuthenticationInteractorTest : SysuiTestCase() { + private val keyguardManager = mock() + private val executor = Executor { it.run() } + + @Test + fun runAfterAuthentication_settingDisabled_runsImmediately() { + val underTest = createInteractor(authenticationRequired = false) + var actionRan = false + + underTest.runAfterAuthentication { actionRan = true } + + assertThat(actionRan).isTrue() + verify(keyguardManager, never()).isDeviceSecure + } + + @Test + fun runAfterAuthentication_noSecureLock_runsImmediately() { + whenever(keyguardManager.isDeviceSecure).thenReturn(false) + val underTest = createInteractor(authenticationRequired = true) + var actionRan = false + + underTest.runAfterAuthentication { actionRan = true } + + assertThat(actionRan).isTrue() + } + + @Test + fun runAfterAuthentication_secureLock_waitsForSuccessfulAuthentication() { + whenever(keyguardManager.isDeviceSecure).thenReturn(true) + val underTest = createInteractor(authenticationRequired = true) + var callback: BiometricPrompt.AuthenticationCallback? = null + underTest.showPrompt = { _, _, _, authenticationCallback -> + callback = authenticationCallback + } + var actionRan = false + + underTest.runAfterAuthentication { actionRan = true } + + assertThat(actionRan).isFalse() + callback!!.onAuthenticationSucceeded(mock()) + assertThat(actionRan).isTrue() + } + + @Test + fun runAfterAuthentication_authenticationError_doesNotRunActionAndAllowsRetry() { + whenever(keyguardManager.isDeviceSecure).thenReturn(true) + val underTest = createInteractor(authenticationRequired = true) + var callback: BiometricPrompt.AuthenticationCallback? = null + var promptCount = 0 + underTest.showPrompt = { _, _, _, authenticationCallback -> + promptCount++ + callback = authenticationCallback + } + var actionCount = 0 + + underTest.runAfterAuthentication { actionCount++ } + callback!!.onAuthenticationError(1, "cancelled") + underTest.runAfterAuthentication { actionCount++ } + + assertThat(actionCount).isEqualTo(0) + assertThat(promptCount).isEqualTo(2) + } + + @Test + fun runAfterAuthentication_promptAlreadyShowing_ignoresSecondRequest() { + whenever(keyguardManager.isDeviceSecure).thenReturn(true) + val underTest = createInteractor(authenticationRequired = true) + var promptCount = 0 + underTest.showPrompt = { _, _, _, _ -> promptCount++ } + + underTest.runAfterAuthentication {} + underTest.runAfterAuthentication {} + + assertThat(promptCount).isEqualTo(1) + } + + @Test + fun cancelAuthentication_ownedRequest_cancelsSystemPrompt() { + whenever(keyguardManager.isDeviceSecure).thenReturn(true) + val underTest = createInteractor(authenticationRequired = true) + underTest.showPrompt = { _, _, _, _ -> } + + val signal = underTest.runAfterAuthentication {} + underTest.cancelAuthentication(signal) + + assertThat(signal!!.isCanceled).isTrue() + } + + @Test + fun cancelAuthentication_foreignRequest_keepsOwnedPromptActive() { + whenever(keyguardManager.isDeviceSecure).thenReturn(true) + val underTest = createInteractor(authenticationRequired = true) + underTest.showPrompt = { _, _, _, _ -> } + val ownedSignal = underTest.runAfterAuthentication {} + val foreignSignal = CancellationSignal() + + underTest.cancelAuthentication(foreignSignal) + underTest.cancelAuthentication(ownedSignal) + + assertThat(foreignSignal.isCanceled).isFalse() + assertThat(ownedSignal!!.isCanceled).isTrue() + } + + @Test + fun runAfterAuthentication_usesCurrentUserContextForPromptAndSecurityCheck() { + val userContext = mock() + var checkedContext: Context? = null + val underTest = + AirplaneModeAuthenticationInteractor( + authenticationContext = { userContext }, + mainExecutor = executor, + isAuthenticationRequired = { true }, + isDeviceSecure = { + checkedContext = it + true + }, + ) + var promptContext: Context? = null + underTest.showPrompt = { context, _, _, _ -> promptContext = context } + + underTest.runAfterAuthentication {} + + assertThat(checkedContext).isSameInstanceAs(userContext) + assertThat(promptContext).isSameInstanceAs(userContext) + } + + @Test + fun authenticateIfRequired_authenticationError_returnsFalse() = runTest { + whenever(keyguardManager.isDeviceSecure).thenReturn(true) + val underTest = createInteractor(authenticationRequired = true) + var callback: BiometricPrompt.AuthenticationCallback? = null + underTest.showPrompt = { _, _, _, authenticationCallback -> + callback = authenticationCallback + } + + val result = + async(start = CoroutineStart.UNDISPATCHED) { underTest.authenticateIfRequired() } + callback!!.onAuthenticationError(1, "cancelled") + + assertThat(result.await()).isFalse() + } + + @Test + fun authenticateIfRequired_authenticationSucceeds_returnsTrue() = runTest { + whenever(keyguardManager.isDeviceSecure).thenReturn(true) + val underTest = createInteractor(authenticationRequired = true) + var callback: BiometricPrompt.AuthenticationCallback? = null + underTest.showPrompt = { _, _, _, authenticationCallback -> + callback = authenticationCallback + } + + val result = + async(start = CoroutineStart.UNDISPATCHED) { underTest.authenticateIfRequired() } + callback!!.onAuthenticationSucceeded(mock()) + + assertThat(result.await()).isTrue() + } + + @Test + fun authenticateIfRequired_coroutineCancelled_cancelsSystemPrompt() = runTest { + whenever(keyguardManager.isDeviceSecure).thenReturn(true) + val underTest = createInteractor(authenticationRequired = true) + var cancellationSignal: CancellationSignal? = null + underTest.showPrompt = { _, signal, _, _ -> cancellationSignal = signal } + + val result = + async(start = CoroutineStart.UNDISPATCHED) { underTest.authenticateIfRequired() } + result.cancelAndJoin() + + assertThat(cancellationSignal!!.isCanceled).isTrue() + } + + private fun createInteractor(authenticationRequired: Boolean) = + AirplaneModeAuthenticationInteractor( + authenticationContext = { mContext }, + mainExecutor = executor, + isAuthenticationRequired = { authenticationRequired }, + isDeviceSecure = { keyguardManager.isDeviceSecure }, + ) +} diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml index d132e0eb58bad..3d82d97420ad0 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -4115,6 +4115,9 @@ Turn off airplane mode + + Confirm turning off airplane mode + %1$s wants to add the following tile to Quick Settings diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/AirplaneModeTile.java b/packages/SystemUI/src/com/android/systemui/qs/tiles/AirplaneModeTile.java index 910b21fdcbfea..85fa23c031e2f 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/AirplaneModeTile.java +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/AirplaneModeTile.java @@ -23,6 +23,7 @@ import android.content.Intent; import android.content.IntentFilter; import android.net.ConnectivityManager; +import android.os.CancellationSignal; import android.os.Handler; import android.os.Looper; import android.os.UserManager; @@ -53,6 +54,7 @@ import com.android.systemui.qs.tileimpl.QSTileImpl; import com.android.systemui.res.R; import com.android.systemui.settings.UserTracker; +import com.android.systemui.statusbar.pipeline.airplane.domain.interactor.AirplaneModeAuthenticationInteractor; import com.android.systemui.util.settings.GlobalSettings; import com.android.systemui.util.settings.SettingObserver; @@ -70,6 +72,8 @@ public class AirplaneModeTile extends QSTileImpl { private final SettingObserver mSetting; private final BroadcastDispatcher mBroadcastDispatcher; private final Lazy mLazyConnectivityManager; + private final AirplaneModeAuthenticationInteractor mAuthenticationInteractor; + @Nullable private volatile CancellationSignal mAuthenticationSignal; private boolean mListening; @Nullable @@ -90,12 +94,14 @@ public AirplaneModeTile( BroadcastDispatcher broadcastDispatcher, Lazy lazyConnectivityManager, GlobalSettings globalSettings, - UserTracker userTracker + UserTracker userTracker, + AirplaneModeAuthenticationInteractor authenticationInteractor ) { super(host, uiEventLogger, backgroundLooper, mainHandler, falsingManager, metricsLogger, statusBarStateController, activityStarter, qsLogger); mBroadcastDispatcher = broadcastDispatcher; mLazyConnectivityManager = lazyConnectivityManager; + mAuthenticationInteractor = authenticationInteractor; mSetting = new SettingObserver(globalSettings, mHandler, Global.AIRPLANE_MODE_ON) { @Override @@ -127,7 +133,21 @@ public void handleClick(@Nullable Expandable expandable) { mClickJob = SatelliteDialogUtils.mayStartSatelliteWarningDialog( mContext, this, TYPE_IS_AIRPLANE_MODE, isAllowClick -> { if (isAllowClick) { - setEnabled(!airplaneModeEnabled); + if (airplaneModeEnabled) { + CancellationSignal authenticationSignal = + mAuthenticationInteractor.runAfterAuthentication( + () -> { + mAuthenticationSignal = null; + if (mSetting.getValue() != 0) { + setEnabled(false); + } + }); + if (authenticationSignal != null) { + mAuthenticationSignal = authenticationSignal; + } + } else { + setEnabled(true); + } } return null; }); @@ -137,6 +157,13 @@ private void setEnabled(boolean enabled) { mLazyConnectivityManager.get().setAirplaneMode(enabled); } + @Override + protected void handleDestroy() { + super.handleDestroy(); + mAuthenticationInteractor.cancelAuthentication(mAuthenticationSignal); + mAuthenticationSignal = null; + } + @Override public Intent getLongClickIntent() { return new Intent(Settings.ACTION_AIRPLANE_MODE_SETTINGS); diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/InternetDetailsContentController.java b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/InternetDetailsContentController.java index e8d1cfd137085..5ebb32bda07a7 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/InternetDetailsContentController.java +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/InternetDetailsContentController.java @@ -43,6 +43,7 @@ import android.net.wifi.WifiConfiguration; import android.net.wifi.WifiManager; import android.os.Bundle; +import android.os.CancellationSignal; import android.os.Handler; import android.os.UserHandle; import android.os.UserManager; @@ -96,6 +97,7 @@ import com.android.systemui.shade.domain.interactor.ShadeDialogContextInteractor; import com.android.systemui.statusbar.connectivity.AccessPointController; import com.android.systemui.statusbar.core.NewStatusBarIcons; +import com.android.systemui.statusbar.pipeline.airplane.domain.interactor.AirplaneModeAuthenticationInteractor; import com.android.systemui.statusbar.policy.KeyguardStateController; import com.android.systemui.statusbar.policy.LocationController; import com.android.systemui.toast.SystemUIToast; @@ -192,6 +194,8 @@ public class InternetDetailsContentController implements AccessPointController.A private SubscriptionManager mSubscriptionManager; private TelephonyManager mTelephonyManager; private ConnectivityManager mConnectivityManager; + private final AirplaneModeAuthenticationInteractor mAirplaneModeAuthenticationInteractor; + @Nullable private CancellationSignal mAirplaneModeAuthenticationSignal; private CarrierConfigTracker mCarrierConfigTracker; private Handler mHandler; private Handler mWorkerHandler; @@ -307,7 +311,8 @@ public InternetDetailsContentController(@ShadeDisplayAware Context context, DialogTransitionAnimator dialogTransitionAnimator, WifiStateWorker wifiStateWorker, FeatureFlags featureFlags, ShadeDialogContextInteractor shadeDialogContextInteractor, - UserRepository userRepository + UserRepository userRepository, + AirplaneModeAuthenticationInteractor airplaneModeAuthenticationInteractor ) { if (DEBUG) { Log.d(TAG, "Init InternetDetailsContentController"); @@ -345,6 +350,7 @@ public InternetDetailsContentController(@ShadeDisplayAware Context context, mFeatureFlags = featureFlags; mShadeDialogContextInteractor = shadeDialogContextInteractor; mUserRepository = userRepository; + mAirplaneModeAuthenticationInteractor = airplaneModeAuthenticationInteractor; } void onStart(@NonNull InternetDialogCallback callback, @@ -415,6 +421,9 @@ void onStop() { mKeyguardUpdateMonitor.removeCallback(mKeyguardUpdateCallback); mConnectivityManager.unregisterNetworkCallback(mConnectivityManagerNetworkCallback); mConnectedWifiInternetMonitor.unregisterCallback(); + mAirplaneModeAuthenticationInteractor.cancelAuthentication( + mAirplaneModeAuthenticationSignal); + mAirplaneModeAuthenticationSignal = null; mCallback = null; if (mSatelliteManager != null) { @@ -447,7 +456,16 @@ boolean isAirplaneModeEnabled() { } void setAirplaneModeDisabled() { - mConnectivityManager.setAirplaneMode(false); + CancellationSignal authenticationSignal = + mAirplaneModeAuthenticationInteractor.runAfterAuthentication( + () -> { + if (isAirplaneModeEnabled()) { + mConnectivityManager.setAirplaneMode(false); + } + }); + if (authenticationSignal != null) { + mAirplaneModeAuthenticationSignal = authenticationSignal; + } } protected int getDefaultDataSubscriptionId() { diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/airplane/domain/interactor/AirplaneModeTileUserActionInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/airplane/domain/interactor/AirplaneModeTileUserActionInteractor.kt index e86d951501399..3f329fb0b1fe1 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/airplane/domain/interactor/AirplaneModeTileUserActionInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/airplane/domain/interactor/AirplaneModeTileUserActionInteractor.kt @@ -24,6 +24,7 @@ import com.android.systemui.qs.tiles.base.domain.interactor.QSTileUserActionInte import com.android.systemui.qs.tiles.base.domain.model.QSTileInput import com.android.systemui.qs.tiles.base.shared.model.QSTileUserAction import com.android.systemui.qs.tiles.impl.airplane.domain.model.AirplaneModeTileModel +import com.android.systemui.statusbar.pipeline.airplane.domain.interactor.AirplaneModeAuthenticationInteractor import com.android.systemui.statusbar.pipeline.airplane.domain.interactor.AirplaneModeInteractor import javax.inject.Inject @@ -32,6 +33,7 @@ class AirplaneModeTileUserActionInteractor @Inject constructor( private val airplaneModeInteractor: AirplaneModeInteractor, + private val authenticationInteractor: AirplaneModeAuthenticationInteractor, private val qsTileIntentUserActionHandler: QSTileIntentUserInputHandler, ) : QSTileUserActionInteractor { @@ -39,7 +41,10 @@ constructor( with(input) { when (action) { is QSTileUserAction.Click -> { - when (airplaneModeInteractor.setIsAirplaneMode(!data.isEnabled)) { + val newState = !data.isEnabled + if (!newState && !authenticationInteractor.authenticateIfRequired()) return + if (!newState && !airplaneModeInteractor.isAirplaneMode.value) return + when (airplaneModeInteractor.setIsAirplaneMode(newState)) { AirplaneModeInteractor.SetResult.SUCCESS -> { // do nothing } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/airplane/domain/interactor/AirplaneModeAuthenticationInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/airplane/domain/interactor/AirplaneModeAuthenticationInteractor.kt new file mode 100644 index 0000000000000..ea8b66903f89f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/airplane/domain/interactor/AirplaneModeAuthenticationInteractor.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2026 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.statusbar.pipeline.airplane.domain.interactor + +import android.app.KeyguardManager +import android.content.Context +import android.ext.settings.ExtSettings +import android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG +import android.hardware.biometrics.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import android.hardware.biometrics.BiometricPrompt +import android.os.CancellationSignal +import androidx.annotation.VisibleForTesting +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.res.R +import com.android.systemui.settings.UserTracker +import java.util.concurrent.Executor +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +/** Requests fresh device authentication before user-initiated airplane mode disablement. */ +@SysUISingleton +class AirplaneModeAuthenticationInteractor +@VisibleForTesting +internal constructor( + private val authenticationContext: () -> Context, + @Main private val mainExecutor: Executor, + private val isAuthenticationRequired: (Context) -> Boolean, + private val isDeviceSecure: (Context) -> Boolean, +) { + @Inject + constructor( + userTracker: UserTracker, + @Main mainExecutor: Executor, + ) : this( + authenticationContext = { userTracker.userContext }, + mainExecutor, + isAuthenticationRequired = { context -> + ExtSettings.REQUIRE_AUTHENTICATION_TO_DISABLE_AIRPLANE_MODE.get(context) + }, + isDeviceSecure = { context -> + context.getSystemService(KeyguardManager::class.java).isDeviceSecure + }, + ) + + private val promptLock = Any() + private var cancellationSignal: CancellationSignal? = null + + @VisibleForTesting + internal var showPrompt: + (Context, CancellationSignal, Executor, BiometricPrompt.AuthenticationCallback) -> Unit = + { context, signal, executor, callback -> + BiometricPrompt.Builder(context) + .setTitle(context.getString(R.string.airplane_mode_authentication_title)) + .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + .setConfirmationRequired(true) + .setAllowBackgroundAuthentication(true) + .build() + .authenticate(signal, executor, callback) + } + + /** Runs [action] immediately when disabled, or after successful fresh authentication. */ + fun runAfterAuthentication(action: Runnable): CancellationSignal? = + requestAuthentication { authenticated -> + if (authenticated) action.run() + } + + /** Cancels [signal] only if it still owns the active prompt. */ + fun cancelAuthentication(signal: CancellationSignal?) { + if (signal != null) cancelAuthenticationSignal(signal) + } + + /** Returns whether fresh authentication succeeded or was not required. */ + suspend fun authenticateIfRequired(): Boolean = + suspendCancellableCoroutine { continuation -> + val signal = requestAuthentication { authenticated -> + if (continuation.isActive) continuation.resume(authenticated) + } + continuation.invokeOnCancellation { + if (signal != null) cancelAuthenticationSignal(signal) + } + } + + private fun requestAuthentication(onResult: (Boolean) -> Unit): CancellationSignal? { + val context = authenticationContext() + if (!isAuthenticationRequired(context) || !isDeviceSecure(context)) { + onResult(true) + return null + } + + val signal = + synchronized(promptLock) { + if (cancellationSignal == null) { + CancellationSignal().also { cancellationSignal = it } + } else { + null + } + } + if (signal == null) { + onResult(false) + return null + } + + val callback = + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { + completeAuthentication(signal, onResult, true) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { + completeAuthentication(signal, onResult, false) + } + } + + try { + showPrompt(context, signal, mainExecutor, callback) + } catch (e: RuntimeException) { + completeAuthentication(signal, onResult, false) + } + return signal + } + + private fun completeAuthentication( + signal: CancellationSignal, + onResult: (Boolean) -> Unit, + authenticated: Boolean, + ) { + val shouldComplete = + synchronized(promptLock) { + if (cancellationSignal !== signal) { + false + } else { + cancellationSignal = null + true + } + } + if (shouldComplete) onResult(authenticated) + } + + private fun cancelAuthenticationSignal(signal: CancellationSignal) { + val shouldCancel = + synchronized(promptLock) { + if (cancellationSignal !== signal) { + false + } else { + cancellationSignal = null + true + } + } + if (shouldCancel) signal.cancel() + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/InternetDetailsContentControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/InternetDetailsContentControllerTest.java index d33ad36485ca8..9cccf7fc41a01 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/InternetDetailsContentControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/InternetDetailsContentControllerTest.java @@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -48,6 +49,7 @@ import android.net.NetworkCapabilities; import android.net.wifi.WifiConfiguration; import android.net.wifi.WifiManager; +import android.os.CancellationSignal; import android.os.Handler; import android.os.UserHandle; import android.os.UserManager; @@ -84,6 +86,7 @@ import com.android.systemui.kosmos.KosmosJavaAdapter; import com.android.systemui.plugins.ActivityStarter; import com.android.systemui.res.R; +import com.android.systemui.statusbar.pipeline.airplane.domain.interactor.AirplaneModeAuthenticationInteractor; import com.android.systemui.statusbar.connectivity.AccessPointController; import com.android.systemui.statusbar.policy.KeyguardStateController; import com.android.systemui.statusbar.policy.LocationController; @@ -123,6 +126,7 @@ public class InternetDetailsContentControllerTest extends SysuiTestCase { private static final int SUB_ID2 = 2; private MockitoSession mStaticMockSession; + @Mock private AirplaneModeAuthenticationInteractor mAirplaneModeAuthenticationInteractor; //SystemUIToast private static final int GRAVITY_FLAGS = Gravity.FILL_HORIZONTAL | Gravity.FILL_VERTICAL; @@ -266,7 +270,8 @@ public void setUp() { mock(KeyguardUpdateMonitor.class), mGlobalSettings, mKeyguardStateController, mWindowManager, mToastFactory, mWorkerHandler, mCarrierConfigTracker, mLocationController, mDialogTransitionAnimator, mWifiStateWorker, mFlags, - mKosmos.getShadeDialogContextInteractor(), mUserRepository); + mKosmos.getShadeDialogContextInteractor(), mUserRepository, + mAirplaneModeAuthenticationInteractor); mSubscriptionManager.addOnSubscriptionsChangedListener(mExecutor, mInternetDetailsContentController.mOnSubscriptionsChangedListener); mInternetDetailsContentController.onStart( @@ -289,6 +294,41 @@ public void tearDown() { mContext.getResources().updateConfiguration(mConfig, null); } + @Test + public void setAirplaneModeDisabled_authenticationSucceeds_disablesAirplaneMode() { + when(mGlobalSettings.getInt(AIRPLANE_MODE_ON, 0)).thenReturn(1); + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(mAirplaneModeAuthenticationInteractor).runAfterAuthentication(any()); + + mInternetDetailsContentController.setAirplaneModeDisabled(); + + verify(mAirplaneModeAuthenticationInteractor).runAfterAuthentication(any()); + verify(mConnectivityManager).setAirplaneMode(false); + } + + @Test + public void setAirplaneModeDisabled_authenticatedAfterAlreadyDisabled_doesNotWriteAgain() { + when(mGlobalSettings.getInt(AIRPLANE_MODE_ON, 0)).thenReturn(0); + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(mAirplaneModeAuthenticationInteractor).runAfterAuthentication(any()); + + mInternetDetailsContentController.setAirplaneModeDisabled(); + + verify(mConnectivityManager, never()).setAirplaneMode(false); + } + + @Test + public void setAirplaneModeDisabled_authenticationCancelled_keepsAirplaneModeEnabled() { + mInternetDetailsContentController.setAirplaneModeDisabled(); + + verify(mAirplaneModeAuthenticationInteractor).runAfterAuthentication(any()); + verify(mConnectivityManager, never()).setAirplaneMode(false); + } + @Test public void connectCarrierNetwork_mergedCarrierEntryCanConnect_connectAndCreateSysUiToast() { InternetDetailsContentController spyController = spy(mInternetDetailsContentController); @@ -1329,6 +1369,11 @@ public void getConfiguratorQrCodeGeneratorIntentOrNull_notOwnedNetwork_returnNul @Test public void onStop_cleanUp() { + CancellationSignal signal = mock(CancellationSignal.class); + when(mAirplaneModeAuthenticationInteractor.runAfterAuthentication(any())) + .thenReturn(signal, null); + mInternetDetailsContentController.setAirplaneModeDisabled(); + mInternetDetailsContentController.setAirplaneModeDisabled(); doReturn(SUB_ID).when(mTelephonyManager).getSubscriptionId(); assertThat( mInternetDetailsContentController.mSubIdTelephonyManagerMap.get(SUB_ID)).isEqualTo( @@ -1350,6 +1395,7 @@ public void onStop_cleanUp() { verify(mAccessPointController).removeAccessPointCallback(mInternetDetailsContentController); verify(mConnectivityManager).unregisterNetworkCallback( any(ConnectivityManager.NetworkCallback.class)); + verify(mAirplaneModeAuthenticationInteractor).cancelAuthentication(signal); assertThat(mInternetDetailsContentController.mCallback).isNull(); }