[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/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
+        }
+    }
+}