Merge changes from topic "set-thread-enabled" into main

* changes:
  Add service implementation for ThreadNetworkController#setEnabled
  Add setEnabled API
diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt
index d346af3..8251f85 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -500,6 +500,7 @@
     method @RequiresPermission(allOf={android.Manifest.permission.ACCESS_NETWORK_STATE, "android.permission.THREAD_NETWORK_PRIVILEGED"}) public void registerOperationalDatasetCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback);
     method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerStateCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.thread.ThreadNetworkController.StateCallback);
     method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void scheduleMigration(@NonNull android.net.thread.PendingOperationalDataset, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
+    method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void setEnabled(boolean, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method @RequiresPermission(allOf={android.Manifest.permission.ACCESS_NETWORK_STATE, "android.permission.THREAD_NETWORK_PRIVILEGED"}) public void unregisterOperationalDatasetCallback(@NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback);
     method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void unregisterStateCallback(@NonNull android.net.thread.ThreadNetworkController.StateCallback);
     field public static final int DEVICE_ROLE_CHILD = 2; // 0x2
@@ -507,6 +508,9 @@
     field public static final int DEVICE_ROLE_LEADER = 4; // 0x4
     field public static final int DEVICE_ROLE_ROUTER = 3; // 0x3
     field public static final int DEVICE_ROLE_STOPPED = 0; // 0x0
+    field public static final int STATE_DISABLED = 0; // 0x0
+    field public static final int STATE_DISABLING = 2; // 0x2
+    field public static final int STATE_ENABLED = 1; // 0x1
     field public static final int THREAD_VERSION_1_3 = 4; // 0x4
   }
 
@@ -518,6 +522,7 @@
   public static interface ThreadNetworkController.StateCallback {
     method public void onDeviceRoleChanged(int);
     method public default void onPartitionIdChanged(long);
+    method public default void onThreadEnableStateChanged(int);
   }
 
   @FlaggedApi("com.android.net.thread.flags.thread_enabled") public class ThreadNetworkException extends java.lang.Exception {
@@ -530,6 +535,7 @@
     field public static final int ERROR_REJECTED_BY_PEER = 8; // 0x8
     field public static final int ERROR_RESOURCE_EXHAUSTED = 10; // 0xa
     field public static final int ERROR_RESPONSE_BAD_FORMAT = 9; // 0x9
+    field public static final int ERROR_THREAD_DISABLED = 12; // 0xc
     field public static final int ERROR_TIMEOUT = 3; // 0x3
     field public static final int ERROR_UNAVAILABLE = 4; // 0x4
     field public static final int ERROR_UNKNOWN = 11; // 0xb
diff --git a/thread/framework/java/android/net/thread/IStateCallback.aidl b/thread/framework/java/android/net/thread/IStateCallback.aidl
index d7cbda9..9d0a571 100644
--- a/thread/framework/java/android/net/thread/IStateCallback.aidl
+++ b/thread/framework/java/android/net/thread/IStateCallback.aidl
@@ -22,4 +22,5 @@
 oneway interface IStateCallback {
     void onDeviceRoleChanged(int deviceRole);
     void onPartitionIdChanged(long partitionId);
+    void onThreadEnableStateChanged(int enabledState);
 }
diff --git a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
index a9da8d6..485e25d 100644
--- a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
+++ b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
@@ -42,4 +42,6 @@
 
     int getThreadVersion();
     void createRandomizedDataset(String networkName, IActiveOperationalDatasetReceiver receiver);
+
+    void setEnabled(boolean enabled, in IOperationReceiver receiver);
 }
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index 7242ed7..db761a3 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -68,6 +68,15 @@
     /** The device is a Thread Leader. */
     public static final int DEVICE_ROLE_LEADER = 4;
 
+    /** The Thread radio is disabled. */
+    public static final int STATE_DISABLED = 0;
+
+    /** The Thread radio is enabled. */
+    public static final int STATE_ENABLED = 1;
+
+    /** The Thread radio is being disabled. */
+    public static final int STATE_DISABLING = 2;
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef({
@@ -79,6 +88,13 @@
     })
     public @interface DeviceRole {}
 
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            prefix = {"STATE_"},
+            value = {STATE_DISABLED, STATE_ENABLED, STATE_DISABLING})
+    public @interface EnabledState {}
+
     /** Thread standard version 1.3. */
     public static final int THREAD_VERSION_1_3 = 4;
 
@@ -106,6 +122,40 @@
         mControllerService = controllerService;
     }
 
+    /**
+     * Enables/Disables the radio of this ThreadNetworkController. The requested enabled state will
+     * be persistent and survives device reboots.
+     *
+     * <p>When Thread is in {@code STATE_DISABLED}, {@link ThreadNetworkController} APIs which
+     * require the Thread radio will fail with error code {@link
+     * ThreadNetworkException#ERROR_THREAD_DISABLED}. When Thread is in {@code STATE_DISABLING},
+     * {@link ThreadNetworkController} APIs that return a {@link ThreadNetworkException} will fail
+     * with error code {@link ThreadNetworkException#ERROR_BUSY}.
+     *
+     * <p>On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called. It indicates
+     * the operation has completed. But there maybe subsequent calls to update the enabled state,
+     * callers of this method should use {@link #registerStateCallback} to subscribe to the Thread
+     * enabled state changes.
+     *
+     * <p>On failure, {@link OutcomeReceiver#onError} of {@code receiver} will be invoked with a
+     * specific error in {@link ThreadNetworkException#ERROR_}.
+     *
+     * @param enabled {@code true} for enabling Thread
+     * @param executor the executor to execute {@code receiver}
+     * @param receiver the receiver to receive result of this operation
+     */
+    @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+    public void setEnabled(
+            boolean enabled,
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        try {
+            mControllerService.setEnabled(enabled, new OperationReceiverProxy(executor, receiver));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
     /** Returns the Thread version this device is operating on. */
     @ThreadVersion
     public int getThreadVersion() {
@@ -170,6 +220,16 @@
          * @param partitionId the new Thread partition ID
          */
         default void onPartitionIdChanged(long partitionId) {}
+
+        /**
+         * The Thread enabled state has changed.
+         *
+         * <p>The Thread enabled state can be set with {@link setEnabled}, it may also be updated by
+         * airplane mode or admin control.
+         *
+         * @param enabledState the new Thread enabled state
+         */
+        default void onThreadEnableStateChanged(@EnabledState int enabledState) {}
     }
 
     private static final class StateCallbackProxy extends IStateCallback.Stub {
@@ -200,6 +260,16 @@
                 Binder.restoreCallingIdentity(identity);
             }
         }
+
+        @Override
+        public void onThreadEnableStateChanged(@EnabledState int enabled) {
+            final long identity = Binder.clearCallingIdentity();
+            try {
+                mExecutor.execute(() -> mCallback.onThreadEnableStateChanged(enabled));
+            } finally {
+                Binder.restoreCallingIdentity(identity);
+            }
+        }
     }
 
     /**
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkException.java b/thread/framework/java/android/net/thread/ThreadNetworkException.java
index af0a84b..23ed53e 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkException.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkException.java
@@ -48,6 +48,7 @@
         ERROR_RESPONSE_BAD_FORMAT,
         ERROR_RESOURCE_EXHAUSTED,
         ERROR_UNKNOWN,
+        ERROR_THREAD_DISABLED,
     })
     public @interface ErrorCode {}
 
@@ -129,6 +130,13 @@
      */
     public static final int ERROR_UNKNOWN = 11;
 
+    /**
+     * The operation failed because the Thread radio is disabled by {@link
+     * ThreadNetworkController#setEnabled}, airplane mode or device admin. The caller should retry
+     * only after Thread is enabled.
+     */
+    public static final int ERROR_THREAD_DISABLED = 12;
+
     private final int mErrorCode;
 
     /** Creates a new {@link ThreadNetworkException} object with given error code and message. */
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 1c51c42..7b9f290 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -27,6 +27,9 @@
 import static android.net.thread.ActiveOperationalDataset.MESH_LOCAL_PREFIX_FIRST_BYTE;
 import static android.net.thread.ActiveOperationalDataset.SecurityPolicy.DEFAULT_ROTATION_TIME_HOURS;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_DETACHED;
+import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
+import static android.net.thread.ThreadNetworkController.STATE_DISABLING;
+import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkController.THREAD_VERSION_1_3;
 import static android.net.thread.ThreadNetworkException.ERROR_ABORTED;
 import static android.net.thread.ThreadNetworkException.ERROR_BUSY;
@@ -35,6 +38,7 @@
 import static android.net.thread.ThreadNetworkException.ERROR_REJECTED_BY_PEER;
 import static android.net.thread.ThreadNetworkException.ERROR_RESOURCE_EXHAUSTED;
 import static android.net.thread.ThreadNetworkException.ERROR_RESPONSE_BAD_FORMAT;
+import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
 import static android.net.thread.ThreadNetworkException.ERROR_TIMEOUT;
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_CHANNEL;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
@@ -48,7 +52,11 @@
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_REASSEMBLY_TIMEOUT;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_REJECTED;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_RESPONSE_TIMEOUT;
+import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_THREAD_DISABLED;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_UNSUPPORTED_CHANNEL;
+import static com.android.server.thread.openthread.IOtDaemon.OT_STATE_DISABLED;
+import static com.android.server.thread.openthread.IOtDaemon.OT_STATE_DISABLING;
+import static com.android.server.thread.openthread.IOtDaemon.OT_STATE_ENABLED;
 import static com.android.server.thread.openthread.IOtDaemon.TUN_IF_NAME;
 
 import android.Manifest.permission;
@@ -160,6 +168,7 @@
     private UpstreamNetworkCallback mUpstreamNetworkCallback;
     private TestNetworkSpecifier mUpstreamTestNetworkSpecifier;
     private final HashMap<Network, String> mNetworkToInterface;
+    private final ThreadPersistentSettings mPersistentSettings;
 
     private BorderRouterConfigurationParcel mBorderRouterConfig;
 
@@ -171,7 +180,8 @@
             Supplier<IOtDaemon> otDaemonSupplier,
             ConnectivityManager connectivityManager,
             TunInterfaceController tunIfController,
-            InfraInterfaceController infraIfController) {
+            InfraInterfaceController infraIfController,
+            ThreadPersistentSettings persistentSettings) {
         mContext = context;
         mHandler = handler;
         mNetworkProvider = networkProvider;
@@ -182,9 +192,11 @@
         mUpstreamNetworkRequest = newUpstreamNetworkRequest();
         mNetworkToInterface = new HashMap<Network, String>();
         mBorderRouterConfig = new BorderRouterConfigurationParcel();
+        mPersistentSettings = persistentSettings;
     }
 
-    public static ThreadNetworkControllerService newInstance(Context context) {
+    public static ThreadNetworkControllerService newInstance(
+            Context context, ThreadPersistentSettings persistentSettings) {
         HandlerThread handlerThread = new HandlerThread("ThreadHandlerThread");
         handlerThread.start();
         NetworkProvider networkProvider =
@@ -197,7 +209,8 @@
                 () -> IOtDaemon.Stub.asInterface(ServiceManagerWrapper.waitForService("ot_daemon")),
                 context.getSystemService(ConnectivityManager.class),
                 new TunInterfaceController(TUN_IF_NAME),
-                new InfraInterfaceController());
+                new InfraInterfaceController(),
+                persistentSettings);
     }
 
     private static Inet6Address bytesToInet6Address(byte[] addressBytes) {
@@ -273,7 +286,9 @@
         if (otDaemon == null) {
             throw new RemoteException("Internal error: failed to start OT daemon");
         }
-        otDaemon.initialize(mTunIfController.getTunFd());
+        otDaemon.initialize(
+                mTunIfController.getTunFd(),
+                mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED));
         otDaemon.registerStateCallback(mOtDaemonCallbackProxy, -1);
         otDaemon.asBinder().linkToDeath(() -> mHandler.post(this::onOtDaemonDied), 0);
         mOtDaemon = otDaemon;
@@ -308,6 +323,26 @@
                 });
     }
 
+    public void setEnabled(@NonNull boolean isEnabled, @NonNull IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+
+        mHandler.post(() -> setEnabledInternal(isEnabled, new OperationReceiverWrapper(receiver)));
+    }
+
+    private void setEnabledInternal(
+            @NonNull boolean isEnabled, @Nullable OperationReceiverWrapper receiver) {
+        // The persistent setting keeps the desired enabled state, thus it's set regardless
+        // the otDaemon set enabled state operation succeeded or not, so that it can recover
+        // to the desired value after reboot.
+        mPersistentSettings.put(ThreadPersistentSettings.THREAD_ENABLED.key, isEnabled);
+        try {
+            getOtDaemon().setThreadEnabled(isEnabled, newOtStatusReceiver(receiver));
+        } catch (RemoteException e) {
+            Log.e(TAG, "otDaemon.setThreadEnabled failed", e);
+            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        }
+    }
+
     private void requestUpstreamNetwork() {
         if (mUpstreamNetworkCallback != null) {
             throw new AssertionError("The upstream network request is already there.");
@@ -658,6 +693,8 @@
                 return ERROR_REJECTED_BY_PEER;
             case OT_ERROR_UNSUPPORTED_CHANNEL:
                 return ERROR_UNSUPPORTED_CHANNEL;
+            case OT_ERROR_THREAD_DISABLED:
+                return ERROR_THREAD_DISABLED;
             default:
                 return ERROR_INTERNAL_ERROR;
         }
@@ -1001,6 +1038,15 @@
             }
         }
 
+        private void notifyThreadEnabledUpdated(IStateCallback callback, int enabledState) {
+            try {
+                callback.onThreadEnableStateChanged(enabledState);
+                Log.i(TAG, "onThreadEnableStateChanged " + enabledState);
+            } catch (RemoteException ignored) {
+                // do nothing if the client is dead
+            }
+        }
+
         public void unregisterStateCallback(IStateCallback callback) {
             checkOnHandlerThread();
             if (!mStateCallbacks.containsKey(callback)) {
@@ -1065,6 +1111,31 @@
         }
 
         @Override
+        public void onThreadEnabledChanged(int state) {
+            mHandler.post(() -> onThreadEnabledChangedInternal(state));
+        }
+
+        private void onThreadEnabledChangedInternal(int state) {
+            checkOnHandlerThread();
+            for (IStateCallback callback : mStateCallbacks.keySet()) {
+                notifyThreadEnabledUpdated(callback, otStateToAndroidState(state));
+            }
+        }
+
+        private static int otStateToAndroidState(int state) {
+            switch (state) {
+                case OT_STATE_ENABLED:
+                    return STATE_ENABLED;
+                case OT_STATE_DISABLED:
+                    return STATE_DISABLED;
+                case OT_STATE_DISABLING:
+                    return STATE_DISABLING;
+                default:
+                    throw new IllegalArgumentException("Unknown ot state " + state);
+            }
+        }
+
+        @Override
         public void onStateChanged(OtDaemonState newState, long listenerId) {
             mHandler.post(() -> onStateChangedInternal(newState, listenerId));
         }
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkService.java b/thread/service/java/com/android/server/thread/ThreadNetworkService.java
index 53f2d4f..5cf27f7 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkService.java
@@ -18,16 +18,21 @@
 
 import static android.content.pm.PackageManager.PERMISSION_GRANTED;
 
+import static com.android.net.module.util.DeviceConfigUtils.TETHERING_MODULE_NAME;
+
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.content.ApexEnvironment;
 import android.content.Context;
 import android.net.thread.IThreadNetworkController;
 import android.net.thread.IThreadNetworkManager;
 import android.os.Binder;
 import android.os.ParcelFileDescriptor;
+import android.util.AtomicFile;
 
 import com.android.server.SystemService;
 
+import java.io.File;
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
 import java.util.Collections;
@@ -40,11 +45,18 @@
     private final Context mContext;
     @Nullable private ThreadNetworkCountryCode mCountryCode;
     @Nullable private ThreadNetworkControllerService mControllerService;
+    private final ThreadPersistentSettings mPersistentSettings;
     @Nullable private ThreadNetworkShellCommand mShellCommand;
 
     /** Creates a new {@link ThreadNetworkService} object. */
     public ThreadNetworkService(Context context) {
         mContext = context;
+        mPersistentSettings =
+                new ThreadPersistentSettings(
+                        new AtomicFile(
+                                new File(
+                                        getOrCreateThreadnetworkDir(),
+                                        ThreadPersistentSettings.FILE_NAME)));
     }
 
     /**
@@ -54,7 +66,9 @@
      */
     public void onBootPhase(int phase) {
         if (phase == SystemService.PHASE_SYSTEM_SERVICES_READY) {
-            mControllerService = ThreadNetworkControllerService.newInstance(mContext);
+            mPersistentSettings.initialize();
+            mControllerService =
+                    ThreadNetworkControllerService.newInstance(mContext, mPersistentSettings);
             mControllerService.initialize();
         } else if (phase == SystemService.PHASE_BOOT_COMPLETED) {
             // Country code initialization is delayed to the BOOT_COMPLETED phase because it will
@@ -109,4 +123,19 @@
 
         pw.println();
     }
+
+    /** Get device protected storage dir for the tethering apex. */
+    private static File getOrCreateThreadnetworkDir() {
+        final File threadnetworkDir;
+        final File apexDataDir =
+                ApexEnvironment.getApexEnvironment(TETHERING_MODULE_NAME)
+                        .getDeviceProtectedDataDir();
+        threadnetworkDir = new File(apexDataDir, "thread");
+
+        if (threadnetworkDir.exists() || threadnetworkDir.mkdirs()) {
+            return threadnetworkDir;
+        }
+        throw new IllegalStateException(
+                "Cannot write into thread network data directory: " + threadnetworkDir);
+    }
 }
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index 7a6c9aa..7a129dc 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -16,11 +16,19 @@
 
 package android.net.thread.cts;
 
+import static android.Manifest.permission.ACCESS_NETWORK_STATE;
+import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_CHILD;
+import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
+import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_ROUTER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED;
+import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
+import static android.net.thread.ThreadNetworkController.STATE_DISABLING;
+import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkController.THREAD_VERSION_1_3;
 import static android.net.thread.ThreadNetworkException.ERROR_ABORTED;
 import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION;
 import static android.net.thread.ThreadNetworkException.ERROR_REJECTED_BY_PEER;
+import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
 
 import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
 
@@ -29,12 +37,12 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assume.assumeNotNull;
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
-import android.Manifest.permission;
 import android.content.Context;
 import android.net.ConnectivityManager;
 import android.net.Network;
@@ -54,12 +62,11 @@
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.LargeTest;
 
+import com.android.net.module.util.ArrayTrackRecord;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.DevSdkIgnoreRunner;
 
-import com.google.common.util.concurrent.SettableFuture;
-
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -68,9 +75,11 @@
 
 import java.time.Duration;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -81,8 +90,11 @@
 @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) // Thread is available on only U+
 public class ThreadNetworkControllerTest {
     private static final int JOIN_TIMEOUT_MILLIS = 30 * 1000;
+    private static final int LEAVE_TIMEOUT_MILLIS = 2_000;
+    private static final int MIGRATION_TIMEOUT_MILLIS = 40 * 1_000;
     private static final int NETWORK_CALLBACK_TIMEOUT_MILLIS = 10 * 1000;
-    private static final int CALLBACK_TIMEOUT_MILLIS = 1000;
+    private static final int CALLBACK_TIMEOUT_MILLIS = 1_000;
+    private static final int ENABLED_TIMEOUT_MILLIS = 2_000;
     private static final String PERMISSION_THREAD_NETWORK_PRIVILEGED =
             "android.permission.THREAD_NETWORK_PRIVILEGED";
 
@@ -95,7 +107,7 @@
     private Set<String> mGrantedPermissions;
 
     @Before
-    public void setUp() {
+    public void setUp() throws Exception {
         mExecutor = Executors.newSingleThreadExecutor();
         mManager = mContext.getSystemService(ThreadNetworkManager.class);
         mGrantedPermissions = new HashSet<String>();
@@ -103,13 +115,17 @@
         // TODO: we will also need it in tearDown(), it's better to have a Rule to skip
         // tests if a feature is not available.
         assumeNotNull(mManager);
+
+        for (ThreadNetworkController controller : getAllControllers()) {
+            setEnabledAndWait(controller, true);
+        }
     }
 
     @After
     public void tearDown() throws Exception {
         if (mManager != null) {
-            leaveAndWait();
             dropAllPermissions();
+            leaveAndWait();
         }
     }
 
@@ -118,12 +134,10 @@
     }
 
     private void leaveAndWait() throws Exception {
-        grantPermissions(PERMISSION_THREAD_NETWORK_PRIVILEGED);
-
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<Void> future = SettableFuture.create();
-            controller.leave(mExecutor, future::set);
-            future.get();
+            CompletableFuture<Void> future = new CompletableFuture<>();
+            leave(controller, future::complete);
+            future.get(LEAVE_TIMEOUT_MILLIS, MILLISECONDS);
         }
     }
 
@@ -142,8 +156,8 @@
 
     private static ActiveOperationalDataset newRandomizedDataset(
             String networkName, ThreadNetworkController controller) throws Exception {
-        SettableFuture<ActiveOperationalDataset> future = SettableFuture.create();
-        controller.createRandomizedDataset(networkName, directExecutor(), future::set);
+        CompletableFuture<ActiveOperationalDataset> future = new CompletableFuture<>();
+        controller.createRandomizedDataset(networkName, directExecutor(), future::complete);
         return future.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
     }
 
@@ -152,33 +166,140 @@
     }
 
     private static int getDeviceRole(ThreadNetworkController controller) throws Exception {
-        SettableFuture<Integer> future = SettableFuture.create();
-        StateCallback callback = future::set;
+        CompletableFuture<Integer> future = new CompletableFuture<>();
+        StateCallback callback = future::complete;
         controller.registerStateCallback(directExecutor(), callback);
         int role = future.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
         controller.unregisterStateCallback(callback);
         return role;
     }
 
+    private static int waitForAttachedState(ThreadNetworkController controller) throws Exception {
+        List<Integer> attachedRoles = new ArrayList<>();
+        attachedRoles.add(DEVICE_ROLE_CHILD);
+        attachedRoles.add(DEVICE_ROLE_ROUTER);
+        attachedRoles.add(DEVICE_ROLE_LEADER);
+        return waitForStateAnyOf(controller, attachedRoles);
+    }
+
     private static int waitForStateAnyOf(
             ThreadNetworkController controller, List<Integer> deviceRoles) throws Exception {
-        SettableFuture<Integer> future = SettableFuture.create();
+        CompletableFuture<Integer> future = new CompletableFuture<>();
         StateCallback callback =
                 newRole -> {
                     if (deviceRoles.contains(newRole)) {
-                        future.set(newRole);
+                        future.complete(newRole);
                     }
                 };
         controller.registerStateCallback(directExecutor(), callback);
-        int role = future.get();
+        int role = future.get(JOIN_TIMEOUT_MILLIS, MILLISECONDS);
         controller.unregisterStateCallback(callback);
         return role;
     }
 
+    private static void waitForEnabledState(ThreadNetworkController controller, int state)
+            throws Exception {
+        CompletableFuture<Integer> future = new CompletableFuture<>();
+        StateCallback callback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onThreadEnableStateChanged(int enabled) {
+                        if (enabled == state) {
+                            future.complete(enabled);
+                        }
+                    }
+                };
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                () -> controller.registerStateCallback(directExecutor(), callback));
+        future.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
+        runAsShell(ACCESS_NETWORK_STATE, () -> controller.unregisterStateCallback(callback));
+    }
+
+    private void leave(
+            ThreadNetworkController controller,
+            OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED, () -> controller.leave(mExecutor, receiver));
+    }
+
+    private void scheduleMigration(
+            ThreadNetworkController controller,
+            PendingOperationalDataset pendingDataset,
+            OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> controller.scheduleMigration(pendingDataset, mExecutor, receiver));
+    }
+
+    private class EnabledStateListener {
+        private ArrayTrackRecord<Integer> mEnabledStates = new ArrayTrackRecord<>();
+        private final ArrayTrackRecord<Integer>.ReadHead mReadHead = mEnabledStates.newReadHead();
+        ThreadNetworkController mController;
+        StateCallback mCallback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onThreadEnableStateChanged(int enabled) {
+                        mEnabledStates.add(enabled);
+                    }
+                };
+
+        EnabledStateListener(ThreadNetworkController controller) {
+            this.mController = controller;
+            runAsShell(
+                    ACCESS_NETWORK_STATE,
+                    () -> controller.registerStateCallback(mExecutor, mCallback));
+        }
+
+        public void expectThreadEnabledState(int enabled) {
+            assertNotNull(mReadHead.poll(ENABLED_TIMEOUT_MILLIS, e -> (e == enabled)));
+        }
+
+        public void unregisterStateCallback() {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(mCallback));
+        }
+    }
+
+    private int booleanToEnabledState(boolean enabled) {
+        return enabled ? STATE_ENABLED : STATE_DISABLED;
+    }
+
+    private void setEnabledAndWait(ThreadNetworkController controller, boolean enabled)
+            throws Exception {
+        CompletableFuture<Void> setFuture = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> controller.setEnabled(enabled, mExecutor, newOutcomeReceiver(setFuture)));
+        setFuture.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
+        waitForEnabledState(controller, booleanToEnabledState(enabled));
+    }
+
+    private CompletableFuture joinRandomizedDataset(ThreadNetworkController controller)
+            throws Exception {
+        ActiveOperationalDataset activeDataset = newRandomizedDataset("TestNet", controller);
+        CompletableFuture<Void> joinFuture = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture)));
+        return joinFuture;
+    }
+
+    private void joinRandomizedDatasetAndWait(ThreadNetworkController controller) throws Exception {
+        CompletableFuture<Void> joinFuture = joinRandomizedDataset(controller);
+        joinFuture.get(JOIN_TIMEOUT_MILLIS, MILLISECONDS);
+        runAsShell(ACCESS_NETWORK_STATE, () -> assertThat(isAttached(controller)).isTrue());
+    }
+
     private static ActiveOperationalDataset getActiveOperationalDataset(
             ThreadNetworkController controller) throws Exception {
-        SettableFuture<ActiveOperationalDataset> future = SettableFuture.create();
-        OperationalDatasetCallback callback = future::set;
+        CompletableFuture<ActiveOperationalDataset> future = new CompletableFuture<>();
+        OperationalDatasetCallback callback = future::complete;
         controller.registerOperationalDatasetCallback(directExecutor(), callback);
         ActiveOperationalDataset dataset = future.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
         controller.unregisterOperationalDatasetCallback(callback);
@@ -187,27 +308,27 @@
 
     private static PendingOperationalDataset getPendingOperationalDataset(
             ThreadNetworkController controller) throws Exception {
-        SettableFuture<ActiveOperationalDataset> activeFuture = SettableFuture.create();
-        SettableFuture<PendingOperationalDataset> pendingFuture = SettableFuture.create();
+        CompletableFuture<ActiveOperationalDataset> activeFuture = new CompletableFuture<>();
+        CompletableFuture<PendingOperationalDataset> pendingFuture = new CompletableFuture<>();
         controller.registerOperationalDatasetCallback(
                 directExecutor(), newDatasetCallback(activeFuture, pendingFuture));
-        return pendingFuture.get();
+        return pendingFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
     }
 
     private static OperationalDatasetCallback newDatasetCallback(
-            SettableFuture<ActiveOperationalDataset> activeFuture,
-            SettableFuture<PendingOperationalDataset> pendingFuture) {
+            CompletableFuture<ActiveOperationalDataset> activeFuture,
+            CompletableFuture<PendingOperationalDataset> pendingFuture) {
         return new OperationalDatasetCallback() {
             @Override
             public void onActiveOperationalDatasetChanged(
                     ActiveOperationalDataset activeOpDataset) {
-                activeFuture.set(activeOpDataset);
+                activeFuture.complete(activeOpDataset);
             }
 
             @Override
             public void onPendingOperationalDatasetChanged(
                     PendingOperationalDataset pendingOpDataset) {
-                pendingFuture.set(pendingOpDataset);
+                pendingFuture.complete(pendingOpDataset);
             }
         };
     }
@@ -221,16 +342,17 @@
 
     @Test
     public void registerStateCallback_permissionsGranted_returnsCurrentStates() throws Exception {
-        grantPermissions(permission.ACCESS_NETWORK_STATE);
+        grantPermissions(ACCESS_NETWORK_STATE);
 
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<Integer> deviceRole = SettableFuture.create();
-            StateCallback callback = deviceRole::set;
+            CompletableFuture<Integer> deviceRole = new CompletableFuture<>();
+            StateCallback callback = deviceRole::complete;
 
             try {
                 controller.registerStateCallback(mExecutor, callback);
 
-                assertThat(deviceRole.get()).isEqualTo(DEVICE_ROLE_STOPPED);
+                assertThat(deviceRole.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
+                        .isEqualTo(DEVICE_ROLE_STOPPED);
             } finally {
                 controller.unregisterStateCallback(callback);
             }
@@ -238,6 +360,36 @@
     }
 
     @Test
+    public void registerStateCallback_returnsUpdatedEnabledStates() throws Exception {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            CompletableFuture<Void> setFuture1 = new CompletableFuture<>();
+            CompletableFuture<Void> setFuture2 = new CompletableFuture<>();
+            EnabledStateListener listener = new EnabledStateListener(controller);
+
+            runAsShell(
+                    PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                    () -> {
+                        controller.setEnabled(false, mExecutor, newOutcomeReceiver(setFuture1));
+                    });
+            setFuture1.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
+
+            runAsShell(
+                    PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                    () -> {
+                        controller.setEnabled(true, mExecutor, newOutcomeReceiver(setFuture2));
+                    });
+            setFuture2.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
+
+            listener.expectThreadEnabledState(STATE_ENABLED);
+            listener.expectThreadEnabledState(STATE_DISABLING);
+            listener.expectThreadEnabledState(STATE_DISABLED);
+            listener.expectThreadEnabledState(STATE_ENABLED);
+
+            listener.unregisterStateCallback();
+        }
+    }
+
+    @Test
     public void registerStateCallback_noPermissions_throwsSecurityException() throws Exception {
         dropAllPermissions();
 
@@ -251,11 +403,11 @@
     @Test
     public void registerStateCallback_alreadyRegistered_throwsIllegalArgumentException()
             throws Exception {
-        grantPermissions(permission.ACCESS_NETWORK_STATE);
+        grantPermissions(ACCESS_NETWORK_STATE);
 
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<Integer> deviceRole = SettableFuture.create();
-            StateCallback callback = role -> deviceRole.set(role);
+            CompletableFuture<Integer> deviceRole = new CompletableFuture<>();
+            StateCallback callback = role -> deviceRole.complete(role);
             controller.registerStateCallback(mExecutor, callback);
 
             assertThrows(
@@ -267,9 +419,9 @@
     @Test
     public void unregisterStateCallback_noPermissions_throwsSecurityException() throws Exception {
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<Integer> deviceRole = SettableFuture.create();
-            StateCallback callback = role -> deviceRole.set(role);
-            grantPermissions(permission.ACCESS_NETWORK_STATE);
+            CompletableFuture<Integer> deviceRole = new CompletableFuture<>();
+            StateCallback callback = role -> deviceRole.complete(role);
+            grantPermissions(ACCESS_NETWORK_STATE);
             controller.registerStateCallback(mExecutor, callback);
 
             try {
@@ -278,7 +430,7 @@
                         SecurityException.class,
                         () -> controller.unregisterStateCallback(callback));
             } finally {
-                grantPermissions(permission.ACCESS_NETWORK_STATE);
+                grantPermissions(ACCESS_NETWORK_STATE);
                 controller.unregisterStateCallback(callback);
             }
         }
@@ -286,10 +438,10 @@
 
     @Test
     public void unregisterStateCallback_callbackRegistered_success() throws Exception {
-        grantPermissions(permission.ACCESS_NETWORK_STATE);
+        grantPermissions(ACCESS_NETWORK_STATE);
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<Integer> deviceRole = SettableFuture.create();
-            StateCallback callback = role -> deviceRole.set(role);
+            CompletableFuture<Integer> deviceRole = new CompletableFuture<>();
+            StateCallback callback = role -> deviceRole.complete(role);
             controller.registerStateCallback(mExecutor, callback);
 
             controller.unregisterStateCallback(callback);
@@ -300,8 +452,8 @@
     public void unregisterStateCallback_callbackNotRegistered_throwsIllegalArgumentException()
             throws Exception {
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<Integer> deviceRole = SettableFuture.create();
-            StateCallback callback = role -> deviceRole.set(role);
+            CompletableFuture<Integer> deviceRole = new CompletableFuture<>();
+            StateCallback callback = role -> deviceRole.complete(role);
 
             assertThrows(
                     IllegalArgumentException.class,
@@ -312,10 +464,10 @@
     @Test
     public void unregisterStateCallback_alreadyUnregistered_throwsIllegalArgumentException()
             throws Exception {
-        grantPermissions(permission.ACCESS_NETWORK_STATE);
+        grantPermissions(ACCESS_NETWORK_STATE);
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<Integer> deviceRole = SettableFuture.create();
-            StateCallback callback = deviceRole::set;
+            CompletableFuture<Integer> deviceRole = new CompletableFuture<>();
+            StateCallback callback = deviceRole::complete;
             controller.registerStateCallback(mExecutor, callback);
             controller.unregisterStateCallback(callback);
 
@@ -328,18 +480,18 @@
     @Test
     public void registerOperationalDatasetCallback_permissionsGranted_returnsCurrentStates()
             throws Exception {
-        grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        grantPermissions(ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<ActiveOperationalDataset> activeFuture = SettableFuture.create();
-            SettableFuture<PendingOperationalDataset> pendingFuture = SettableFuture.create();
+            CompletableFuture<ActiveOperationalDataset> activeFuture = new CompletableFuture<>();
+            CompletableFuture<PendingOperationalDataset> pendingFuture = new CompletableFuture<>();
             var callback = newDatasetCallback(activeFuture, pendingFuture);
 
             try {
                 controller.registerOperationalDatasetCallback(mExecutor, callback);
 
-                assertThat(activeFuture.get()).isNull();
-                assertThat(pendingFuture.get()).isNull();
+                assertThat(activeFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+                assertThat(pendingFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
             } finally {
                 controller.unregisterOperationalDatasetCallback(callback);
             }
@@ -352,8 +504,8 @@
         dropAllPermissions();
 
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<ActiveOperationalDataset> activeFuture = SettableFuture.create();
-            SettableFuture<PendingOperationalDataset> pendingFuture = SettableFuture.create();
+            CompletableFuture<ActiveOperationalDataset> activeFuture = new CompletableFuture<>();
+            CompletableFuture<PendingOperationalDataset> pendingFuture = new CompletableFuture<>();
             var callback = newDatasetCallback(activeFuture, pendingFuture);
 
             assertThrows(
@@ -364,10 +516,10 @@
 
     @Test
     public void unregisterOperationalDatasetCallback_callbackRegistered_success() throws Exception {
-        grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        grantPermissions(ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<ActiveOperationalDataset> activeFuture = SettableFuture.create();
-            SettableFuture<PendingOperationalDataset> pendingFuture = SettableFuture.create();
+            CompletableFuture<ActiveOperationalDataset> activeFuture = new CompletableFuture<>();
+            CompletableFuture<PendingOperationalDataset> pendingFuture = new CompletableFuture<>();
             var callback = newDatasetCallback(activeFuture, pendingFuture);
             controller.registerOperationalDatasetCallback(mExecutor, callback);
 
@@ -381,10 +533,10 @@
         dropAllPermissions();
 
         for (ThreadNetworkController controller : getAllControllers()) {
-            SettableFuture<ActiveOperationalDataset> activeFuture = SettableFuture.create();
-            SettableFuture<PendingOperationalDataset> pendingFuture = SettableFuture.create();
+            CompletableFuture<ActiveOperationalDataset> activeFuture = new CompletableFuture<>();
+            CompletableFuture<PendingOperationalDataset> pendingFuture = new CompletableFuture<>();
             var callback = newDatasetCallback(activeFuture, pendingFuture);
-            grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+            grantPermissions(ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
             controller.registerOperationalDatasetCallback(mExecutor, callback);
 
             try {
@@ -393,24 +545,23 @@
                         SecurityException.class,
                         () -> controller.unregisterOperationalDatasetCallback(callback));
             } finally {
-                grantPermissions(
-                        permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+                grantPermissions(ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
                 controller.unregisterOperationalDatasetCallback(callback);
             }
         }
     }
 
     private static <V> OutcomeReceiver<V, ThreadNetworkException> newOutcomeReceiver(
-            SettableFuture<V> future) {
+            CompletableFuture<V> future) {
         return new OutcomeReceiver<V, ThreadNetworkException>() {
             @Override
             public void onResult(V result) {
-                future.set(result);
+                future.complete(result);
             }
 
             @Override
             public void onError(ThreadNetworkException e) {
-                future.setException(e);
+                future.completeExceptionally(e);
             }
         };
     }
@@ -421,12 +572,12 @@
 
         for (ThreadNetworkController controller : getAllControllers()) {
             ActiveOperationalDataset activeDataset = newRandomizedDataset("TestNet", controller);
-            SettableFuture<Void> joinFuture = SettableFuture.create();
+            CompletableFuture<Void> joinFuture = new CompletableFuture<>();
 
             controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture));
-            joinFuture.get();
+            joinFuture.get(JOIN_TIMEOUT_MILLIS, MILLISECONDS);
 
-            grantPermissions(permission.ACCESS_NETWORK_STATE);
+            grantPermissions(ACCESS_NETWORK_STATE);
             assertThat(isAttached(controller)).isTrue();
             assertThat(getActiveOperationalDataset(controller)).isEqualTo(activeDataset);
         }
@@ -446,6 +597,20 @@
     }
 
     @Test
+    public void join_threadDisabled_failsWithErrorThreadDisabled() throws Exception {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            setEnabledAndWait(controller, false);
+
+            CompletableFuture<Void> joinFuture = joinRandomizedDataset(controller);
+
+            ThreadNetworkException thrown =
+                    (ThreadNetworkException)
+                            assertThrows(ExecutionException.class, joinFuture::get).getCause();
+            assertThat(thrown.getErrorCode()).isEqualTo(ERROR_THREAD_DISABLED);
+        }
+    }
+
+    @Test
     public void join_concurrentRequests_firstOneIsAborted() throws Exception {
         grantPermissions(PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
@@ -461,8 +626,8 @@
                     new ActiveOperationalDataset.Builder(activeDataset1)
                             .setNetworkKey(KEY_2)
                             .build();
-            SettableFuture<Void> joinFuture1 = SettableFuture.create();
-            SettableFuture<Void> joinFuture2 = SettableFuture.create();
+            CompletableFuture<Void> joinFuture1 = new CompletableFuture<>();
+            CompletableFuture<Void> joinFuture2 = new CompletableFuture<>();
 
             controller.join(activeDataset1, mExecutor, newOutcomeReceiver(joinFuture1));
             controller.join(activeDataset2, mExecutor, newOutcomeReceiver(joinFuture2));
@@ -471,8 +636,8 @@
                     (ThreadNetworkException)
                             assertThrows(ExecutionException.class, joinFuture1::get).getCause();
             assertThat(thrown.getErrorCode()).isEqualTo(ERROR_ABORTED);
-            joinFuture2.get();
-            grantPermissions(permission.ACCESS_NETWORK_STATE);
+            joinFuture2.get(JOIN_TIMEOUT_MILLIS, MILLISECONDS);
+            grantPermissions(ACCESS_NETWORK_STATE);
             assertThat(isAttached(controller)).isTrue();
             assertThat(getActiveOperationalDataset(controller)).isEqualTo(activeDataset2);
         }
@@ -480,19 +645,14 @@
 
     @Test
     public void leave_withPrivilegedPermission_success() throws Exception {
-        grantPermissions(PERMISSION_THREAD_NETWORK_PRIVILEGED);
-
         for (ThreadNetworkController controller : getAllControllers()) {
-            ActiveOperationalDataset activeDataset = newRandomizedDataset("TestNet", controller);
-            SettableFuture<Void> joinFuture = SettableFuture.create();
-            SettableFuture<Void> leaveFuture = SettableFuture.create();
-            controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture));
-            joinFuture.get();
+            joinRandomizedDatasetAndWait(controller);
 
-            controller.leave(mExecutor, newOutcomeReceiver(leaveFuture));
-            leaveFuture.get();
+            CompletableFuture<Void> leaveFuture = new CompletableFuture<>();
+            leave(controller, newOutcomeReceiver(leaveFuture));
+            leaveFuture.get(LEAVE_TIMEOUT_MILLIS, MILLISECONDS);
 
-            grantPermissions(permission.ACCESS_NETWORK_STATE);
+            grantPermissions(ACCESS_NETWORK_STATE);
             assertThat(getDeviceRole(controller)).isEqualTo(DEVICE_ROLE_STOPPED);
         }
     }
@@ -507,30 +667,46 @@
     }
 
     @Test
+    public void leave_threadDisabled_success() throws Exception {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            joinRandomizedDatasetAndWait(controller);
+
+            CompletableFuture<Void> leaveFuture = new CompletableFuture<>();
+            setEnabledAndWait(controller, false);
+            leave(controller, newOutcomeReceiver(leaveFuture));
+
+            leaveFuture.get(LEAVE_TIMEOUT_MILLIS, MILLISECONDS);
+            runAsShell(
+                    ACCESS_NETWORK_STATE,
+                    () -> assertThat(getDeviceRole(controller)).isEqualTo(DEVICE_ROLE_STOPPED));
+        }
+    }
+
+    @Test
     public void leave_concurrentRequests_bothSuccess() throws Exception {
         grantPermissions(PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         for (ThreadNetworkController controller : getAllControllers()) {
             ActiveOperationalDataset activeDataset = newRandomizedDataset("TestNet", controller);
-            SettableFuture<Void> joinFuture = SettableFuture.create();
-            SettableFuture<Void> leaveFuture1 = SettableFuture.create();
-            SettableFuture<Void> leaveFuture2 = SettableFuture.create();
+            CompletableFuture<Void> joinFuture = new CompletableFuture<>();
+            CompletableFuture<Void> leaveFuture1 = new CompletableFuture<>();
+            CompletableFuture<Void> leaveFuture2 = new CompletableFuture<>();
             controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture));
-            joinFuture.get();
+            joinFuture.get(JOIN_TIMEOUT_MILLIS, MILLISECONDS);
 
             controller.leave(mExecutor, newOutcomeReceiver(leaveFuture1));
             controller.leave(mExecutor, newOutcomeReceiver(leaveFuture2));
 
-            leaveFuture1.get();
-            leaveFuture2.get();
-            grantPermissions(permission.ACCESS_NETWORK_STATE);
+            leaveFuture1.get(LEAVE_TIMEOUT_MILLIS, MILLISECONDS);
+            leaveFuture2.get(LEAVE_TIMEOUT_MILLIS, MILLISECONDS);
+            grantPermissions(ACCESS_NETWORK_STATE);
             assertThat(getDeviceRole(controller)).isEqualTo(DEVICE_ROLE_STOPPED);
         }
     }
 
     @Test
     public void scheduleMigration_withPrivilegedPermission_newDatasetApplied() throws Exception {
-        grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        grantPermissions(ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         for (ThreadNetworkController controller : getAllControllers()) {
             ActiveOperationalDataset activeDataset1 =
@@ -549,24 +725,24 @@
                             activeDataset2,
                             OperationalDatasetTimestamp.fromInstant(Instant.now()),
                             Duration.ofSeconds(30));
-            SettableFuture<Void> joinFuture = SettableFuture.create();
-            SettableFuture<Void> migrateFuture = SettableFuture.create();
+            CompletableFuture<Void> joinFuture = new CompletableFuture<>();
+            CompletableFuture<Void> migrateFuture = new CompletableFuture<>();
             controller.join(activeDataset1, mExecutor, newOutcomeReceiver(joinFuture));
-            joinFuture.get();
+            joinFuture.get(JOIN_TIMEOUT_MILLIS, MILLISECONDS);
 
             controller.scheduleMigration(
                     pendingDataset2, mExecutor, newOutcomeReceiver(migrateFuture));
-            migrateFuture.get();
+            migrateFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
 
-            SettableFuture<Boolean> dataset2IsApplied = SettableFuture.create();
-            SettableFuture<Boolean> pendingDatasetIsRemoved = SettableFuture.create();
+            CompletableFuture<Boolean> dataset2IsApplied = new CompletableFuture<>();
+            CompletableFuture<Boolean> pendingDatasetIsRemoved = new CompletableFuture<>();
             OperationalDatasetCallback datasetCallback =
                     new OperationalDatasetCallback() {
                         @Override
                         public void onActiveOperationalDatasetChanged(
                                 ActiveOperationalDataset activeDataset) {
                             if (activeDataset.equals(activeDataset2)) {
-                                dataset2IsApplied.set(true);
+                                dataset2IsApplied.complete(true);
                             }
                         }
 
@@ -574,20 +750,20 @@
                         public void onPendingOperationalDatasetChanged(
                                 PendingOperationalDataset pendingDataset) {
                             if (pendingDataset == null) {
-                                pendingDatasetIsRemoved.set(true);
+                                pendingDatasetIsRemoved.complete(true);
                             }
                         }
                     };
             controller.registerOperationalDatasetCallback(directExecutor(), datasetCallback);
-            assertThat(dataset2IsApplied.get()).isTrue();
-            assertThat(pendingDatasetIsRemoved.get()).isTrue();
+            assertThat(dataset2IsApplied.get(MIGRATION_TIMEOUT_MILLIS, MILLISECONDS)).isTrue();
+            assertThat(pendingDatasetIsRemoved.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isTrue();
             controller.unregisterOperationalDatasetCallback(datasetCallback);
         }
     }
 
     @Test
     public void scheduleMigration_whenNotAttached_failWithPreconditionError() throws Exception {
-        grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        grantPermissions(ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         for (ThreadNetworkController controller : getAllControllers()) {
             PendingOperationalDataset pendingDataset =
@@ -595,7 +771,7 @@
                             newRandomizedDataset("TestNet", controller),
                             OperationalDatasetTimestamp.fromInstant(Instant.now()),
                             Duration.ofSeconds(30));
-            SettableFuture<Void> migrateFuture = SettableFuture.create();
+            CompletableFuture<Void> migrateFuture = new CompletableFuture<>();
 
             controller.scheduleMigration(
                     pendingDataset, mExecutor, newOutcomeReceiver(migrateFuture));
@@ -610,7 +786,7 @@
     @Test
     public void scheduleMigration_secondRequestHasSmallerTimestamp_rejectedByLeader()
             throws Exception {
-        grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        grantPermissions(ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         for (ThreadNetworkController controller : getAllControllers()) {
             final ActiveOperationalDataset activeDataset =
@@ -638,15 +814,15 @@
                             activeDataset2,
                             new OperationalDatasetTimestamp(20, 0, false),
                             Duration.ofSeconds(30));
-            SettableFuture<Void> joinFuture = SettableFuture.create();
-            SettableFuture<Void> migrateFuture1 = SettableFuture.create();
-            SettableFuture<Void> migrateFuture2 = SettableFuture.create();
+            CompletableFuture<Void> joinFuture = new CompletableFuture<>();
+            CompletableFuture<Void> migrateFuture1 = new CompletableFuture<>();
+            CompletableFuture<Void> migrateFuture2 = new CompletableFuture<>();
             controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture));
-            joinFuture.get();
+            joinFuture.get(JOIN_TIMEOUT_MILLIS, MILLISECONDS);
 
             controller.scheduleMigration(
                     pendingDataset1, mExecutor, newOutcomeReceiver(migrateFuture1));
-            migrateFuture1.get();
+            migrateFuture1.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
             controller.scheduleMigration(
                     pendingDataset2, mExecutor, newOutcomeReceiver(migrateFuture2));
 
@@ -660,7 +836,7 @@
     @Test
     public void scheduleMigration_secondRequestHasLargerTimestamp_newDatasetApplied()
             throws Exception {
-        grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        grantPermissions(ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         for (ThreadNetworkController controller : getAllControllers()) {
             final ActiveOperationalDataset activeDataset =
@@ -688,28 +864,28 @@
                             activeDataset2,
                             new OperationalDatasetTimestamp(200, 0, false),
                             Duration.ofSeconds(30));
-            SettableFuture<Void> joinFuture = SettableFuture.create();
-            SettableFuture<Void> migrateFuture1 = SettableFuture.create();
-            SettableFuture<Void> migrateFuture2 = SettableFuture.create();
+            CompletableFuture<Void> joinFuture = new CompletableFuture<>();
+            CompletableFuture<Void> migrateFuture1 = new CompletableFuture<>();
+            CompletableFuture<Void> migrateFuture2 = new CompletableFuture<>();
             controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture));
-            joinFuture.get();
+            joinFuture.get(JOIN_TIMEOUT_MILLIS, MILLISECONDS);
 
             controller.scheduleMigration(
                     pendingDataset1, mExecutor, newOutcomeReceiver(migrateFuture1));
-            migrateFuture1.get();
+            migrateFuture1.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
             controller.scheduleMigration(
                     pendingDataset2, mExecutor, newOutcomeReceiver(migrateFuture2));
-            migrateFuture2.get();
+            migrateFuture2.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
 
-            SettableFuture<Boolean> dataset2IsApplied = SettableFuture.create();
-            SettableFuture<Boolean> pendingDatasetIsRemoved = SettableFuture.create();
+            CompletableFuture<Boolean> dataset2IsApplied = new CompletableFuture<>();
+            CompletableFuture<Boolean> pendingDatasetIsRemoved = new CompletableFuture<>();
             OperationalDatasetCallback datasetCallback =
                     new OperationalDatasetCallback() {
                         @Override
                         public void onActiveOperationalDatasetChanged(
                                 ActiveOperationalDataset activeDataset) {
                             if (activeDataset.equals(activeDataset2)) {
-                                dataset2IsApplied.set(true);
+                                dataset2IsApplied.complete(true);
                             }
                         }
 
@@ -717,18 +893,41 @@
                         public void onPendingOperationalDatasetChanged(
                                 PendingOperationalDataset pendingDataset) {
                             if (pendingDataset == null) {
-                                pendingDatasetIsRemoved.set(true);
+                                pendingDatasetIsRemoved.complete(true);
                             }
                         }
                     };
             controller.registerOperationalDatasetCallback(directExecutor(), datasetCallback);
-            assertThat(dataset2IsApplied.get()).isTrue();
-            assertThat(pendingDatasetIsRemoved.get()).isTrue();
+            assertThat(dataset2IsApplied.get(MIGRATION_TIMEOUT_MILLIS, MILLISECONDS)).isTrue();
+            assertThat(pendingDatasetIsRemoved.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isTrue();
             controller.unregisterOperationalDatasetCallback(datasetCallback);
         }
     }
 
     @Test
+    public void scheduleMigration_threadDisabled_failsWithErrorThreadDisabled() throws Exception {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            ActiveOperationalDataset activeDataset = newRandomizedDataset("TestNet", controller);
+            PendingOperationalDataset pendingDataset =
+                    new PendingOperationalDataset(
+                            activeDataset,
+                            OperationalDatasetTimestamp.fromInstant(Instant.now()),
+                            Duration.ofSeconds(30));
+            joinRandomizedDatasetAndWait(controller);
+            CompletableFuture<Void> migrationFuture = new CompletableFuture<>();
+
+            setEnabledAndWait(controller, false);
+
+            scheduleMigration(controller, pendingDataset, newOutcomeReceiver(migrationFuture));
+
+            ThreadNetworkException thrown =
+                    (ThreadNetworkException)
+                            assertThrows(ExecutionException.class, migrationFuture::get).getCause();
+            assertThat(thrown.getErrorCode()).isEqualTo(ERROR_THREAD_DISABLED);
+        }
+    }
+
+    @Test
     public void createRandomizedDataset_wrongNetworkNameLength_throwsIllegalArgumentException() {
         for (ThreadNetworkController controller : getAllControllers()) {
             assertThrows(
@@ -760,11 +959,105 @@
     }
 
     @Test
+    public void setEnabled_permissionsGranted_succeeds() throws Exception {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            CompletableFuture<Void> setFuture1 = new CompletableFuture<>();
+            CompletableFuture<Void> setFuture2 = new CompletableFuture<>();
+
+            runAsShell(
+                    PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                    () -> controller.setEnabled(false, mExecutor, newOutcomeReceiver(setFuture1)));
+            setFuture1.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
+            waitForEnabledState(controller, booleanToEnabledState(false));
+
+            runAsShell(
+                    PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                    () -> controller.setEnabled(true, mExecutor, newOutcomeReceiver(setFuture2)));
+            setFuture2.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
+            waitForEnabledState(controller, booleanToEnabledState(true));
+        }
+    }
+
+    @Test
+    public void setEnabled_noPermissions_throwsSecurityException() throws Exception {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            CompletableFuture<Void> setFuture = new CompletableFuture<>();
+            assertThrows(
+                    SecurityException.class,
+                    () -> controller.setEnabled(false, mExecutor, newOutcomeReceiver(setFuture)));
+        }
+    }
+
+    @Test
+    public void setEnabled_disable_leavesThreadNetwork() throws Exception {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            joinRandomizedDatasetAndWait(controller);
+
+            setEnabledAndWait(controller, false);
+
+            runAsShell(
+                    ACCESS_NETWORK_STATE,
+                    () -> assertThat(getDeviceRole(controller)).isEqualTo(DEVICE_ROLE_STOPPED));
+        }
+    }
+
+    @Test
+    public void setEnabled_toggleAfterJoin_joinsThreadNetworkAgain() throws Exception {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            joinRandomizedDatasetAndWait(controller);
+
+            setEnabledAndWait(controller, false);
+
+            runAsShell(
+                    ACCESS_NETWORK_STATE,
+                    () -> assertThat(getDeviceRole(controller)).isEqualTo(DEVICE_ROLE_STOPPED));
+
+            setEnabledAndWait(controller, true);
+
+            runAsShell(ACCESS_NETWORK_STATE, () -> waitForAttachedState(controller));
+        }
+    }
+
+    @Test
+    public void setEnabled_enableFollowedByDisable_allSucceed() throws Exception {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            joinRandomizedDatasetAndWait(controller);
+            CompletableFuture<Void> setFuture1 = new CompletableFuture<>();
+            CompletableFuture<Void> setFuture2 = new CompletableFuture<>();
+            EnabledStateListener listener = new EnabledStateListener(controller);
+            listener.expectThreadEnabledState(STATE_ENABLED);
+
+            runAsShell(
+                    PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                    () -> {
+                        controller.setEnabled(true, mExecutor, newOutcomeReceiver(setFuture1));
+                        controller.setEnabled(false, mExecutor, newOutcomeReceiver(setFuture2));
+                    });
+
+            setFuture1.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
+            setFuture2.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
+
+            listener.expectThreadEnabledState(STATE_DISABLING);
+            listener.expectThreadEnabledState(STATE_DISABLED);
+
+            runAsShell(
+                    ACCESS_NETWORK_STATE,
+                    () -> assertThat(getDeviceRole(controller)).isEqualTo(DEVICE_ROLE_STOPPED));
+
+            listener.unregisterStateCallback();
+        }
+    }
+    // TODO (b/322437869): add test case to verify when Thread is in DISABLING state, any commands
+    // (join/leave/scheduleMigration/setEnabled) fail with ERROR_BUSY. This is not currently tested
+    // because DISABLING has very short lifecycle, it's not possible to guarantee the command can be
+    // sent before state changes to DISABLED.
+
+    @Test
     public void threadNetworkCallback_deviceAttached_threadNetworkIsAvailable() throws Exception {
         ThreadNetworkController controller = mManager.getAllThreadNetworkControllers().get(0);
         ActiveOperationalDataset activeDataset = newRandomizedDataset("TestNet", controller);
-        SettableFuture<Void> joinFuture = SettableFuture.create();
-        SettableFuture<Network> networkFuture = SettableFuture.create();
+        CompletableFuture<Void> joinFuture = new CompletableFuture<>();
+        CompletableFuture<Network> networkFuture = new CompletableFuture<>();
         ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
         NetworkRequest networkRequest =
                 new NetworkRequest.Builder()
@@ -774,7 +1067,7 @@
                 new ConnectivityManager.NetworkCallback() {
                     @Override
                     public void onAvailable(Network network) {
-                        networkFuture.set(network);
+                        networkFuture.complete(network);
                     }
                 };
 
@@ -782,12 +1075,11 @@
                 PERMISSION_THREAD_NETWORK_PRIVILEGED,
                 () -> controller.join(activeDataset, mExecutor, newOutcomeReceiver(joinFuture)));
         runAsShell(
-                permission.ACCESS_NETWORK_STATE,
+                ACCESS_NETWORK_STATE,
                 () -> cm.registerNetworkCallback(networkRequest, networkCallback));
 
         joinFuture.get(JOIN_TIMEOUT_MILLIS, MILLISECONDS);
-        runAsShell(
-                permission.ACCESS_NETWORK_STATE, () -> assertThat(isAttached(controller)).isTrue());
+        runAsShell(ACCESS_NETWORK_STATE, () -> assertThat(isAttached(controller)).isTrue());
         assertThat(networkFuture.get(NETWORK_CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNotNull();
     }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index 44a8ab7..1d83abc 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -24,6 +24,7 @@
 import static com.google.common.io.BaseEncoding.base16;
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
@@ -85,6 +86,7 @@
     @Mock private TunInterfaceController mMockTunIfController;
     @Mock private ParcelFileDescriptor mMockTunFd;
     @Mock private InfraInterfaceController mMockInfraIfController;
+    @Mock private ThreadPersistentSettings mMockPersistentSettings;
     private Context mContext;
     private TestLooper mTestLooper;
     private FakeOtDaemon mFakeOtDaemon;
@@ -104,6 +106,8 @@
 
         when(mMockTunIfController.getTunFd()).thenReturn(mMockTunFd);
 
+        when(mMockPersistentSettings.get(any())).thenReturn(true);
+
         mService =
                 new ThreadNetworkControllerService(
                         ApplicationProvider.getApplicationContext(),
@@ -112,7 +116,8 @@
                         () -> mFakeOtDaemon,
                         mMockConnectivityManager,
                         mMockTunIfController,
-                        mMockInfraIfController);
+                        mMockInfraIfController,
+                        mMockPersistentSettings);
         mService.setTestNetworkAgent(mMockNetworkAgent);
     }