Merge "Add mulit devices mdns tests" into main
diff --git a/framework-t/src/android/net/nsd/NsdServiceInfo.java b/framework-t/src/android/net/nsd/NsdServiceInfo.java
index 2f675a9..d8cccb2 100644
--- a/framework-t/src/android/net/nsd/NsdServiceInfo.java
+++ b/framework-t/src/android/net/nsd/NsdServiceInfo.java
@@ -180,8 +180,18 @@
         return new ArrayList<>(mHostAddresses);
     }
 
-    /** Set the host addresses */
+    /**
+     * Set the host addresses.
+     *
+     * <p>When registering hosts/services, there can only be one registration including address
+     * records for a given hostname.
+     *
+     * <p>For example, if a client registers a service with the hostname "MyHost" and the address
+     * records of 192.168.1.1 and 192.168.1.2, then other registrations for the hostname "MyHost"
+     * must not have any address record, otherwise there will be a conflict.
+     */
     public void setHostAddresses(@NonNull List<InetAddress> addresses) {
+        // TODO: b/284905335 - Notify the client when there is a conflict.
         mHostAddresses.clear();
         mHostAddresses.addAll(addresses);
     }
diff --git a/thread/framework/java/android/net/thread/IConfigurationReceiver.aidl b/thread/framework/java/android/net/thread/IConfigurationReceiver.aidl
new file mode 100644
index 0000000..dcc4545
--- /dev/null
+++ b/thread/framework/java/android/net/thread/IConfigurationReceiver.aidl
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import android.net.thread.ThreadConfiguration;
+
+/** Receives the result of a Thread Configuration change. @hide */
+oneway interface IConfigurationReceiver {
+    void onConfigurationChanged(in ThreadConfiguration configuration);
+}
diff --git a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
index c5ca557..f50de74 100644
--- a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
+++ b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
@@ -19,11 +19,13 @@
 import android.net.thread.ActiveOperationalDataset;
 import android.net.thread.ChannelMaxPower;
 import android.net.thread.IActiveOperationalDatasetReceiver;
-import android.net.thread.IOperationalDatasetCallback;
+import android.net.thread.IConfigurationReceiver;
 import android.net.thread.IOperationReceiver;
+import android.net.thread.IOperationalDatasetCallback;
 import android.net.thread.IScheduleMigrationReceiver;
 import android.net.thread.IStateCallback;
 import android.net.thread.PendingOperationalDataset;
+import android.net.thread.ThreadConfiguration;
 
 /**
 * Interface for communicating with ThreadNetworkControllerService.
@@ -46,4 +48,7 @@
     void createRandomizedDataset(String networkName, IActiveOperationalDatasetReceiver receiver);
 
     void setEnabled(boolean enabled, in IOperationReceiver receiver);
+    void setConfiguration(in ThreadConfiguration config, in IOperationReceiver receiver);
+    void registerConfigurationCallback(in IConfigurationReceiver receiver);
+    void unregisterConfigurationCallback(in IConfigurationReceiver receiver);
 }
diff --git a/thread/framework/java/android/net/thread/ThreadConfiguration.aidl b/thread/framework/java/android/net/thread/ThreadConfiguration.aidl
new file mode 100644
index 0000000..9473411
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ThreadConfiguration.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+parcelable ThreadConfiguration;
diff --git a/thread/framework/java/android/net/thread/ThreadConfiguration.java b/thread/framework/java/android/net/thread/ThreadConfiguration.java
new file mode 100644
index 0000000..e09b3a6
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ThreadConfiguration.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.net.thread;
+
+import android.annotation.NonNull;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.Objects;
+
+/**
+ * Data interface for Thread device configuration.
+ *
+ * <p>An example usage of creating a {@link ThreadConfiguration} that turns on NAT64 feature based
+ * on an existing {@link ThreadConfiguration}:
+ *
+ * <pre>{@code
+ * ThreadConfiguration config =
+ *     new ThreadConfiguration.Builder(existingConfig).setNat64Enabled(true).build();
+ * }</pre>
+ *
+ * @see ThreadNetworkController#setConfiguration
+ * @see ThreadNetworkController#registerConfigurationCallback
+ * @see ThreadNetworkController#unregisterConfigurationCallback
+ * @hide
+ */
+// @FlaggedApi(ThreadNetworkFlags.FLAG_CONFIGURATION_ENABLED)
+// @SystemApi
+public final class ThreadConfiguration implements Parcelable {
+    private final boolean mNat64Enabled;
+    private final boolean mDhcp6PdEnabled;
+
+    private ThreadConfiguration(Builder builder) {
+        this(builder.mNat64Enabled, builder.mDhcp6PdEnabled);
+    }
+
+    private ThreadConfiguration(boolean nat64Enabled, boolean dhcp6PdEnabled) {
+        this.mNat64Enabled = nat64Enabled;
+        this.mDhcp6PdEnabled = dhcp6PdEnabled;
+    }
+
+    /** Returns {@code true} if NAT64 is enabled. */
+    public boolean isNat64Enabled() {
+        return mNat64Enabled;
+    }
+
+    /** Returns {@code true} if DHCPv6 Prefix Delegation is enabled. */
+    public boolean isDhcp6PdEnabled() {
+        return mDhcp6PdEnabled;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        } else if (!(other instanceof ThreadConfiguration)) {
+            return false;
+        } else {
+            ThreadConfiguration otherConfig = (ThreadConfiguration) other;
+            return mNat64Enabled == otherConfig.mNat64Enabled
+                    && mDhcp6PdEnabled == otherConfig.mDhcp6PdEnabled;
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mNat64Enabled, mDhcp6PdEnabled);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append('{');
+        sb.append("Nat64Enabled=").append(mNat64Enabled);
+        sb.append(", Dhcp6PdEnabled=").append(mDhcp6PdEnabled);
+        sb.append('}');
+        return sb.toString();
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeBoolean(mNat64Enabled);
+        dest.writeBoolean(mDhcp6PdEnabled);
+    }
+
+    public static final @NonNull Creator<ThreadConfiguration> CREATOR =
+            new Creator<>() {
+                @Override
+                public ThreadConfiguration createFromParcel(Parcel in) {
+                    ThreadConfiguration.Builder builder = new ThreadConfiguration.Builder();
+                    builder.setNat64Enabled(in.readBoolean());
+                    builder.setDhcp6PdEnabled(in.readBoolean());
+                    return builder.build();
+                }
+
+                @Override
+                public ThreadConfiguration[] newArray(int size) {
+                    return new ThreadConfiguration[size];
+                }
+            };
+
+    /** The builder for creating {@link ThreadConfiguration} objects. */
+    public static final class Builder {
+        private boolean mNat64Enabled = false;
+        private boolean mDhcp6PdEnabled = false;
+
+        /** Creates a new {@link Builder} object with all features disabled. */
+        public Builder() {}
+
+        /**
+         * Creates a new {@link Builder} object from a {@link ThreadConfiguration} object.
+         *
+         * @param config the Border Router configurations to be copied
+         */
+        public Builder(@NonNull ThreadConfiguration config) {
+            Objects.requireNonNull(config);
+
+            mNat64Enabled = config.mNat64Enabled;
+            mDhcp6PdEnabled = config.mDhcp6PdEnabled;
+        }
+
+        /**
+         * Enables or disables NAT64 for the device.
+         *
+         * <p>Enabling this feature will allow Thread devices to connect to the internet/cloud over
+         * IPv4.
+         */
+        @NonNull
+        public Builder setNat64Enabled(boolean enabled) {
+            this.mNat64Enabled = enabled;
+            return this;
+        }
+
+        /**
+         * Enables or disables Prefix Delegation for the device.
+         *
+         * <p>Enabling this feature will allow Thread devices to connect to the internet/cloud over
+         * IPv6.
+         */
+        @NonNull
+        public Builder setDhcp6PdEnabled(boolean enabled) {
+            this.mDhcp6PdEnabled = enabled;
+            return this;
+        }
+
+        /** Creates a new {@link ThreadConfiguration} object. */
+        @NonNull
+        public ThreadConfiguration build() {
+            return new ThreadConfiguration(this);
+        }
+    }
+}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index 8d6b40a..30b3d6a 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -41,6 +41,7 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.Executor;
+import java.util.function.Consumer;
 
 /**
  * Provides the primary APIs for controlling all aspects of a Thread network.
@@ -124,6 +125,12 @@
     private final Map<OperationalDatasetCallback, OperationalDatasetCallbackProxy>
             mOpDatasetCallbackMap = new HashMap<>();
 
+    private final Object mConfigurationCallbackMapLock = new Object();
+
+    @GuardedBy("mConfigurationCallbackMapLock")
+    private final Map<Consumer<ThreadConfiguration>, ConfigurationCallbackProxy>
+            mConfigurationCallbackMap = new HashMap<>();
+
     /** @hide */
     public ThreadNetworkController(@NonNull IThreadNetworkController controllerService) {
         requireNonNull(controllerService, "controllerService cannot be null");
@@ -579,6 +586,97 @@
     }
 
     /**
+     * Configures the Thread features for this device.
+     *
+     * <p>This method sets the {@link ThreadConfiguration} for this device. On success, the {@link
+     * OutcomeReceiver#onResult} will be called, and the {@code configuration} will be applied and
+     * persisted to the device; the configuration changes can be observed by {@link
+     * #registerConfigurationCallback}. On failure, {@link OutcomeReceiver#onError} of {@code
+     * receiver} will be invoked with a specific error.
+     *
+     * @param configuration the configuration to set
+     * @param executor the executor to execute {@code receiver}
+     * @param receiver the receiver to receive result of this operation
+     * @hide
+     */
+    // @FlaggedApi(ThreadNetworkFlags.FLAG_CONFIGURATION_ENABLED)
+    // @RequiresPermission(permission.THREAD_NETWORK_PRIVILEGED)
+    public void setConfiguration(
+            @NonNull ThreadConfiguration configuration,
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        requireNonNull(configuration, "Configuration cannot be null");
+        requireNonNull(executor, "executor cannot be null");
+        requireNonNull(receiver, "receiver cannot be null");
+        try {
+            mControllerService.setConfiguration(
+                    configuration, new OperationReceiverProxy(executor, receiver));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Registers a callback to be called when the configuration is changed.
+     *
+     * <p>Upon return of this method, {@code callback} will be invoked immediately with the new
+     * {@link ThreadConfiguration}.
+     *
+     * @param executor the executor to execute the {@code callback}
+     * @param callback the callback to receive Thread configuration changes
+     * @throws IllegalArgumentException if {@code callback} has already been registered
+     * @hide
+     */
+    // @FlaggedApi(ThreadNetworkFlags.FLAG_CONFIGURATION_ENABLED)
+    // @RequiresPermission(permission.THREAD_NETWORK_PRIVILEGED)
+    public void registerConfigurationCallback(
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull Consumer<ThreadConfiguration> callback) {
+        requireNonNull(executor, "executor cannot be null");
+        requireNonNull(callback, "callback cannot be null");
+        synchronized (mConfigurationCallbackMapLock) {
+            if (mConfigurationCallbackMap.containsKey(callback)) {
+                throw new IllegalArgumentException("callback has already been registered");
+            }
+            ConfigurationCallbackProxy callbackProxy =
+                    new ConfigurationCallbackProxy(executor, callback);
+            mConfigurationCallbackMap.put(callback, callbackProxy);
+            try {
+                mControllerService.registerConfigurationCallback(callbackProxy);
+            } catch (RemoteException e) {
+                mConfigurationCallbackMap.remove(callback);
+                e.rethrowFromSystemServer();
+            }
+        }
+    }
+
+    /**
+     * Unregisters the configuration callback.
+     *
+     * @param callback the callback which has been registered with {@link
+     *     #registerConfigurationCallback}
+     * @throws IllegalArgumentException if {@code callback} hasn't been registered
+     * @hide
+     */
+    // @FlaggedApi(ThreadNetworkFlags.FLAG_CONFIGURATION_ENABLED)
+    // @RequiresPermission(permission.THREAD_NETWORK_PRIVILEGED)
+    public void unregisterConfigurationCallback(@NonNull Consumer<ThreadConfiguration> callback) {
+        requireNonNull(callback, "callback cannot be null");
+        synchronized (mConfigurationCallbackMapLock) {
+            ConfigurationCallbackProxy callbackProxy = mConfigurationCallbackMap.get(callback);
+            if (callbackProxy == null) {
+                throw new IllegalArgumentException("callback hasn't been registered");
+            }
+            try {
+                mControllerService.unregisterConfigurationCallback(callbackProxy);
+                mConfigurationCallbackMap.remove(callbackProxy.mConfigurationConsumer);
+            } catch (RemoteException e) {
+                e.rethrowFromSystemServer();
+            }
+        }
+    }
+
+    /**
      * Sets to use a specified test network as the upstream.
      *
      * @param testNetworkInterfaceName The name of the test network interface. When it's null,
@@ -764,4 +862,26 @@
             propagateError(mExecutor, mResultReceiver, errorCode, errorMessage);
         }
     }
+
+    private static final class ConfigurationCallbackProxy extends IConfigurationReceiver.Stub {
+        final Executor mExecutor;
+        final Consumer<ThreadConfiguration> mConfigurationConsumer;
+
+        ConfigurationCallbackProxy(
+                @CallbackExecutor Executor executor,
+                Consumer<ThreadConfiguration> ConfigurationConsumer) {
+            this.mExecutor = executor;
+            this.mConfigurationConsumer = ConfigurationConsumer;
+        }
+
+        @Override
+        public void onConfigurationChanged(ThreadConfiguration configuration) {
+            final long identity = Binder.clearCallingIdentity();
+            try {
+                mExecutor.execute(() -> mConfigurationConsumer.accept(configuration));
+            } finally {
+                Binder.restoreCallingIdentity(identity);
+            }
+        }
+    }
 }
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkFlags.java b/thread/framework/java/android/net/thread/ThreadNetworkFlags.java
index e6ab988..691bbf5 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkFlags.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkFlags.java
@@ -27,5 +27,9 @@
     /** @hide */
     public static final String FLAG_THREAD_ENABLED = "com.android.net.thread.flags.thread_enabled";
 
+    /** @hide */
+    public static final String FLAG_CONFIGURATION_ENABLED =
+            "com.android.net.thread.flags.configuration_enabled";
+
     private ThreadNetworkFlags() {}
 }
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 0c77dee..1d823e0 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -91,12 +91,14 @@
 import android.net.thread.ActiveOperationalDataset.SecurityPolicy;
 import android.net.thread.ChannelMaxPower;
 import android.net.thread.IActiveOperationalDatasetReceiver;
+import android.net.thread.IConfigurationReceiver;
 import android.net.thread.IOperationReceiver;
 import android.net.thread.IOperationalDatasetCallback;
 import android.net.thread.IStateCallback;
 import android.net.thread.IThreadNetworkController;
 import android.net.thread.OperationalDatasetTimestamp;
 import android.net.thread.PendingOperationalDataset;
+import android.net.thread.ThreadConfiguration;
 import android.net.thread.ThreadNetworkController;
 import android.net.thread.ThreadNetworkController.DeviceRole;
 import android.net.thread.ThreadNetworkException;
@@ -189,6 +191,8 @@
     private final OtDaemonCallbackProxy mOtDaemonCallbackProxy = new OtDaemonCallbackProxy();
     private final ConnectivityResources mResources;
     private final Supplier<String> mCountryCodeSupplier;
+    private final Map<IConfigurationReceiver, IBinder.DeathRecipient> mConfigurationReceivers =
+            new HashMap<>();
 
     // This should not be directly used for calling IOtDaemon APIs because ot-daemon may die and
     // {@code mOtDaemon} will be set to {@code null}. Instead, use {@code getOtDaemon()}
@@ -518,17 +522,86 @@
         }
     }
 
+    @Override
+    public void setConfiguration(
+            @NonNull ThreadConfiguration configuration, @NonNull IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        mHandler.post(() -> setConfigurationInternal(configuration, receiver));
+    }
+
+    private void setConfigurationInternal(
+            @NonNull ThreadConfiguration configuration,
+            @NonNull IOperationReceiver operationReceiver) {
+        checkOnHandlerThread();
+
+        Log.i(TAG, "Set Thread configuration: " + configuration);
+
+        final boolean changed = mPersistentSettings.putConfiguration(configuration);
+        try {
+            operationReceiver.onSuccess();
+        } catch (RemoteException e) {
+            // do nothing if the client is dead
+        }
+        if (changed) {
+            for (IConfigurationReceiver configReceiver : mConfigurationReceivers.keySet()) {
+                try {
+                    configReceiver.onConfigurationChanged(configuration);
+                } catch (RemoteException e) {
+                    // do nothing if the client is dead
+                }
+            }
+        }
+    }
+
+    @Override
+    public void registerConfigurationCallback(@NonNull IConfigurationReceiver callback) {
+        enforceAllPermissionsGranted(permission.THREAD_NETWORK_PRIVILEGED);
+        mHandler.post(() -> registerConfigurationCallbackInternal(callback));
+    }
+
+    private void registerConfigurationCallbackInternal(@NonNull IConfigurationReceiver callback) {
+        checkOnHandlerThread();
+        if (mConfigurationReceivers.containsKey(callback)) {
+            throw new IllegalStateException("Registering the same IConfigurationReceiver twice");
+        }
+        IBinder.DeathRecipient deathRecipient =
+                () -> mHandler.post(() -> unregisterConfigurationCallbackInternal(callback));
+        try {
+            callback.asBinder().linkToDeath(deathRecipient, 0);
+        } catch (RemoteException e) {
+            return;
+        }
+        mConfigurationReceivers.put(callback, deathRecipient);
+        try {
+            callback.onConfigurationChanged(mPersistentSettings.getConfiguration());
+        } catch (RemoteException e) {
+            // do nothing if the client is dead
+        }
+    }
+
+    @Override
+    public void unregisterConfigurationCallback(@NonNull IConfigurationReceiver callback) {
+        enforceAllPermissionsGranted(permission.THREAD_NETWORK_PRIVILEGED);
+        mHandler.post(() -> unregisterConfigurationCallbackInternal(callback));
+    }
+
+    private void unregisterConfigurationCallbackInternal(@NonNull IConfigurationReceiver callback) {
+        checkOnHandlerThread();
+        if (!mConfigurationReceivers.containsKey(callback)) {
+            return;
+        }
+        callback.asBinder().unlinkToDeath(mConfigurationReceivers.remove(callback), 0);
+    }
+
     private void registerUserRestrictionsReceiver() {
         mContext.registerReceiver(
                 new BroadcastReceiver() {
                     @Override
                     public void onReceive(Context context, Intent intent) {
-                        onUserRestrictionsChanged(isThreadUserRestricted());
+                        mHandler.post(() -> onUserRestrictionsChanged(isThreadUserRestricted()));
                     }
                 },
-                new IntentFilter(UserManager.ACTION_USER_RESTRICTIONS_CHANGED),
-                null /* broadcastPermission */,
-                mHandler);
+                new IntentFilter(UserManager.ACTION_USER_RESTRICTIONS_CHANGED));
     }
 
     private void onUserRestrictionsChanged(boolean newUserRestrictedState) {
@@ -580,12 +653,10 @@
                 new BroadcastReceiver() {
                     @Override
                     public void onReceive(Context context, Intent intent) {
-                        onAirplaneModeChanged(isAirplaneModeOn());
+                        mHandler.post(() -> onAirplaneModeChanged(isAirplaneModeOn()));
                     }
                 },
-                new IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED),
-                null /* broadcastPermission */,
-                mHandler);
+                new IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED));
     }
 
     private void onAirplaneModeChanged(boolean newAirplaneModeOn) {
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
index f18aac9..747cc96 100644
--- a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -18,9 +18,11 @@
 
 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.ThreadConfiguration;
 import android.os.PersistableBundle;
 import android.util.AtomicFile;
 import android.util.Log;
@@ -74,6 +76,16 @@
     /** Stores the Thread country code, null if no country code is stored. */
     public static final Key<String> THREAD_COUNTRY_CODE = new Key<>("thread_country_code", null);
 
+    /** Stores the Thread NAT64 feature toggle state, true for enabled and false for disabled. */
+    private static final Key<Boolean> CONFIG_NAT64_ENABLED =
+            new Key<>("config_nat64_enabled", false);
+
+    /**
+     * Stores the Thread DHCPv6-PD feature toggle state, true for enabled and false for disabled.
+     */
+    private static final Key<Boolean> CONFIG_DHCP6_PD_ENABLED =
+            new Key<>("config_dhcp6_pd_enabled", false);
+
     /******** Thread persistent setting keys ***************/
 
     @GuardedBy("mLock")
@@ -175,6 +187,30 @@
     }
 
     /**
+     * Store a {@link ThreadConfiguration} to the persistent settings.
+     *
+     * @param configuration {@link ThreadConfiguration} to be stored.
+     * @return {@code true} if the configuration was changed, {@code false} otherwise.
+     */
+    public boolean putConfiguration(@NonNull ThreadConfiguration configuration) {
+        if (getConfiguration().equals(configuration)) {
+            return false;
+        }
+        putObject(CONFIG_NAT64_ENABLED.key, configuration.isNat64Enabled());
+        putObject(CONFIG_DHCP6_PD_ENABLED.key, configuration.isDhcp6PdEnabled());
+        writeToStoreFile();
+        return true;
+    }
+
+    /** Retrieve the {@link ThreadConfiguration} from the persistent settings. */
+    public ThreadConfiguration getConfiguration() {
+        return new ThreadConfiguration.Builder()
+                .setNat64Enabled(get(CONFIG_NAT64_ENABLED))
+                .setDhcp6PdEnabled(get(CONFIG_DHCP6_PD_ENABLED))
+                .build();
+    }
+
+    /**
      * Base class to store string key and its default value.
      *
      * @param <T> Type of the value.
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 0e95703..41f34ff 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -45,6 +45,7 @@
 
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.fail;
+
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
 import android.content.Context;
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 2f58943..de6d1e1 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -59,6 +59,7 @@
 import android.net.thread.ActiveOperationalDataset;
 import android.net.thread.IActiveOperationalDatasetReceiver;
 import android.net.thread.IOperationReceiver;
+import android.net.thread.ThreadConfiguration;
 import android.net.thread.ThreadNetworkException;
 import android.os.Handler;
 import android.os.IBinder;
@@ -539,9 +540,7 @@
                 .when(mContext)
                 .registerReceiver(
                         any(BroadcastReceiver.class),
-                        argThat(actualIntentFilter -> actualIntentFilter.hasAction(action)),
-                        any(),
-                        any());
+                        argThat(actualIntentFilter -> actualIntentFilter.hasAction(action)));
 
         return receiverRef;
     }
@@ -708,4 +707,35 @@
         inOrder.verify(mMockTunIfController, times(1)).setInterfaceUp(false);
         inOrder.verify(mMockTunIfController, times(1)).setInterfaceUp(true);
     }
+
+    @Test
+    public void setConfiguration_configurationUpdated() throws Exception {
+        mService.initialize();
+        final IOperationReceiver mockReceiver1 = mock(IOperationReceiver.class);
+        final IOperationReceiver mockReceiver2 = mock(IOperationReceiver.class);
+        final IOperationReceiver mockReceiver3 = mock(IOperationReceiver.class);
+        ThreadConfiguration config1 =
+                new ThreadConfiguration.Builder()
+                        .setNat64Enabled(false)
+                        .setDhcp6PdEnabled(false)
+                        .build();
+        ThreadConfiguration config2 =
+                new ThreadConfiguration.Builder()
+                        .setNat64Enabled(true)
+                        .setDhcp6PdEnabled(true)
+                        .build();
+        ThreadConfiguration config3 =
+                new ThreadConfiguration.Builder(config2).build(); // Same as config2
+
+        mService.setConfiguration(config1, mockReceiver1);
+        mService.setConfiguration(config2, mockReceiver2);
+        mService.setConfiguration(config3, mockReceiver3);
+        mTestLooper.dispatchAll();
+
+        assertThat(mPersistentSettings.getConfiguration()).isEqualTo(config3);
+        InOrder inOrder = Mockito.inOrder(mockReceiver1, mockReceiver2, mockReceiver3);
+        inOrder.verify(mockReceiver1).onSuccess();
+        inOrder.verify(mockReceiver2).onSuccess();
+        inOrder.verify(mockReceiver3).onSuccess();
+    }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
index 7d2fe91..c932ac8 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
@@ -21,16 +21,12 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import static org.mockito.Mockito.any;
-import static org.mockito.Mockito.anyInt;
-import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.eq;
-import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.validateMockitoUsage;
-import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import android.content.res.Resources;
+import android.net.thread.ThreadConfiguration;
 import android.os.PersistableBundle;
 import android.util.AtomicFile;
 
@@ -42,13 +38,14 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
 import java.io.ByteArrayOutputStream;
-import java.io.FileInputStream;
 import java.io.FileOutputStream;
 
 /** Unit tests for {@link ThreadPersistentSettings}. */
@@ -57,12 +54,15 @@
 public class ThreadPersistentSettingsTest {
     private static final String TEST_COUNTRY_CODE = "CN";
 
-    @Mock private AtomicFile mAtomicFile;
     @Mock Resources mResources;
     @Mock ConnectivityResources mConnectivityResources;
 
+    private AtomicFile mAtomicFile;
     private ThreadPersistentSettings mThreadPersistentSettings;
 
+    @Rule(order = 0)
+    public final TemporaryFolder mTemporaryFolder = new TemporaryFolder();
+
     @Before
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
@@ -70,8 +70,7 @@
         when(mConnectivityResources.get()).thenReturn(mResources);
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(true);
 
-        FileOutputStream fos = mock(FileOutputStream.class);
-        when(mAtomicFile.startWrite()).thenReturn(fos);
+        mAtomicFile = createAtomicFile();
         mThreadPersistentSettings =
                 new ThreadPersistentSettings(mAtomicFile, mConnectivityResources);
     }
@@ -85,7 +84,7 @@
     @Test
     public void initialize_readsFromFile() throws Exception {
         byte[] data = createXmlForParsing(THREAD_ENABLED.key, false);
-        setupAtomicFileMockForRead(data);
+        setupAtomicFileForRead(data);
 
         mThreadPersistentSettings.initialize();
 
@@ -95,7 +94,7 @@
     @Test
     public void initialize_ThreadDisabledInResources_returnsThreadDisabled() throws Exception {
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(false);
-        setupAtomicFileMockForRead(new byte[0]);
+        setupAtomicFileForRead(new byte[0]);
 
         mThreadPersistentSettings.initialize();
 
@@ -107,7 +106,7 @@
             throws Exception {
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(false);
         byte[] data = createXmlForParsing(THREAD_ENABLED.key, true);
-        setupAtomicFileMockForRead(data);
+        setupAtomicFileForRead(data);
 
         mThreadPersistentSettings.initialize();
 
@@ -119,9 +118,6 @@
         mThreadPersistentSettings.put(THREAD_ENABLED.key, true);
 
         assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isTrue();
-        // Confirm that file writes have been triggered.
-        verify(mAtomicFile).startWrite();
-        verify(mAtomicFile).finishWrite(any());
     }
 
     @Test
@@ -129,9 +125,8 @@
         mThreadPersistentSettings.put(THREAD_ENABLED.key, false);
 
         assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
-        // Confirm that file writes have been triggered.
-        verify(mAtomicFile).startWrite();
-        verify(mAtomicFile).finishWrite(any());
+        mThreadPersistentSettings.initialize();
+        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
     }
 
     @Test
@@ -139,10 +134,8 @@
         mThreadPersistentSettings.put(THREAD_COUNTRY_CODE.key, TEST_COUNTRY_CODE);
 
         assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
-
-        // Confirm that file writes have been triggered.
-        verify(mAtomicFile).startWrite();
-        verify(mAtomicFile).finishWrite(any());
+        mThreadPersistentSettings.initialize();
+        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
     }
 
     @Test
@@ -150,10 +143,63 @@
         mThreadPersistentSettings.put(THREAD_COUNTRY_CODE.key, null);
 
         assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isNull();
+        mThreadPersistentSettings.initialize();
+        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isNull();
+    }
 
-        // Confirm that file writes have been triggered.
-        verify(mAtomicFile).startWrite();
-        verify(mAtomicFile).finishWrite(any());
+    @Test
+    public void putConfiguration_sameValues_returnsFalse() {
+        ThreadConfiguration configuration =
+                new ThreadConfiguration.Builder()
+                        .setNat64Enabled(true)
+                        .setDhcp6PdEnabled(true)
+                        .build();
+        mThreadPersistentSettings.putConfiguration(configuration);
+
+        assertThat(mThreadPersistentSettings.putConfiguration(configuration)).isFalse();
+    }
+
+    @Test
+    public void putConfiguration_differentValues_returnsTrue() {
+        ThreadConfiguration configuration1 =
+                new ThreadConfiguration.Builder()
+                        .setNat64Enabled(false)
+                        .setDhcp6PdEnabled(false)
+                        .build();
+        mThreadPersistentSettings.putConfiguration(configuration1);
+        ThreadConfiguration configuration2 =
+                new ThreadConfiguration.Builder()
+                        .setNat64Enabled(true)
+                        .setDhcp6PdEnabled(true)
+                        .build();
+
+        assertThat(mThreadPersistentSettings.putConfiguration(configuration2)).isTrue();
+    }
+
+    @Test
+    public void putConfiguration_nat64Enabled_valuesUpdatedAndPersisted() throws Exception {
+        ThreadConfiguration configuration =
+                new ThreadConfiguration.Builder().setNat64Enabled(true).build();
+        mThreadPersistentSettings.putConfiguration(configuration);
+
+        assertThat(mThreadPersistentSettings.getConfiguration()).isEqualTo(configuration);
+        mThreadPersistentSettings.initialize();
+        assertThat(mThreadPersistentSettings.getConfiguration()).isEqualTo(configuration);
+    }
+
+    @Test
+    public void putConfiguration_dhcp6PdEnabled_valuesUpdatedAndPersisted() throws Exception {
+        ThreadConfiguration configuration =
+                new ThreadConfiguration.Builder().setDhcp6PdEnabled(true).build();
+        mThreadPersistentSettings.putConfiguration(configuration);
+
+        assertThat(mThreadPersistentSettings.getConfiguration()).isEqualTo(configuration);
+        mThreadPersistentSettings.initialize();
+        assertThat(mThreadPersistentSettings.getConfiguration()).isEqualTo(configuration);
+    }
+
+    private AtomicFile createAtomicFile() throws Exception {
+        return new AtomicFile(mTemporaryFolder.newFile());
     }
 
     private byte[] createXmlForParsing(String key, Boolean value) throws Exception {
@@ -164,19 +210,9 @@
         return outputStream.toByteArray();
     }
 
-    private void setupAtomicFileMockForRead(byte[] dataToRead) throws Exception {
-        FileInputStream is = mock(FileInputStream.class);
-        when(mAtomicFile.openRead()).thenReturn(is);
-        when(is.available()).thenReturn(dataToRead.length).thenReturn(0);
-        doAnswer(
-                        invocation -> {
-                            byte[] data = invocation.getArgument(0);
-                            int pos = invocation.getArgument(1);
-                            if (pos == dataToRead.length) return 0; // read complete.
-                            System.arraycopy(dataToRead, 0, data, 0, dataToRead.length);
-                            return dataToRead.length;
-                        })
-                .when(is)
-                .read(any(), anyInt(), anyInt());
+    private void setupAtomicFileForRead(byte[] dataToRead) throws Exception {
+        try (FileOutputStream outputStream = new FileOutputStream(mAtomicFile.getBaseFile())) {
+            outputStream.write(dataToRead);
+        }
     }
 }