Merge "[Thread] add Thread toggle in settings" into main am: 522e193947

Original change: https://android-review.googlesource.com/c/platform/packages/apps/Settings/+/2909326

Change-Id: I8640f2cf4a90203972a5db703ebb3e027224c746
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index f0f3a52..580904e 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -12004,6 +12004,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 cb4167b..b1276d8 100644
--- a/res/xml/connected_devices_advanced.xml
+++ b/res/xml/connected_devices_advanced.xml
@@ -70,6 +70,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 88fa356..f4856b2 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -26,6 +26,7 @@
         "androidx.test.rules",
         "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
+        }
+    }
+}