Merge "Set noparent to avoid it recursively looks up. Android biometric team should approve all change in this subdirectories." into main
diff --git a/res/values/strings.xml b/res/values/strings.xml
index d920f1c..bac6af3 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -12076,11 +12076,17 @@
     <!-- 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>
+    <!-- Title for Thread network settings main switch [CHAR_LIMIT=60] -->
+    <string name="thread_network_settings_main_switch_title">Use Thread</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>
+    <!-- Title for Thread network settings footer [CHAR_LIMIT=NONE] -->
+    <string name="thread_network_settings_footer_title">Thread helps connect your smart home devices, boosting efficiency, and performance.\n\nWhen enabled, this device is eligible to join a Thread network, allowing control of Matter supported devices through this phone.</string>
+
+    <!-- Text for Thread network settings learn more link [CHAR_LIMIT=NONE] -->
+    <string name="thread_network_settings_learn_more">Learn more about Thread</string>
+
+    <!-- URL for Thread network settings learn more link [CHAR_LIMIT=NONE] -->
+    <string name="thread_network_settings_learn_more_link" translatable="false">https://developers.home.google.com</string>
 
     <!-- Label for the camera use toggle [CHAR LIMIT=40] -->
     <string name="camera_toggle_title">Camera access</string>
diff --git a/res/xml/connected_devices_advanced.xml b/res/xml/connected_devices_advanced.xml
index b1276d8..49bdbaa 100644
--- a/res/xml/connected_devices_advanced.xml
+++ b/res/xml/connected_devices_advanced.xml
@@ -54,12 +54,23 @@
         settings:keywords="@string/keywords_wifi_display_settings"/>
 
     <com.android.settingslib.RestrictedPreference
-        android:key="connected_device_printing"
-        android:title="@string/print_settings"
-        android:summary="@string/summary_placeholder"
-        android:icon="@*android:drawable/ic_settings_print"
+        android:fragment="com.android.settings.connecteddevice.threadnetwork.ThreadNetworkFragment"
+        android:key="thread_network_settings"
+        android:title="@string/thread_network_settings_title"
+        android:icon="@*android:drawable/ic_thread_network"
+        android:order="-5"
+        settings:searchable="false"
+        settings:controller="com.android.settings.connecteddevice.threadnetwork.ThreadNetworkFragmentController"
+        settings:userRestriction="no_thread_network"
+        settings:useAdminDisabledSummary="true"/>
+
+    <com.android.settingslib.RestrictedPreference
         android:fragment="com.android.settings.print.PrintSettingsFragment"
-        android:order="-3"/>
+        android:icon="@*android:drawable/ic_settings_print"
+        android:key="connected_device_printing"
+        android:order="-3"
+        android:summary="@string/summary_placeholder"
+        android:title="@string/print_settings" />
 
     <SwitchPreferenceCompat
         android:key="uwb_settings"
@@ -70,15 +81,6 @@
         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/res/xml/thread_network_settings.xml b/res/xml/thread_network_settings.xml
new file mode 100644
index 0000000..549d650
--- /dev/null
+++ b/res/xml/thread_network_settings.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+  -->
+<PreferenceScreen
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:settings="http://schemas.android.com/apk/res-auto"
+    android:title="@string/thread_network_settings_title">
+
+    <com.android.settingslib.widget.MainSwitchPreference
+        android:key="toggle_thread_network"
+        android:title="@string/thread_network_settings_main_switch_title"
+        settings:controller="com.android.settings.connecteddevice.threadnetwork.ThreadNetworkToggleController"/>
+
+    <com.android.settingslib.widget.FooterPreference
+        android:key="thread_network_settings_footer"
+        android:title="@string/thread_network_settings_footer_title"
+        android:selectable="false"
+        settings:searchable="false"
+        settings:controller="com.android.settings.connecteddevice.threadnetwork.ThreadNetworkFooterController"/>
+</PreferenceScreen>
diff --git a/src/com/android/settings/connecteddevice/threadnetwork/BaseThreadNetworkController.kt b/src/com/android/settings/connecteddevice/threadnetwork/BaseThreadNetworkController.kt
new file mode 100644
index 0000000..583706a
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/threadnetwork/BaseThreadNetworkController.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.net.thread.ThreadNetworkController
+import android.net.thread.ThreadNetworkController.StateCallback
+import android.net.thread.ThreadNetworkException
+import android.os.OutcomeReceiver
+import androidx.annotation.VisibleForTesting
+import java.util.concurrent.Executor
+
+/**
+ * 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)
+}
\ No newline at end of file
diff --git a/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFooterController.kt b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFooterController.kt
new file mode 100644
index 0000000..1e3b624
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFooterController.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.Context
+import android.util.Log
+import androidx.preference.PreferenceScreen
+import com.android.settings.R
+import com.android.settings.core.BasePreferenceController
+import com.android.settingslib.HelpUtils
+import com.android.settingslib.widget.FooterPreference
+
+/**
+ * The footer preference controller for Thread settings in
+ * "Connected devices > Connection preferences > Thread".
+ */
+class ThreadNetworkFooterController(
+    context: Context,
+    preferenceKey: String
+) : BasePreferenceController(context, preferenceKey) {
+    override fun getAvailabilityStatus(): Int {
+        // The thread_network_settings screen won't be displayed and it doesn't matter if this
+        // controller always return AVAILABLE
+        return AVAILABLE
+    }
+
+    override fun displayPreference(screen: PreferenceScreen) {
+        val footer: FooterPreference? = screen.findPreference(KEY_PREFERENCE_FOOTER)
+        if (footer != null) {
+            footer.setLearnMoreAction { _ -> openLocaleLearnMoreLink() }
+            footer.setLearnMoreText(mContext.getString(R.string.thread_network_settings_learn_more))
+        }
+    }
+
+    private fun openLocaleLearnMoreLink() {
+        val intent = HelpUtils.getHelpIntent(
+            mContext,
+            mContext.getString(R.string.thread_network_settings_learn_more_link),
+            mContext::class.java.name
+        )
+        if (intent != null) {
+            mContext.startActivity(intent)
+        } else {
+            Log.w(TAG, "HelpIntent is null")
+        }
+    }
+
+    companion object {
+        private const val TAG = "ThreadNetworkSettings"
+        private const val KEY_PREFERENCE_FOOTER = "thread_network_settings_footer"
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFragment.kt b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFragment.kt
new file mode 100644
index 0000000..fd385d7
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFragment.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.app.settings.SettingsEnums
+import com.android.settings.R
+import com.android.settings.dashboard.DashboardFragment
+import com.android.settings.search.BaseSearchIndexProvider
+import com.android.settingslib.search.SearchIndexable
+
+/** The fragment for Thread settings in "Connected devices > Connection preferences > Thread". */
+@SearchIndexable(forTarget = SearchIndexable.ALL and SearchIndexable.ARC.inv())
+class ThreadNetworkFragment : DashboardFragment() {
+    override fun getPreferenceScreenResId() = R.xml.thread_network_settings
+
+    override fun getLogTag() = "ThreadNetworkFragment"
+
+    override fun getMetricsCategory() = SettingsEnums.CONNECTED_DEVICE_PREFERENCES_THREAD
+
+    companion object {
+        /** For Search. */
+        @JvmField
+        val SEARCH_INDEX_DATA_PROVIDER = BaseSearchIndexProvider(R.xml.thread_network_settings)
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFragmentController.kt b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFragmentController.kt
new file mode 100644
index 0000000..beb824a
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFragmentController.kt
@@ -0,0 +1,108 @@
+/*
+ * 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.Context
+import android.net.thread.ThreadNetworkController
+import android.net.thread.ThreadNetworkController.StateCallback
+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.settings.R
+import com.android.settings.core.BasePreferenceController
+import com.android.settings.flags.Flags
+import java.util.concurrent.Executor
+
+/**
+ * The fragment controller for Thread settings in
+ * "Connected devices > Connection preferences > Thread".
+ */
+class ThreadNetworkFragmentController @VisibleForTesting constructor(
+    context: Context,
+    preferenceKey: String,
+    private val executor: Executor,
+    private val threadController: BaseThreadNetworkController?
+) : BasePreferenceController(context, preferenceKey), LifecycleEventObserver {
+    private val stateCallback: StateCallback
+    private var threadEnabled = false
+    private var preference: Preference? = null
+
+    constructor(context: Context, preferenceKey: String) : this(
+        context,
+        preferenceKey,
+        ContextCompat.getMainExecutor(context),
+        ThreadNetworkUtils.getThreadNetworkController(context)
+    )
+
+    init {
+        stateCallback = newStateCallback()
+    }
+
+    override fun getAvailabilityStatus(): Int {
+        return if (!Flags.threadSettingsEnabled()) {
+            CONDITIONALLY_UNAVAILABLE
+        } else if (threadController == null) {
+            UNSUPPORTED_ON_DEVICE
+        } else {
+            AVAILABLE
+        }
+    }
+
+    override fun getSummary(): CharSequence {
+        return if (threadEnabled) {
+            mContext.getText(R.string.switch_on_text)
+        } else {
+            mContext.getText(R.string.switch_off_text)
+        }
+    }
+
+    override fun displayPreference(screen: PreferenceScreen) {
+        super.displayPreference(screen)
+        preference = screen.findPreference(preferenceKey)
+    }
+
+    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+        if (threadController == null) {
+            return
+        }
+
+        when (event) {
+            Lifecycle.Event.ON_START ->
+                threadController.registerStateCallback(executor, stateCallback)
+
+            Lifecycle.Event.ON_STOP ->
+                threadController.unregisterStateCallback(stateCallback)
+
+            else -> {}
+        }
+    }
+
+    private fun newStateCallback(): StateCallback {
+        return object : StateCallback {
+            override fun onThreadEnableStateChanged(enabledState: Int) {
+                threadEnabled = enabledState == ThreadNetworkController.STATE_ENABLED
+                preference?.let { preference -> refreshSummary(preference) }
+            }
+
+            override fun onDeviceRoleChanged(role: Int) {}
+        }
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkPreferenceController.kt b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkPreferenceController.kt
deleted file mode 100644
index 1c01750..0000000
--- a/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkPreferenceController.kt
+++ /dev/null
@@ -1,236 +0,0 @@
-/*
- * 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.settings.R
-import com.android.settings.core.TogglePreferenceController
-import com.android.settings.flags.Flags
-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.threadSettingsEnabled()) {
-            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(source: 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/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkToggleController.kt b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkToggleController.kt
new file mode 100644
index 0000000..2af4675
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkToggleController.kt
@@ -0,0 +1,146 @@
+/*
+ * 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.Context
+import android.net.thread.ThreadNetworkController
+import android.net.thread.ThreadNetworkController.StateCallback
+import android.net.thread.ThreadNetworkException
+import android.os.OutcomeReceiver
+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.settings.R
+import com.android.settings.core.TogglePreferenceController
+import com.android.settings.flags.Flags
+import java.util.concurrent.Executor
+
+/**
+ * Controller for the "Use Thread" toggle in "Connected devices > Connection preferences > Thread".
+ */
+class ThreadNetworkToggleController @VisibleForTesting constructor(
+    context: Context,
+    key: String,
+    private val executor: Executor,
+    private val threadController: BaseThreadNetworkController?
+) : TogglePreferenceController(context, key), LifecycleEventObserver {
+    private val stateCallback: StateCallback
+    private var threadEnabled = false
+    private var preference: Preference? = null
+
+    constructor(context: Context, key: String) : this(
+        context,
+        key,
+        ContextCompat.getMainExecutor(context),
+        ThreadNetworkUtils.getThreadNetworkController(context)
+    )
+
+    init {
+        stateCallback = newStateCallback()
+    }
+
+    val isThreadSupportedOnDevice: Boolean
+        get() = threadController != null
+
+    private fun newStateCallback(): StateCallback {
+        return object : StateCallback {
+            override fun onThreadEnableStateChanged(enabledState: Int) {
+                threadEnabled = enabledState == ThreadNetworkController.STATE_ENABLED
+                preference?.let { preference -> updateState(preference) }
+            }
+
+            override fun onDeviceRoleChanged(role: Int) {}
+        }
+    }
+
+    override fun getAvailabilityStatus(): Int {
+        return if (!Flags.threadSettingsEnabled()) {
+            CONDITIONALLY_UNAVAILABLE
+        } else if (!isThreadSupportedOnDevice) {
+            UNSUPPORTED_ON_DEVICE
+        } else {
+            AVAILABLE
+        }
+    }
+
+    override fun displayPreference(screen: PreferenceScreen) {
+        super.displayPreference(screen)
+        preference = screen.findPreference(preferenceKey)
+    }
+
+    override fun isChecked(): Boolean {
+        return threadEnabled
+    }
+
+    override fun setChecked(isChecked: Boolean): Boolean {
+        if (threadController == null) {
+            return false
+        }
+
+        // Avoids dead loop of setChecked -> threadController.setEnabled() ->
+        // StateCallback.onThreadEnableStateChanged -> updateState -> setChecked
+        if (isChecked == isChecked()) {
+            return true
+        }
+
+        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(source: LifecycleOwner, event: Lifecycle.Event) {
+        if (threadController == null) {
+            return
+        }
+
+        when (event) {
+            Lifecycle.Event.ON_START -> {
+                threadController.registerStateCallback(executor, stateCallback)
+            }
+
+            Lifecycle.Event.ON_STOP -> {
+                threadController.unregisterStateCallback(stateCallback)
+            }
+
+            else -> {}
+        }
+    }
+
+    override fun getSliceHighlightMenuRes(): Int {
+        return R.string.menu_key_connected_devices
+    }
+
+    companion object {
+        private const val TAG = "ThreadNetworkSettings"
+    }
+}
diff --git a/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkUtils.kt b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkUtils.kt
new file mode 100644
index 0000000..70830ed
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkUtils.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.Context
+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 androidx.annotation.VisibleForTesting
+import java.util.concurrent.Executor
+
+/** Common utilities for Thread settings classes. */
+object ThreadNetworkUtils {
+    /**
+     * Retrieves the [BaseThreadNetworkController] instance that is backed by the Android
+     * [ThreadNetworkController].
+     */
+    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)
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/settings/development/DevelopmentSettingsDashboardFragment.java b/src/com/android/settings/development/DevelopmentSettingsDashboardFragment.java
index 4038f4d..b1e4f87 100644
--- a/src/com/android/settings/development/DevelopmentSettingsDashboardFragment.java
+++ b/src/com/android/settings/development/DevelopmentSettingsDashboardFragment.java
@@ -376,6 +376,13 @@
                         || enableAngleController.isDefaultValue())) {
                     disableDeveloperOptions();
                 } else {
+                    // Disabling developer options in page-agnostic mode isn't supported as device
+                    // isn't in production state
+                    if (Enable16kUtils.isPageAgnosticModeOn(getContext())) {
+                        Enable16kUtils.showPageAgnosticWarning(getContext());
+                        onDisableDevelopmentOptionsRejected();
+                        return;
+                    }
                     DisableDevSettingsDialogFragment.show(this /* host */);
                 }
             }
diff --git a/src/com/android/settings/development/Enable16kPagesPreferenceController.java b/src/com/android/settings/development/Enable16kPagesPreferenceController.java
index 23a6a22..0572b1b 100644
--- a/src/com/android/settings/development/Enable16kPagesPreferenceController.java
+++ b/src/com/android/settings/development/Enable16kPagesPreferenceController.java
@@ -207,7 +207,10 @@
         int status = data.getInt(SystemUpdateManager.KEY_STATUS);
         if (status != SystemUpdateManager.STATUS_UNKNOWN
                 && status != SystemUpdateManager.STATUS_IDLE) {
-            throw new RuntimeException("System has pending update!");
+            throw new RuntimeException(
+                    "System has pending update! Please restart the device to complete applying"
+                            + " pending update. If you are seeing this after using 16KB developer"
+                            + " options, please check configuration and OTA packages!");
         }
 
         // Publish system update info
@@ -313,7 +316,7 @@
     }
 
     private void displayToast(String message) {
-        Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show();
+        Toast.makeText(mContext, message, Toast.LENGTH_LONG).show();
     }
 
     @Override
@@ -330,7 +333,7 @@
 
                     @Override
                     public void onFailure(@NonNull Throwable t) {
-                        Log.e(TAG, "Failed to change the /data partition with ext4");
+                        Log.e(TAG, "Failed to change the /data partition to ext4");
                         displayToast(mContext.getString(R.string.format_ext4_failure_toast));
                     }
                 },
@@ -405,6 +408,7 @@
                         LinearLayout.LayoutParams.WRAP_CONTENT,
                         LinearLayout.LayoutParams.WRAP_CONTENT);
         progressBar.setLayoutParams(params);
+        progressBar.setPadding(0, 24, 0, 24);
         builder.setView(progressBar);
         builder.setCancelable(false);
         return builder.create();
diff --git a/src/com/android/settings/development/EnableExt4WarningDialog.java b/src/com/android/settings/development/EnableExt4WarningDialog.java
index c8ba521..0e1dffd 100644
--- a/src/com/android/settings/development/EnableExt4WarningDialog.java
+++ b/src/com/android/settings/development/EnableExt4WarningDialog.java
@@ -70,8 +70,9 @@
     public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
         return new AlertDialog.Builder(getActivity())
                 .setTitle(R.string.confirm_format_ext4_title)
+                .setIcon(R.drawable.ic_delete_accent)
                 .setMessage(R.string.confirm_format_ext4_text)
-                .setPositiveButton(android.R.string.ok, this /* onClickListener */)
+                .setPositiveButton(R.string.main_clear_confirm_title, this /* onClickListener */)
                 .setNegativeButton(android.R.string.cancel, this /* onClickListener */)
                 .create();
     }
diff --git a/tests/spa_unit/AndroidManifest.xml b/tests/spa_unit/AndroidManifest.xml
index 5a7f565..e3bc5ad 100644
--- a/tests/spa_unit/AndroidManifest.xml
+++ b/tests/spa_unit/AndroidManifest.xml
@@ -22,6 +22,7 @@
     <uses-permission android:name="android.permission.MANAGE_APPOPS" />
     <uses-permission android:name="android.permission.UPDATE_APP_OPS_STATS" />
     <uses-permission android:name="android.permission.WRITE_DEVICE_CONFIG" />
+    <uses-permission android:name="com.android.settings.BATTERY_DATA" />
 
     <application android:debuggable="true">
         <provider android:name="com.android.settings.slices.SettingsSliceProvider"
diff --git a/tests/unit/src/com/android/settings/conecteddevice/threadnetwork/ThreadNetworkPreferenceControllerTest.kt b/tests/unit/src/com/android/settings/conecteddevice/threadnetwork/ThreadNetworkPreferenceControllerTest.kt
deleted file mode 100644
index 976096c..0000000
--- a/tests/unit/src/com/android/settings/conecteddevice/threadnetwork/ThreadNetworkPreferenceControllerTest.kt
+++ /dev/null
@@ -1,255 +0,0 @@
-/*
- * 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.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.android.settings.flags.Flags
-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_SETTINGS_ENABLED)
-        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_SETTINGS_ENABLED)
-        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
-        }
-    }
-}
diff --git a/tests/unit/src/com/android/settings/connecteddevice/threadnetwork/FakeThreadNetworkController.kt b/tests/unit/src/com/android/settings/connecteddevice/threadnetwork/FakeThreadNetworkController.kt
new file mode 100644
index 0000000..e30226e
--- /dev/null
+++ b/tests/unit/src/com/android/settings/connecteddevice/threadnetwork/FakeThreadNetworkController.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.net.thread.ThreadNetworkController
+import android.net.thread.ThreadNetworkException
+import android.os.OutcomeReceiver
+import java.util.concurrent.Executor
+
+/** A fake implementation of [BaseThreadNetworkController] for unit tests. */
+class FakeThreadNetworkController : BaseThreadNetworkController {
+    var isEnabled = false
+        private set
+    var registeredStateCallback: ThreadNetworkController.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(
+                        ThreadNetworkController.STATE_DISABLING
+                    )
+                }
+                executor.execute {
+                    registeredStateCallback!!.onThreadEnableStateChanged(
+                        ThreadNetworkController.STATE_DISABLED
+                    )
+                }
+            } else {
+                executor.execute {
+                    registeredStateCallback!!.onThreadEnableStateChanged(
+                        ThreadNetworkController.STATE_ENABLED
+                    )
+                }
+            }
+        }
+        executor.execute { receiver.onResult(null) }
+    }
+
+    override fun registerStateCallback(
+        executor: Executor,
+        callback: ThreadNetworkController.StateCallback
+    ) {
+        require(callback !== registeredStateCallback) { "callback is already registered" }
+        registeredStateCallback = callback
+        val enabledState =
+            if (isEnabled) ThreadNetworkController.STATE_ENABLED else ThreadNetworkController.STATE_DISABLED
+        executor.execute { registeredStateCallback!!.onThreadEnableStateChanged(enabledState) }
+    }
+
+    override fun unregisterStateCallback(callback: ThreadNetworkController.StateCallback) {
+        requireNotNull(registeredStateCallback) { "callback is already unregistered" }
+        registeredStateCallback = null
+    }
+}
diff --git a/tests/unit/src/com/android/settings/conecteddevice/threadnetwork/OWNERS b/tests/unit/src/com/android/settings/connecteddevice/threadnetwork/OWNERS
similarity index 100%
rename from tests/unit/src/com/android/settings/conecteddevice/threadnetwork/OWNERS
rename to tests/unit/src/com/android/settings/connecteddevice/threadnetwork/OWNERS
diff --git a/tests/unit/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFragmentControllerTest.kt b/tests/unit/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFragmentControllerTest.kt
new file mode 100644
index 0000000..13e4291
--- /dev/null
+++ b/tests/unit/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkFragmentControllerTest.kt
@@ -0,0 +1,112 @@
+/*
+ * 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.Context
+import android.platform.test.flag.junit.SetFlagsRule
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.settings.core.BasePreferenceController.AVAILABLE
+import com.android.settings.core.BasePreferenceController.CONDITIONALLY_UNAVAILABLE
+import com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE
+import com.android.settings.flags.Flags
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.MoreExecutors
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.spy
+import java.util.concurrent.Executor
+
+/** Unit tests for [ThreadNetworkFragmentController].  */
+@RunWith(AndroidJUnit4::class)
+class ThreadNetworkFragmentControllerTest {
+    @get:Rule
+    val mSetFlagsRule = SetFlagsRule()
+    private lateinit var context: Context
+    private lateinit var executor: Executor
+    private lateinit var controller: ThreadNetworkFragmentController
+    private lateinit var fakeThreadNetworkController: FakeThreadNetworkController
+
+    @Before
+    fun setUp() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_THREAD_SETTINGS_ENABLED)
+        context = spy(ApplicationProvider.getApplicationContext<Context>())
+        executor = MoreExecutors.directExecutor()
+        fakeThreadNetworkController = FakeThreadNetworkController()
+        controller = newControllerWithThreadFeatureSupported(true)
+    }
+
+    private fun newControllerWithThreadFeatureSupported(
+        present: Boolean
+    ): ThreadNetworkFragmentController {
+        return ThreadNetworkFragmentController(
+            context,
+            "thread_network_settings" /* key */,
+            executor,
+            if (present) fakeThreadNetworkController else null
+        )
+    }
+
+    @Test
+    fun availabilityStatus_flagDisabled_returnsConditionallyUnavailable() {
+        mSetFlagsRule.disableFlags(Flags.FLAG_THREAD_SETTINGS_ENABLED)
+        startController(controller)
+
+        assertThat(controller.availabilityStatus).isEqualTo(CONDITIONALLY_UNAVAILABLE)
+    }
+
+    @Test
+    fun availabilityStatus_threadFeatureNotSupported_returnsUnsupported() {
+        controller = newControllerWithThreadFeatureSupported(false)
+        startController(controller)
+
+        assertThat(fakeThreadNetworkController.registeredStateCallback).isNull()
+        assertThat(controller.availabilityStatus).isEqualTo(UNSUPPORTED_ON_DEVICE)
+    }
+
+    @Test
+    fun availabilityStatus_threadFeatureSupported_returnsAvailable() {
+        controller = newControllerWithThreadFeatureSupported(true)
+        startController(controller)
+
+        assertThat(controller.availabilityStatus).isEqualTo(AVAILABLE)
+    }
+
+    @Test
+    fun getSummary_ThreadIsEnabled_returnsOn() {
+        startController(controller)
+        fakeThreadNetworkController.setEnabled(true, executor) {}
+
+        assertThat(controller.summary).isEqualTo("On")
+    }
+
+    @Test
+    fun getSummary_ThreadIsDisabled_returnsOff() {
+        startController(controller)
+        fakeThreadNetworkController.setEnabled(false, executor) {}
+
+        assertThat(controller.summary).isEqualTo("Off")
+    }
+
+    private fun startController(controller: ThreadNetworkFragmentController) {
+        controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+    }
+}
diff --git a/tests/unit/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkToggleControllerTest.kt b/tests/unit/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkToggleControllerTest.kt
new file mode 100644
index 0000000..065ff96
--- /dev/null
+++ b/tests/unit/src/com/android/settings/connecteddevice/threadnetwork/ThreadNetworkToggleControllerTest.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.Context
+import android.platform.test.flag.junit.SetFlagsRule
+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.settings.core.BasePreferenceController.CONDITIONALLY_UNAVAILABLE
+import com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE
+import com.android.settings.flags.Flags
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.MoreExecutors
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.spy
+import java.util.concurrent.Executor
+
+/** Unit tests for [ThreadNetworkToggleController].  */
+@RunWith(AndroidJUnit4::class)
+class ThreadNetworkToggleControllerTest {
+    @get:Rule
+    val mSetFlagsRule = SetFlagsRule()
+    private lateinit var context: Context
+    private lateinit var executor: Executor
+    private lateinit var controller: ThreadNetworkToggleController
+    private lateinit var fakeThreadNetworkController: FakeThreadNetworkController
+    private lateinit var preference: SwitchPreference
+
+    @Before
+    fun setUp() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_THREAD_SETTINGS_ENABLED)
+        context = spy(ApplicationProvider.getApplicationContext<Context>())
+        executor = MoreExecutors.directExecutor()
+        fakeThreadNetworkController = FakeThreadNetworkController()
+        controller = newControllerWithThreadFeatureSupported(true)
+        val preferenceManager = PreferenceManager(context)
+        val preferenceScreen = preferenceManager.createPreferenceScreen(context)
+        preference = SwitchPreference(context)
+        preference.key = "toggle_thread_network"
+        preferenceScreen.addPreference(preference)
+        controller.displayPreference(preferenceScreen)
+    }
+
+    private fun newControllerWithThreadFeatureSupported(
+        present: Boolean
+    ): ThreadNetworkToggleController {
+        return ThreadNetworkToggleController(
+            context,
+            "toggle_thread_network" /* key */,
+            executor,
+            if (present) fakeThreadNetworkController else null
+        )
+    }
+
+    @Test
+    fun availabilityStatus_flagDisabled_returnsConditionallyUnavailable() {
+        mSetFlagsRule.disableFlags(Flags.FLAG_THREAD_SETTINGS_ENABLED)
+        assertThat(controller.availabilityStatus).isEqualTo(CONDITIONALLY_UNAVAILABLE)
+    }
+
+    @Test
+    fun availabilityStatus_threadFeatureNotSupported_returnsUnsupported() {
+        controller = newControllerWithThreadFeatureSupported(false)
+        startController(controller)
+
+        assertThat(fakeThreadNetworkController.registeredStateCallback).isNull()
+        assertThat(controller.availabilityStatus).isEqualTo(UNSUPPORTED_ON_DEVICE)
+    }
+
+    @Test
+    fun isChecked_threadSetEnabled_returnsTrue() {
+        fakeThreadNetworkController.setEnabled(true, executor) { }
+        startController(controller)
+
+        assertThat(controller.isChecked).isTrue()
+    }
+
+    @Test
+    fun isChecked_threadSetDisabled_returnsFalse() {
+        fakeThreadNetworkController.setEnabled(false, executor) { }
+        startController(controller)
+
+        assertThat(controller.isChecked).isFalse()
+    }
+
+    @Test
+    fun setChecked_setChecked_threadIsEnabled() {
+        startController(controller)
+
+        controller.setChecked(true)
+
+        assertThat(fakeThreadNetworkController.isEnabled).isTrue()
+    }
+
+    @Test
+    fun setChecked_setUnchecked_threadIsDisabled() {
+        startController(controller)
+
+        controller.setChecked(false)
+
+        assertThat(fakeThreadNetworkController.isEnabled).isFalse()
+    }
+
+    private fun startController(controller: ThreadNetworkToggleController) {
+        controller.onStateChanged(mock(LifecycleOwner::class.java), Lifecycle.Event.ON_START)
+    }
+}