Add setEnabled API

setEnabled API is added to ThreadNetworkController, this
can be used by applications with privileged permission to set
Thread to enable/disabled state.

The Thread enabled state can be subscribed with
ThreadNetworkController#registerStateCallback.

When Thread is disabled, the join/scheduleMigration APIs will
fail with ERROR_THREAD_DISABLED.

When Thread is disabling, all commands will fail with ERROR_BUSY.

Bug: 299243765

Test: atest CtsThreadNetworkTestCases:android.net.thread.cts.ThreadNetworkControllerTest

Change-Id: Ifa7845bf1d5664ecd31ce74e24b3a839f92bba13
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/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();
     }
 }