[Thread] add Thread toggle in settings
Allows a user to disable or enable Thread network / radio from
settings.
In this commit, a toggle is added to Settings > Connected devices >
Connection preferences to control Thread state. See the screenshots
below:
1. Thread is on: https://screenshot.googleplex.com/7FqqbzRX6rGwvSb
2. Thread is off: https://screenshot.googleplex.com/AmfRAhzuukULAAG
3. Thread is disabled by airplane mode: https://screenshot.googleplex.com/7zcesyomrveCqFE
4. Thread is searchable: https://screenshot.googleplex.com/9yFL2jeSuEhJwrS
Requirements:
1. the in-take bug: b/327098435
2. See the screenshot above
3. Test with `atest SettingsUnitTests` and manual tests
4. +2 from Yuwen
5. Flagged by "com.android.net.thread.platform.flags.Flags.thread_enabled_platform"
6. It doesn't need B&R, no preferences are added (the state is in
Android framework (mainline module))
7. Confirmed searchable
8. Code written in Kotlin
Bug: 296990038
Bug: 319077562
Test: atest SettingsUnitTests
Change-Id: Ifa2264b8e59a5112290fd0224cb75ad228732077
diff --git a/res/values/strings.xml b/res/values/strings.xml
index def5890..e652235 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -11724,6 +11724,15 @@
<!-- Summary for UWB preference when UWB is unavailable due to regulatory requirements. [CHAR_LIMIT=NONE]-->
<string name="uwb_settings_summary_no_uwb_regulatory">UWB is unavailable in the current location</string>
+ <!-- Title for Thread network preference [CHAR_LIMIT=60] -->
+ <string name="thread_network_settings_title">Thread</string>
+
+ <!-- Summary for Thread network preference. [CHAR_LIMIT=NONE]-->
+ <string name="thread_network_settings_summary">Connect to compatible devices using Thread for a seamless smart home experience</string>
+
+ <!-- Summary for Thread network preference when airplane mode is enabled. [CHAR_LIMIT=NONE]-->
+ <string name="thread_network_settings_summary_airplane_mode">Turn off airplane mode to use Thread</string>
+
<!-- Label for the camera use toggle [CHAR LIMIT=40] -->
<string name="camera_toggle_title">Camera access</string>
<!-- Label for the camera use toggle [CHAR LIMIT=40] -->
diff --git a/res/xml/connected_devices_advanced.xml b/res/xml/connected_devices_advanced.xml
index df97043..443ce58 100644
--- a/res/xml/connected_devices_advanced.xml
+++ b/res/xml/connected_devices_advanced.xml
@@ -63,6 +63,15 @@
settings:userRestriction="no_ultra_wideband_radio"
settings:useAdminDisabledSummary="true"/>
+ <com.android.settingslib.RestrictedSwitchPreference
+ android:key="thread_network_settings"
+ android:title="@string/thread_network_settings_title"
+ android:order="110"
+ android:summary="@string/summary_placeholder"
+ settings:controller="com.android.settings.connecteddevice.threadnetwork.ThreadNetworkPreferenceController"
+ settings:userRestriction="no_thread_network"
+ settings:useAdminDisabledSummary="true"/>
+
<PreferenceCategory
android:key="dashboard_tile_placeholder"
android:order="-8"/>
diff --git a/src/com/android/settings/connecteddevice/threadnetwork/OWNERS b/src/com/android/settings/connecteddevice/threadnetwork/OWNERS
new file mode 100644
index 0000000..4a35359
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/threadnetwork/OWNERS
@@ -0,0 +1 @@
+include platform/packages/modules/Connectivity:/thread/OWNERS
diff --git a/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkPreferenceController.kt b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkPreferenceController.kt
new file mode 100644
index 0000000..10e3f84
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkPreferenceController.kt
@@ -0,0 +1,236 @@
+/*
+ * Copyright (C) 2024 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.connecteddevice.threadnetwork
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.net.thread.ThreadNetworkController
+import android.net.thread.ThreadNetworkController.StateCallback
+import android.net.thread.ThreadNetworkException
+import android.net.thread.ThreadNetworkManager
+import android.os.OutcomeReceiver
+import android.provider.Settings
+import android.util.Log
+import androidx.annotation.VisibleForTesting
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.preference.Preference
+import androidx.preference.PreferenceScreen
+import com.android.net.thread.platform.flags.Flags
+import com.android.settings.R
+import com.android.settings.core.TogglePreferenceController
+import java.util.concurrent.Executor
+
+/** Controller for the "Thread" toggle in "Connected devices > Connection preferences". */
+class ThreadNetworkPreferenceController @VisibleForTesting constructor(
+ context: Context,
+ key: String,
+ private val executor: Executor,
+ private val threadController: BaseThreadNetworkController?
+) : TogglePreferenceController(context, key), LifecycleEventObserver {
+ private val stateCallback: StateCallback
+ private val airplaneModeReceiver: BroadcastReceiver
+ private var threadEnabled = false
+ private var airplaneModeOn = false
+ private var preference: Preference? = null
+
+ /**
+ * A testable interface for [ThreadNetworkController] which is `final`.
+ *
+ * We are in a awkward situation that Android API guideline suggest `final` for API classes
+ * while Robolectric test is being deprecated for platform testing (See
+ * tests/robotests/new_tests_hook.sh). This force us to use "mockito-target-extended" but it's
+ * conflicting with the default "mockito-target" which is somehow indirectly depended by the
+ * `SettingsUnitTests` target.
+ */
+ @VisibleForTesting
+ interface BaseThreadNetworkController {
+ fun setEnabled(
+ enabled: Boolean,
+ executor: Executor,
+ receiver: OutcomeReceiver<Void?, ThreadNetworkException>
+ )
+
+ fun registerStateCallback(executor: Executor, callback: StateCallback)
+
+ fun unregisterStateCallback(callback: StateCallback)
+ }
+
+ constructor(context: Context, key: String) : this(
+ context,
+ key,
+ ContextCompat.getMainExecutor(context),
+ getThreadNetworkController(context)
+ )
+
+ init {
+ stateCallback = newStateCallback()
+ airplaneModeReceiver = newAirPlaneModeReceiver()
+ }
+
+ val isThreadSupportedOnDevice: Boolean
+ get() = threadController != null
+
+ private fun newStateCallback(): StateCallback {
+ return object : StateCallback {
+ override fun onThreadEnableStateChanged(enabledState: Int) {
+ threadEnabled = enabledState == ThreadNetworkController.STATE_ENABLED
+ }
+
+ override fun onDeviceRoleChanged(role: Int) {}
+ }
+ }
+
+ private fun newAirPlaneModeReceiver(): BroadcastReceiver {
+ return object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ airplaneModeOn = isAirplaneModeOn(context)
+ Log.i(TAG, "Airplane mode is " + if (airplaneModeOn) "ON" else "OFF")
+ preference?.let { preference -> updateState(preference) }
+ }
+ }
+ }
+
+ override fun getAvailabilityStatus(): Int {
+ return if (!Flags.threadEnabledPlatform()) {
+ CONDITIONALLY_UNAVAILABLE
+ } else if (!isThreadSupportedOnDevice) {
+ UNSUPPORTED_ON_DEVICE
+ } else if (airplaneModeOn) {
+ DISABLED_DEPENDENT_SETTING
+ } else {
+ AVAILABLE
+ }
+ }
+
+ override fun displayPreference(screen: PreferenceScreen) {
+ super.displayPreference(screen)
+ preference = screen.findPreference(preferenceKey)
+ }
+
+ override fun isChecked(): Boolean {
+ // TODO (b/322742298):
+ // Check airplane mode here because it's planned to disable Thread state in airplane mode
+ // (code in the mainline module). But it's currently not implemented yet (b/322742298).
+ // By design, the toggle should be unchecked in airplane mode, so explicitly check the
+ // airplane mode here to acchieve the same UX.
+ return !airplaneModeOn && threadEnabled
+ }
+
+ override fun setChecked(isChecked: Boolean): Boolean {
+ if (threadController == null) {
+ return false
+ }
+ val action = if (isChecked) "enable" else "disable"
+ threadController.setEnabled(
+ isChecked,
+ executor,
+ object : OutcomeReceiver<Void?, ThreadNetworkException> {
+ override fun onError(e: ThreadNetworkException) {
+ // TODO(b/327549838): gracefully handle the failure by resetting the UI state
+ Log.e(TAG, "Failed to $action Thread", e)
+ }
+
+ override fun onResult(unused: Void?) {
+ Log.d(TAG, "Successfully $action Thread")
+ }
+ })
+ return true
+ }
+
+ override fun onStateChanged(lifecycleOwner: LifecycleOwner, event: Lifecycle.Event) {
+ if (threadController == null) {
+ return
+ }
+
+ when (event) {
+ Lifecycle.Event.ON_START -> {
+ threadController.registerStateCallback(executor, stateCallback)
+ airplaneModeOn = isAirplaneModeOn(mContext)
+ mContext.registerReceiver(
+ airplaneModeReceiver,
+ IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED)
+ )
+ preference?.let { preference -> updateState(preference) }
+ }
+ Lifecycle.Event.ON_STOP -> {
+ threadController.unregisterStateCallback(stateCallback)
+ mContext.unregisterReceiver(airplaneModeReceiver)
+ }
+ else -> {}
+ }
+ }
+
+ override fun updateState(preference: Preference) {
+ super.updateState(preference)
+ preference.isEnabled = !airplaneModeOn
+ refreshSummary(preference)
+ }
+
+ override fun getSummary(): CharSequence {
+ val resId: Int = if (airplaneModeOn) {
+ R.string.thread_network_settings_summary_airplane_mode
+ } else {
+ R.string.thread_network_settings_summary
+ }
+ return mContext.getResources().getString(resId)
+ }
+
+ override fun getSliceHighlightMenuRes(): Int {
+ return R.string.menu_key_connected_devices
+ }
+
+ companion object {
+ private const val TAG = "ThreadNetworkSettings"
+ private fun getThreadNetworkController(context: Context): BaseThreadNetworkController? {
+ if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_THREAD_NETWORK)) {
+ return null
+ }
+ val manager = context.getSystemService(ThreadNetworkManager::class.java) ?: return null
+ val controller = manager.allThreadNetworkControllers[0]
+ return object : BaseThreadNetworkController {
+ override fun setEnabled(
+ enabled: Boolean,
+ executor: Executor,
+ receiver: OutcomeReceiver<Void?, ThreadNetworkException>
+ ) {
+ controller.setEnabled(enabled, executor, receiver)
+ }
+
+ override fun registerStateCallback(executor: Executor, callback: StateCallback) {
+ controller.registerStateCallback(executor, callback)
+ }
+
+ override fun unregisterStateCallback(callback: StateCallback) {
+ controller.unregisterStateCallback(callback)
+ }
+ }
+ }
+
+ private fun isAirplaneModeOn(context: Context): Boolean {
+ return Settings.Global.getInt(
+ context.contentResolver,
+ Settings.Global.AIRPLANE_MODE_ON,
+ 0
+ ) == 1
+ }
+ }
+}
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index b4b79dd..9dbbe18 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -30,6 +30,7 @@
"androidx.test.espresso.intents-nodeps",
"androidx.test.ext.junit",
"androidx.preference_preference",
+ "flag-junit",
"mockito-target-minus-junit4",
"platform-test-annotations",
"platform-test-rules",
diff --git a/tests/unit/src/com/android/settings/conecteddevice/threadnetwork/OWNERS b/tests/unit/src/com/android/settings/conecteddevice/threadnetwork/OWNERS
new file mode 100644
index 0000000..4a35359
--- /dev/null
+++ b/tests/unit/src/com/android/settings/conecteddevice/threadnetwork/OWNERS
@@ -0,0 +1 @@
+include platform/packages/modules/Connectivity:/thread/OWNERS
diff --git a/tests/unit/src/com/android/settings/conecteddevice/threadnetwork/ThreadNetworkPreferenceControllerTest.kt b/tests/unit/src/com/android/settings/conecteddevice/threadnetwork/ThreadNetworkPreferenceControllerTest.kt
new file mode 100644
index 0000000..644095d
--- /dev/null
+++ b/tests/unit/src/com/android/settings/conecteddevice/threadnetwork/ThreadNetworkPreferenceControllerTest.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright (C) 2024 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.connecteddevice.threadnetwork
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.net.thread.ThreadNetworkController.STATE_DISABLED
+import android.net.thread.ThreadNetworkController.STATE_DISABLING
+import android.net.thread.ThreadNetworkController.STATE_ENABLED
+import android.net.thread.ThreadNetworkController.StateCallback
+import android.net.thread.ThreadNetworkException
+import android.os.OutcomeReceiver
+import android.platform.test.flag.junit.SetFlagsRule
+import android.provider.Settings
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.preference.PreferenceManager
+import androidx.preference.SwitchPreference
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.net.thread.platform.flags.Flags
+import com.android.settings.R
+import com.android.settings.core.BasePreferenceController.AVAILABLE
+import com.android.settings.core.BasePreferenceController.CONDITIONALLY_UNAVAILABLE
+import com.android.settings.core.BasePreferenceController.DISABLED_DEPENDENT_SETTING
+import com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE
+import com.android.settings.connecteddevice.threadnetwork.ThreadNetworkPreferenceController.BaseThreadNetworkController
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import java.util.concurrent.Executor
+
+/** Unit tests for [ThreadNetworkPreferenceController]. */
+@RunWith(AndroidJUnit4::class)
+class ThreadNetworkPreferenceControllerTest {
+ @get:Rule
+ val mSetFlagsRule = SetFlagsRule()
+ private lateinit var context: Context
+ private lateinit var executor: Executor
+ private lateinit var controller: ThreadNetworkPreferenceController
+ private lateinit var fakeThreadNetworkController: FakeThreadNetworkController
+ private lateinit var preference: SwitchPreference
+ private val broadcastReceiverArgumentCaptor = ArgumentCaptor.forClass(
+ BroadcastReceiver::class.java
+ )
+
+ @Before
+ fun setUp() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_THREAD_ENABLED_PLATFORM)
+ context = spy(ApplicationProvider.getApplicationContext<Context>())
+ executor = ContextCompat.getMainExecutor(context)
+ fakeThreadNetworkController = FakeThreadNetworkController(executor)
+ controller = newControllerWithThreadFeatureSupported(true)
+ val preferenceManager = PreferenceManager(context)
+ val preferenceScreen = preferenceManager.createPreferenceScreen(context)
+ preference = SwitchPreference(context)
+ preference.key = "thread_network_settings"
+ preferenceScreen.addPreference(preference)
+ controller.displayPreference(preferenceScreen)
+
+ Settings.Global.putInt(context.contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0)
+ }
+
+ private fun newControllerWithThreadFeatureSupported(
+ present: Boolean
+ ): ThreadNetworkPreferenceController {
+ return ThreadNetworkPreferenceController(
+ context,
+ "thread_network_settings" /* key */,
+ executor,
+ if (present) fakeThreadNetworkController else null
+ )
+ }
+
+ @Test
+ fun availabilityStatus_flagDisabled_returnsConditionallyUnavailable() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_THREAD_ENABLED_PLATFORM)
+ assertThat(controller.getAvailabilityStatus()).isEqualTo(CONDITIONALLY_UNAVAILABLE)
+ }
+
+ @Test
+ fun availabilityStatus_airPlaneModeOn_returnsDisabledDependentSetting() {
+ Settings.Global.putInt(context.contentResolver, Settings.Global.AIRPLANE_MODE_ON, 1)
+ controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+
+ assertThat(controller.getAvailabilityStatus()).isEqualTo(DISABLED_DEPENDENT_SETTING)
+ }
+
+ @Test
+ fun availabilityStatus_airPlaneModeOff_returnsAvailable() {
+ Settings.Global.putInt(context.contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0)
+ controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+
+ assertThat(controller.getAvailabilityStatus()).isEqualTo(AVAILABLE)
+ }
+
+ @Test
+ fun availabilityStatus_threadFeatureNotSupported_returnsUnsupported() {
+ controller = newControllerWithThreadFeatureSupported(false)
+ controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+
+ assertThat(fakeThreadNetworkController.registeredStateCallback).isNull()
+ assertThat(controller.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE)
+ }
+
+ @Test
+ fun isChecked_threadSetEnabled_returnsTrue() {
+ fakeThreadNetworkController.setEnabled(true, executor) { }
+ controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+
+ assertThat(controller.isChecked).isTrue()
+ }
+
+ @Test
+ fun isChecked_threadSetDisabled_returnsFalse() {
+ fakeThreadNetworkController.setEnabled(false, executor) { }
+ controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+
+ assertThat(controller.isChecked).isFalse()
+ }
+
+ @Test
+ fun setChecked_setChecked_threadIsEnabled() {
+ controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+
+ controller.setChecked(true)
+
+ assertThat(fakeThreadNetworkController.isEnabled).isTrue()
+ }
+
+ @Test
+ fun setChecked_setUnchecked_threadIsDisabled() {
+ controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+
+ controller.setChecked(false)
+
+ assertThat(fakeThreadNetworkController.isEnabled).isFalse()
+ }
+
+ @Test
+ fun updatePreference_airPlaneModeOff_preferenceEnabled() {
+ Settings.Global.putInt(context.contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0)
+ controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+
+ assertThat(preference.isEnabled).isTrue()
+ assertThat(preference.summary).isEqualTo(
+ context.resources.getString(R.string.thread_network_settings_summary)
+ )
+ }
+
+ @Test
+ fun updatePreference_airPlaneModeOn_preferenceDisabled() {
+ Settings.Global.putInt(context.contentResolver, Settings.Global.AIRPLANE_MODE_ON, 1)
+ controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+
+ assertThat(preference.isEnabled).isFalse()
+ assertThat(preference.summary).isEqualTo(
+ context.resources.getString(R.string.thread_network_settings_summary_airplane_mode)
+ )
+ }
+
+ @Test
+ fun updatePreference_airPlaneModeTurnedOn_preferenceDisabled() {
+ Settings.Global.putInt(context.contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0)
+ startControllerAndCaptureCallbacks()
+
+ Settings.Global.putInt(context.contentResolver, Settings.Global.AIRPLANE_MODE_ON, 1)
+ broadcastReceiverArgumentCaptor.value.onReceive(context, Intent())
+
+ assertThat(preference.isEnabled).isFalse()
+ assertThat(preference.summary).isEqualTo(
+ context.resources.getString(R.string.thread_network_settings_summary_airplane_mode)
+ )
+ }
+
+ private fun startControllerAndCaptureCallbacks() {
+ controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+ verify(context)!!.registerReceiver(broadcastReceiverArgumentCaptor.capture(), any())
+ }
+
+ private class FakeThreadNetworkController(private val executor: Executor) :
+ BaseThreadNetworkController {
+ var isEnabled = true
+ private set
+ var registeredStateCallback: StateCallback? = null
+ private set
+
+ override fun setEnabled(
+ enabled: Boolean,
+ executor: Executor,
+ receiver: OutcomeReceiver<Void?, ThreadNetworkException>
+ ) {
+ isEnabled = enabled
+ if (registeredStateCallback != null) {
+ if (!isEnabled) {
+ executor.execute {
+ registeredStateCallback!!.onThreadEnableStateChanged(
+ STATE_DISABLING
+ )
+ }
+ executor.execute {
+ registeredStateCallback!!.onThreadEnableStateChanged(
+ STATE_DISABLED
+ )
+ }
+ } else {
+ executor.execute {
+ registeredStateCallback!!.onThreadEnableStateChanged(
+ STATE_ENABLED
+ )
+ }
+ }
+ }
+ executor.execute { receiver.onResult(null) }
+ }
+
+ override fun registerStateCallback(
+ executor: Executor,
+ callback: StateCallback
+ ) {
+ require(callback !== registeredStateCallback) { "callback is already registered" }
+ registeredStateCallback = callback
+ val enabledState =
+ if (isEnabled) STATE_ENABLED else STATE_DISABLED
+ executor.execute { registeredStateCallback!!.onThreadEnableStateChanged(enabledState) }
+ }
+
+ override fun unregisterStateCallback(callback: StateCallback) {
+ requireNotNull(registeredStateCallback) { "callback is already unregistered" }
+ registeredStateCallback = null
+ }
+ }
+}