[Thread] add Thread Operational Dataset API

Design doc: go/thread-android-api
Android FR: b/235016403

Bug: 262683651
Change-Id: Icbd4ee4150e3fd78df627c2e726c259e7ee50871
diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt
index 06d3238..6c98a4f 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -417,6 +417,81 @@
 
 package android.net.thread {
 
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ActiveOperationalDataset implements android.os.Parcelable {
+    method @NonNull public static android.net.thread.ActiveOperationalDataset createRandomDataset();
+    method public int describeContents();
+    method @NonNull public static android.net.thread.ActiveOperationalDataset fromThreadTlvs(@NonNull byte[]);
+    method @NonNull public android.net.thread.OperationalDatasetTimestamp getActiveTimestamp();
+    method @IntRange(from=0, to=65535) public int getChannel();
+    method @NonNull @Size(min=1) public android.util.SparseArray<byte[]> getChannelMask();
+    method @IntRange(from=0, to=255) public int getChannelPage();
+    method @NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_EXTENDED_PAN_ID) public byte[] getExtendedPanId();
+    method @NonNull public android.net.IpPrefix getMeshLocalPrefix();
+    method @NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_NETWORK_KEY) public byte[] getNetworkKey();
+    method @NonNull @Size(min=android.net.thread.ActiveOperationalDataset.LENGTH_MIN_NETWORK_NAME_BYTES, max=android.net.thread.ActiveOperationalDataset.LENGTH_MAX_NETWORK_NAME_BYTES) public String getNetworkName();
+    method @IntRange(from=0, to=65534) public int getPanId();
+    method @NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_PSKC) public byte[] getPskc();
+    method @NonNull public android.net.thread.ActiveOperationalDataset.SecurityPolicy getSecurityPolicy();
+    method @NonNull public byte[] toThreadTlvs();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field public static final int CHANNEL_MAX_24_GHZ = 26; // 0x1a
+    field public static final int CHANNEL_MIN_24_GHZ = 11; // 0xb
+    field public static final int CHANNEL_PAGE_24_GHZ = 0; // 0x0
+    field @NonNull public static final android.os.Parcelable.Creator<android.net.thread.ActiveOperationalDataset> CREATOR;
+    field public static final int LENGTH_EXTENDED_PAN_ID = 8; // 0x8
+    field public static final int LENGTH_MAX_DATASET_TLVS = 254; // 0xfe
+    field public static final int LENGTH_MAX_NETWORK_NAME_BYTES = 16; // 0x10
+    field public static final int LENGTH_MESH_LOCAL_PREFIX_BITS = 64; // 0x40
+    field public static final int LENGTH_MIN_NETWORK_NAME_BYTES = 1; // 0x1
+    field public static final int LENGTH_NETWORK_KEY = 16; // 0x10
+    field public static final int LENGTH_PSKC = 16; // 0x10
+  }
+
+  public static final class ActiveOperationalDataset.Builder {
+    ctor public ActiveOperationalDataset.Builder(@NonNull android.net.thread.ActiveOperationalDataset);
+    ctor public ActiveOperationalDataset.Builder();
+    method @NonNull public android.net.thread.ActiveOperationalDataset build();
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setActiveTimestamp(@NonNull android.net.thread.OperationalDatasetTimestamp);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setChannel(@IntRange(from=0, to=255) int, @IntRange(from=0, to=65535) int);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setChannelMask(@NonNull @Size(min=1) android.util.SparseArray<byte[]>);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setExtendedPanId(@NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_EXTENDED_PAN_ID) byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setMeshLocalPrefix(@NonNull android.net.IpPrefix);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setNetworkKey(@NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_NETWORK_KEY) byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setNetworkName(@NonNull @Size(min=android.net.thread.ActiveOperationalDataset.LENGTH_MIN_NETWORK_NAME_BYTES, max=android.net.thread.ActiveOperationalDataset.LENGTH_MAX_NETWORK_NAME_BYTES) String);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setPanId(@IntRange(from=0, to=65534) int);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setPskc(@NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_PSKC) byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setSecurityPolicy(@NonNull android.net.thread.ActiveOperationalDataset.SecurityPolicy);
+  }
+
+  public static final class ActiveOperationalDataset.SecurityPolicy {
+    ctor public ActiveOperationalDataset.SecurityPolicy(@IntRange(from=1, to=65535) int, @NonNull @Size(min=android.net.thread.ActiveOperationalDataset.SecurityPolicy.LENGTH_MIN_SECURITY_POLICY_FLAGS) byte[]);
+    method @NonNull @Size(min=android.net.thread.ActiveOperationalDataset.SecurityPolicy.LENGTH_MIN_SECURITY_POLICY_FLAGS) public byte[] getFlags();
+    method @IntRange(from=1, to=65535) public int getRotationTimeHours();
+    field public static final int DEFAULT_ROTATION_TIME_HOURS = 672; // 0x2a0
+    field public static final int LENGTH_MIN_SECURITY_POLICY_FLAGS = 1; // 0x1
+  }
+
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class OperationalDatasetTimestamp {
+    ctor public OperationalDatasetTimestamp(@IntRange(from=0, to=281474976710655L) long, @IntRange(from=0, to=32767) int, boolean);
+    method @NonNull public static android.net.thread.OperationalDatasetTimestamp fromInstant(@NonNull java.time.Instant);
+    method @IntRange(from=0, to=281474976710655L) public long getSeconds();
+    method @IntRange(from=0, to=32767) public int getTicks();
+    method public boolean isAuthoritativeSource();
+    method @NonNull public java.time.Instant toInstant();
+  }
+
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class PendingOperationalDataset implements android.os.Parcelable {
+    ctor public PendingOperationalDataset(@NonNull android.net.thread.ActiveOperationalDataset, @NonNull android.net.thread.OperationalDatasetTimestamp, @NonNull java.time.Duration);
+    method public int describeContents();
+    method @NonNull public static android.net.thread.PendingOperationalDataset fromThreadTlvs(@NonNull byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset getActiveOperationalDataset();
+    method @NonNull public java.time.Duration getDelayTimer();
+    method @NonNull public android.net.thread.OperationalDatasetTimestamp getPendingTimestamp();
+    method @NonNull public byte[] toThreadTlvs();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.net.thread.PendingOperationalDataset> CREATOR;
+  }
+
   @FlaggedApi("com.android.net.thread.flags.thread_enabled") public class ThreadNetworkController {
     method public int getThreadVersion();
     field public static final int THREAD_VERSION_1_3 = 4; // 0x4
diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.aidl b/thread/framework/java/android/net/thread/ActiveOperationalDataset.aidl
new file mode 100644
index 0000000..8bf12a4
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2023 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 ActiveOperationalDataset;
diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
new file mode 100644
index 0000000..c9b047a
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
@@ -0,0 +1,1165 @@
+/*
+ * Copyright (C) 2023 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 static android.net.thread.ActiveOperationalDataset.SecurityPolicy.DEFAULT_ROTATION_TIME_HOURS;
+
+import static com.android.internal.util.Preconditions.checkArgument;
+import static com.android.internal.util.Preconditions.checkState;
+import static com.android.net.module.util.HexDump.dumpHexString;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.FlaggedApi;
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.Size;
+import android.annotation.SystemApi;
+import android.net.IpPrefix;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.ByteArrayOutputStream;
+import java.net.Inet6Address;
+import java.net.UnknownHostException;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Random;
+
+/**
+ * Data interface for managing a Thread Active Operational Dataset.
+ *
+ * <p>An example usage of creating an Active Operational Dataset with random parameters:
+ *
+ * <pre>{@code
+ * ActiveOperationalDataset activeDataset = ActiveOperationalDataset.createRandomDataset();
+ * }</pre>
+ *
+ * <p>or random Dataset with customized Network Name:
+ *
+ * <pre>{@code
+ * ActiveOperationalDataset activeDataset =
+ *         new ActiveOperationalDataset.Builder(ActiveOperationalDataset.createRandomDataset())
+ *                 .setNetworkName("MyThreadNet").build();
+ * }</pre>
+ *
+ * <p>If the Active Operational Dataset is already known as <a
+ * href="https://www.threadgroup.org">Thread TLVs</a>, you can simply use:
+ *
+ * <pre>{@code
+ * ActiveOperationalDataset activeDataset = ActiveOperationalDataset.fromThreadTlvs(datasetTlvs);
+ * }</pre>
+ *
+ * @hide
+ */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
+@SystemApi
+public final class ActiveOperationalDataset implements Parcelable {
+    /** The maximum length of the Active Operational Dataset TLV array in bytes. */
+    public static final int LENGTH_MAX_DATASET_TLVS = 254;
+    /** The length of Extended PAN ID in bytes. */
+    public static final int LENGTH_EXTENDED_PAN_ID = 8;
+    /** The minimum length of Network Name as UTF-8 bytes. */
+    public static final int LENGTH_MIN_NETWORK_NAME_BYTES = 1;
+    /** The maximum length of Network Name as UTF-8 bytes. */
+    public static final int LENGTH_MAX_NETWORK_NAME_BYTES = 16;
+    /** The length of Network Key in bytes. */
+    public static final int LENGTH_NETWORK_KEY = 16;
+    /** The length of Mesh-Local Prefix in bits. */
+    public static final int LENGTH_MESH_LOCAL_PREFIX_BITS = 64;
+    /** The length of PSKc in bytes. */
+    public static final int LENGTH_PSKC = 16;
+    /** The 2.4 GHz channel page. */
+    public static final int CHANNEL_PAGE_24_GHZ = 0;
+    /** The minimum 2.4GHz channel. */
+    public static final int CHANNEL_MIN_24_GHZ = 11;
+    /** The maximum 2.4GHz channel. */
+    public static final int CHANNEL_MAX_24_GHZ = 26;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_CHANNEL = 0;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_PAN_ID = 1;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_EXTENDED_PAN_ID = 2;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_NETWORK_NAME = 3;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_PSKC = 4;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_NETWORK_KEY = 5;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_MESH_LOCAL_PREFIX = 7;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_SECURITY_POLICY = 12;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_ACTIVE_TIMESTAMP = 14;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_CHANNEL_MASK = 53;
+
+    private static final byte MESH_LOCAL_PREFIX_FIRST_BYTE = (byte) 0xfd;
+    private static final int LENGTH_CHANNEL = 3;
+    private static final int LENGTH_PAN_ID = 2;
+
+    @NonNull
+    public static final Creator<ActiveOperationalDataset> CREATOR =
+            new Creator<>() {
+                @Override
+                public ActiveOperationalDataset createFromParcel(Parcel in) {
+                    return ActiveOperationalDataset.fromThreadTlvs(in.createByteArray());
+                }
+
+                @Override
+                public ActiveOperationalDataset[] newArray(int size) {
+                    return new ActiveOperationalDataset[size];
+                }
+            };
+
+    private final OperationalDatasetTimestamp mActiveTimestamp;
+    private final String mNetworkName;
+    private final byte[] mExtendedPanId;
+    private final int mPanId;
+    private final int mChannel;
+    private final int mChannelPage;
+    private final SparseArray<byte[]> mChannelMask;
+    private final byte[] mPskc;
+    private final byte[] mNetworkKey;
+    private final IpPrefix mMeshLocalPrefix;
+    private final SecurityPolicy mSecurityPolicy;
+    private final SparseArray<byte[]> mUnknownTlvs;
+
+    private ActiveOperationalDataset(Builder builder) {
+        this(
+                requireNonNull(builder.mActiveTimestamp),
+                requireNonNull(builder.mNetworkName),
+                requireNonNull(builder.mExtendedPanId),
+                requireNonNull(builder.mPanId),
+                requireNonNull(builder.mChannelPage),
+                requireNonNull(builder.mChannel),
+                requireNonNull(builder.mChannelMask),
+                requireNonNull(builder.mPskc),
+                requireNonNull(builder.mNetworkKey),
+                requireNonNull(builder.mMeshLocalPrefix),
+                requireNonNull(builder.mSecurityPolicy),
+                requireNonNull(builder.mUnknownTlvs));
+    }
+
+    private ActiveOperationalDataset(
+            OperationalDatasetTimestamp activeTimestamp,
+            String networkName,
+            byte[] extendedPanId,
+            int panId,
+            int channelPage,
+            int channel,
+            SparseArray<byte[]> channelMask,
+            byte[] pskc,
+            byte[] networkKey,
+            IpPrefix meshLocalPrefix,
+            SecurityPolicy securityPolicy,
+            SparseArray<byte[]> unknownTlvs) {
+        this.mActiveTimestamp = activeTimestamp;
+        this.mNetworkName = networkName;
+        this.mExtendedPanId = extendedPanId.clone();
+        this.mPanId = panId;
+        this.mChannel = channel;
+        this.mChannelPage = channelPage;
+        this.mChannelMask = deepCloneSparseArray(channelMask);
+        this.mPskc = pskc.clone();
+        this.mNetworkKey = networkKey.clone();
+        this.mMeshLocalPrefix = meshLocalPrefix;
+        this.mSecurityPolicy = securityPolicy;
+        this.mUnknownTlvs = deepCloneSparseArray(unknownTlvs);
+    }
+
+    /**
+     * Creates a new {@link ActiveOperationalDataset} object from a series of Thread TLVs.
+     *
+     * <p>{@code tlvs} can be obtained from the value of a Thread Active Operational Dataset TLV
+     * (see the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition) or the return value of {@link #toThreadTlvs}.
+     *
+     * @param tlvs a series of Thread TLVs which contain the Active Operational Dataset
+     * @return the decoded Active Operational Dataset
+     * @throws IllegalArgumentException if {@code tlvs} is malformed or the length is larger than
+     *     {@link LENGTH_MAX_DATASET_TLVS}
+     */
+    @NonNull
+    public static ActiveOperationalDataset fromThreadTlvs(@NonNull byte[] tlvs) {
+        requireNonNull(tlvs, "tlvs cannot be null");
+        if (tlvs.length > LENGTH_MAX_DATASET_TLVS) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "tlvs length exceeds max length %d (actual is %d)",
+                            LENGTH_MAX_DATASET_TLVS, tlvs.length));
+        }
+
+        Builder builder = new Builder();
+        int i = 0;
+        while (i < tlvs.length) {
+            int type = tlvs[i++] & 0xff;
+            if (i >= tlvs.length) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Found TLV type %d at end of operational dataset with length %d",
+                                type, tlvs.length));
+            }
+
+            int length = tlvs[i++] & 0xff;
+            if (i + length > tlvs.length) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Found TLV type %d with length %d which exceeds the remaining data"
+                                        + " in the operational dataset with length %d",
+                                type, length, tlvs.length));
+            }
+
+            initWithTlv(builder, type, Arrays.copyOfRange(tlvs, i, i + length));
+            i += length;
+        }
+        try {
+            return builder.build();
+        } catch (IllegalStateException e) {
+            throw new IllegalArgumentException(
+                    "Failed to build the ActiveOperationalDataset object", e);
+        }
+    }
+
+    private static void initWithTlv(Builder builder, int type, byte[] value) {
+        // The max length of the dataset is 254 bytes, so the max length of a single TLV value is
+        // 252 (254 - 1 - 1)
+        if (value.length > LENGTH_MAX_DATASET_TLVS - 2) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "Length of TLV %d exceeds %d (actualLength = %d)",
+                            (type & 0xff), LENGTH_MAX_DATASET_TLVS - 2, value.length));
+        }
+
+        switch (type) {
+            case TYPE_CHANNEL:
+                checkArgument(
+                        value.length == LENGTH_CHANNEL,
+                        "Invalid channel (length = %d, expectedLength = %d)",
+                        value.length,
+                        LENGTH_CHANNEL);
+                builder.setChannel((value[0] & 0xff), ((value[1] & 0xff) << 8) | (value[2] & 0xff));
+                break;
+            case TYPE_PAN_ID:
+                checkArgument(
+                        value.length == LENGTH_PAN_ID,
+                        "Invalid PAN ID (length = %d, expectedLength = %d)",
+                        value.length,
+                        LENGTH_PAN_ID);
+                builder.setPanId(((value[0] & 0xff) << 8) | (value[1] & 0xff));
+                break;
+            case TYPE_EXTENDED_PAN_ID:
+                builder.setExtendedPanId(value);
+                break;
+            case TYPE_NETWORK_NAME:
+                builder.setNetworkName(new String(value, UTF_8));
+                break;
+            case TYPE_PSKC:
+                builder.setPskc(value);
+                break;
+            case TYPE_NETWORK_KEY:
+                builder.setNetworkKey(value);
+                break;
+            case TYPE_MESH_LOCAL_PREFIX:
+                builder.setMeshLocalPrefix(value);
+                break;
+            case TYPE_SECURITY_POLICY:
+                builder.setSecurityPolicy(SecurityPolicy.fromTlvValue(value));
+                break;
+            case TYPE_ACTIVE_TIMESTAMP:
+                builder.setActiveTimestamp(OperationalDatasetTimestamp.fromTlvValue(value));
+                break;
+            case TYPE_CHANNEL_MASK:
+                builder.setChannelMask(decodeChannelMask(value));
+                break;
+            default:
+                builder.addUnknownTlv(type & 0xff, value);
+                break;
+        }
+    }
+
+    private static SparseArray<byte[]> decodeChannelMask(byte[] tlvValue) {
+        SparseArray<byte[]> channelMask = new SparseArray<>();
+        int i = 0;
+        while (i < tlvValue.length) {
+            int channelPage = tlvValue[i++] & 0xff;
+            if (i >= tlvValue.length) {
+                throw new IllegalArgumentException(
+                        "Invalid channel mask - channel mask length is missing");
+            }
+
+            int maskLength = tlvValue[i++] & 0xff;
+            if (i + maskLength > tlvValue.length) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Invalid channel mask - channel mask is incomplete "
+                                        + "(offset = %d, length = %d, totalLength = %d)",
+                                i, maskLength, tlvValue.length));
+            }
+
+            channelMask.put(channelPage, Arrays.copyOfRange(tlvValue, i, i + maskLength));
+            i += maskLength;
+        }
+        return channelMask;
+    }
+
+    private static void encodeChannelMask(
+            SparseArray<byte[]> channelMask, ByteArrayOutputStream outputStream) {
+        ByteArrayOutputStream entryStream = new ByteArrayOutputStream();
+
+        for (int i = 0; i < channelMask.size(); i++) {
+            int key = channelMask.keyAt(i);
+            byte[] value = channelMask.get(key);
+            entryStream.write(key);
+            entryStream.write(value.length);
+            entryStream.write(value, 0, value.length);
+        }
+
+        byte[] entries = entryStream.toByteArray();
+
+        outputStream.write(TYPE_CHANNEL_MASK);
+        outputStream.write(entries.length);
+        outputStream.write(entries, 0, entries.length);
+    }
+
+    /**
+     * Creates a new {@link ActiveOperationalDataset} object with randomized or default parameters.
+     *
+     * <p>The randomized (or default) value for each parameter:
+     *
+     * <ul>
+     *   <li>{@code Active Timestamp} defaults to {@code new OperationalDatasetTimestamp(1, 0,
+     *       false)}
+     *   <li>{@code Network Name} defaults to "THREAD-PAN-<PAN ID decimal>", for example
+     *       "THREAD-PAN-12345"
+     *   <li>{@code Extended PAN ID} filled with randomly generated bytes
+     *   <li>{@code PAN ID} randomly generated integer in range of [0, 0xfffe]
+     *   <li>{@code Channel Page} defaults to {@link #CHANNEL_PAGE_24_GHZ}
+     *   <li>{@code Channel} randomly selected channel in range of [{@link #CHANNEL_MIN_24_GHZ},
+     *       {@link #CHANNEL_MAX_24_GHZ}]
+     *   <li>{@code Channel Mask} all bits from {@link #CHANNEL_MIN_24_GHZ} to {@link
+     *       #CHANNEL_MAX_24_GHZ} are set to {@code true}
+     *   <li>{@code PSKc} filled with bytes generated by secure random generator
+     *   <li>{@code Network Key} filled with bytes generated by secure random generator
+     *   <li>{@code Mesh-local Prefix} filled with randomly generated bytes except that the first
+     *       byte is always set to {@code 0xfd}
+     *   <li>{@code Security Policy} defaults to {@code new SecurityPolicy(
+     *       DEFAULT_ROTATION_TIME_HOURS, new byte[]{(byte)0xff, (byte)0xf8})}. This is the default
+     *       values required by the Thread 1.2 specification
+     * </ul>
+     *
+     * <p>This method is the recommended way to create a randomized operational dataset for a new
+     * Thread network. It may be desired to change one or more of the generated value(s). For
+     * example, to use a more meaningful Network Name. To do that, create a new {@link Builder}
+     * object from this dataset with {@link Builder#Builder(ActiveOperationalDataset)} and override
+     * the value with the setters of {@link Builder}.
+     *
+     * <p>Note that it's highly discouraged to change the randomly generated Extended PAN ID,
+     * Network Key or PSKc, as it will compromise the security of a Thread network.
+     */
+    @NonNull
+    public static ActiveOperationalDataset createRandomDataset() {
+        return createRandomDataset(new Random(Instant.now().toEpochMilli()), new SecureRandom());
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public static ActiveOperationalDataset createRandomDataset(
+            Random random, SecureRandom secureRandom) {
+        int panId = random.nextInt(/* bound= */ 0xffff);
+        byte[] meshLocalPrefix = newRandomBytes(random, LENGTH_MESH_LOCAL_PREFIX_BITS / 8);
+        meshLocalPrefix[0] = MESH_LOCAL_PREFIX_FIRST_BYTE;
+
+        SparseArray<byte[]> channelMask = new SparseArray<>(1);
+        channelMask.put(CHANNEL_PAGE_24_GHZ, new byte[] {0x00, 0x1f, (byte) 0xff, (byte) 0xe0});
+
+        return new Builder()
+                .setActiveTimestamp(
+                        new OperationalDatasetTimestamp(
+                                /* seconds= */ 1,
+                                /* ticks= */ 0,
+                                /* isAuthoritativeSource= */ false))
+                .setExtendedPanId(newRandomBytes(random, LENGTH_EXTENDED_PAN_ID))
+                .setPanId(panId)
+                .setNetworkName("THREAD-PAN-" + panId)
+                .setChannel(
+                        CHANNEL_PAGE_24_GHZ,
+                        random.nextInt(CHANNEL_MAX_24_GHZ - CHANNEL_MIN_24_GHZ + 1)
+                                + CHANNEL_MIN_24_GHZ)
+                .setChannelMask(channelMask)
+                .setPskc(newRandomBytes(secureRandom, LENGTH_PSKC))
+                .setNetworkKey(newRandomBytes(secureRandom, LENGTH_NETWORK_KEY))
+                .setMeshLocalPrefix(meshLocalPrefix)
+                .setSecurityPolicy(
+                        new SecurityPolicy(
+                                DEFAULT_ROTATION_TIME_HOURS, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                .build();
+    }
+
+    private static byte[] newRandomBytes(Random random, int length) {
+        byte[] result = new byte[length];
+        random.nextBytes(result);
+        return result;
+    }
+
+    private static boolean areByteSparseArraysEqual(
+            @NonNull SparseArray<byte[]> first, @NonNull SparseArray<byte[]> second) {
+        if (first == second) {
+            return true;
+        } else if (first == null || second == null) {
+            return false;
+        } else if (first.size() != second.size()) {
+            return false;
+        } else {
+            for (int i = 0; i < first.size(); i++) {
+                int firstKey = first.keyAt(i);
+                int secondKey = second.keyAt(i);
+                if (firstKey != secondKey) {
+                    return false;
+                }
+
+                byte[] firstValue = first.valueAt(i);
+                byte[] secondValue = second.valueAt(i);
+                if (!Arrays.equals(firstValue, secondValue)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+    }
+
+    /** An easy-to-use wrapper of {@link Arrays#deepHashCode}. */
+    private static int deepHashCode(Object... values) {
+        return Arrays.deepHashCode(values);
+    }
+
+    /**
+     * Converts this {@link ActiveOperationalDataset} object to a series of Thread TLVs.
+     *
+     * <p>See the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition of the Thread TLV format.
+     *
+     * @return a series of Thread TLVs which contain this Active Operational Dataset
+     */
+    @NonNull
+    public byte[] toThreadTlvs() {
+        ByteArrayOutputStream dataset = new ByteArrayOutputStream();
+
+        dataset.write(TYPE_ACTIVE_TIMESTAMP);
+        byte[] activeTimestampBytes = mActiveTimestamp.toTlvValue();
+        dataset.write(activeTimestampBytes.length);
+        dataset.write(activeTimestampBytes, 0, activeTimestampBytes.length);
+
+        dataset.write(TYPE_NETWORK_NAME);
+        byte[] networkNameBytes = mNetworkName.getBytes(UTF_8);
+        dataset.write(networkNameBytes.length);
+        dataset.write(networkNameBytes, 0, networkNameBytes.length);
+
+        dataset.write(TYPE_EXTENDED_PAN_ID);
+        dataset.write(mExtendedPanId.length);
+        dataset.write(mExtendedPanId, 0, mExtendedPanId.length);
+
+        dataset.write(TYPE_PAN_ID);
+        dataset.write(LENGTH_PAN_ID);
+        dataset.write(mPanId >> 8);
+        dataset.write(mPanId);
+
+        dataset.write(TYPE_CHANNEL);
+        dataset.write(LENGTH_CHANNEL);
+        dataset.write(mChannelPage);
+        dataset.write(mChannel >> 8);
+        dataset.write(mChannel);
+
+        encodeChannelMask(mChannelMask, dataset);
+
+        dataset.write(TYPE_PSKC);
+        dataset.write(mPskc.length);
+        dataset.write(mPskc, 0, mPskc.length);
+
+        dataset.write(TYPE_NETWORK_KEY);
+        dataset.write(mNetworkKey.length);
+        dataset.write(mNetworkKey, 0, mNetworkKey.length);
+
+        dataset.write(TYPE_MESH_LOCAL_PREFIX);
+        dataset.write(mMeshLocalPrefix.getPrefixLength() / 8);
+        dataset.write(mMeshLocalPrefix.getRawAddress(), 0, mMeshLocalPrefix.getPrefixLength() / 8);
+
+        dataset.write(TYPE_SECURITY_POLICY);
+        byte[] securityPolicyBytes = mSecurityPolicy.toTlvValue();
+        dataset.write(securityPolicyBytes.length);
+        dataset.write(securityPolicyBytes, 0, securityPolicyBytes.length);
+
+        for (int i = 0; i < mUnknownTlvs.size(); i++) {
+            byte[] value = mUnknownTlvs.valueAt(i);
+            dataset.write(mUnknownTlvs.keyAt(i));
+            dataset.write(value.length);
+            dataset.write(value, 0, value.length);
+        }
+
+        return dataset.toByteArray();
+    }
+
+    /** Returns the Active Timestamp. */
+    @NonNull
+    public OperationalDatasetTimestamp getActiveTimestamp() {
+        return mActiveTimestamp;
+    }
+
+    /** Returns the Network Name. */
+    @NonNull
+    @Size(min = LENGTH_MIN_NETWORK_NAME_BYTES, max = LENGTH_MAX_NETWORK_NAME_BYTES)
+    public String getNetworkName() {
+        return mNetworkName;
+    }
+
+    /** Returns the Extended PAN ID. */
+    @NonNull
+    @Size(LENGTH_EXTENDED_PAN_ID)
+    public byte[] getExtendedPanId() {
+        return mExtendedPanId.clone();
+    }
+
+    /** Returns the PAN ID. */
+    @IntRange(from = 0, to = 0xfffe)
+    public int getPanId() {
+        return mPanId;
+    }
+
+    /** Returns the Channel. */
+    @IntRange(from = 0, to = 65535)
+    public int getChannel() {
+        return mChannel;
+    }
+
+    /** Returns the Channel Page. */
+    @IntRange(from = 0, to = 255)
+    public int getChannelPage() {
+        return mChannelPage;
+    }
+
+    /**
+     * Returns the Channel masks. For the returned {@link SparseArray}, the key is the Channel Page
+     * and the value is the Channel Mask.
+     */
+    @NonNull
+    @Size(min = 1)
+    public SparseArray<byte[]> getChannelMask() {
+        return deepCloneSparseArray(mChannelMask);
+    }
+
+    private static SparseArray<byte[]> deepCloneSparseArray(SparseArray<byte[]> src) {
+        SparseArray<byte[]> dst = new SparseArray<>(src.size());
+        for (int i = 0; i < src.size(); i++) {
+            dst.put(src.keyAt(i), src.valueAt(i).clone());
+        }
+        return dst;
+    }
+
+    /** Returns the PSKc. */
+    @NonNull
+    @Size(LENGTH_PSKC)
+    public byte[] getPskc() {
+        return mPskc.clone();
+    }
+
+    /** Returns the Network Key. */
+    @NonNull
+    @Size(LENGTH_NETWORK_KEY)
+    public byte[] getNetworkKey() {
+        return mNetworkKey.clone();
+    }
+
+    /**
+     * Returns the Mesh-local Prefix. The length of the returned prefix is always {@link
+     * #LENGTH_MESH_LOCAL_PREFIX_BITS}.
+     */
+    @NonNull
+    public IpPrefix getMeshLocalPrefix() {
+        return mMeshLocalPrefix;
+    }
+
+    /** Returns the Security Policy. */
+    @NonNull
+    public SecurityPolicy getSecurityPolicy() {
+        return mSecurityPolicy;
+    }
+
+    /**
+     * Returns Thread TLVs which are not recognized by this device. The returned {@link SparseArray}
+     * associates TLV values to their keys.
+     *
+     * @hide
+     */
+    @NonNull
+    public SparseArray<byte[]> getUnknownTlvs() {
+        return deepCloneSparseArray(mUnknownTlvs);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeByteArray(toThreadTlvs());
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other == this) {
+            return true;
+        } else if (!(other instanceof ActiveOperationalDataset)) {
+            return false;
+        } else {
+            ActiveOperationalDataset otherDataset = (ActiveOperationalDataset) other;
+            return mActiveTimestamp.equals(otherDataset.mActiveTimestamp)
+                    && mNetworkName.equals(otherDataset.mNetworkName)
+                    && Arrays.equals(mExtendedPanId, otherDataset.mExtendedPanId)
+                    && mPanId == otherDataset.mPanId
+                    && mChannelPage == otherDataset.mChannelPage
+                    && mChannel == otherDataset.mChannel
+                    && areByteSparseArraysEqual(mChannelMask, otherDataset.mChannelMask)
+                    && Arrays.equals(mPskc, otherDataset.mPskc)
+                    && Arrays.equals(mNetworkKey, otherDataset.mNetworkKey)
+                    && mMeshLocalPrefix.equals(otherDataset.mMeshLocalPrefix)
+                    && mSecurityPolicy.equals(otherDataset.mSecurityPolicy)
+                    && areByteSparseArraysEqual(mUnknownTlvs, otherDataset.mUnknownTlvs);
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return deepHashCode(
+                mActiveTimestamp,
+                mNetworkName,
+                mExtendedPanId,
+                mPanId,
+                mChannel,
+                mChannelPage,
+                mChannelMask,
+                mPskc,
+                mNetworkKey,
+                mMeshLocalPrefix,
+                mSecurityPolicy);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{networkName=")
+                .append(getNetworkName())
+                .append(", extendedPanId=")
+                .append(dumpHexString(getExtendedPanId()))
+                .append(", panId=")
+                .append(getPanId())
+                .append(", channel=")
+                .append(getChannel())
+                .append(", activeTimestamp=")
+                .append(getActiveTimestamp())
+                .append("}");
+        return sb.toString();
+    }
+
+    /** The builder for creating {@link ActiveOperationalDataset} objects. */
+    public static final class Builder {
+        private OperationalDatasetTimestamp mActiveTimestamp;
+        private String mNetworkName;
+        private byte[] mExtendedPanId;
+        private Integer mPanId;
+        private Integer mChannel;
+        private Integer mChannelPage;
+        private SparseArray<byte[]> mChannelMask;
+        private byte[] mPskc;
+        private byte[] mNetworkKey;
+        private IpPrefix mMeshLocalPrefix;
+        private SecurityPolicy mSecurityPolicy;
+        private SparseArray<byte[]> mUnknownTlvs;
+
+        /**
+         * Creates a {@link Builder} object with values from an {@link ActiveOperationalDataset}
+         * object.
+         */
+        public Builder(@NonNull ActiveOperationalDataset activeOpDataset) {
+            requireNonNull(activeOpDataset, "activeOpDataset cannot be null");
+
+            this.mActiveTimestamp = activeOpDataset.mActiveTimestamp;
+            this.mNetworkName = activeOpDataset.mNetworkName;
+            this.mExtendedPanId = activeOpDataset.mExtendedPanId.clone();
+            this.mPanId = activeOpDataset.mPanId;
+            this.mChannel = activeOpDataset.mChannel;
+            this.mChannelPage = activeOpDataset.mChannelPage;
+            this.mChannelMask = deepCloneSparseArray(activeOpDataset.mChannelMask);
+            this.mPskc = activeOpDataset.mPskc.clone();
+            this.mNetworkKey = activeOpDataset.mNetworkKey.clone();
+            this.mMeshLocalPrefix = activeOpDataset.mMeshLocalPrefix;
+            this.mSecurityPolicy = activeOpDataset.mSecurityPolicy;
+            this.mUnknownTlvs = deepCloneSparseArray(activeOpDataset.mUnknownTlvs);
+        }
+
+        /**
+         * Creates an empty {@link Builder} object.
+         *
+         * <p>An empty builder cannot build a new {@link ActiveOperationalDataset} object. The
+         * Active Operational Dataset parameters must be set with setters of this builder.
+         */
+        public Builder() {
+            mChannelMask = new SparseArray<>();
+            mUnknownTlvs = new SparseArray<>();
+        }
+
+        /**
+         * Sets the Active Timestamp.
+         *
+         * @param activeTimestamp Active Timestamp of the Operational Dataset
+         */
+        @NonNull
+        public Builder setActiveTimestamp(@NonNull OperationalDatasetTimestamp activeTimestamp) {
+            requireNonNull(activeTimestamp, "activeTimestamp cannot be null");
+            this.mActiveTimestamp = activeTimestamp;
+            return this;
+        }
+
+        /**
+         * Sets the Network Name.
+         *
+         * @param networkName the name of the Thread network
+         * @throws IllegalArgumentException if length of the UTF-8 representation of {@code
+         *     networkName} isn't in range of [{@link #LENGTH_MIN_NETWORK_NAME_BYTES}, {@link
+         *     #LENGTH_MAX_NETWORK_NAME_BYTES}].
+         */
+        @NonNull
+        public Builder setNetworkName(
+                @NonNull
+                        @Size(
+                                min = LENGTH_MIN_NETWORK_NAME_BYTES,
+                                max = LENGTH_MAX_NETWORK_NAME_BYTES)
+                        String networkName) {
+            requireNonNull(networkName, "networkName cannot be null");
+
+            int nameLength = networkName.getBytes(UTF_8).length;
+            checkArgument(
+                    nameLength >= LENGTH_MIN_NETWORK_NAME_BYTES
+                            && nameLength <= LENGTH_MAX_NETWORK_NAME_BYTES,
+                    "Invalid network name (length = %d, expectedLengthRange = [%d, %d])",
+                    nameLength,
+                    LENGTH_MIN_NETWORK_NAME_BYTES,
+                    LENGTH_MAX_NETWORK_NAME_BYTES);
+            this.mNetworkName = networkName;
+            return this;
+        }
+
+        /**
+         * Sets the Extended PAN ID.
+         *
+         * <p>Use with caution. A randomly generated Extended PAN ID should be used for real Thread
+         * networks. It's discouraged to call this method to override the default value created by
+         * {@link ActiveOperationalDataset#createRandomDataset} in production.
+         *
+         * @throws IllegalArgumentException if length of {@code extendedPanId} is not {@link
+         *     #LENGTH_EXTENDED_PAN_ID}.
+         */
+        @NonNull
+        public Builder setExtendedPanId(
+                @NonNull @Size(LENGTH_EXTENDED_PAN_ID) byte[] extendedPanId) {
+            requireNonNull(extendedPanId, "extendedPanId cannot be null");
+            checkArgument(
+                    extendedPanId.length == LENGTH_EXTENDED_PAN_ID,
+                    "Invalid extended PAN ID (length = %d, expectedLength = %d)",
+                    extendedPanId.length,
+                    LENGTH_EXTENDED_PAN_ID);
+            this.mExtendedPanId = extendedPanId.clone();
+            return this;
+        }
+
+        /**
+         * Sets the PAN ID.
+         *
+         * @throws IllegalArgumentException if {@code panId} is not in range of 0x0-0xfffe
+         */
+        @NonNull
+        public Builder setPanId(@IntRange(from = 0, to = 0xfffe) int panId) {
+            checkArgument(
+                    panId >= 0 && panId <= 0xfffe,
+                    "PAN ID exceeds allowed range (panid = %d, allowedRange = [0x0, 0xffff])",
+                    panId);
+            this.mPanId = panId;
+            return this;
+        }
+
+        /**
+         * Sets the Channel Page and Channel.
+         *
+         * <p>Channel Pages other than {@link #CHANNEL_PAGE_24_GHZ} are undefined and may lead to
+         * unexpected behavior if it's applied to Thread devices.
+         *
+         * @throws IllegalArgumentException if invalid channel is specified for the {@code
+         *     channelPage}
+         */
+        @NonNull
+        public Builder setChannel(
+                @IntRange(from = 0, to = 255) int page,
+                @IntRange(from = 0, to = 65535) int channel) {
+            checkArgument(
+                    page >= 0 && page <= 255,
+                    "Invalid channel page (page = %d, allowedRange = [0, 255])",
+                    page);
+            if (page == CHANNEL_PAGE_24_GHZ) {
+                checkArgument(
+                        channel >= CHANNEL_MIN_24_GHZ && channel <= CHANNEL_MAX_24_GHZ,
+                        "Invalid channel %d in page %d (allowedChannelRange = [%d, %d])",
+                        channel,
+                        page,
+                        CHANNEL_MIN_24_GHZ,
+                        CHANNEL_MAX_24_GHZ);
+            } else {
+                checkArgument(
+                        channel >= 0 && channel <= 65535,
+                        "Invalid channel %d in page %d "
+                                + "(channel = %d, allowedChannelRange = [0, 65535])",
+                        channel,
+                        page,
+                        channel);
+            }
+
+            this.mChannelPage = page;
+            this.mChannel = channel;
+            return this;
+        }
+
+        /**
+         * Sets the Channel Mask.
+         *
+         * @throws IllegalArgumentException if {@code channelMask} is empty
+         */
+        @NonNull
+        public Builder setChannelMask(@NonNull @Size(min = 1) SparseArray<byte[]> channelMask) {
+            requireNonNull(channelMask, "channelMask cannot be null");
+            checkArgument(channelMask.size() > 0, "channelMask is empty");
+            this.mChannelMask = deepCloneSparseArray(channelMask);
+            return this;
+        }
+
+        /**
+         * Sets the PSKc.
+         *
+         * <p>Use with caution. A randomly generated PSKc should be used for real Thread networks.
+         * It's discouraged to call this method to override the default value created by {@link
+         * ActiveOperationalDataset#createRandomDataset} in production.
+         *
+         * @param pskc the key stretched version of the Commissioning Credential for the network
+         * @throws IllegalArgumentException if length of {@code pskc} is not {@link #LENGTH_PSKC}
+         */
+        @NonNull
+        public Builder setPskc(@NonNull @Size(LENGTH_PSKC) byte[] pskc) {
+            requireNonNull(pskc, "pskc cannot be null");
+            checkArgument(
+                    pskc.length == LENGTH_PSKC,
+                    "Invalid PSKc length (length = %d, expectedLength = %d)",
+                    pskc.length,
+                    LENGTH_PSKC);
+            this.mPskc = pskc.clone();
+            return this;
+        }
+
+        /**
+         * Sets the Network Key.
+         *
+         * <p>Use with caution, randomly generated Network Key should be used for real Thread
+         * networks. It's discouraged to call this method to override the default value created by
+         * {@link ActiveOperationalDataset#createRandomDataset} in production.
+         *
+         * @param networkKey a 128-bit security key-derivation key for the Thread Network
+         * @throws IllegalArgumentException if length of {@code networkKey} is not {@link
+         *     #LENGTH_NETWORK_KEY}
+         */
+        @NonNull
+        public Builder setNetworkKey(@NonNull @Size(LENGTH_NETWORK_KEY) byte[] networkKey) {
+            requireNonNull(networkKey, "networkKey cannot be null");
+            checkArgument(
+                    networkKey.length == LENGTH_NETWORK_KEY,
+                    "Invalid network key length (length = %d, expectedLength = %d)",
+                    networkKey.length,
+                    LENGTH_NETWORK_KEY);
+            this.mNetworkKey = networkKey.clone();
+            return this;
+        }
+
+        /**
+         * Sets the Mesh-Local Prefix.
+         *
+         * @param meshLocalPrefix the prefix used for realm-local traffic within the mesh
+         * @throws IllegalArgumentException if prefix length of {@code meshLocalPrefix} isn't {@link
+         *     #LENGTH_MESH_LOCAL_PREFIX_BITS} or {@code meshLocalPrefix} doesn't start with {@code
+         *     0xfd}
+         */
+        @NonNull
+        public Builder setMeshLocalPrefix(@NonNull IpPrefix meshLocalPrefix) {
+            requireNonNull(meshLocalPrefix, "meshLocalPrefix cannot be null");
+            checkArgument(
+                    meshLocalPrefix.getPrefixLength() == LENGTH_MESH_LOCAL_PREFIX_BITS,
+                    "Invalid mesh-local prefix length (length = %d, expectedLength = %d)",
+                    meshLocalPrefix.getPrefixLength(),
+                    LENGTH_MESH_LOCAL_PREFIX_BITS);
+            checkArgument(
+                    meshLocalPrefix.getRawAddress()[0] == MESH_LOCAL_PREFIX_FIRST_BYTE,
+                    "Mesh-local prefix must start with 0xfd: " + meshLocalPrefix);
+            this.mMeshLocalPrefix = meshLocalPrefix;
+            return this;
+        }
+
+        @NonNull
+        private Builder setMeshLocalPrefix(byte[] meshLocalPrefix) {
+            final int prefixLength = meshLocalPrefix.length * 8;
+            checkArgument(
+                    prefixLength == LENGTH_MESH_LOCAL_PREFIX_BITS,
+                    "Invalid mesh-local prefix length (length = %d, expectedLength = %d)",
+                    prefixLength,
+                    LENGTH_MESH_LOCAL_PREFIX_BITS);
+            byte[] ip6RawAddress = new byte[16];
+            System.arraycopy(meshLocalPrefix, 0, ip6RawAddress, 0, meshLocalPrefix.length);
+            try {
+                return setMeshLocalPrefix(
+                        new IpPrefix(Inet6Address.getByAddress(ip6RawAddress), prefixLength));
+            } catch (UnknownHostException e) {
+                // Can't happen because numeric address is provided
+                throw new AssertionError(e);
+            }
+        }
+
+        /** Sets the Security Policy. */
+        @NonNull
+        public Builder setSecurityPolicy(@NonNull SecurityPolicy securityPolicy) {
+            requireNonNull(securityPolicy, "securityPolicy cannot be null");
+            this.mSecurityPolicy = securityPolicy;
+            return this;
+        }
+
+        /**
+         * Sets additional unknown TLVs.
+         *
+         * @hide
+         */
+        @NonNull
+        public Builder setUnknownTlvs(@NonNull SparseArray<byte[]> unknownTlvs) {
+            requireNonNull(unknownTlvs, "unknownTlvs cannot be null");
+            mUnknownTlvs = deepCloneSparseArray(unknownTlvs);
+            return this;
+        }
+
+        /** Adds one more unknown TLV. @hide */
+        @VisibleForTesting
+        @NonNull
+        public Builder addUnknownTlv(int type, byte[] value) {
+            mUnknownTlvs.put(type, value);
+            return this;
+        }
+
+        /**
+         * Creates a new {@link ActiveOperationalDataset} object.
+         *
+         * @throws IllegalStateException if any of the fields isn't set or the total length exceeds
+         *     {@link #LENGTH_MAX_DATASET_TLVS} bytes
+         */
+        @NonNull
+        public ActiveOperationalDataset build() {
+            checkState(mActiveTimestamp != null, "Active Timestamp is missing");
+            checkState(mNetworkName != null, "Network Name is missing");
+            checkState(mExtendedPanId != null, "Extended PAN ID is missing");
+            checkState(mPanId != null, "PAN ID is missing");
+            checkState(mChannel != null, "Channel is missing");
+            checkState(mChannelPage != null, "Channel Page is missing");
+            checkState(mChannelMask.size() != 0, "Channel Mask is missing");
+            checkState(mPskc != null, "PSKc is missing");
+            checkState(mNetworkKey != null, "Network Key is missing");
+            checkState(mMeshLocalPrefix != null, "Mesh Local Prefix is missing");
+            checkState(mSecurityPolicy != null, "Security Policy is missing");
+
+            int length = getTotalDatasetLength();
+            if (length > LENGTH_MAX_DATASET_TLVS) {
+                throw new IllegalStateException(
+                        String.format(
+                                "Total dataset length exceeds max length %d (actual is %d)",
+                                LENGTH_MAX_DATASET_TLVS, length));
+            }
+
+            return new ActiveOperationalDataset(this);
+        }
+
+        private int getTotalDatasetLength() {
+            int length =
+                    2 * 9 // 9 fields with 1 byte of type and 1 byte of length
+                            + OperationalDatasetTimestamp.LENGTH_TIMESTAMP
+                            + mNetworkName.getBytes(UTF_8).length
+                            + LENGTH_EXTENDED_PAN_ID
+                            + LENGTH_PAN_ID
+                            + LENGTH_CHANNEL
+                            + LENGTH_PSKC
+                            + LENGTH_NETWORK_KEY
+                            + LENGTH_MESH_LOCAL_PREFIX_BITS / 8
+                            + mSecurityPolicy.toTlvValue().length;
+
+            for (int i = 0; i < mChannelMask.size(); i++) {
+                length += 2 + mChannelMask.valueAt(i).length;
+            }
+
+            // For the type and length bytes of the Channel Mask TLV because the masks are encoded
+            // as TLVs in TLV.
+            length += 2;
+
+            for (int i = 0; i < mUnknownTlvs.size(); i++) {
+                length += 2 + mUnknownTlvs.valueAt(i).length;
+            }
+
+            return length;
+        }
+    }
+
+    /**
+     * The Security Policy of Thread Operational Dataset which provides an administrator with a way
+     * to enable or disable certain security related behaviors.
+     */
+    public static final class SecurityPolicy {
+        /** The default Rotation Time in hours. */
+        public static final int DEFAULT_ROTATION_TIME_HOURS = 672;
+        /** The minimum length of Security Policy flags in bytes. */
+        public static final int LENGTH_MIN_SECURITY_POLICY_FLAGS = 1;
+        /** The length of Rotation Time TLV value in bytes. */
+        private static final int LENGTH_SECURITY_POLICY_ROTATION_TIME = 2;
+
+        private final int mRotationTimeHours;
+        private final byte[] mFlags;
+
+        /**
+         * Creates a new {@link SecurityPolicy} object.
+         *
+         * @param rotationTimeHours the value for Thread key rotation in hours. Must be in range of
+         *     0x1-0xffff.
+         * @param flags security policy flags with length of either 1 byte for Thread 1.1 or 2 bytes
+         *     for Thread 1.2 or higher.
+         * @throws IllegalArgumentException if {@code rotationTimeHours} is not in range of
+         *     0x1-0xffff or length of {@code flags} is smaller than {@link
+         *     #LENGTH_MIN_SECURITY_POLICY_FLAGS}.
+         */
+        public SecurityPolicy(
+                @IntRange(from = 0x1, to = 0xffff) int rotationTimeHours,
+                @NonNull @Size(min = LENGTH_MIN_SECURITY_POLICY_FLAGS) byte[] flags) {
+            requireNonNull(flags, "flags cannot be null");
+            checkArgument(
+                    rotationTimeHours >= 1 && rotationTimeHours <= 0xffff,
+                    "Rotation time exceeds allowed range (rotationTimeHours = %d, allowedRange ="
+                            + " [0x1, 0xffff])",
+                    rotationTimeHours);
+            checkArgument(
+                    flags.length >= LENGTH_MIN_SECURITY_POLICY_FLAGS,
+                    "Invalid security policy flags length (length = %d, minimumLength = %d)",
+                    flags.length,
+                    LENGTH_MIN_SECURITY_POLICY_FLAGS);
+            this.mRotationTimeHours = rotationTimeHours;
+            this.mFlags = flags.clone();
+        }
+
+        /**
+         * Creates a new {@link SecurityPolicy} object from the Security Policy TLV value.
+         *
+         * @hide
+         */
+        @VisibleForTesting
+        @NonNull
+        public static SecurityPolicy fromTlvValue(byte[] encodedSecurityPolicy) {
+            checkArgument(
+                    encodedSecurityPolicy.length
+                            >= LENGTH_SECURITY_POLICY_ROTATION_TIME
+                                    + LENGTH_MIN_SECURITY_POLICY_FLAGS,
+                    "Invalid Security Policy TLV length (length = %d, minimumLength = %d)",
+                    encodedSecurityPolicy.length,
+                    LENGTH_SECURITY_POLICY_ROTATION_TIME + LENGTH_MIN_SECURITY_POLICY_FLAGS);
+
+            return new SecurityPolicy(
+                    ((encodedSecurityPolicy[0] & 0xff) << 8) | (encodedSecurityPolicy[1] & 0xff),
+                    Arrays.copyOfRange(
+                            encodedSecurityPolicy,
+                            LENGTH_SECURITY_POLICY_ROTATION_TIME,
+                            encodedSecurityPolicy.length));
+        }
+
+        /**
+         * Converts this {@link SecurityPolicy} object to Security Policy TLV value.
+         *
+         * @hide
+         */
+        @VisibleForTesting
+        @NonNull
+        public byte[] toTlvValue() {
+            ByteArrayOutputStream result = new ByteArrayOutputStream();
+            result.write(mRotationTimeHours >> 8);
+            result.write(mRotationTimeHours);
+            result.write(mFlags, 0, mFlags.length);
+            return result.toByteArray();
+        }
+
+        /** Returns the Security Policy Rotation Time in hours. */
+        @IntRange(from = 0x1, to = 0xffff)
+        public int getRotationTimeHours() {
+            return mRotationTimeHours;
+        }
+
+        /** Returns 1 byte flags for Thread 1.1 or 2 bytes flags for Thread 1.2. */
+        @NonNull
+        @Size(min = LENGTH_MIN_SECURITY_POLICY_FLAGS)
+        public byte[] getFlags() {
+            return mFlags.clone();
+        }
+
+        @Override
+        public boolean equals(@Nullable Object other) {
+            if (this == other) {
+                return true;
+            } else if (!(other instanceof SecurityPolicy)) {
+                return false;
+            } else {
+                SecurityPolicy otherSecurityPolicy = (SecurityPolicy) other;
+                return mRotationTimeHours == otherSecurityPolicy.mRotationTimeHours
+                        && Arrays.equals(mFlags, otherSecurityPolicy.mFlags);
+            }
+        }
+
+        @Override
+        public int hashCode() {
+            return deepHashCode(mRotationTimeHours, mFlags);
+        }
+
+        @Override
+        public String toString() {
+            StringBuilder sb = new StringBuilder();
+            sb.append("{rotation=")
+                    .append(mRotationTimeHours)
+                    .append(", flags=")
+                    .append(dumpHexString(mFlags))
+                    .append("}");
+            return sb.toString();
+        }
+    }
+}
diff --git a/thread/framework/java/android/net/thread/OperationalDatasetTimestamp.java b/thread/framework/java/android/net/thread/OperationalDatasetTimestamp.java
new file mode 100644
index 0000000..bda9373
--- /dev/null
+++ b/thread/framework/java/android/net/thread/OperationalDatasetTimestamp.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2023 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 static com.android.internal.util.Preconditions.checkArgument;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.FlaggedApi;
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+
+import java.nio.ByteBuffer;
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * The timestamp of Thread Operational Dataset.
+ *
+ * @see ActiveOperationalDataset
+ * @see PendingOperationalDataset
+ * @hide
+ */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
+@SystemApi
+public final class OperationalDatasetTimestamp {
+    /** @hide */
+    public static final int LENGTH_TIMESTAMP = Long.BYTES;
+
+    private static final long TICKS_UPPER_BOUND = 0x8000;
+
+    private final Instant mInstant;
+    private final boolean mIsAuthoritativeSource;
+
+    /**
+     * Creates a new {@link OperationalDatasetTimestamp} object from an {@link Instant}.
+     *
+     * <p>The {@code seconds} is set to {@code instant.getEpochSecond()}, {@code ticks} is set to
+     * {@link instant#getNano()} based on frequency of 32768 Hz, and {@code isAuthoritativeSource}
+     * is set to {@code true}.
+     *
+     * @throws IllegalArgumentException if {@code instant.getEpochSecond()} is larger than {@code
+     *     0xffffffffffffL}
+     */
+    @NonNull
+    public static OperationalDatasetTimestamp fromInstant(@NonNull Instant instant) {
+        return new OperationalDatasetTimestamp(instant, /* isAuthoritativeSource= */ true);
+    }
+
+    /** Converts this {@link OperationalDatasetTimestamp} object to an {@link Instant}. */
+    @NonNull
+    public Instant toInstant() {
+        return mInstant;
+    }
+
+    /**
+     * Creates a new {@link OperationalDatasetTimestamp} object from the OperationalDatasetTimestamp
+     * TLV value.
+     *
+     * @hide
+     */
+    @NonNull
+    public static OperationalDatasetTimestamp fromTlvValue(@NonNull byte[] encodedTimestamp) {
+        requireNonNull(encodedTimestamp, "encodedTimestamp cannot be null");
+        checkArgument(
+                encodedTimestamp.length == LENGTH_TIMESTAMP,
+                "Invalid Thread OperationalDatasetTimestamp length (length = %d,"
+                        + " expectedLength=%d)",
+                encodedTimestamp.length,
+                LENGTH_TIMESTAMP);
+        long longTimestamp = ByteBuffer.wrap(encodedTimestamp).getLong();
+        return new OperationalDatasetTimestamp(
+                (longTimestamp >> 16) & 0x0000ffffffffffffL,
+                (int) ((longTimestamp >> 1) & 0x7fffL),
+                (longTimestamp & 0x01) != 0);
+    }
+
+    /**
+     * Converts this {@link OperationalDatasetTimestamp} object to Thread TLV value.
+     *
+     * @hide
+     */
+    @NonNull
+    public byte[] toTlvValue() {
+        byte[] tlv = new byte[LENGTH_TIMESTAMP];
+        ByteBuffer buffer = ByteBuffer.wrap(tlv);
+        long encodedValue =
+                (mInstant.getEpochSecond() << 16)
+                        | ((mInstant.getNano() * TICKS_UPPER_BOUND / 1000000000L) << 1)
+                        | (mIsAuthoritativeSource ? 1 : 0);
+        buffer.putLong(encodedValue);
+        return tlv;
+    }
+
+    /**
+     * Creates a new {@link OperationalDatasetTimestamp} object.
+     *
+     * @param seconds the value encodes a Unix Time value. Must be in the range of
+     *     0x0-0xffffffffffffL
+     * @param ticks the value encodes the fractional Unix Time value in 32.768 kHz resolution. Must
+     *     be in the range of 0x0-0x7fff
+     * @param isAuthoritativeSource the flag indicates the time was obtained from an authoritative
+     *     source: either NTP (Network Time Protocol), GPS (Global Positioning System), cell
+     *     network, or other method
+     * @throws IllegalArgumentException if the {@code seconds} is not in range of
+     *     0x0-0xffffffffffffL or {@code ticks} is not in range of 0x0-0x7fff
+     */
+    public OperationalDatasetTimestamp(
+            @IntRange(from = 0x0, to = 0xffffffffffffL) long seconds,
+            @IntRange(from = 0x0, to = 0x7fff) int ticks,
+            boolean isAuthoritativeSource) {
+        this(makeInstant(seconds, ticks), isAuthoritativeSource);
+    }
+
+    private static Instant makeInstant(long seconds, int ticks) {
+        checkArgument(
+                seconds >= 0 && seconds <= 0xffffffffffffL,
+                "seconds exceeds allowed range (seconds = %d,"
+                        + " allowedRange = [0x0, 0xffffffffffffL])",
+                seconds);
+        checkArgument(
+                ticks >= 0 && ticks <= 0x7fff,
+                "ticks exceeds allowed ranged (ticks = %d, allowedRange" + " = [0x0, 0x7fff])",
+                ticks);
+        long nanos = Math.round((double) ticks * 1000000000L / TICKS_UPPER_BOUND);
+        return Instant.ofEpochSecond(seconds, nanos);
+    }
+
+    /**
+     * Creates new {@link OperationalDatasetTimestamp} object.
+     *
+     * @throws IllegalArgumentException if {@code instant.getEpochSecond()} is larger than {@code
+     *     0xffffffffffffL}
+     */
+    private OperationalDatasetTimestamp(@NonNull Instant instant, boolean isAuthoritativeSource) {
+        requireNonNull(instant, "instant cannot be null");
+        long seconds = instant.getEpochSecond();
+        checkArgument(
+                seconds >= 0 && seconds <= 0xffffffffffffL,
+                "instant seconds exceeds allowed range (seconds = %d, allowedRange = [0x0,"
+                        + " 0xffffffffffffL])",
+                seconds);
+        mInstant = instant;
+        mIsAuthoritativeSource = isAuthoritativeSource;
+    }
+
+    /**
+     * Returns the rounded ticks converted from the nano seconds.
+     *
+     * <p>Note that rhe return value can be as large as {@code TICKS_UPPER_BOUND}.
+     */
+    private static int getRoundedTicks(long nanos) {
+        return (int) Math.round((double) nanos * TICKS_UPPER_BOUND / 1000000000L);
+    }
+
+    /** Returns the seconds portion of the timestamp. */
+    public @IntRange(from = 0x0, to = 0xffffffffffffL) long getSeconds() {
+        return mInstant.getEpochSecond() + getRoundedTicks(mInstant.getNano()) / TICKS_UPPER_BOUND;
+    }
+
+    /** Returns the ticks portion of the timestamp. */
+    public @IntRange(from = 0x0, to = 0x7fff) int getTicks() {
+        // the rounded ticks can be 0x8000 if mInstant.getNano() >= 999984742
+        return (int) (getRoundedTicks(mInstant.getNano()) % TICKS_UPPER_BOUND);
+    }
+
+    /** Returns {@code true} if the timestamp comes from an authoritative source. */
+    public boolean isAuthoritativeSource() {
+        return mIsAuthoritativeSource;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{seconds=")
+                .append(getSeconds())
+                .append(", ticks=")
+                .append(getTicks())
+                .append(", isAuthoritativeSource=")
+                .append(isAuthoritativeSource())
+                .append(", instant=")
+                .append(toInstant())
+                .append("}");
+        return sb.toString();
+    }
+
+    @Override
+    public boolean equals(@Nullable Object other) {
+        if (this == other) {
+            return true;
+        } else if (!(other instanceof OperationalDatasetTimestamp)) {
+            return false;
+        } else {
+            OperationalDatasetTimestamp otherTimestamp = (OperationalDatasetTimestamp) other;
+            return mInstant.equals(otherTimestamp.mInstant)
+                    && mIsAuthoritativeSource == otherTimestamp.mIsAuthoritativeSource;
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mInstant, mIsAuthoritativeSource);
+    }
+}
diff --git a/thread/framework/java/android/net/thread/PendingOperationalDataset.aidl b/thread/framework/java/android/net/thread/PendingOperationalDataset.aidl
new file mode 100644
index 0000000..e5bc05e
--- /dev/null
+++ b/thread/framework/java/android/net/thread/PendingOperationalDataset.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2023 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 PendingOperationalDataset;
diff --git a/thread/framework/java/android/net/thread/PendingOperationalDataset.java b/thread/framework/java/android/net/thread/PendingOperationalDataset.java
new file mode 100644
index 0000000..4762d7f
--- /dev/null
+++ b/thread/framework/java/android/net/thread/PendingOperationalDataset.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2023 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 static com.android.internal.util.Preconditions.checkArgument;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.FlaggedApi;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.SparseArray;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.Objects;
+
+/**
+ * Data interface for managing a Thread Pending Operational Dataset.
+ *
+ * <p>The Pending Operational Dataset represents an Operational Dataset which will become Active in
+ * a given delay. This is typically used to deploy new network parameters (e.g. Network Key or
+ * Channel) to all devices in the network.
+ *
+ * @hide
+ */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
+@SystemApi
+public final class PendingOperationalDataset implements Parcelable {
+    // Value defined in Thread spec 8.10.1.16
+    private static final int TYPE_PENDING_TIMESTAMP = 51;
+
+    // Values defined in Thread spec 8.10.1.17
+    private static final int TYPE_DELAY_TIMER = 52;
+    private static final int LENGTH_DELAY_TIMER_BYTES = 4;
+
+    @NonNull
+    public static final Creator<PendingOperationalDataset> CREATOR =
+            new Creator<>() {
+                @Override
+                public PendingOperationalDataset createFromParcel(Parcel in) {
+                    return PendingOperationalDataset.fromThreadTlvs(in.createByteArray());
+                }
+
+                @Override
+                public PendingOperationalDataset[] newArray(int size) {
+                    return new PendingOperationalDataset[size];
+                }
+            };
+
+    @NonNull private final ActiveOperationalDataset mActiveOpDataset;
+    @NonNull private final OperationalDatasetTimestamp mPendingTimestamp;
+    @NonNull private final Duration mDelayTimer;
+
+    /** Creates a new {@link PendingOperationalDataset} object. */
+    public PendingOperationalDataset(
+            @NonNull ActiveOperationalDataset activeOpDataset,
+            @NonNull OperationalDatasetTimestamp pendingTimestamp,
+            @NonNull Duration delayTimer) {
+        requireNonNull(activeOpDataset, "activeOpDataset cannot be null");
+        requireNonNull(pendingTimestamp, "pendingTimestamp cannot be null");
+        requireNonNull(delayTimer, "delayTimer cannot be null");
+        this.mActiveOpDataset = activeOpDataset;
+        this.mPendingTimestamp = pendingTimestamp;
+        this.mDelayTimer = delayTimer;
+    }
+
+    /**
+     * Creates a new {@link PendingOperationalDataset} object from a series of Thread TLVs.
+     *
+     * <p>{@code tlvs} can be obtained from the value of a Thread Pending Operational Dataset TLV
+     * (see the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition) or the return value of {@link #toThreadTlvs}.
+     *
+     * @throws IllegalArgumentException if {@code tlvs} is malformed or contains an invalid Thread
+     *     TLV
+     */
+    @NonNull
+    public static PendingOperationalDataset fromThreadTlvs(@NonNull byte[] tlvs) {
+        requireNonNull(tlvs, "tlvs cannot be null");
+
+        SparseArray<byte[]> newUnknownTlvs = new SparseArray<>();
+        OperationalDatasetTimestamp pendingTimestamp = null;
+        Duration delayTimer = null;
+        ActiveOperationalDataset activeDataset = ActiveOperationalDataset.fromThreadTlvs(tlvs);
+        SparseArray<byte[]> unknownTlvs = activeDataset.getUnknownTlvs();
+        for (int i = 0; i < unknownTlvs.size(); i++) {
+            int key = unknownTlvs.keyAt(i);
+            byte[] value = unknownTlvs.valueAt(i);
+            switch (key) {
+                case TYPE_PENDING_TIMESTAMP:
+                    pendingTimestamp = OperationalDatasetTimestamp.fromTlvValue(value);
+                    break;
+                case TYPE_DELAY_TIMER:
+                    checkArgument(
+                            value.length == LENGTH_DELAY_TIMER_BYTES,
+                            "Invalid delay timer (length = %d, expectedLength = %d)",
+                            value.length,
+                            LENGTH_DELAY_TIMER_BYTES);
+                    int millis = ByteBuffer.wrap(value).getInt();
+                    delayTimer = Duration.ofMillis(Integer.toUnsignedLong(millis));
+                    break;
+                default:
+                    newUnknownTlvs.put(key, value);
+                    break;
+            }
+        }
+
+        if (pendingTimestamp == null) {
+            throw new IllegalArgumentException("Pending Timestamp is missing");
+        }
+        if (delayTimer == null) {
+            throw new IllegalArgumentException("Delay Timer is missing");
+        }
+
+        activeDataset =
+                new ActiveOperationalDataset.Builder(activeDataset)
+                        .setUnknownTlvs(newUnknownTlvs)
+                        .build();
+        return new PendingOperationalDataset(activeDataset, pendingTimestamp, delayTimer);
+    }
+
+    /** Returns the Active Operational Dataset. */
+    @NonNull
+    public ActiveOperationalDataset getActiveOperationalDataset() {
+        return mActiveOpDataset;
+    }
+
+    /** Returns the Pending Timestamp. */
+    @NonNull
+    public OperationalDatasetTimestamp getPendingTimestamp() {
+        return mPendingTimestamp;
+    }
+
+    /** Returns the Delay Timer. */
+    @NonNull
+    public Duration getDelayTimer() {
+        return mDelayTimer;
+    }
+
+    /**
+     * Converts this {@link PendingOperationalDataset} object to a series of Thread TLVs.
+     *
+     * <p>See the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition of the Thread TLV format.
+     */
+    @NonNull
+    public byte[] toThreadTlvs() {
+        ByteArrayOutputStream dataset = new ByteArrayOutputStream();
+
+        byte[] activeDatasetBytes = mActiveOpDataset.toThreadTlvs();
+        dataset.write(activeDatasetBytes, 0, activeDatasetBytes.length);
+
+        dataset.write(TYPE_PENDING_TIMESTAMP);
+        byte[] pendingTimestampBytes = mPendingTimestamp.toTlvValue();
+        dataset.write(pendingTimestampBytes.length);
+        dataset.write(pendingTimestampBytes, 0, pendingTimestampBytes.length);
+
+        dataset.write(TYPE_DELAY_TIMER);
+        byte[] delayTimerBytes = new byte[LENGTH_DELAY_TIMER_BYTES];
+        ByteBuffer.wrap(delayTimerBytes).putInt((int) mDelayTimer.toMillis());
+        dataset.write(delayTimerBytes.length);
+        dataset.write(delayTimerBytes, 0, delayTimerBytes.length);
+
+        return dataset.toByteArray();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        } else if (!(other instanceof PendingOperationalDataset)) {
+            return false;
+        } else {
+            PendingOperationalDataset otherDataset = (PendingOperationalDataset) other;
+            return mActiveOpDataset.equals(otherDataset.mActiveOpDataset)
+                    && mPendingTimestamp.equals(otherDataset.mPendingTimestamp)
+                    && mDelayTimer.equals(otherDataset.mDelayTimer);
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mActiveOpDataset, mPendingTimestamp, mDelayTimer);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{activeDataset=")
+                .append(getActiveOperationalDataset())
+                .append(", pendingTimestamp=")
+                .append(getPendingTimestamp())
+                .append(", delayTimer=")
+                .append(getDelayTimer())
+                .append("}");
+        return sb.toString();
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeByteArray(toThreadTlvs());
+    }
+}
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
index 278798e..ce770e0 100644
--- a/thread/tests/cts/Android.bp
+++ b/thread/tests/cts/Android.bp
@@ -37,6 +37,7 @@
         "androidx.test.ext.junit",
         "compatibility-device-util-axt",
         "ctstestrunner-axt",
+        "guava-android-testlib",
         "net-tests-utils",
         "truth",
     ],
diff --git a/thread/tests/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
index 5ba605f..ffc181c 100644
--- a/thread/tests/cts/AndroidTest.xml
+++ b/thread/tests/cts/AndroidTest.xml
@@ -47,5 +47,7 @@
 
     <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
         <option name="package" value="android.net.thread.cts" />
+        <!-- Ignores tests introduced by guava-android-testlib -->
+        <option name="exclude-annotation" value="org.junit.Ignore"/>
     </test>
 </configuration>
diff --git a/thread/tests/cts/src/android/net/thread/cts/ActiveOperationalDatasetTest.java b/thread/tests/cts/src/android/net/thread/cts/ActiveOperationalDatasetTest.java
new file mode 100644
index 0000000..39df21b
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/ActiveOperationalDatasetTest.java
@@ -0,0 +1,737 @@
+/*
+ * Copyright (C) 2023 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.cts;
+
+import static android.net.thread.ActiveOperationalDataset.CHANNEL_PAGE_24_GHZ;
+
+import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.net.IpPrefix;
+import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.ActiveOperationalDataset.Builder;
+import android.net.thread.ActiveOperationalDataset.SecurityPolicy;
+import android.net.thread.OperationalDatasetTimestamp;
+import android.util.SparseArray;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.primitives.Bytes;
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+
+/** CTS tests for {@link ActiveOperationalDataset}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class ActiveOperationalDatasetTest {
+    private static final int TYPE_ACTIVE_TIMESTAMP = 14;
+    private static final int TYPE_CHANNEL = 0;
+    private static final int TYPE_CHANNEL_MASK = 53;
+    private static final int TYPE_EXTENDED_PAN_ID = 2;
+    private static final int TYPE_MESH_LOCAL_PREFIX = 7;
+    private static final int TYPE_NETWORK_KEY = 5;
+    private static final int TYPE_NETWORK_NAME = 3;
+    private static final int TYPE_PAN_ID = 1;
+    private static final int TYPE_PSKC = 4;
+    private static final int TYPE_SECURITY_POLICY = 12;
+
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new":
+    // Active Timestamp: 1
+    // Channel: 19
+    // Channel Mask: 0x07FFF800
+    // Ext PAN ID: ACC214689BC40BDF
+    // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64
+    // Network Key: F26B3153760F519A63BAFDDFFC80D2AF
+    // Network Name: OpenThread-d9a0
+    // PAN ID: 0xD9A0
+    // PSKc: A245479C836D551B9CA557F7B9D351B4
+    // Security Policy: 672 onrcb
+    private static final byte[] VALID_DATASET =
+            base16().decode(
+                            "0E080000000000010000000300001335060004001FFFE002"
+                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                    + "B9D351B40C0402A0FFF8");
+
+    private static byte[] removeTlv(byte[] dataset, int type) {
+        ByteArrayOutputStream os = new ByteArrayOutputStream(dataset.length);
+        int i = 0;
+        while (i < dataset.length) {
+            int ty = dataset[i++] & 0xff;
+            byte length = dataset[i++];
+            if (ty != type) {
+                byte[] value = Arrays.copyOfRange(dataset, i, i + length);
+                os.write(ty);
+                os.write(length);
+                os.writeBytes(value);
+            }
+            i += length;
+        }
+        return os.toByteArray();
+    }
+
+    private static byte[] addTlv(byte[] dataset, String tlvHex) {
+        return Bytes.concat(dataset, base16().decode(tlvHex));
+    }
+
+    private static byte[] replaceTlv(byte[] dataset, int type, String newTlvHex) {
+        return addTlv(removeTlv(dataset, type), newTlvHex);
+    }
+
+    @Test
+    public void parcelable_parcelingIsLossLess() {
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(VALID_DATASET);
+
+        assertParcelingIsLossless(dataset);
+    }
+
+    @Test
+    public void fromThreadTlvs_tooLongTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = new byte[255];
+        invalidTlv[0] = (byte) 0xff;
+
+        // This is invalid because the TLV has max total length of 254 bytes and the value length
+        // can't exceeds 252 ( = 254 - 1 - 1)
+        invalidTlv[1] = (byte) 253;
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidNetworkKeyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_NETWORK_KEY, "05080000000000000000");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noNetworkKeyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_NETWORK_KEY);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidActiveTimestampTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_ACTIVE_TIMESTAMP, "0E0700000000010000");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noActiveTimestampTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_ACTIVE_TIMESTAMP);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidNetworkNameTlv_emptyName_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_NETWORK_NAME, "0300");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidNetworkNameTlv_tooLongName_throwsIllegalArgument() {
+        byte[] invalidTlv =
+                replaceTlv(
+                        VALID_DATASET, TYPE_NETWORK_NAME, "03114142434445464748494A4B4C4D4E4F5051");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noNetworkNameTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_NETWORK_NAME);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidChannelTlv_channelMissing_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "000100");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_undefinedChannelPage_success() {
+        byte[] datasetTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "0003010020");
+
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(datasetTlv);
+
+        assertThat(dataset.getChannelPage()).isEqualTo(0x01);
+        assertThat(dataset.getChannel()).isEqualTo(0x20);
+    }
+
+    @Test
+    public void fromThreadTlvs_invalid2P4GhzChannel_throwsIllegalArgument() {
+        byte[] invalidTlv1 = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "000300000A");
+        byte[] invalidTlv2 = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "000300001B");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv1));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv2));
+    }
+
+    @Test
+    public void fromThreadTlvs_valid2P4GhzChannelTlv_success() {
+        byte[] validTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "0003000010");
+
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(validTlv);
+
+        assertThat(dataset.getChannel()).isEqualTo(16);
+    }
+
+    @Test
+    public void fromThreadTlvs_noChannelTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_CHANNEL);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_prematureEndOfChannelMaskEntry_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL_MASK, "350100");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_inconsistentChannelMaskLength_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL_MASK, "3506000500010000");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_unsupportedChannelMaskLength_success() {
+        ActiveOperationalDataset dataset =
+                ActiveOperationalDataset.fromThreadTlvs(
+                        replaceTlv(VALID_DATASET, TYPE_CHANNEL_MASK, "350700050001000000"));
+
+        SparseArray<byte[]> channelMask = dataset.getChannelMask();
+        assertThat(channelMask.size()).isEqualTo(1);
+        assertThat(channelMask.get(CHANNEL_PAGE_24_GHZ))
+                .isEqualTo(new byte[] {0x00, 0x01, 0x00, 0x00, 0x00});
+    }
+
+    @Test
+    public void fromThreadTlvs_noChannelMaskTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_CHANNEL_MASK);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_PAN_ID, "010101");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_PAN_ID);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidExtendedPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_EXTENDED_PAN_ID, "020700010203040506");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noExtendedPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_EXTENDED_PAN_ID);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidPskcTlv_throwsIllegalArgument() {
+        byte[] invalidTlv =
+                replaceTlv(VALID_DATASET, TYPE_PSKC, "0411000102030405060708090A0B0C0D0E0F10");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noPskcTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_PSKC);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidMeshLocalPrefixTlv_throwsIllegalArgument() {
+        byte[] invalidTlv =
+                replaceTlv(VALID_DATASET, TYPE_MESH_LOCAL_PREFIX, "0709FD0001020304050607");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noMeshLocalPrefixTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_MESH_LOCAL_PREFIX);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_tooShortSecurityPolicyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_SECURITY_POLICY, "0C0101");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noSecurityPolicyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_SECURITY_POLICY);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_lengthAndDataMissing_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(new byte[] {(byte) 0x00}));
+    }
+
+    @Test
+    public void fromThreadTlvs_prematureEndOfData_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(new byte[] {0x00, 0x03, 0x00, 0x00}));
+    }
+
+    @Test
+    public void fromThreadTlvs_validFullDataset_success() {
+        // A valid Thread active operational dataset:
+        // Active Timestamp: 1
+        // Channel: 19
+        // Channel Mask: 0x07FFF800
+        // Ext PAN ID: ACC214689BC40BDF
+        // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64
+        // Network Key: F26B3153760F519A63BAFDDFFC80D2AF
+        // Network Name: OpenThread-d9a0
+        // PAN ID: 0xD9A0
+        // PSKc: A245479C836D551B9CA557F7B9D351B4
+        // Security Policy: 672 onrcb
+        byte[] validDatasetTlv =
+                base16().decode(
+                                "0E080000000000010000000300001335060004001FFFE002"
+                                        + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                        + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                        + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                        + "B9D351B40C0402A0FFF8");
+
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(validDatasetTlv);
+
+        assertThat(dataset.getNetworkKey())
+                .isEqualTo(base16().decode("F26B3153760F519A63BAFDDFFC80D2AF"));
+        assertThat(dataset.getPanId()).isEqualTo(0xd9a0);
+        assertThat(dataset.getExtendedPanId()).isEqualTo(base16().decode("ACC214689BC40BDF"));
+        assertThat(dataset.getChannel()).isEqualTo(19);
+        assertThat(dataset.getNetworkName()).isEqualTo("OpenThread-d9a0");
+        assertThat(dataset.getPskc())
+                .isEqualTo(base16().decode("A245479C836D551B9CA557F7B9D351B4"));
+        assertThat(dataset.getActiveTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(1, 0, false));
+        SparseArray<byte[]> channelMask = dataset.getChannelMask();
+        assertThat(channelMask.size()).isEqualTo(1);
+        assertThat(channelMask.get(CHANNEL_PAGE_24_GHZ))
+                .isEqualTo(new byte[] {0x00, 0x1f, (byte) 0xff, (byte) 0xe0});
+        assertThat(dataset.getMeshLocalPrefix())
+                .isEqualTo(new IpPrefix("fd64:db12:25f4:7e0b::/64"));
+        assertThat(dataset.getSecurityPolicy())
+                .isEqualTo(new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}));
+    }
+
+    @Test
+    public void fromThreadTlvs_containsUnknownTlvs_unknownTlvsRetained() {
+        final byte[] datasetWithUnknownTlvs = addTlv(VALID_DATASET, "AA01FFBB020102");
+
+        ActiveOperationalDataset dataset =
+                ActiveOperationalDataset.fromThreadTlvs(datasetWithUnknownTlvs);
+
+        byte[] newDatasetTlvs = dataset.toThreadTlvs();
+        String newDatasetTlvsHex = base16().encode(newDatasetTlvs);
+        assertThat(newDatasetTlvs.length).isEqualTo(datasetWithUnknownTlvs.length);
+        assertThat(newDatasetTlvsHex).contains("AA01FF");
+        assertThat(newDatasetTlvsHex).contains("BB020102");
+    }
+
+    @Test
+    public void toThreadTlvs_conversionIsLossLess() {
+        ActiveOperationalDataset dataset1 = ActiveOperationalDataset.createRandomDataset();
+
+        ActiveOperationalDataset dataset2 =
+                ActiveOperationalDataset.fromThreadTlvs(dataset1.toThreadTlvs());
+
+        assertThat(dataset2).isEqualTo(dataset1);
+    }
+
+    @Test
+    public void builder_buildWithdefaultValues_throwsIllegalState() {
+        assertThrows(IllegalStateException.class, () -> new Builder().build());
+    }
+
+    @Test
+    public void builder_setValidNetworkKey_success() {
+        final byte[] networkKey =
+                new byte[] {
+                    0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
+                    0x0d, 0x0e, 0x0f
+                };
+
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setNetworkKey(networkKey)
+                        .build();
+
+        assertThat(dataset.getNetworkKey()).isEqualTo(networkKey);
+    }
+
+    @Test
+    public void builder_setInvalidNetworkKey_throwsIllegalArgument() {
+        byte[] invalidNetworkKey = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class, () -> builder.setNetworkKey(invalidNetworkKey));
+    }
+
+    @Test
+    public void builder_setValidExtendedPanId_success() {
+        byte[] extendedPanId = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
+
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setExtendedPanId(extendedPanId)
+                        .build();
+
+        assertThat(dataset.getExtendedPanId()).isEqualTo(extendedPanId);
+    }
+
+    @Test
+    public void builder_setInvalidExtendedPanId_throwsIllegalArgument() {
+        byte[] extendedPanId = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06};
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setExtendedPanId(extendedPanId));
+    }
+
+    @Test
+    public void builder_setValidPanId_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setPanId(0xfffe)
+                        .build();
+
+        assertThat(dataset.getPanId()).isEqualTo(0xfffe);
+    }
+
+    @Test
+    public void builder_setInvalidPanId_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setPanId(0xffff));
+    }
+
+    @Test
+    public void builder_setInvalidChannel_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setChannel(0, 0));
+        assertThrows(IllegalArgumentException.class, () -> builder.setChannel(0, 27));
+    }
+
+    @Test
+    public void builder_setValid2P4GhzChannel_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setChannel(CHANNEL_PAGE_24_GHZ, 16)
+                        .build();
+
+        assertThat(dataset.getChannel()).isEqualTo(16);
+        assertThat(dataset.getChannelPage()).isEqualTo(CHANNEL_PAGE_24_GHZ);
+    }
+
+    @Test
+    public void builder_setValidNetworkName_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setNetworkName("ot-network")
+                        .build();
+
+        assertThat(dataset.getNetworkName()).isEqualTo("ot-network");
+    }
+
+    @Test
+    public void builder_setEmptyNetworkName_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setNetworkName(""));
+    }
+
+    @Test
+    public void builder_setTooLongNetworkName_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class, () -> builder.setNetworkName("openthread-network"));
+    }
+
+    @Test
+    public void builder_setTooLongUtf8NetworkName_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        // UTF-8 encoded length of "我的线程网络" is 18 bytes which exceeds the max length
+        assertThrows(IllegalArgumentException.class, () -> builder.setNetworkName("我的线程网络"));
+    }
+
+    @Test
+    public void builder_setValidUtf8NetworkName_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setNetworkName("我的网络")
+                        .build();
+
+        assertThat(dataset.getNetworkName()).isEqualTo("我的网络");
+    }
+
+    @Test
+    public void builder_setValidPskc_success() {
+        byte[] pskc = base16().decode("A245479C836D551B9CA557F7B9D351B4");
+
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset()).setPskc(pskc).build();
+
+        assertThat(dataset.getPskc()).isEqualTo(pskc);
+    }
+
+    @Test
+    public void builder_setTooLongPskc_throwsIllegalArgument() {
+        byte[] tooLongPskc = base16().decode("A245479C836D551B9CA557F7B9D351B400");
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setPskc(tooLongPskc));
+    }
+
+    @Test
+    public void builder_setValidChannelMask_success() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+        SparseArray<byte[]> channelMask = new SparseArray<byte[]>(1);
+        channelMask.put(0, new byte[] {0x00, 0x00, 0x01, 0x00});
+
+        ActiveOperationalDataset dataset = builder.setChannelMask(channelMask).build();
+
+        SparseArray<byte[]> resultChannelMask = dataset.getChannelMask();
+        assertThat(resultChannelMask.size()).isEqualTo(1);
+        assertThat(resultChannelMask.get(0)).isEqualTo(new byte[] {0x00, 0x00, 0x01, 0x00});
+    }
+
+    @Test
+    public void builder_setEmptyChannelMask_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setChannelMask(new SparseArray<byte[]>()));
+    }
+
+    @Test
+    public void builder_setValidActiveTimestamp_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setActiveTimestamp(
+                                new OperationalDatasetTimestamp(
+                                        /* seconds= */ 1,
+                                        /* ticks= */ 0,
+                                        /* isAuthoritativeSource= */ true))
+                        .build();
+
+        assertThat(dataset.getActiveTimestamp().getSeconds()).isEqualTo(1);
+        assertThat(dataset.getActiveTimestamp().getTicks()).isEqualTo(0);
+        assertThat(dataset.getActiveTimestamp().isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void builder_wrongMeshLocalPrefixLength_throwsIllegalArguments() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        // The Mesh-Local Prefix length must be 64 bits
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fd00::/32")));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fd00::/96")));
+
+        // The Mesh-Local Prefix must start with 0xfd
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fc00::/64")));
+    }
+
+    @Test
+    public void builder_meshLocalPrefixNotStartWith0xfd_throwsIllegalArguments() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fc00::/64")));
+    }
+
+    @Test
+    public void builder_setValidMeshLocalPrefix_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setMeshLocalPrefix(new IpPrefix("fd00::/64"))
+                        .build();
+
+        assertThat(dataset.getMeshLocalPrefix()).isEqualTo(new IpPrefix("fd00::/64"));
+    }
+
+    @Test
+    public void builder_setValid1P2SecurityPolicy_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setSecurityPolicy(
+                                new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                        .build();
+
+        assertThat(dataset.getSecurityPolicy().getRotationTimeHours()).isEqualTo(672);
+        assertThat(dataset.getSecurityPolicy().getFlags())
+                .isEqualTo(new byte[] {(byte) 0xff, (byte) 0xf8});
+    }
+
+    @Test
+    public void builder_setValid1P1SecurityPolicy_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setSecurityPolicy(new SecurityPolicy(672, new byte[] {(byte) 0xff}))
+                        .build();
+
+        assertThat(dataset.getSecurityPolicy().getRotationTimeHours()).isEqualTo(672);
+        assertThat(dataset.getSecurityPolicy().getFlags()).isEqualTo(new byte[] {(byte) 0xff});
+    }
+
+    @Test
+    public void securityPolicy_invalidRotationTime_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> new SecurityPolicy(0, new byte[] {(byte) 0xff, (byte) 0xf8}));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> new SecurityPolicy(0x1ffff, new byte[] {(byte) 0xff, (byte) 0xf8}));
+    }
+
+    @Test
+    public void securityPolicy_emptyFlags_throwsIllegalArguments() {
+        assertThrows(IllegalArgumentException.class, () -> new SecurityPolicy(672, new byte[] {}));
+    }
+
+    @Test
+    public void securityPolicy_tooLongFlags_success() {
+        SecurityPolicy securityPolicy =
+                new SecurityPolicy(672, new byte[] {0, 1, 2, 3, 4, 5, 6, 7});
+
+        assertThat(securityPolicy.getFlags()).isEqualTo(new byte[] {0, 1, 2, 3, 4, 5, 6, 7});
+    }
+
+    @Test
+    public void securityPolicy_equals() {
+        new EqualsTester()
+                .addEqualityGroup(
+                        new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}),
+                        new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                .addEqualityGroup(
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff}),
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff}))
+                .addEqualityGroup(
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff, (byte) 0xf8}),
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                .testEquals();
+    }
+}
diff --git a/thread/tests/cts/src/android/net/thread/cts/OperationalDatasetTimestampTest.java b/thread/tests/cts/src/android/net/thread/cts/OperationalDatasetTimestampTest.java
new file mode 100644
index 0000000..9be3d56
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/OperationalDatasetTimestampTest.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2023 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.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.net.thread.OperationalDatasetTimestamp;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Instant;
+
+/** Tests for {@link OperationalDatasetTimestamp}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class OperationalDatasetTimestampTest {
+    @Test
+    public void fromInstant_tooLargeInstant_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        OperationalDatasetTimestamp.fromInstant(
+                                Instant.ofEpochSecond(0xffffffffffffL + 1L)));
+    }
+
+    @Test
+    public void fromInstant_ticksIsRounded() {
+        Instant instant = Instant.ofEpochSecond(100L);
+
+        // 32767.5 / 32768 * 1000000000 = 999984741.2109375 and given the `ticks` is rounded, so
+        // the `ticks` should be 32767 for 999984741 and 0 (carried over to seconds) for 999984742.
+        OperationalDatasetTimestamp timestampTicks32767 =
+                OperationalDatasetTimestamp.fromInstant(instant.plusNanos(999984741));
+        OperationalDatasetTimestamp timestampTicks0 =
+                OperationalDatasetTimestamp.fromInstant(instant.plusNanos(999984742));
+
+        assertThat(timestampTicks32767.getSeconds()).isEqualTo(100L);
+        assertThat(timestampTicks0.getSeconds()).isEqualTo(101L);
+        assertThat(timestampTicks32767.getTicks()).isEqualTo(32767);
+        assertThat(timestampTicks0.getTicks()).isEqualTo(0);
+        assertThat(timestampTicks32767.isAuthoritativeSource()).isTrue();
+        assertThat(timestampTicks0.isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void toInstant_nanosIsRounded() {
+        // 32767 / 32768 * 1000000000 = 999969482.421875
+        assertThat(new OperationalDatasetTimestamp(100L, 32767, false).toInstant().getNano())
+                .isEqualTo(999969482);
+
+        // 32766 / 32768 * 1000000000 = 999938964.84375
+        assertThat(new OperationalDatasetTimestamp(100L, 32766, false).toInstant().getNano())
+                .isEqualTo(999938965);
+    }
+
+    @Test
+    public void toInstant_onlyAuthoritativeSourceDiscarded() {
+        OperationalDatasetTimestamp timestamp1 =
+                new OperationalDatasetTimestamp(100L, 0x7fff, false);
+
+        OperationalDatasetTimestamp timestamp2 =
+                OperationalDatasetTimestamp.fromInstant(timestamp1.toInstant());
+
+        assertThat(timestamp2.getSeconds()).isEqualTo(100L);
+        assertThat(timestamp2.getTicks()).isEqualTo(0x7fff);
+        assertThat(timestamp2.isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void constructor_tooLargeSeconds_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        new OperationalDatasetTimestamp(
+                                /* seconds= */ 0x0001112233445566L,
+                                /* ticks= */ 0,
+                                /* isAuthoritativeSource= */ true));
+    }
+
+    @Test
+    public void constructor_tooLargeTicks_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        new OperationalDatasetTimestamp(
+                                /* seconds= */ 0x01L,
+                                /* ticks= */ 0x8000,
+                                /* isAuthoritativeSource= */ true));
+    }
+
+    @Test
+    public void equalityTests() {
+        new EqualsTester()
+                .addEqualityGroup(
+                        new OperationalDatasetTimestamp(100, 100, false),
+                        new OperationalDatasetTimestamp(100, 100, false))
+                .addEqualityGroup(
+                        new OperationalDatasetTimestamp(0, 0, false),
+                        new OperationalDatasetTimestamp(0, 0, false))
+                .addEqualityGroup(
+                        new OperationalDatasetTimestamp(0xffffffffffffL, 0x7fff, true),
+                        new OperationalDatasetTimestamp(0xffffffffffffL, 0x7fff, true))
+                .testEquals();
+    }
+}
diff --git a/thread/tests/cts/src/android/net/thread/cts/PendingOperationalDatasetTest.java b/thread/tests/cts/src/android/net/thread/cts/PendingOperationalDatasetTest.java
new file mode 100644
index 0000000..7a49957
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/PendingOperationalDatasetTest.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright (C) 2023 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.cts;
+
+import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.net.IpPrefix;
+import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.OperationalDatasetTimestamp;
+import android.net.thread.PendingOperationalDataset;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.primitives.Bytes;
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Duration;
+
+/** Tests for {@link PendingOperationalDataset}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class PendingOperationalDatasetTest {
+    private static final ActiveOperationalDataset DEFAULT_ACTIVE_DATASET =
+            ActiveOperationalDataset.createRandomDataset();
+
+    @Test
+    public void parcelable_parcelingIsLossLess() {
+        PendingOperationalDataset dataset =
+                new PendingOperationalDataset(
+                        DEFAULT_ACTIVE_DATASET,
+                        new OperationalDatasetTimestamp(31536000, 200, false),
+                        Duration.ofHours(100));
+
+        assertParcelingIsLossless(dataset);
+    }
+
+    @Test
+    public void equalityTests() {
+        ActiveOperationalDataset activeDataset1 = ActiveOperationalDataset.createRandomDataset();
+        ActiveOperationalDataset activeDataset2 = ActiveOperationalDataset.createRandomDataset();
+
+        new EqualsTester()
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset1,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)),
+                        new PendingOperationalDataset(
+                                activeDataset1,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)))
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)),
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)))
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(0)),
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(0)))
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(100)),
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(100)))
+                .testEquals();
+    }
+
+    @Test
+    public void constructor_correctValuesAreSet() {
+        PendingOperationalDataset dataset =
+                new PendingOperationalDataset(
+                        DEFAULT_ACTIVE_DATASET,
+                        new OperationalDatasetTimestamp(31536000, 200, false),
+                        Duration.ofHours(100));
+
+        assertThat(dataset.getActiveOperationalDataset()).isEqualTo(DEFAULT_ACTIVE_DATASET);
+        assertThat(dataset.getPendingTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(31536000, 200, false));
+        assertThat(dataset.getDelayTimer()).isEqualTo(Duration.ofHours(100));
+    }
+
+    @Test
+    public void fromThreadTlvs_openthreadTlvs_success() {
+        // An example Pending Operational Dataset which is generated with OpenThread CLI:
+        // Pending Timestamp: 2
+        // Active Timestamp: 1
+        // Channel: 26
+        // Channel Mask: 0x07fff800
+        // Delay: 46354
+        // Ext PAN ID: a74182f4d3f4de41
+        // Mesh Local Prefix: fd46:c1b9:e159:5574::/64
+        // Network Key: ed916e454d96fd00184f10a6f5c9e1d3
+        // Network Name: OpenThread-bff8
+        // PAN ID: 0xbff8
+        // PSKc: 264f78414adc683191863d968f72d1b7
+        // Security Policy: 672 onrc
+        final byte[] OPENTHREAD_PENDING_DATASET_TLVS =
+                base16().lowerCase()
+                        .decode(
+                                "0e0800000000000100003308000000000002000034040000b51200030000"
+                                        + "1a35060004001fffe00208a74182f4d3f4de410708fd46c1b9"
+                                        + "e15955740510ed916e454d96fd00184f10a6f5c9e1d3030f4f"
+                                        + "70656e5468726561642d626666380102bff80410264f78414a"
+                                        + "dc683191863d968f72d1b70c0402a0f7f8");
+
+        PendingOperationalDataset pendingDataset =
+                PendingOperationalDataset.fromThreadTlvs(OPENTHREAD_PENDING_DATASET_TLVS);
+
+        ActiveOperationalDataset activeDataset = pendingDataset.getActiveOperationalDataset();
+        assertThat(pendingDataset.getPendingTimestamp().getSeconds()).isEqualTo(2L);
+        assertThat(activeDataset.getActiveTimestamp().getSeconds()).isEqualTo(1L);
+        assertThat(activeDataset.getChannel()).isEqualTo(26);
+        assertThat(activeDataset.getChannelMask().get(0))
+                .isEqualTo(new byte[] {0x00, 0x1f, (byte) 0xff, (byte) 0xe0});
+        assertThat(pendingDataset.getDelayTimer().toMillis()).isEqualTo(46354);
+        assertThat(activeDataset.getExtendedPanId())
+                .isEqualTo(base16().lowerCase().decode("a74182f4d3f4de41"));
+        assertThat(activeDataset.getMeshLocalPrefix())
+                .isEqualTo(new IpPrefix("fd46:c1b9:e159:5574::/64"));
+        assertThat(activeDataset.getNetworkKey())
+                .isEqualTo(base16().lowerCase().decode("ed916e454d96fd00184f10a6f5c9e1d3"));
+        assertThat(activeDataset.getNetworkName()).isEqualTo("OpenThread-bff8");
+        assertThat(activeDataset.getPanId()).isEqualTo(0xbff8);
+        assertThat(activeDataset.getPskc())
+                .isEqualTo(base16().lowerCase().decode("264f78414adc683191863d968f72d1b7"));
+        assertThat(activeDataset.getSecurityPolicy().getRotationTimeHours()).isEqualTo(672);
+        assertThat(activeDataset.getSecurityPolicy().getFlags())
+                .isEqualTo(new byte[] {(byte) 0xf7, (byte) 0xf8});
+    }
+
+    @Test
+    public void fromThreadTlvs_completePendingDatasetTlvs_success() {
+        // Type Length Value
+        // 0x33 0x08 0x0000000000010000 (Pending Timestamp TLV)
+        // 0x34 0x04 0x0000012C (Delay Timer TLV)
+        final byte[] pendingTimestampAndDelayTimerTlvs =
+                base16().decode("3308000000000001000034040000012C");
+        final byte[] pendingDatasetTlvs =
+                Bytes.concat(
+                        pendingTimestampAndDelayTimerTlvs, DEFAULT_ACTIVE_DATASET.toThreadTlvs());
+
+        PendingOperationalDataset dataset =
+                PendingOperationalDataset.fromThreadTlvs(pendingDatasetTlvs);
+
+        assertThat(dataset.getActiveOperationalDataset()).isEqualTo(DEFAULT_ACTIVE_DATASET);
+        assertThat(dataset.getPendingTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(1, 0, false));
+        assertThat(dataset.getDelayTimer()).isEqualTo(Duration.ofMillis(300));
+    }
+
+    @Test
+    public void fromThreadTlvs_PendingTimestampTlvIsMissing_throwsIllegalArgument() {
+        // Type Length Value
+        // 0x34 0x04 0x00000064 (Delay Timer TLV)
+        final byte[] pendingTimestampAndDelayTimerTlvs = base16().decode("34040000012C");
+        final byte[] pendingDatasetTlvs =
+                Bytes.concat(
+                        pendingTimestampAndDelayTimerTlvs, DEFAULT_ACTIVE_DATASET.toThreadTlvs());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(pendingDatasetTlvs));
+    }
+
+    @Test
+    public void fromThreadTlvs_delayTimerTlvIsMissing_throwsIllegalArgument() {
+        // Type Length Value
+        // 0x33 0x08 0x0000000000010000 (Pending Timestamp TLV)
+        final byte[] pendingTimestampAndDelayTimerTlvs = base16().decode("33080000000000010000");
+        final byte[] pendingDatasetTlvs =
+                Bytes.concat(
+                        pendingTimestampAndDelayTimerTlvs, DEFAULT_ACTIVE_DATASET.toThreadTlvs());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(pendingDatasetTlvs));
+    }
+
+    @Test
+    public void fromThreadTlvs_activeDatasetTlvs_throwsIllegalArgument() {
+        final byte[] activeDatasetTlvs = DEFAULT_ACTIVE_DATASET.toThreadTlvs();
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(activeDatasetTlvs));
+    }
+
+    @Test
+    public void fromThreadTlvs_malformedTlvs_throwsIllegalArgument() {
+        final byte[] invalidTlvs = new byte[] {0x00};
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(invalidTlvs));
+    }
+
+    @Test
+    public void toThreadTlvs_conversionIsLossLess() {
+        PendingOperationalDataset dataset1 =
+                new PendingOperationalDataset(
+                        DEFAULT_ACTIVE_DATASET,
+                        new OperationalDatasetTimestamp(31536000, 200, false),
+                        Duration.ofHours(100));
+
+        PendingOperationalDataset dataset2 =
+                PendingOperationalDataset.fromThreadTlvs(dataset1.toThreadTlvs());
+
+        assertThat(dataset2).isEqualTo(dataset1);
+    }
+}
diff --git a/thread/tests/unit/Android.bp b/thread/tests/unit/Android.bp
new file mode 100644
index 0000000..1f16ad1
--- /dev/null
+++ b/thread/tests/unit/Android.bp
@@ -0,0 +1,50 @@
+//
+// Copyright (C) 2023 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "ThreadNetworkUnitTests",
+    min_sdk_version: "33",
+    sdk_version: "module_current",
+    manifest: "AndroidManifest.xml",
+    test_config: "AndroidTest.xml",
+    srcs: [
+        "src/**/*.java",
+    ],
+    test_suites: [
+        "general-tests",
+    ],
+    static_libs: [
+        "androidx.test.ext.junit",
+        "compatibility-device-util-axt",
+        "ctstestrunner-axt",
+        "framework-connectivity-pre-jarjar",
+        "framework-connectivity-t-pre-jarjar",
+        "guava-android-testlib",
+        "net-tests-utils",
+        "truth-prebuilt",
+    ],
+    libs: [
+        "android.test.base",
+        "android.test.runner",
+    ],
+    // Test coverage system runs on different devices. Need to
+    // compile for all architectures.
+    compile_multilib: "both",
+}
diff --git a/thread/tests/unit/AndroidManifest.xml b/thread/tests/unit/AndroidManifest.xml
new file mode 100644
index 0000000..ace7c52
--- /dev/null
+++ b/thread/tests/unit/AndroidManifest.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (C) 2023 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.
+ -->
+
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.net.thread.unittests">
+
+    <application android:debuggable="true">
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="android.net.thread.unittests"
+        android:label="Unit tests for android.net.thread" />
+</manifest>
diff --git a/thread/tests/unit/AndroidTest.xml b/thread/tests/unit/AndroidTest.xml
new file mode 100644
index 0000000..663ff74
--- /dev/null
+++ b/thread/tests/unit/AndroidTest.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (C) 2023 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.
+ -->
+
+<configuration description="Config for Thread network unit test cases">
+    <option name="test-tag" value="ThreadNetworkUnitTests" />
+    <option name="test-suite-tag" value="apct" />
+
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="ThreadNetworkUnitTests.apk" />
+        <option name="check-min-sdk" value="true" />
+        <option name="cleanup-apks" value="true" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="android.net.thread.unittests" />
+        <!-- Ignores tests introduced by guava-android-testlib -->
+        <option name="exclude-annotation" value="org.junit.Ignore"/>
+    </test>
+</configuration>
diff --git a/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java b/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java
new file mode 100644
index 0000000..78eb3d0
--- /dev/null
+++ b/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2023 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 static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.net.IpPrefix;
+import android.net.thread.ActiveOperationalDataset.Builder;
+import android.net.thread.ActiveOperationalDataset.SecurityPolicy;
+import android.util.SparseArray;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.primitives.Bytes;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.security.SecureRandom;
+import java.util.Random;
+
+/** Unit tests for {@link ActiveOperationalDataset}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class ActiveOperationalDatasetTest {
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new":
+    // Active Timestamp: 1
+    // Channel: 19
+    // Channel Mask: 0x07FFF800
+    // Ext PAN ID: ACC214689BC40BDF
+    // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64
+    // Network Key: F26B3153760F519A63BAFDDFFC80D2AF
+    // Network Name: OpenThread-d9a0
+    // PAN ID: 0xD9A0
+    // PSKc: A245479C836D551B9CA557F7B9D351B4
+    // Security Policy: 672 onrcb
+    private static final byte[] VALID_DATASET =
+            base16().decode(
+                            "0E080000000000010000000300001335060004001FFFE002"
+                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                    + "B9D351B40C0402A0FFF8");
+
+    @Mock private Random mockRandom;
+    @Mock private SecureRandom mockSecureRandom;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+    }
+
+    private static byte[] addTlv(byte[] dataset, String tlvHex) {
+        return Bytes.concat(dataset, base16().decode(tlvHex));
+    }
+
+    @Test
+    public void fromThreadTlvs_containsUnknownTlvs_unknownTlvsRetained() {
+        byte[] datasetWithUnknownTlvs = addTlv(VALID_DATASET, "AA01FFBB020102");
+
+        ActiveOperationalDataset dataset1 =
+                ActiveOperationalDataset.fromThreadTlvs(datasetWithUnknownTlvs);
+        ActiveOperationalDataset dataset2 =
+                ActiveOperationalDataset.fromThreadTlvs(dataset1.toThreadTlvs());
+
+        SparseArray<byte[]> unknownTlvs = dataset2.getUnknownTlvs();
+        assertThat(unknownTlvs.size()).isEqualTo(2);
+        assertThat(unknownTlvs.get(0xAA)).isEqualTo(new byte[] {(byte) 0xFF});
+        assertThat(unknownTlvs.get(0xBB)).isEqualTo(new byte[] {0x01, 0x02});
+        assertThat(dataset2).isEqualTo(dataset1);
+    }
+
+    @Test
+    public void createRandomDataset_fieldsAreRandomized() {
+        // Always return the max bounded value
+        doAnswer(invocation -> (int) invocation.getArgument(0) - 1)
+                .when(mockRandom)
+                .nextInt(anyInt());
+        doAnswer(
+                        invocation -> {
+                            byte[] output = invocation.getArgument(0);
+                            for (int i = 0; i < output.length; ++i) {
+                                output[i] = (byte) (i + 10);
+                            }
+                            return null;
+                        })
+                .when(mockRandom)
+                .nextBytes(any(byte[].class));
+        doAnswer(
+                        invocation -> {
+                            byte[] output = invocation.getArgument(0);
+                            for (int i = 0; i < output.length; ++i) {
+                                output[i] = (byte) (i + 30);
+                            }
+                            return null;
+                        })
+                .when(mockSecureRandom)
+                .nextBytes(any(byte[].class));
+
+        ActiveOperationalDataset dataset =
+                ActiveOperationalDataset.createRandomDataset(mockRandom, mockSecureRandom);
+
+        assertThat(dataset.getActiveTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(1, 0, false));
+        assertThat(dataset.getExtendedPanId())
+                .isEqualTo(new byte[] {10, 11, 12, 13, 14, 15, 16, 17});
+        assertThat(dataset.getMeshLocalPrefix())
+                .isEqualTo(new IpPrefix("fd0b:0c0d:0e0f:1011::/64"));
+        verify(mockRandom, times(2)).nextBytes(any(byte[].class));
+        assertThat(dataset.getPanId()).isEqualTo(0xfffe); // PAN ID <= 0xfffe
+        verify(mockRandom, times(1)).nextInt(eq(0xffff));
+        assertThat(dataset.getChannel()).isEqualTo(26);
+        verify(mockRandom, times(1)).nextInt(eq(16));
+        assertThat(dataset.getChannelPage()).isEqualTo(0);
+        assertThat(dataset.getChannelMask().size()).isEqualTo(1);
+        assertThat(dataset.getPskc())
+                .isEqualTo(
+                        new byte[] {
+                            30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45
+                        });
+        assertThat(dataset.getNetworkKey())
+                .isEqualTo(
+                        new byte[] {
+                            30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45
+                        });
+        verify(mockSecureRandom, times(2)).nextBytes(any(byte[].class));
+        assertThat(dataset.getSecurityPolicy())
+                .isEqualTo(new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}));
+    }
+
+    @Test
+    public void builder_buildWithTooLongTlvs_throwsIllegalState() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+        for (int i = 0; i < 10; i++) {
+            builder.addUnknownTlv(i, new byte[20]);
+        }
+
+        assertThrows(IllegalStateException.class, () -> new Builder().build());
+    }
+
+    @Test
+    public void builder_setUnknownTlvs_success() {
+        ActiveOperationalDataset dataset1 = ActiveOperationalDataset.fromThreadTlvs(VALID_DATASET);
+        SparseArray<byte[]> unknownTlvs = new SparseArray<>(2);
+        unknownTlvs.put(0x33, new byte[] {1, 2, 3});
+        unknownTlvs.put(0x44, new byte[] {1, 2, 3, 4});
+
+        ActiveOperationalDataset dataset2 =
+                new ActiveOperationalDataset.Builder(dataset1).setUnknownTlvs(unknownTlvs).build();
+
+        assertThat(dataset1.getUnknownTlvs().size()).isEqualTo(0);
+        assertThat(dataset2.getUnknownTlvs().size()).isEqualTo(2);
+        assertThat(dataset2.getUnknownTlvs().get(0x33)).isEqualTo(new byte[] {1, 2, 3});
+        assertThat(dataset2.getUnknownTlvs().get(0x44)).isEqualTo(new byte[] {1, 2, 3, 4});
+    }
+
+    @Test
+    public void securityPolicy_fromTooShortTlvValue_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> SecurityPolicy.fromTlvValue(new byte[] {0x01}));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> SecurityPolicy.fromTlvValue(new byte[] {0x01, 0x02}));
+    }
+
+    @Test
+    public void securityPolicy_toTlvValue_conversionIsLossLess() {
+        SecurityPolicy policy1 = new SecurityPolicy(200, new byte[] {(byte) 0xFF, (byte) 0xF8});
+
+        SecurityPolicy policy2 = SecurityPolicy.fromTlvValue(policy1.toTlvValue());
+
+        assertThat(policy2).isEqualTo(policy1);
+    }
+}
diff --git a/thread/tests/unit/src/android/net/thread/OperationalDatasetTimestampTest.java b/thread/tests/unit/src/android/net/thread/OperationalDatasetTimestampTest.java
new file mode 100644
index 0000000..32063fc
--- /dev/null
+++ b/thread/tests/unit/src/android/net/thread/OperationalDatasetTimestampTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2023 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 static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link OperationalDatasetTimestamp}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class OperationalDatasetTimestampTest {
+    @Test
+    public void fromTlvValue_invalidTimestamp_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> OperationalDatasetTimestamp.fromTlvValue(new byte[7]));
+    }
+
+    @Test
+    public void fromTlvValue_goodValue_success() {
+        OperationalDatasetTimestamp timestamp =
+                OperationalDatasetTimestamp.fromTlvValue(base16().decode("FFEEDDCCBBAA9989"));
+
+        assertThat(timestamp.getSeconds()).isEqualTo(0xFFEEDDCCBBAAL);
+        // 0x9989 is 0x4CC4 << 1 + 1
+        assertThat(timestamp.getTicks()).isEqualTo(0x4CC4);
+        assertThat(timestamp.isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void toTlvValue_conversionIsLossLess() {
+        OperationalDatasetTimestamp timestamp1 = new OperationalDatasetTimestamp(100L, 10, true);
+
+        OperationalDatasetTimestamp timestamp2 =
+                OperationalDatasetTimestamp.fromTlvValue(timestamp1.toTlvValue());
+
+        assertThat(timestamp2).isEqualTo(timestamp1);
+    }
+}