Merge "changes the API setChannelMaxPowers from hide API to system API" into main
diff --git a/Tethering/common/TetheringLib/Android.bp b/Tethering/common/TetheringLib/Android.bp
index 203d828..6e00756 100644
--- a/Tethering/common/TetheringLib/Android.bp
+++ b/Tethering/common/TetheringLib/Android.bp
@@ -49,7 +49,10 @@
         "//packages/modules/NetworkStack/tests:__subpackages__",
         "//packages/modules/Wifi/service/tests/wifitests",
     ],
-    stub_only_libs: ["framework-connectivity.stubs.module_lib"],
+    stub_only_libs: [
+        "framework-connectivity.stubs.module_lib",
+        "sdk_module-lib_current_framework-wifi",
+    ],
 
     jarjar_rules: ":framework-tethering-jarjar-rules",
     installable: true,
@@ -97,13 +100,17 @@
     srcs: [
         ":framework-tethering-srcs",
     ],
-    libs: ["framework-connectivity.stubs.module_lib"],
+    libs: [
+        "framework-connectivity.stubs.module_lib",
+        "sdk_module-lib_current_framework-wifi",
+    ],
     static_libs: [
         "com.android.net.flags-aconfig-java",
     ],
     aidl: {
         include_dirs: [
             "packages/modules/Connectivity/framework/aidl-export",
+            "packages/modules/Wifi/framework/aidl-export",
         ],
     },
     apex_available: ["com.android.tethering"],
diff --git a/Tethering/common/TetheringLib/api/system-current.txt b/Tethering/common/TetheringLib/api/system-current.txt
index cccafd5..3efaac2 100644
--- a/Tethering/common/TetheringLib/api/system-current.txt
+++ b/Tethering/common/TetheringLib/api/system-current.txt
@@ -102,6 +102,7 @@
     method public int getConnectivityScope();
     method @Nullable public android.net.LinkAddress getLocalIpv4Address();
     method public boolean getShouldShowEntitlementUi();
+    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") @Nullable public android.net.wifi.SoftApConfiguration getSoftApConfiguration();
     method public int getTetheringType();
     method public boolean isExemptFromEntitlementCheck();
     method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") public void writeToParcel(@NonNull android.os.Parcel, int);
@@ -114,6 +115,7 @@
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setConnectivityScope(int);
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setExemptFromEntitlementCheck(boolean);
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setShouldShowEntitlementUi(boolean);
+    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setSoftApConfiguration(@Nullable android.net.wifi.SoftApConfiguration);
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setStaticIpv4Addresses(@NonNull android.net.LinkAddress, @NonNull android.net.LinkAddress);
   }
 
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
index 1f6011a..5aca642 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
@@ -26,6 +26,8 @@
 import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
 import android.content.Context;
+import android.net.wifi.SoftApConfiguration;
+import android.net.wifi.WifiManager;
 import android.os.Bundle;
 import android.os.ConditionVariable;
 import android.os.IBinder;
@@ -744,6 +746,7 @@
                 mBuilderParcel.exemptFromEntitlementCheck = false;
                 mBuilderParcel.showProvisioningUi = true;
                 mBuilderParcel.connectivityScope = getDefaultConnectivityScope(type);
+                mBuilderParcel.softApConfig = null;
             }
 
             /**
@@ -803,6 +806,30 @@
                 return this;
             }
 
+            /**
+             * Set the desired SoftApConfiguration for {@link #TETHERING_WIFI}. If this is null or
+             * not set, then the persistent tethering SoftApConfiguration from
+             * {@link WifiManager#getSoftApConfiguration()} will be used.
+             * </p>
+             * If TETHERING_WIFI is already enabled and a new request is made with a different
+             * SoftApConfiguration, the request will be accepted if the device can support an
+             * additional tethering Wi-Fi AP interface. Otherwise, the request will be rejected.
+             *
+             * @param softApConfig SoftApConfiguration to use.
+             * @throws IllegalArgumentException if the tethering type isn't TETHERING_WIFI.
+             */
+            @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+            @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
+            @NonNull
+            public Builder setSoftApConfiguration(@Nullable SoftApConfiguration softApConfig) {
+                if (mBuilderParcel.tetheringType != TETHERING_WIFI) {
+                    throw new IllegalArgumentException(
+                            "SoftApConfiguration can only be set for TETHERING_WIFI");
+                }
+                mBuilderParcel.softApConfig = softApConfig;
+                return this;
+            }
+
             /** Build {@link TetheringRequest} with the currently set configuration. */
             @NonNull
             public TetheringRequest build() {
@@ -884,6 +911,15 @@
         }
 
         /**
+         * Get the desired SoftApConfiguration of the request, if one was specified.
+         */
+        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @Nullable
+        public SoftApConfiguration getSoftApConfiguration() {
+            return mRequestParcel.softApConfig;
+        }
+
+        /**
          * Get a TetheringRequestParcel from the configuration
          * @hide
          */
@@ -896,9 +932,10 @@
             return "TetheringRequest [ type= " + mRequestParcel.tetheringType
                     + ", localIPv4Address= " + mRequestParcel.localIPv4Address
                     + ", staticClientAddress= " + mRequestParcel.staticClientAddress
-                    + ", exemptFromEntitlementCheck= "
-                    + mRequestParcel.exemptFromEntitlementCheck + ", showProvisioningUi= "
-                    + mRequestParcel.showProvisioningUi + " ]";
+                    + ", exemptFromEntitlementCheck= " + mRequestParcel.exemptFromEntitlementCheck
+                    + ", showProvisioningUi= " + mRequestParcel.showProvisioningUi
+                    + ", softApConfig= " + mRequestParcel.softApConfig
+                    + " ]";
         }
 
         @Override
@@ -912,7 +949,8 @@
                     && Objects.equals(parcel.staticClientAddress, otherParcel.staticClientAddress)
                     && parcel.exemptFromEntitlementCheck == otherParcel.exemptFromEntitlementCheck
                     && parcel.showProvisioningUi == otherParcel.showProvisioningUi
-                    && parcel.connectivityScope == otherParcel.connectivityScope;
+                    && parcel.connectivityScope == otherParcel.connectivityScope
+                    && Objects.equals(parcel.softApConfig, otherParcel.softApConfig);
         }
 
         @Override
@@ -920,7 +958,7 @@
             TetheringRequestParcel parcel = getParcel();
             return Objects.hash(parcel.tetheringType, parcel.localIPv4Address,
                     parcel.staticClientAddress, parcel.exemptFromEntitlementCheck,
-                    parcel.showProvisioningUi, parcel.connectivityScope);
+                    parcel.showProvisioningUi, parcel.connectivityScope, parcel.softApConfig);
         }
     }
 
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl b/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
index f13c970..ea7a353 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
@@ -17,6 +17,7 @@
 package android.net;
 
 import android.net.LinkAddress;
+import android.net.wifi.SoftApConfiguration;
 
 /**
  * Configuration details for requesting tethering.
@@ -29,4 +30,5 @@
     boolean exemptFromEntitlementCheck;
     boolean showProvisioningUi;
     int connectivityScope;
+    SoftApConfiguration softApConfig;
 }
diff --git a/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
index ac4d8b1..2202106 100644
--- a/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
+++ b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
@@ -65,6 +65,8 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.VisibleForTesting;
 
+import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.DeviceConfigUtils;
 import com.android.networkstack.tethering.UpstreamNetworkState;
 
 import java.util.ArrayList;
@@ -86,6 +88,11 @@
     private static final String SETTINGS_PKG_NAME = "com.android.settings";
     private static final String SYSTEMUI_PKG_NAME = "com.android.systemui";
     private static final String GMS_PKG_NAME = "com.google.android.gms";
+    /**
+     * A feature flag to control whether upstream data usage metrics should be enabled.
+     */
+    private static final String TETHER_UPSTREAM_DATA_USAGE_METRICS =
+            "tether_upstream_data_usage_metrics";
     private final SparseArray<NetworkTetheringReported.Builder> mBuilderMap = new SparseArray<>();
     private final SparseArray<Long> mDownstreamStartTime = new SparseArray<Long>();
     private final ArrayList<RecordUpstreamEvent> mUpstreamEventList = new ArrayList<>();
@@ -119,6 +126,16 @@
         public long timeNow() {
             return System.currentTimeMillis();
         }
+
+        /**
+         * Indicates whether {@link #TETHER_UPSTREAM_DATA_USAGE_METRICS} is enabled.
+         */
+        public boolean isUpstreamDataUsageMetricsEnabled(Context context) {
+            // Getting data usage requires building a NetworkTemplate. However, the
+            // NetworkTemplate#Builder API was introduced in Android T.
+            return SdkLevel.isAtLeastT() && DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+                    context, TETHER_UPSTREAM_DATA_USAGE_METRICS);
+        }
     }
 
     /**
@@ -135,16 +152,36 @@
         mDependencies = dependencies;
     }
 
+    private static class DataUsage {
+        final long mTxBytes;
+        final long mRxBytes;
+
+        DataUsage(long txBytes, long rxBytes) {
+            mTxBytes = txBytes;
+            mRxBytes = rxBytes;
+        }
+
+        public long getTxBytes() {
+            return mTxBytes;
+        }
+
+        public long getRxBytes() {
+            return mRxBytes;
+        }
+    }
+
     private static class RecordUpstreamEvent {
-        public final long mStartTime;
-        public final long mStopTime;
-        public final UpstreamType mUpstreamType;
+        final long mStartTime;
+        final long mStopTime;
+        final UpstreamType mUpstreamType;
+        final DataUsage mDataUsage;
 
         RecordUpstreamEvent(final long startTime, final long stopTime,
-                final UpstreamType upstream) {
+                final UpstreamType upstream, final DataUsage dataUsage) {
             mStartTime = startTime;
             mStopTime = stopTime;
             mUpstreamType = upstream;
+            mDataUsage = dataUsage;
         }
     }
 
@@ -182,6 +219,15 @@
         statsBuilder.setErrorCode(errorCodeToEnum(errCode));
     }
 
+    private DataUsage calculateDataUsage(@Nullable UpstreamType upstream) {
+        if (upstream != null && mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)
+                && isUsageSupportedForUpstreamType(upstream)) {
+            // TODO: Implement data usage calculation for the upstream type.
+            return new DataUsage(0L, 0L);
+        }
+        return new DataUsage(0L, 0L);
+    }
+
     /**
      * Update the list of upstream types and their duration whenever the current upstream type
      * changes.
@@ -193,8 +239,9 @@
 
         final long newTime = mDependencies.timeNow();
         if (mCurrentUpstream != null) {
+            final DataUsage dataUsage = calculateDataUsage(upstream);
             mUpstreamEventList.add(new RecordUpstreamEvent(mCurrentUpStreamStartTime, newTime,
-                    mCurrentUpstream));
+                    mCurrentUpstream, dataUsage));
         }
         mCurrentUpstream = upstream;
         mCurrentUpStreamStartTime = newTime;
@@ -245,13 +292,14 @@
             final long startTime = Math.max(downstreamStartTime, event.mStartTime);
             // Handle completed upstream events.
             addUpstreamEvent(upstreamEventsBuilder, startTime, event.mStopTime,
-                    event.mUpstreamType, 0L /* txBytes */, 0L /* rxBytes */);
+                    event.mUpstreamType, event.mDataUsage.mTxBytes, event.mDataUsage.mRxBytes);
         }
         final long startTime = Math.max(downstreamStartTime, mCurrentUpStreamStartTime);
         final long stopTime = mDependencies.timeNow();
         // Handle the last upstream event.
+        final DataUsage dataUsage = calculateDataUsage(mCurrentUpstream);
         addUpstreamEvent(upstreamEventsBuilder, startTime, stopTime, mCurrentUpstream,
-                0L /* txBytes */, 0L /* rxBytes */);
+                dataUsage.mTxBytes, dataUsage.mRxBytes);
         statsBuilder.setUpstreamEvents(upstreamEventsBuilder);
         statsBuilder.setDurationMillis(stopTime - downstreamStartTime);
     }
diff --git a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
index 3944a8a..1eb6255 100644
--- a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
+++ b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
@@ -26,7 +26,9 @@
 import static android.net.TetheringManager.TETHERING_ETHERNET;
 import static android.net.TetheringTester.buildTcpPacket;
 import static android.net.TetheringTester.buildUdpPacket;
+import static android.net.TetheringTester.buildUdpPackets;
 import static android.net.TetheringTester.isAddressIpv4;
+import static android.net.TetheringTester.isExpectedFragmentIpPacket;
 import static android.net.TetheringTester.isExpectedIcmpPacket;
 import static android.net.TetheringTester.isExpectedTcpPacket;
 import static android.net.TetheringTester.isExpectedUdpPacket;
@@ -58,12 +60,14 @@
 import android.os.Handler;
 import android.os.HandlerThread;
 import android.os.SystemClock;
+import android.util.ArrayMap;
 import android.util.Log;
 
 import androidx.annotation.NonNull;
 import androidx.test.platform.app.InstrumentationRegistry;
 
 import com.android.net.module.util.Struct;
+import com.android.net.module.util.structs.FragmentHeader;
 import com.android.net.module.util.structs.Ipv6Header;
 import com.android.testutils.HandlerUtils;
 import com.android.testutils.TapPacketReader;
@@ -678,6 +682,57 @@
         });
     }
 
+    protected void sendDownloadFragmentedUdpPackets(@NonNull final Inet6Address srcIp,
+            @NonNull final Inet6Address dstIp, @NonNull final TetheringTester tester,
+            @NonNull final ByteBuffer payload, int l2mtu) throws Exception {
+        final List<ByteBuffer> testPackets = buildUdpPackets(null /* srcMac */, null /* dstMac */,
+                srcIp, dstIp, REMOTE_PORT, LOCAL_PORT, payload, l2mtu);
+        assertTrue("No packet fragmentation occurs", testPackets.size() > 1);
+
+        short id = 0;
+        final ArrayMap<Short, ByteBuffer> fragmentPayloads = new ArrayMap<>();
+        for (ByteBuffer testPacket : testPackets) {
+            Struct.parse(Ipv6Header.class, testPacket);
+            final FragmentHeader fragmentHeader = Struct.parse(FragmentHeader.class, testPacket);
+            // Conversion of IPv6's fragmentOffset field to IPv4's flagsAndFragmentOffset field.
+            // IPv6 Fragment Header:
+            //   '13 bits of offset in multiples of 8' + 2 zero bits + more fragment bit
+            //      0                   1                   2                   3
+            //      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+            //     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+            //     |  Next Header  |   Reserved    |      Fragment Offset    |Res|M|
+            //     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+            //     |                         Identification                        |
+            //     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+            // IPv4 Header:
+            //   zero bit + don't frag bit + more frag bit + '13 bits of offset in multiples of 8'
+            //      0                   1                   2                   3
+            //      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+            //     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+            //     |Version|  IHL  |Type of Service|          Total Length         |
+            //     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+            //     |         Identification        |Flags|      Fragment Offset    |
+            //     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+            //     +                           . . .                               +
+            //     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+            short offset = (short) (((fragmentHeader.fragmentOffset & 0x1) << 13)
+                    | (fragmentHeader.fragmentOffset >> 3));
+            // RFC6145: for fragment id, copied from the low-order 16 bits in the identification
+            //          field in the Fragment Header.
+            id = (short) (fragmentHeader.identification & 0xffff);
+            final byte[] fragmentPayload = new byte[testPacket.remaining()];
+            testPacket.get(fragmentPayload);
+            testPacket.flip();
+            fragmentPayloads.put(offset, ByteBuffer.wrap(fragmentPayload));
+        }
+
+        final short fragId = id;
+        tester.verifyDownloadBatch(testPackets, p -> {
+            Log.d(TAG, "Packet in downstream: " + dumpHexString(p));
+            return isExpectedFragmentIpPacket(p, fragId, fragmentPayloads);
+        });
+    }
+
     protected void sendDownloadPacketTcp(@NonNull final InetAddress srcIp,
             @NonNull final InetAddress dstIp, short seq, short ack, byte tcpFlags,
             @NonNull final ByteBuffer payload, @NonNull final TetheringTester tester,
diff --git a/Tethering/tests/integration/base/android/net/TetheringTester.java b/Tethering/tests/integration/base/android/net/TetheringTester.java
index ae4ae55..b152b4c 100644
--- a/Tethering/tests/integration/base/android/net/TetheringTester.java
+++ b/Tethering/tests/integration/base/android/net/TetheringTester.java
@@ -27,6 +27,7 @@
 import static android.system.OsConstants.IPPROTO_IPV6;
 import static android.system.OsConstants.IPPROTO_TCP;
 import static android.system.OsConstants.IPPROTO_UDP;
+
 import static com.android.net.module.util.DnsPacket.ANSECTION;
 import static com.android.net.module.util.DnsPacket.DnsHeader;
 import static com.android.net.module.util.DnsPacket.DnsRecord;
@@ -53,6 +54,7 @@
 import static com.android.net.module.util.NetworkStackConstants.NEIGHBOR_ADVERTISEMENT_FLAG_OVERRIDE;
 import static com.android.net.module.util.NetworkStackConstants.NEIGHBOR_ADVERTISEMENT_FLAG_SOLICITED;
 import static com.android.net.module.util.NetworkStackConstants.TCPHDR_SYN;
+
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.fail;
 
@@ -91,6 +93,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 import java.util.Random;
 import java.util.concurrent.TimeoutException;
 import java.util.function.Predicate;
@@ -497,6 +500,51 @@
         });
     }
 
+    /**
+     * Checks if the given raw packet data represents an expected fragmented IP packet.
+     *
+     * @param rawPacket the raw packet data to check.
+     * @param id the identification field of the fragmented IP packet.
+     * @param expectedPayloads a map of fragment offsets to their corresponding payload data.
+     * @return true if the packet is a valid fragmented IP packet with matching payload fragments;
+     *         false otherwise.
+     */
+    public static boolean isExpectedFragmentIpPacket(@NonNull final byte[] rawPacket, int id,
+            @NonNull final Map<Short, ByteBuffer> expectedPayloads) {
+        final ByteBuffer buf = ByteBuffer.wrap(rawPacket);
+        try {
+            // Validate Ethernet header and IPv4 header.
+            if (!hasExpectedEtherHeader(buf, true /* isIpv4 */)) return false;
+            final Ipv4Header ipv4Header = Struct.parse(Ipv4Header.class, buf);
+            if (ipv4Header.protocol != (byte) IPPROTO_UDP) return false;
+            if (ipv4Header.id != id) return false;
+            // Validate payload data which expected at a specific fragment offset.
+            final ByteBuffer expectedPayload =
+                    expectedPayloads.get(ipv4Header.flagsAndFragmentOffset);
+            if (expectedPayload == null) return false;
+            if (buf.remaining() != expectedPayload.limit()) return false;
+            // Validate UDP header (which located in the 1st fragment).
+            // TODO: Validate the checksum field in UDP header. Currently, it'll be altered by NAT.
+            if ((ipv4Header.flagsAndFragmentOffset & 0x1FFF) == 0) {
+                final UdpHeader receivedUdpHeader = Struct.parse(UdpHeader.class, buf);
+                final UdpHeader expectedUdpHeader = Struct.parse(UdpHeader.class, expectedPayload);
+                if (receivedUdpHeader == null || expectedUdpHeader == null) return false;
+                if (receivedUdpHeader.srcPort != expectedUdpHeader.srcPort
+                        || receivedUdpHeader.dstPort != expectedUdpHeader.dstPort
+                        || receivedUdpHeader.length != expectedUdpHeader.length) {
+                    return false;
+                }
+                return true;
+            }
+            // Check the contents of the remaining payload.
+            return Arrays.equals(getRemaining(buf),
+                    getRemaining(expectedPayload.asReadOnlyBuffer()));
+        } catch (Exception e) {
+            // A failed packet parsing indicates that the packet is not a fragmented IPv4 packet.
+            return false;
+        }
+    }
+
     // |expectedPayload| is copied as read-only because the caller may reuse it.
     // See hasExpectedDnsMessage.
     public static boolean isExpectedUdpDnsPacket(@NonNull final byte[] rawPacket, boolean hasEth,
@@ -683,10 +731,10 @@
     }
 
     @NonNull
-    public static ByteBuffer buildUdpPacket(
+    public static List<ByteBuffer> buildUdpPackets(
             @Nullable final MacAddress srcMac, @Nullable final MacAddress dstMac,
             @NonNull final InetAddress srcIp, @NonNull final InetAddress dstIp,
-            short srcPort, short dstPort, @Nullable final ByteBuffer payload)
+            short srcPort, short dstPort, @Nullable final ByteBuffer payload, int l2mtu)
             throws Exception {
         final int ipProto = getIpProto(srcIp, dstIp);
         final boolean hasEther = (srcMac != null && dstMac != null);
@@ -720,7 +768,30 @@
             payload.clear();
         }
 
-        return packetBuilder.finalizePacket();
+        return l2mtu == 0
+                ? Arrays.asList(packetBuilder.finalizePacket())
+                : packetBuilder.finalizePacket(l2mtu);
+    }
+
+    /**
+     * Builds a UDP packet.
+     *
+     * @param srcMac the source MAC address.
+     * @param dstMac the destination MAC address.
+     * @param srcIp the source IP address.
+     * @param dstIp the destination IP address.
+     * @param srcPort the source port number.
+     * @param dstPort the destination port number.
+     * @param payload the optional payload data to be included in the packet.
+     * @return a ByteBuffer containing the constructed UDP packet.
+     */
+    @NonNull
+    public static ByteBuffer buildUdpPacket(
+            @Nullable final MacAddress srcMac, @Nullable final MacAddress dstMac,
+            @NonNull final InetAddress srcIp, @NonNull final InetAddress dstIp,
+            short srcPort, short dstPort, @Nullable final ByteBuffer payload)
+            throws Exception {
+        return buildUdpPackets(srcMac, dstMac, srcIp, dstIp, srcPort, dstPort, payload, 0).get(0);
     }
 
     @NonNull
@@ -994,6 +1065,28 @@
         return verifyPacketNotNull("Download fail", getDownloadPacket(filter));
     }
 
+    /**
+     * Sends a batch of download packets and verifies against a specified filtering condition.
+     *
+     * This method is designed for testing fragmented packets. All packets are sent before
+     * verification because the kernel buffers fragments until the last one is received.
+     * Captured packets are then verified against the provided filter.
+     *
+     * @param packets the list of ByteBuffers containing the packets to send.
+     * @param filter a Predicate that defines the filtering condition to apply to each received
+     *               packet. If the filter returns true for a packet's data, it is considered to
+     *               meet the verification criteria.
+     */
+    public void verifyDownloadBatch(final List<ByteBuffer> packets, final Predicate<byte[]> filter)
+            throws Exception {
+        for (ByteBuffer packet : packets) {
+            sendDownloadPacket(packet);
+        }
+        for (int i = 0; i < packets.size(); ++i) {
+            verifyPacketNotNull("Download fail", getDownloadPacket(filter));
+        }
+    }
+
     // Send DHCPDISCOVER to DHCP server to see if DHCP server is still alive to handle
     // the upcoming DHCP packets. This method should be only used when we know the DHCP
     // server has been created successfully before.
diff --git a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
index 9cdba2f..049f5f0 100644
--- a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
+++ b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
@@ -33,6 +33,9 @@
 import static com.android.net.module.util.HexDump.dumpHexString;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REPLY_TYPE;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REQUEST_TYPE;
+import static com.android.net.module.util.NetworkStackConstants.IPV4_HEADER_MIN_LEN;
+import static com.android.net.module.util.NetworkStackConstants.IPV6_HEADER_LEN;
+import static com.android.net.module.util.NetworkStackConstants.UDP_HEADER_LEN;
 import static com.android.testutils.DeviceInfoUtils.KVersion;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
@@ -62,6 +65,10 @@
 import com.android.net.module.util.BpfDump;
 import com.android.net.module.util.Ipv6Utils;
 import com.android.net.module.util.Struct;
+import com.android.net.module.util.bpf.ClatEgress4Key;
+import com.android.net.module.util.bpf.ClatEgress4Value;
+import com.android.net.module.util.bpf.ClatIngress6Key;
+import com.android.net.module.util.bpf.ClatIngress6Value;
 import com.android.net.module.util.bpf.Tether4Key;
 import com.android.net.module.util.bpf.Tether4Value;
 import com.android.net.module.util.bpf.TetherStatsKey;
@@ -122,6 +129,8 @@
     private static final int TX_UDP_PACKET_SIZE = 30;
     private static final int TX_UDP_PACKET_COUNT = 123;
 
+    private static final String DUMPSYS_CLAT_RAWMAP_EGRESS4_ARG = "clatEgress4RawBpfMap";
+    private static final String DUMPSYS_CLAT_RAWMAP_INGRESS6_ARG = "clatIngress6RawBpfMap";
     private static final String DUMPSYS_TETHERING_RAWMAP_ARG = "bpfRawMap";
     private static final String DUMPSYS_RAWMAP_ARG_STATS = "--stats";
     private static final String DUMPSYS_RAWMAP_ARG_UPSTREAM4 = "--upstream4";
@@ -901,11 +910,10 @@
 
     @NonNull
     private <K extends Struct, V extends Struct> HashMap<K, V> dumpAndParseRawMap(
-            Class<K> keyClass, Class<V> valueClass, @NonNull String mapArg)
+            Class<K> keyClass, Class<V> valueClass, @NonNull String service, @NonNull String[] args)
             throws Exception {
-        final String[] args = new String[] {DUMPSYS_TETHERING_RAWMAP_ARG, mapArg};
         final String rawMapStr = runAsShell(DUMP, () ->
-                DumpTestUtils.dumpService(Context.TETHERING_SERVICE, args));
+                DumpTestUtils.dumpService(service, args));
         final HashMap<K, V> map = new HashMap<>();
 
         for (final String line : rawMapStr.split(LINE_DELIMITER)) {
@@ -918,10 +926,10 @@
 
     @Nullable
     private <K extends Struct, V extends Struct> HashMap<K, V> pollRawMapFromDump(
-            Class<K> keyClass, Class<V> valueClass, @NonNull String mapArg)
+            Class<K> keyClass, Class<V> valueClass, @NonNull String service, @NonNull String[] args)
             throws Exception {
         for (int retryCount = 0; retryCount < DUMP_POLLING_MAX_RETRY; retryCount++) {
-            final HashMap<K, V> map = dumpAndParseRawMap(keyClass, valueClass, mapArg);
+            final HashMap<K, V> map = dumpAndParseRawMap(keyClass, valueClass, service, args);
             if (!map.isEmpty()) return map;
 
             Thread.sleep(DUMP_POLLING_INTERVAL_MS);
@@ -977,8 +985,10 @@
         Thread.sleep(UDP_STREAM_SLACK_MS);
 
         // [1] Verify IPv4 upstream rule map.
+        final String[] upstreamArgs = new String[] {DUMPSYS_TETHERING_RAWMAP_ARG,
+                DUMPSYS_RAWMAP_ARG_UPSTREAM4};
         final HashMap<Tether4Key, Tether4Value> upstreamMap = pollRawMapFromDump(
-                Tether4Key.class, Tether4Value.class, DUMPSYS_RAWMAP_ARG_UPSTREAM4);
+                Tether4Key.class, Tether4Value.class, Context.TETHERING_SERVICE, upstreamArgs);
         assertNotNull(upstreamMap);
         assertEquals(1, upstreamMap.size());
 
@@ -1017,8 +1027,10 @@
         }
 
         // Dump stats map to verify.
+        final String[] statsArgs = new String[] {DUMPSYS_TETHERING_RAWMAP_ARG,
+                DUMPSYS_RAWMAP_ARG_STATS};
         final HashMap<TetherStatsKey, TetherStatsValue> statsMap = pollRawMapFromDump(
-                TetherStatsKey.class, TetherStatsValue.class, DUMPSYS_RAWMAP_ARG_STATS);
+                TetherStatsKey.class, TetherStatsValue.class, Context.TETHERING_SERVICE, statsArgs);
         assertNotNull(statsMap);
         assertEquals(1, statsMap.size());
 
@@ -1053,4 +1065,102 @@
 
         runUdp4Test();
     }
+
+    private ClatEgress4Value getClatEgress4Value() throws Exception {
+        // Command: dumpsys connectivity clatEgress4RawBpfMap
+        final String[] args = new String[] {DUMPSYS_CLAT_RAWMAP_EGRESS4_ARG};
+        final HashMap<ClatEgress4Key, ClatEgress4Value> egress4Map = pollRawMapFromDump(
+                ClatEgress4Key.class, ClatEgress4Value.class, Context.CONNECTIVITY_SERVICE, args);
+        assertNotNull(egress4Map);
+        assertEquals(1, egress4Map.size());
+        return egress4Map.entrySet().iterator().next().getValue();
+    }
+
+    private ClatIngress6Value getClatIngress6Value() throws Exception {
+        // Command: dumpsys connectivity clatIngress6RawBpfMap
+        final String[] args = new String[] {DUMPSYS_CLAT_RAWMAP_INGRESS6_ARG};
+        final HashMap<ClatIngress6Key, ClatIngress6Value> ingress6Map = pollRawMapFromDump(
+                ClatIngress6Key.class, ClatIngress6Value.class, Context.CONNECTIVITY_SERVICE, args);
+        assertNotNull(ingress6Map);
+        assertEquals(1, ingress6Map.size());
+        return ingress6Map.entrySet().iterator().next().getValue();
+    }
+
+    /**
+     * Test network topology:
+     *
+     *            public network (rawip)                 private network
+     *                      |         UE (CLAT support)         |
+     * +---------------+    V    +------------+------------+    V    +------------+
+     * | NAT64 Gateway +---------+  Upstream  | Downstream +---------+   Client   |
+     * +---------------+         +------------+------------+         +------------+
+     * remote ip                 public ip                           private ip
+     * [64:ff9b::808:808]:443    [clat ipv6]:9876                    [TetheredDevice ipv4]:9876
+     *
+     * Note that CLAT IPv6 address is generated by ClatCoordinator. Get the CLAT IPv6 address by
+     * sending out an IPv4 packet and extracting the source address from CLAT translated IPv6
+     * packet.
+     */
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testTetherClatBpfOffloadUdp() throws Exception {
+        assumeKernelSupportBpfOffloadUdpV4();
+
+        // CLAT only starts on IPv6 only network.
+        final TetheringTester tester = initTetheringTester(toList(TEST_IP6_ADDR),
+                toList(TEST_IP6_DNS));
+        final TetheredDevice tethered = tester.createTetheredDevice(TEST_MAC, true /* hasIpv6 */);
+
+        // Get CLAT IPv6 address.
+        final Inet6Address clatIp6 = getClatIpv6Address(tester, tethered);
+
+        // Get current values before sending packets.
+        final ClatEgress4Value oldEgress4 = getClatEgress4Value();
+        final ClatIngress6Value oldIngress6 = getClatIngress6Value();
+
+        // Send an IPv4 UDP packet in original direction.
+        // IPv4 packet -- CLAT translation --> IPv6 packet
+        for (int i = 0; i < TX_UDP_PACKET_COUNT; i++) {
+            sendUploadPacketUdp(tethered.macAddr, tethered.routerMacAddr, tethered.ipv4Addr,
+                    REMOTE_IP4_ADDR, tester, true /* is4To6 */);
+        }
+
+        // Send an IPv6 UDP packet in reply direction.
+        // IPv6 packet -- CLAT translation --> IPv4 packet
+        for (int i = 0; i < RX_UDP_PACKET_COUNT; i++) {
+            sendDownloadPacketUdp(REMOTE_NAT64_ADDR, clatIp6, tester, true /* is6To4 */);
+        }
+
+        // Send fragmented IPv6 UDP packets in the reply direction.
+        // IPv6 frament packet -- CLAT translation --> IPv4 fragment packet
+        final int payloadLen = 1500;
+        final int l2mtu = 1000;
+        final int fragPktCnt = 2; // 1500 bytes of UDP payload were fragmented into two packets.
+        final long fragRxBytes = payloadLen + UDP_HEADER_LEN + fragPktCnt * IPV4_HEADER_MIN_LEN;
+        final byte[] payload = new byte[payloadLen];
+        // Initialize the payload with random bytes.
+        Random random = new Random();
+        random.nextBytes(payload);
+        sendDownloadFragmentedUdpPackets(REMOTE_NAT64_ADDR, clatIp6, tester,
+                ByteBuffer.wrap(payload), l2mtu);
+
+        // After sending test packets, get stats again to verify their differences.
+        final ClatEgress4Value newEgress4 = getClatEgress4Value();
+        final ClatIngress6Value newIngress6 = getClatIngress6Value();
+
+        assertEquals(RX_UDP_PACKET_COUNT + fragPktCnt, newIngress6.packets - oldIngress6.packets);
+        assertEquals(RX_UDP_PACKET_COUNT * RX_UDP_PACKET_SIZE + fragRxBytes,
+                newIngress6.bytes - oldIngress6.bytes);
+        assertEquals(TX_UDP_PACKET_COUNT, newEgress4.packets - oldEgress4.packets);
+        // The increase in egress traffic equals the expected size of the translated UDP packets.
+        // Calculation:
+        // - Original UDP packet was TX_UDP_PACKET_SIZE bytes (IPv4 header + UDP header + payload).
+        // - After CLAT translation, each packet is now:
+        //     IPv6 header + unchanged UDP header + unchanged payload
+        // Therefore, the total size of the translated UDP packet should be:
+        //     TX_UDP_PACKET_SIZE + IPV6_HEADER_LEN - IPV4_HEADER_MIN_LEN
+        assertEquals(
+                TX_UDP_PACKET_COUNT * (TX_UDP_PACKET_SIZE + IPV6_HEADER_LEN - IPV4_HEADER_MIN_LEN),
+                newEgress4.bytes - oldEgress4.bytes);
+    }
 }
diff --git a/staticlibs/native/bpf_headers/Android.bp b/bpf/headers/Android.bp
similarity index 100%
rename from staticlibs/native/bpf_headers/Android.bp
rename to bpf/headers/Android.bp
diff --git a/staticlibs/native/bpf_headers/BpfMapTest.cpp b/bpf/headers/BpfMapTest.cpp
similarity index 100%
rename from staticlibs/native/bpf_headers/BpfMapTest.cpp
rename to bpf/headers/BpfMapTest.cpp
diff --git a/staticlibs/native/bpf_headers/BpfRingbufTest.cpp b/bpf/headers/BpfRingbufTest.cpp
similarity index 100%
rename from staticlibs/native/bpf_headers/BpfRingbufTest.cpp
rename to bpf/headers/BpfRingbufTest.cpp
diff --git a/staticlibs/native/bpf_headers/TEST_MAPPING b/bpf/headers/TEST_MAPPING
similarity index 100%
rename from staticlibs/native/bpf_headers/TEST_MAPPING
rename to bpf/headers/TEST_MAPPING
diff --git a/staticlibs/native/bpf_headers/include/bpf/BpfClassic.h b/bpf/headers/include/bpf/BpfClassic.h
similarity index 100%
rename from staticlibs/native/bpf_headers/include/bpf/BpfClassic.h
rename to bpf/headers/include/bpf/BpfClassic.h
diff --git a/staticlibs/native/bpf_headers/include/bpf/BpfMap.h b/bpf/headers/include/bpf/BpfMap.h
similarity index 100%
rename from staticlibs/native/bpf_headers/include/bpf/BpfMap.h
rename to bpf/headers/include/bpf/BpfMap.h
diff --git a/staticlibs/native/bpf_headers/include/bpf/BpfRingbuf.h b/bpf/headers/include/bpf/BpfRingbuf.h
similarity index 100%
rename from staticlibs/native/bpf_headers/include/bpf/BpfRingbuf.h
rename to bpf/headers/include/bpf/BpfRingbuf.h
diff --git a/staticlibs/native/bpf_headers/include/bpf/BpfUtils.h b/bpf/headers/include/bpf/BpfUtils.h
similarity index 100%
rename from staticlibs/native/bpf_headers/include/bpf/BpfUtils.h
rename to bpf/headers/include/bpf/BpfUtils.h
diff --git a/staticlibs/native/bpf_headers/include/bpf/KernelUtils.h b/bpf/headers/include/bpf/KernelUtils.h
similarity index 100%
rename from staticlibs/native/bpf_headers/include/bpf/KernelUtils.h
rename to bpf/headers/include/bpf/KernelUtils.h
diff --git a/staticlibs/native/bpf_headers/include/bpf/WaitForProgsLoaded.h b/bpf/headers/include/bpf/WaitForProgsLoaded.h
similarity index 100%
rename from staticlibs/native/bpf_headers/include/bpf/WaitForProgsLoaded.h
rename to bpf/headers/include/bpf/WaitForProgsLoaded.h
diff --git a/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h b/bpf/headers/include/bpf_helpers.h
similarity index 100%
rename from staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h
rename to bpf/headers/include/bpf_helpers.h
diff --git a/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h b/bpf/headers/include/bpf_map_def.h
similarity index 100%
rename from staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h
rename to bpf/headers/include/bpf_map_def.h
diff --git a/netbpfload/Android.bp b/bpf/loader/Android.bp
similarity index 100%
rename from netbpfload/Android.bp
rename to bpf/loader/Android.bp
diff --git a/netbpfload/NetBpfLoad.cpp b/bpf/loader/NetBpfLoad.cpp
similarity index 98%
rename from netbpfload/NetBpfLoad.cpp
rename to bpf/loader/NetBpfLoad.cpp
index 00362b4..22f12d1 100644
--- a/netbpfload/NetBpfLoad.cpp
+++ b/bpf/loader/NetBpfLoad.cpp
@@ -60,7 +60,7 @@
 
 #include "BpfSyscallWrappers.h"
 #include "bpf/BpfUtils.h"
-#include "bpf/bpf_map_def.h"
+#include "bpf_map_def.h"
 
 using android::base::EndsWith;
 using android::base::StartsWith;
@@ -372,7 +372,7 @@
         value += static_cast<unsigned char>(theBytes[1]);
         value <<= 8;
         value += static_cast<unsigned char>(theBytes[0]);
-        ALOGI("Section %s value is %u [0x%x]", name, value, value);
+        ALOGD("Section %s value is %u [0x%x]", name, value, value);
         return value;
     }
 }
@@ -673,28 +673,28 @@
         if (md[i].zero != 0) abort();
 
         if (bpfloader_ver < md[i].bpfloader_min_ver) {
-            ALOGI("skipping map %s which requires bpfloader min ver 0x%05x", mapNames[i].c_str(),
+            ALOGD("skipping map %s which requires bpfloader min ver 0x%05x", mapNames[i].c_str(),
                   md[i].bpfloader_min_ver);
             mapFds.push_back(unique_fd());
             continue;
         }
 
         if (bpfloader_ver >= md[i].bpfloader_max_ver) {
-            ALOGI("skipping map %s which requires bpfloader max ver 0x%05x", mapNames[i].c_str(),
+            ALOGD("skipping map %s which requires bpfloader max ver 0x%05x", mapNames[i].c_str(),
                   md[i].bpfloader_max_ver);
             mapFds.push_back(unique_fd());
             continue;
         }
 
         if (kvers < md[i].min_kver) {
-            ALOGI("skipping map %s which requires kernel version 0x%x >= 0x%x",
+            ALOGD("skipping map %s which requires kernel version 0x%x >= 0x%x",
                   mapNames[i].c_str(), kvers, md[i].min_kver);
             mapFds.push_back(unique_fd());
             continue;
         }
 
         if (kvers >= md[i].max_kver) {
-            ALOGI("skipping map %s which requires kernel version 0x%x < 0x%x",
+            ALOGD("skipping map %s which requires kernel version 0x%x < 0x%x",
                   mapNames[i].c_str(), kvers, md[i].max_kver);
             mapFds.push_back(unique_fd());
             continue;
@@ -702,7 +702,7 @@
 
         if ((md[i].ignore_on_eng && isEng()) || (md[i].ignore_on_user && isUser()) ||
             (md[i].ignore_on_userdebug && isUserdebug())) {
-            ALOGI("skipping map %s which is ignored on %s builds", mapNames[i].c_str(),
+            ALOGD("skipping map %s which is ignored on %s builds", mapNames[i].c_str(),
                   getBuildType().c_str());
             mapFds.push_back(unique_fd());
             continue;
@@ -713,7 +713,7 @@
             (isX86() && isKernel32Bit() && md[i].ignore_on_x86_32) ||
             (isX86() && isKernel64Bit() && md[i].ignore_on_x86_64) ||
             (isRiscV() && md[i].ignore_on_riscv64)) {
-            ALOGI("skipping map %s which is ignored on %s", mapNames[i].c_str(),
+            ALOGD("skipping map %s which is ignored on %s", mapNames[i].c_str(),
                   describeArch());
             mapFds.push_back(unique_fd());
             continue;
@@ -1109,19 +1109,19 @@
 
     // inclusive lower bound check
     if (bpfloader_ver < bpfLoaderMinVer) {
-        ALOGI("BpfLoader version 0x%05x ignoring ELF object %s with min ver 0x%05x",
+        ALOGD("BpfLoader version 0x%05x ignoring ELF object %s with min ver 0x%05x",
               bpfloader_ver, elfPath, bpfLoaderMinVer);
         return 0;
     }
 
     // exclusive upper bound check
     if (bpfloader_ver >= bpfLoaderMaxVer) {
-        ALOGI("BpfLoader version 0x%05x ignoring ELF object %s with max ver 0x%05x",
+        ALOGD("BpfLoader version 0x%05x ignoring ELF object %s with max ver 0x%05x",
               bpfloader_ver, elfPath, bpfLoaderMaxVer);
         return 0;
     }
 
-    ALOGI("BpfLoader version 0x%05x processing ELF object %s with ver [0x%05x,0x%05x)",
+    ALOGD("BpfLoader version 0x%05x processing ELF object %s with ver [0x%05x,0x%05x)",
           bpfloader_ver, elfPath, bpfLoaderMinVer, bpfLoaderMaxVer);
 
     ret = readCodeSections(elfFile, cs);
diff --git a/netbpfload/initrc-doc/README.txt b/bpf/loader/initrc-doc/README.txt
similarity index 100%
rename from netbpfload/initrc-doc/README.txt
rename to bpf/loader/initrc-doc/README.txt
diff --git a/netbpfload/initrc-doc/bpfloader-sdk30-11-R.rc b/bpf/loader/initrc-doc/bpfloader-sdk30-11-R.rc
similarity index 100%
rename from netbpfload/initrc-doc/bpfloader-sdk30-11-R.rc
rename to bpf/loader/initrc-doc/bpfloader-sdk30-11-R.rc
diff --git a/netbpfload/initrc-doc/bpfloader-sdk31-12-S.rc b/bpf/loader/initrc-doc/bpfloader-sdk31-12-S.rc
similarity index 100%
rename from netbpfload/initrc-doc/bpfloader-sdk31-12-S.rc
rename to bpf/loader/initrc-doc/bpfloader-sdk31-12-S.rc
diff --git a/netbpfload/initrc-doc/bpfloader-sdk33-13-T.rc b/bpf/loader/initrc-doc/bpfloader-sdk33-13-T.rc
similarity index 100%
rename from netbpfload/initrc-doc/bpfloader-sdk33-13-T.rc
rename to bpf/loader/initrc-doc/bpfloader-sdk33-13-T.rc
diff --git a/netbpfload/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc b/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
similarity index 100%
rename from netbpfload/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
rename to bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
diff --git a/netbpfload/initrc-doc/bpfloader-sdk34-14-U.rc b/bpf/loader/initrc-doc/bpfloader-sdk34-14-U.rc
similarity index 100%
rename from netbpfload/initrc-doc/bpfloader-sdk34-14-U.rc
rename to bpf/loader/initrc-doc/bpfloader-sdk34-14-U.rc
diff --git a/netbpfload/netbpfload.33rc b/bpf/loader/netbpfload.33rc
similarity index 100%
rename from netbpfload/netbpfload.33rc
rename to bpf/loader/netbpfload.33rc
diff --git a/netbpfload/netbpfload.35rc b/bpf/loader/netbpfload.35rc
similarity index 100%
rename from netbpfload/netbpfload.35rc
rename to bpf/loader/netbpfload.35rc
diff --git a/netbpfload/netbpfload.rc b/bpf/loader/netbpfload.rc
similarity index 100%
rename from netbpfload/netbpfload.rc
rename to bpf/loader/netbpfload.rc
diff --git a/bpf_progs/Android.bp b/bpf/progs/Android.bp
similarity index 100%
rename from bpf_progs/Android.bp
rename to bpf/progs/Android.bp
diff --git a/bpf_progs/block.c b/bpf/progs/block.c
similarity index 100%
rename from bpf_progs/block.c
rename to bpf/progs/block.c
diff --git a/bpf_progs/bpf_net_helpers.h b/bpf/progs/bpf_net_helpers.h
similarity index 100%
rename from bpf_progs/bpf_net_helpers.h
rename to bpf/progs/bpf_net_helpers.h
diff --git a/bpf_progs/clat_mark.h b/bpf/progs/clat_mark.h
similarity index 100%
rename from bpf_progs/clat_mark.h
rename to bpf/progs/clat_mark.h
diff --git a/bpf_progs/clatd.c b/bpf/progs/clatd.c
similarity index 100%
rename from bpf_progs/clatd.c
rename to bpf/progs/clatd.c
diff --git a/bpf_progs/clatd.h b/bpf/progs/clatd.h
similarity index 100%
rename from bpf_progs/clatd.h
rename to bpf/progs/clatd.h
diff --git a/bpf_progs/dscpPolicy.c b/bpf/progs/dscpPolicy.c
similarity index 96%
rename from bpf_progs/dscpPolicy.c
rename to bpf/progs/dscpPolicy.c
index 93542ee..4bdd3ed 100644
--- a/bpf_progs/dscpPolicy.c
+++ b/bpf/progs/dscpPolicy.c
@@ -28,7 +28,7 @@
 DEFINE_BPF_MAP_GRW(ipv4_dscp_policies_map, ARRAY, uint32_t, DscpPolicy, MAX_POLICIES, AID_SYSTEM)
 DEFINE_BPF_MAP_GRW(ipv6_dscp_policies_map, ARRAY, uint32_t, DscpPolicy, MAX_POLICIES, AID_SYSTEM)
 
-static inline __always_inline void match_policy(struct __sk_buff* skb, bool ipv4) {
+static inline __always_inline void match_policy(struct __sk_buff* skb, const bool ipv4) {
     void* data = (void*)(long)skb->data;
     const void* data_end = (void*)(long)skb->data_end;
 
@@ -145,8 +145,10 @@
             policy = bpf_ipv6_dscp_policies_map_lookup_elem(&key);
         }
 
-        // If the policy lookup failed, just continue (this should not ever happen)
-        if (!policy) continue;
+        // Lookup failure cannot happen on an array with MAX_POLICIES entries.
+        // While 'continue' would make logical sense here, 'return' should be
+        // easier for the verifier to analyze.
+        if (!policy) return;
 
         // If policy iface index does not match skb, then skip to next policy.
         if (policy->ifindex != skb->ifindex) continue;
diff --git a/bpf_progs/dscpPolicy.h b/bpf/progs/dscpPolicy.h
similarity index 100%
rename from bpf_progs/dscpPolicy.h
rename to bpf/progs/dscpPolicy.h
diff --git a/bpf_progs/netd.c b/bpf/progs/netd.c
similarity index 100%
rename from bpf_progs/netd.c
rename to bpf/progs/netd.c
diff --git a/bpf_progs/netd.h b/bpf/progs/netd.h
similarity index 100%
rename from bpf_progs/netd.h
rename to bpf/progs/netd.h
diff --git a/bpf_progs/offload.c b/bpf/progs/offload.c
similarity index 100%
rename from bpf_progs/offload.c
rename to bpf/progs/offload.c
diff --git a/bpf_progs/offload.h b/bpf/progs/offload.h
similarity index 100%
rename from bpf_progs/offload.h
rename to bpf/progs/offload.h
diff --git a/bpf_progs/offload@mainline.c b/bpf/progs/offload@mainline.c
similarity index 100%
rename from bpf_progs/offload@mainline.c
rename to bpf/progs/offload@mainline.c
diff --git a/bpf_progs/test.c b/bpf/progs/test.c
similarity index 100%
rename from bpf_progs/test.c
rename to bpf/progs/test.c
diff --git a/bpf_progs/test@mainline.c b/bpf/progs/test@mainline.c
similarity index 100%
rename from bpf_progs/test@mainline.c
rename to bpf/progs/test@mainline.c
diff --git a/staticlibs/native/bpf_syscall_wrappers/Android.bp b/bpf/syscall_wrappers/Android.bp
similarity index 100%
rename from staticlibs/native/bpf_syscall_wrappers/Android.bp
rename to bpf/syscall_wrappers/Android.bp
diff --git a/staticlibs/native/bpf_syscall_wrappers/include/BpfSyscallWrappers.h b/bpf/syscall_wrappers/include/BpfSyscallWrappers.h
similarity index 100%
rename from staticlibs/native/bpf_syscall_wrappers/include/BpfSyscallWrappers.h
rename to bpf/syscall_wrappers/include/BpfSyscallWrappers.h
diff --git a/common/OWNERS b/common/OWNERS
index e7f5d11..989d286 100644
--- a/common/OWNERS
+++ b/common/OWNERS
@@ -1 +1,2 @@
 per-file thread_flags.aconfig = file:platform/packages/modules/Connectivity:main:/thread/OWNERS
+per-file networksecurity_flags.aconfig = file:platform/packages/modules/Connectivity:main:/networksecurity/OWNERS
\ No newline at end of file
diff --git a/common/flags.aconfig b/common/flags.aconfig
index 1b0da4e..45cbb78 100644
--- a/common/flags.aconfig
+++ b/common/flags.aconfig
@@ -10,6 +10,7 @@
   namespace: "android_core_networking"
   description: "Set data saver through ConnectivityManager API"
   bug: "297836825"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -18,6 +19,7 @@
   namespace: "android_core_networking"
   description: "This flag controls whether isUidNetworkingBlocked is supported"
   bug: "297836825"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -26,6 +28,7 @@
   namespace: "android_core_networking"
   description: "Block network access for apps in a low importance background state"
   bug: "304347838"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -34,6 +37,7 @@
   namespace: "android_core_networking_ipsec"
   description: "The flag controls the access for getIpSecTransformState and IpSecTransformState"
   bug: "308011229"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -42,6 +46,7 @@
   namespace: "android_core_networking"
   description: "The flag controls the access for the parcelable TetheringRequest with getSoftApConfiguration/setSoftApConfiguration API"
   bug: "216524590"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -50,6 +55,7 @@
   namespace: "android_core_networking"
   description: "Flag for API to support requesting restricted wifi"
   bug: "315835605"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -58,6 +64,7 @@
   namespace: "android_core_networking"
   description: "Flag for local network capability API"
   bug: "313000440"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -66,6 +73,7 @@
   namespace: "android_core_networking"
   description: "Flag for satellite transport API"
   bug: "320514105"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -74,6 +82,7 @@
   namespace: "android_core_networking"
   description: "Flag for API to support nsd subtypes"
   bug: "265095929"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -82,6 +91,7 @@
   namespace: "android_core_networking"
   description: "Flag for API to register nsd offload engine"
   bug: "301713539"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -90,6 +100,7 @@
   namespace: "android_core_networking"
   description: "Flag for metered network firewall chain API"
   bug: "332628891"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -98,6 +109,7 @@
   namespace: "android_core_networking"
   description: "Flag for oem deny chains blocked reasons API"
   bug: "328732146"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -106,6 +118,7 @@
   namespace: "android_core_networking"
   description: "Flag for BLOCKED_REASON_NETWORK_RESTRICTED API"
   bug: "339559837"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -114,6 +127,7 @@
   namespace: "android_core_networking"
   description: "Flag for NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED API"
   bug: "343823469"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -122,6 +136,7 @@
   namespace: "android_core_networking"
   description: "Flag for introducing TETHERING_VIRTUAL type"
   bug: "340376953"
+  is_fixed_read_only: true
 }
 
 flag {
@@ -130,4 +145,5 @@
   namespace: "android_core_networking"
   description: "Flag for NetworkStats#addEntries API"
   bug: "335680025"
+  is_fixed_read_only: true
 }
diff --git a/common/networksecurity_flags.aconfig b/common/networksecurity_flags.aconfig
index ef8ffcd..6438ba4 100644
--- a/common/networksecurity_flags.aconfig
+++ b/common/networksecurity_flags.aconfig
@@ -6,4 +6,5 @@
     namespace: "network_security"
     description: "Enable service for certificate transparency log list data"
     bug: "319829948"
+    is_fixed_read_only: true
 }
diff --git a/networksecurity/TEST_MAPPING b/networksecurity/TEST_MAPPING
new file mode 100644
index 0000000..20ecbce
--- /dev/null
+++ b/networksecurity/TEST_MAPPING
@@ -0,0 +1,7 @@
+{
+  "postsubmit": [
+    {
+      "name": "NetworkSecurityUnitTests"
+    }
+  ]
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
new file mode 100644
index 0000000..f35b163
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import android.app.DownloadManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.Uri;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/** Helper class to download certificate transparency log files. */
+class CertificateTransparencyDownloader extends BroadcastReceiver {
+
+    private static final String TAG = "CertificateTransparencyDownloader";
+
+    private final Context mContext;
+    private final DataStore mDataStore;
+    private final DownloadHelper mDownloadHelper;
+    private final CertificateTransparencyInstaller mInstaller;
+
+    @VisibleForTesting
+    CertificateTransparencyDownloader(
+            Context context,
+            DataStore dataStore,
+            DownloadHelper downloadHelper,
+            CertificateTransparencyInstaller installer) {
+        mContext = context;
+        mDataStore = dataStore;
+        mDownloadHelper = downloadHelper;
+        mInstaller = installer;
+    }
+
+    CertificateTransparencyDownloader(Context context, DataStore dataStore) {
+        this(
+                context,
+                dataStore,
+                new DownloadHelper(context),
+                new CertificateTransparencyInstaller());
+    }
+
+    void registerReceiver() {
+        IntentFilter intentFilter = new IntentFilter();
+        intentFilter.addAction(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
+        mContext.registerReceiver(this, intentFilter);
+
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyDownloader initialized successfully");
+        }
+    }
+
+    void startMetadataDownload(String metadataUrl) {
+        long downloadId = download(metadataUrl);
+        if (downloadId == -1) {
+            Log.e(TAG, "Metadata download request failed for " + metadataUrl);
+            return;
+        }
+        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, downloadId);
+        mDataStore.store();
+    }
+
+    void startContentDownload(String contentUrl) {
+        long downloadId = download(contentUrl);
+        if (downloadId == -1) {
+            Log.e(TAG, "Content download request failed for " + contentUrl);
+            return;
+        }
+        mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, downloadId);
+        mDataStore.store();
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        String action = intent.getAction();
+        if (!DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(action)) {
+            Log.w(TAG, "Received unexpected broadcast with action " + action);
+            return;
+        }
+
+        long completedId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
+        if (completedId == -1) {
+            Log.e(TAG, "Invalid completed download Id");
+            return;
+        }
+
+        if (isMetadataDownloadId(completedId)) {
+            handleMetadataDownloadCompleted(completedId);
+            return;
+        }
+
+        if (isContentDownloadId(completedId)) {
+            handleContentDownloadCompleted(completedId);
+            return;
+        }
+
+        Log.e(TAG, "Download id " + completedId + " is neither metadata nor content.");
+    }
+
+    private void handleMetadataDownloadCompleted(long downloadId) {
+        if (!mDownloadHelper.isSuccessful(downloadId)) {
+            Log.w(TAG, "Metadata download failed.");
+            // TODO: re-attempt download
+            return;
+        }
+
+        startContentDownload(mDataStore.getProperty(Config.CONTENT_URL_PENDING));
+    }
+
+    private void handleContentDownloadCompleted(long downloadId) {
+        if (!mDownloadHelper.isSuccessful(downloadId)) {
+            Log.w(TAG, "Content download failed.");
+            // TODO: re-attempt download
+            return;
+        }
+
+        Uri contentUri = getContentDownloadUri();
+        Uri metadataUri = getMetadataDownloadUri();
+        if (contentUri == null || metadataUri == null) {
+            Log.e(TAG, "Invalid URIs");
+            return;
+        }
+
+        // TODO: 1. verify file signature, 2. validate file content.
+
+        String version = mDataStore.getProperty(Config.VERSION_PENDING);
+        String contentUrl = mDataStore.getProperty(Config.CONTENT_URL_PENDING);
+        String metadataUrl = mDataStore.getProperty(Config.METADATA_URL_PENDING);
+        boolean success = false;
+        try (InputStream inputStream = mContext.getContentResolver().openInputStream(contentUri)) {
+            success = mInstaller.install(inputStream, version);
+        } catch (IOException e) {
+            Log.e(TAG, "Could not install new content", e);
+            return;
+        }
+
+        if (success) {
+            // Update information about the stored version on successful install.
+            mDataStore.setProperty(Config.VERSION, version);
+            mDataStore.setProperty(Config.CONTENT_URL, contentUrl);
+            mDataStore.setProperty(Config.METADATA_URL, metadataUrl);
+            mDataStore.store();
+        }
+    }
+
+    private long download(String url) {
+        try {
+            return mDownloadHelper.startDownload(url);
+        } catch (IllegalArgumentException e) {
+            Log.e(TAG, "Download request failed", e);
+            return -1;
+        }
+    }
+
+    @VisibleForTesting
+    boolean isMetadataDownloadId(long downloadId) {
+        return mDataStore.getPropertyLong(Config.METADATA_URL_KEY, -1) == downloadId;
+    }
+
+    @VisibleForTesting
+    boolean isContentDownloadId(long downloadId) {
+        return mDataStore.getPropertyLong(Config.CONTENT_URL_KEY, -1) == downloadId;
+    }
+
+    private Uri getMetadataDownloadUri() {
+        return mDownloadHelper.getUri(mDataStore.getPropertyLong(Config.METADATA_URL_KEY, -1));
+    }
+
+    private Uri getContentDownloadUri() {
+        return mDownloadHelper.getUri(mDataStore.getPropertyLong(Config.CONTENT_URL_KEY, -1));
+    }
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
index 8dd5951..fdac434 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
@@ -30,17 +30,25 @@
 /** Listener class for the Certificate Transparency Phenotype flags. */
 class CertificateTransparencyFlagsListener implements DeviceConfig.OnPropertiesChangedListener {
 
-    private static final String TAG = "CertificateTransparency";
+    private static final String TAG = "CertificateTransparencyFlagsListener";
 
-    private static final String VERSION = "version";
-    private static final String CONTENT_URL = "content_url";
-    private static final String METADATA_URL = "metadata_url";
+    private final DataStore mDataStore;
+    private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
 
-    CertificateTransparencyFlagsListener(Context context) {}
+    CertificateTransparencyFlagsListener(Context context) {
+        mDataStore = new DataStore(Config.PREFERENCES_FILE);
+        mCertificateTransparencyDownloader =
+                new CertificateTransparencyDownloader(context, mDataStore);
+    }
 
     void initialize() {
+        mDataStore.load();
+        mCertificateTransparencyDownloader.registerReceiver();
         DeviceConfig.addOnPropertiesChangedListener(
                 NAMESPACE_TETHERING, Executors.newSingleThreadExecutor(), this);
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyFlagsListener initialized successfully");
+        }
         // TODO: handle property changes triggering on boot before registering this listener.
     }
 
@@ -50,18 +58,38 @@
             return;
         }
 
-        String newVersion = DeviceConfig.getString(NAMESPACE_TETHERING, VERSION, "");
-        String newContentUrl = DeviceConfig.getString(NAMESPACE_TETHERING, CONTENT_URL, "");
-        String newMetadataUrl = DeviceConfig.getString(NAMESPACE_TETHERING, METADATA_URL, "");
+        String newVersion = DeviceConfig.getString(NAMESPACE_TETHERING, Config.VERSION, "");
+        String newContentUrl = DeviceConfig.getString(NAMESPACE_TETHERING, Config.CONTENT_URL, "");
+        String newMetadataUrl =
+                DeviceConfig.getString(NAMESPACE_TETHERING, Config.METADATA_URL, "");
         if (TextUtils.isEmpty(newVersion)
                 || TextUtils.isEmpty(newContentUrl)
                 || TextUtils.isEmpty(newMetadataUrl)) {
             return;
         }
 
-        Log.d(TAG, "newVersion=" + newVersion);
-        Log.d(TAG, "newContentUrl=" + newContentUrl);
-        Log.d(TAG, "newMetadataUrl=" + newMetadataUrl);
-        // TODO: start download of URLs.
+        if (Config.DEBUG) {
+            Log.d(TAG, "newVersion=" + newVersion);
+            Log.d(TAG, "newContentUrl=" + newContentUrl);
+            Log.d(TAG, "newMetadataUrl=" + newMetadataUrl);
+        }
+
+        String oldVersion = mDataStore.getProperty(Config.VERSION);
+        String oldContentUrl = mDataStore.getProperty(Config.CONTENT_URL);
+        String oldMetadataUrl = mDataStore.getProperty(Config.METADATA_URL);
+
+        if (TextUtils.equals(newVersion, oldVersion)
+                && TextUtils.equals(newContentUrl, oldContentUrl)
+                && TextUtils.equals(newMetadataUrl, oldMetadataUrl)) {
+            Log.i(TAG, "No flag changed, ignoring update");
+            return;
+        }
+
+        mDataStore.setProperty(Config.VERSION_PENDING, newVersion);
+        mDataStore.setProperty(Config.CONTENT_URL_PENDING, newContentUrl);
+        mDataStore.setProperty(Config.METADATA_URL_PENDING, newMetadataUrl);
+        mDataStore.store();
+
+        mCertificateTransparencyDownloader.startMetadataDownload(newMetadataUrl);
     }
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java
new file mode 100644
index 0000000..82dcadf
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import android.annotation.SuppressLint;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.util.Log;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+
+/** Installer of CT log lists. */
+public class CertificateTransparencyInstaller {
+
+    private static final String TAG = "CertificateTransparencyInstaller";
+    private static final String CT_DIR_NAME = "/data/misc/keychain/ct/";
+
+    static final String LOGS_DIR_PREFIX = "logs-";
+    static final String LOGS_LIST_FILE_NAME = "log_list.json";
+    static final String CURRENT_DIR_SYMLINK_NAME = "current";
+
+    private final File mCertificateTransparencyDir;
+    private final File mCurrentDirSymlink;
+
+    CertificateTransparencyInstaller(File certificateTransparencyDir) {
+        mCertificateTransparencyDir = certificateTransparencyDir;
+        mCurrentDirSymlink = new File(certificateTransparencyDir, CURRENT_DIR_SYMLINK_NAME);
+    }
+
+    CertificateTransparencyInstaller() {
+        this(new File(CT_DIR_NAME));
+    }
+
+    /**
+     * Install a new log list to use during SCT verification.
+     *
+     * @param newContent an input stream providing the log list
+     * @param version the version of the new log list
+     * @return true if the log list was installed successfully, false otherwise.
+     * @throws IOException if the list cannot be saved in the CT directory.
+     */
+    public boolean install(InputStream newContent, String version) throws IOException {
+        // To support atomically replacing the old configuration directory with the new there's a
+        // bunch of steps. We create a new directory with the logs and then do an atomic update of
+        // the current symlink to point to the new directory.
+        // 1. Ensure that the update dir exists and is readable.
+        makeDir(mCertificateTransparencyDir);
+
+        File newLogsDir = new File(mCertificateTransparencyDir, LOGS_DIR_PREFIX + version);
+        // 2. Handle the corner case where the new directory already exists.
+        if (newLogsDir.exists()) {
+            // If the symlink has already been updated then the update died between steps 6 and 7
+            // and so we cannot delete the directory since it is in use.
+            if (newLogsDir.getCanonicalPath().equals(mCurrentDirSymlink.getCanonicalPath())) {
+                deleteOldLogDirectories();
+                return false;
+            }
+            // If the symlink has not been updated then the previous installation failed and this is
+            // a re-attempt. Clean-up leftover files and try again.
+            deleteContentsAndDir(newLogsDir);
+        }
+        try {
+            // 3. Create /data/misc/keychain/ct/logs-<new_version>/ .
+            makeDir(newLogsDir);
+
+            // 4. Move the log list json file in logs-<new_version>/ .
+            File logListFile = new File(newLogsDir, LOGS_LIST_FILE_NAME);
+            if (Files.copy(newContent, logListFile.toPath()) == 0) {
+                throw new IOException("The log list appears empty");
+            }
+            setWorldReadable(logListFile);
+
+            // 5. Create temp symlink. We rename this to the target symlink to get an atomic update.
+            File tempSymlink = new File(mCertificateTransparencyDir, "new_symlink");
+            try {
+                Os.symlink(newLogsDir.getCanonicalPath(), tempSymlink.getCanonicalPath());
+            } catch (ErrnoException e) {
+                throw new IOException("Failed to create symlink", e);
+            }
+
+            // 6. Update the symlink target, this is the actual update step.
+            tempSymlink.renameTo(mCurrentDirSymlink.getAbsoluteFile());
+        } catch (IOException | RuntimeException e) {
+            deleteContentsAndDir(newLogsDir);
+            throw e;
+        }
+        Log.i(TAG, "CT log directory updated to " + newLogsDir.getAbsolutePath());
+        // 7. Cleanup
+        deleteOldLogDirectories();
+        return true;
+    }
+
+    private void makeDir(File dir) throws IOException {
+        dir.mkdir();
+        if (!dir.isDirectory()) {
+            throw new IOException("Unable to make directory " + dir.getCanonicalPath());
+        }
+        setWorldReadable(dir);
+    }
+
+    // CT files and directories are readable by all apps.
+    @SuppressLint("SetWorldReadable")
+    private void setWorldReadable(File file) throws IOException {
+        if (!file.setReadable(true, false)) {
+            throw new IOException("Failed to set " + file.getCanonicalPath() + " readable");
+        }
+    }
+
+    private void deleteOldLogDirectories() throws IOException {
+        if (!mCertificateTransparencyDir.exists()) {
+            return;
+        }
+        File currentTarget = mCurrentDirSymlink.getCanonicalFile();
+        for (File file : mCertificateTransparencyDir.listFiles()) {
+            if (!currentTarget.equals(file.getCanonicalFile())
+                    && file.getName().startsWith(LOGS_DIR_PREFIX)) {
+                deleteContentsAndDir(file);
+            }
+        }
+    }
+
+    static boolean deleteContentsAndDir(File dir) {
+        if (deleteContents(dir)) {
+            return dir.delete();
+        } else {
+            return false;
+        }
+    }
+
+    private static boolean deleteContents(File dir) {
+        File[] files = dir.listFiles();
+        boolean success = true;
+        if (files != null) {
+            for (File file : files) {
+                if (file.isDirectory()) {
+                    success &= deleteContents(file);
+                }
+                if (!file.delete()) {
+                    Log.w(TAG, "Failed to delete " + file);
+                    success = false;
+                }
+            }
+        }
+        return success;
+    }
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
index 406a57f..52478c0 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
@@ -19,7 +19,6 @@
 import android.content.Context;
 import android.net.ct.ICertificateTransparencyManager;
 import android.os.Build;
-import android.util.Log;
 
 import com.android.net.ct.flags.Flags;
 import com.android.net.module.util.DeviceConfigUtils;
@@ -29,7 +28,6 @@
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class CertificateTransparencyService extends ICertificateTransparencyManager.Stub {
 
-    private static final String TAG = "CertificateTransparency";
     private static final String CERTIFICATE_TRANSPARENCY_ENABLED =
             "certificate_transparency_service_enabled";
 
@@ -59,7 +57,6 @@
 
         switch (phase) {
             case SystemService.PHASE_BOOT_COMPLETED:
-                Log.d(TAG, "setting up flags listeners");
                 mFlagsListener.initialize();
                 break;
             default:
diff --git a/networksecurity/service/src/com/android/server/net/ct/Config.java b/networksecurity/service/src/com/android/server/net/ct/Config.java
new file mode 100644
index 0000000..04b7dac
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/Config.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import android.content.ApexEnvironment;
+
+import com.android.net.module.util.DeviceConfigUtils;
+
+import java.io.File;
+
+/** Class holding the constants used by the CT feature. */
+final class Config {
+
+    static final boolean DEBUG = false;
+
+    // preferences file
+    private static final File DEVICE_PROTECTED_DATA_DIR =
+            ApexEnvironment.getApexEnvironment(DeviceConfigUtils.TETHERING_MODULE_NAME)
+                    .getDeviceProtectedDataDir();
+    private static final String PREFERENCES_FILE_NAME = "ct.preferences";
+    static final File PREFERENCES_FILE = new File(DEVICE_PROTECTED_DATA_DIR, PREFERENCES_FILE_NAME);
+
+    // flags and properties names
+    static final String VERSION_PENDING = "version_pending";
+    static final String VERSION = "version";
+    static final String CONTENT_URL_PENDING = "content_url_pending";
+    static final String CONTENT_URL = "content_url";
+    static final String CONTENT_URL_KEY = "content_url_key";
+    static final String METADATA_URL_PENDING = "metadata_url_pending";
+    static final String METADATA_URL = "metadata_url";
+    static final String METADATA_URL_KEY = "metadata_url_key";
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/DataStore.java b/networksecurity/service/src/com/android/server/net/ct/DataStore.java
new file mode 100644
index 0000000..cd6aebf
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/DataStore.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Optional;
+import java.util.Properties;
+
+/** Class to persist data needed by CT. */
+class DataStore extends Properties {
+
+    private static final String TAG = "CertificateTransparency";
+
+    private final File mPropertyFile;
+
+    DataStore(File file) {
+        super();
+        mPropertyFile = file;
+    }
+
+    void load() {
+        if (!mPropertyFile.exists()) {
+            return;
+        }
+        try (InputStream in = new FileInputStream(mPropertyFile)) {
+            load(in);
+        } catch (IOException e) {
+            Log.e(TAG, "Error loading property store", e);
+        }
+    }
+
+    void store() {
+        try (OutputStream out = new FileOutputStream(mPropertyFile)) {
+            store(out, "");
+        } catch (IOException e) {
+            Log.e(TAG, "Error storing property store", e);
+        }
+    }
+
+    long getPropertyLong(String key, long defaultValue) {
+        return Optional.ofNullable(getProperty(key)).map(Long::parseLong).orElse(defaultValue);
+    }
+
+    Object setPropertyLong(String key, long value) {
+        return setProperty(key, Long.toString(value));
+    }
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java b/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
new file mode 100644
index 0000000..cc8c4c0
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import android.app.DownloadManager;
+import android.app.DownloadManager.Query;
+import android.app.DownloadManager.Request;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+
+import androidx.annotation.VisibleForTesting;
+
+/** Class to handle downloads for Certificate Transparency. */
+public class DownloadHelper {
+
+    private final DownloadManager mDownloadManager;
+
+    @VisibleForTesting
+    DownloadHelper(DownloadManager downloadManager) {
+        mDownloadManager = downloadManager;
+    }
+
+    DownloadHelper(Context context) {
+        this(context.getSystemService(DownloadManager.class));
+    }
+
+    /**
+     * Sends a request to start the download of a provided url.
+     *
+     * @param url the url to download
+     * @return a downloadId if the request was created successfully, -1 otherwise.
+     */
+    public long startDownload(String url) {
+        return mDownloadManager.enqueue(
+                new Request(Uri.parse(url))
+                        .setAllowedOverRoaming(false)
+                        .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
+                        .setRequiresCharging(true));
+    }
+
+    /**
+     * Returns true if the specified download completed successfully.
+     *
+     * @param downloadId the download.
+     * @return true if the download completed successfully.
+     */
+    public boolean isSuccessful(long downloadId) {
+        try (Cursor cursor = mDownloadManager.query(new Query().setFilterById(downloadId))) {
+            if (cursor == null) {
+                return false;
+            }
+            if (cursor.moveToFirst()) {
+                int status =
+                        cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
+                if (DownloadManager.STATUS_SUCCESSFUL == status) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Returns the URI of the specified download, or null if the download did not complete
+     * successfully.
+     *
+     * @param downloadId the download.
+     * @return the {@link Uri} if the download completed successfully, null otherwise.
+     */
+    public Uri getUri(long downloadId) {
+        if (downloadId == -1) {
+            return null;
+        }
+        return mDownloadManager.getUriForDownloadedFile(downloadId);
+    }
+}
diff --git a/networksecurity/tests/unit/Android.bp b/networksecurity/tests/unit/Android.bp
new file mode 100644
index 0000000..639f644
--- /dev/null
+++ b/networksecurity/tests/unit/Android.bp
@@ -0,0 +1,44 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package {
+    default_team: "trendy_team_platform_security",
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "NetworkSecurityUnitTests",
+    defaults: ["mts-target-sdk-version-current"],
+    test_suites: [
+        "general-tests",
+        "mts-tethering",
+    ],
+
+    srcs: ["src/**/*.java"],
+
+    libs: [
+        "android.test.base",
+        "android.test.mock",
+        "android.test.runner",
+    ],
+    static_libs: [
+        "androidx.test.ext.junit",
+        "androidx.test.rules",
+        "junit",
+        "mockito-target-minus-junit4",
+        "service-networksecurity-pre-jarjar",
+        "truth",
+    ],
+
+    sdk_version: "test_current",
+}
diff --git a/networksecurity/tests/unit/AndroidManifest.xml b/networksecurity/tests/unit/AndroidManifest.xml
new file mode 100644
index 0000000..7a3f4b7
--- /dev/null
+++ b/networksecurity/tests/unit/AndroidManifest.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2024 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="com.android.net.ct">
+
+    <application>
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="com.android.net.ct"
+        android:label="NetworkSecurity Mainline Module Tests" />
+</manifest>
diff --git a/networksecurity/tests/unit/AndroidTest.xml b/networksecurity/tests/unit/AndroidTest.xml
new file mode 100644
index 0000000..3c94df7
--- /dev/null
+++ b/networksecurity/tests/unit/AndroidTest.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2021 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="Runs NetworkSecurity Mainline unit Tests.">
+    <option name="test-tag" value="NetworkSecurityUnitTests" />
+
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="NetworkSecurityUnitTests.apk" />
+    </target_preparer>
+
+    <option name="config-descriptor:metadata" key="mainline-param"
+            value="com.google.android.tethering.next.apex" />
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="com.android.net.ct" />
+        <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
+    </test>
+
+    <!-- Only run in MTS if the Tethering Mainline module is installed. -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+        <option name="mainline-module-package-name" value="com.google.android.tethering" />
+    </object>
+</configuration>
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
new file mode 100644
index 0000000..5131a71
--- /dev/null
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.DownloadManager;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.File;
+import java.io.IOException;
+
+/** Tests for the {@link CertificateTransparencyDownloader}. */
+@RunWith(JUnit4.class)
+public class CertificateTransparencyDownloaderTest {
+
+    @Mock private DownloadHelper mDownloadHelper;
+    @Mock private CertificateTransparencyInstaller mCertificateTransparencyInstaller;
+
+    private Context mContext;
+    private File mTempFile;
+    private DataStore mDataStore;
+    private CertificateTransparencyDownloader mCertificateTransparencyDownloader;
+
+    @Before
+    public void setUp() throws IOException {
+        MockitoAnnotations.initMocks(this);
+
+        mContext = InstrumentationRegistry.getInstrumentation().getContext();
+        mTempFile = File.createTempFile("datastore-test", ".properties");
+        mDataStore = new DataStore(mTempFile);
+        mDataStore.load();
+
+        mCertificateTransparencyDownloader =
+                new CertificateTransparencyDownloader(
+                        mContext, mDataStore, mDownloadHelper, mCertificateTransparencyInstaller);
+    }
+
+    @After
+    public void tearDown() {
+        mTempFile.delete();
+    }
+
+    @Test
+    public void testDownloader_startMetadataDownload() {
+        String metadataUrl = "http://test-metadata.org";
+        long downloadId = 666;
+        when(mDownloadHelper.startDownload(metadataUrl)).thenReturn(downloadId);
+
+        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(downloadId)).isFalse();
+        mCertificateTransparencyDownloader.startMetadataDownload(metadataUrl);
+        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(downloadId)).isTrue();
+    }
+
+    @Test
+    public void testDownloader_startContentDownload() {
+        String contentUrl = "http://test-content.org";
+        long downloadId = 666;
+        when(mDownloadHelper.startDownload(contentUrl)).thenReturn(downloadId);
+
+        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(downloadId)).isFalse();
+        mCertificateTransparencyDownloader.startContentDownload(contentUrl);
+        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(downloadId)).isTrue();
+    }
+
+    @Test
+    public void testDownloader_handleMetadataCompleteSuccessful() {
+        long metadataId = 123;
+        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
+        when(mDownloadHelper.isSuccessful(metadataId)).thenReturn(true);
+
+        long contentId = 666;
+        String contentUrl = "http://test-content.org";
+        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
+        when(mDownloadHelper.startDownload(contentUrl)).thenReturn(contentId);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(metadataId));
+
+        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(contentId)).isTrue();
+    }
+
+    @Test
+    public void testDownloader_handleMetadataCompleteFailed() {
+        long metadataId = 123;
+        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
+        when(mDownloadHelper.isSuccessful(metadataId)).thenReturn(false);
+
+        String contentUrl = "http://test-content.org";
+        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(metadataId));
+
+        verify(mDownloadHelper, never()).startDownload(contentUrl);
+    }
+
+    @Test
+    public void testDownloader_handleContentCompleteInstallSuccessful() throws IOException {
+        String version = "666";
+        mDataStore.setProperty(Config.VERSION_PENDING, version);
+
+        long metadataId = 123;
+        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
+        Uri metadataUri = Uri.fromFile(File.createTempFile("log_list-metadata", "txt"));
+        mDataStore.setProperty(Config.METADATA_URL_PENDING, metadataUri.toString());
+        when(mDownloadHelper.getUri(metadataId)).thenReturn(metadataUri);
+
+        long contentId = 666;
+        mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, contentId);
+        when(mDownloadHelper.isSuccessful(contentId)).thenReturn(true);
+        Uri contentUri = Uri.fromFile(File.createTempFile("log_list", "json"));
+        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUri.toString());
+        when(mDownloadHelper.getUri(contentId)).thenReturn(contentUri);
+
+        when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(true);
+
+        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
+        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
+        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(contentId));
+
+        verify(mCertificateTransparencyInstaller, times(1)).install(any(), eq(version));
+        assertThat(mDataStore.getProperty(Config.VERSION)).isEqualTo(version);
+        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isEqualTo(contentUri.toString());
+        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isEqualTo(metadataUri.toString());
+    }
+
+    @Test
+    public void testDownloader_handleContentCompleteInstallFails() throws IOException {
+        String version = "666";
+        mDataStore.setProperty(Config.VERSION_PENDING, version);
+
+        long metadataId = 123;
+        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
+        Uri metadataUri = Uri.fromFile(File.createTempFile("log_list-metadata", "txt"));
+        mDataStore.setProperty(Config.METADATA_URL_PENDING, metadataUri.toString());
+        when(mDownloadHelper.getUri(metadataId)).thenReturn(metadataUri);
+
+        long contentId = 666;
+        mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, contentId);
+        when(mDownloadHelper.isSuccessful(contentId)).thenReturn(true);
+        Uri contentUri = Uri.fromFile(File.createTempFile("log_list", "json"));
+        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUri.toString());
+        when(mDownloadHelper.getUri(contentId)).thenReturn(contentUri);
+
+        when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(false);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(contentId));
+
+        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
+        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
+        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+    }
+
+    private Intent makeDownloadCompleteIntent(long downloadId) {
+        return new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
+                .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
+    }
+}
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java
new file mode 100644
index 0000000..bfb8bdf
--- /dev/null
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.system.ErrnoException;
+import android.system.Os;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/** Tests for the {@link CertificateTransparencyInstaller}. */
+@RunWith(JUnit4.class)
+public class CertificateTransparencyInstallerTest {
+
+    private File mTestDir =
+            new File(
+                    InstrumentationRegistry.getInstrumentation().getContext().getFilesDir(),
+                    "test-dir");
+    private File mTestSymlink =
+            new File(mTestDir, CertificateTransparencyInstaller.CURRENT_DIR_SYMLINK_NAME);
+    private CertificateTransparencyInstaller mCertificateTransparencyInstaller =
+            new CertificateTransparencyInstaller(mTestDir);
+
+    @Before
+    public void setUp() {
+        CertificateTransparencyInstaller.deleteContentsAndDir(mTestDir);
+    }
+
+    @Test
+    public void testCertificateTransparencyInstaller_installSuccessfully() throws IOException {
+        String content = "i_am_a_certificate_and_i_am_transparent";
+        String version = "666";
+        boolean success = false;
+
+        try (InputStream inputStream = asStream(content)) {
+            success = mCertificateTransparencyInstaller.install(inputStream, version);
+        }
+
+        assertThat(success).isTrue();
+        assertThat(mTestDir.exists()).isTrue();
+        assertThat(mTestDir.isDirectory()).isTrue();
+        assertThat(mTestSymlink.exists()).isTrue();
+        assertThat(mTestSymlink.isDirectory()).isTrue();
+
+        File logsDir =
+                new File(mTestDir, CertificateTransparencyInstaller.LOGS_DIR_PREFIX + version);
+        assertThat(logsDir.exists()).isTrue();
+        assertThat(logsDir.isDirectory()).isTrue();
+        assertThat(mTestSymlink.getCanonicalPath()).isEqualTo(logsDir.getCanonicalPath());
+
+        File logsListFile = new File(logsDir, CertificateTransparencyInstaller.LOGS_LIST_FILE_NAME);
+        assertThat(logsListFile.exists()).isTrue();
+        assertThat(readAsString(logsListFile)).isEqualTo(content);
+    }
+
+    @Test
+    public void testCertificateTransparencyInstaller_versionIsAlreadyInstalled()
+            throws IOException, ErrnoException {
+        String existingVersion = "666";
+        String existingContent = "i_was_already_installed_successfully";
+        File existingLogDir =
+                new File(
+                        mTestDir,
+                        CertificateTransparencyInstaller.LOGS_DIR_PREFIX + existingVersion);
+        assertThat(mTestDir.mkdir()).isTrue();
+        assertThat(existingLogDir.mkdir()).isTrue();
+        Os.symlink(existingLogDir.getCanonicalPath(), mTestSymlink.getCanonicalPath());
+        File logsListFile =
+                new File(existingLogDir, CertificateTransparencyInstaller.LOGS_LIST_FILE_NAME);
+        logsListFile.createNewFile();
+        writeToFile(logsListFile, existingContent);
+        boolean success = false;
+
+        try (InputStream inputStream = asStream("i_will_be_ignored")) {
+            success = mCertificateTransparencyInstaller.install(inputStream, existingVersion);
+        }
+
+        assertThat(success).isFalse();
+        assertThat(readAsString(logsListFile)).isEqualTo(existingContent);
+    }
+
+    @Test
+    public void testCertificateTransparencyInstaller_versionInstalledFailed()
+            throws IOException, ErrnoException {
+        String existingVersion = "666";
+        String existingContent = "somebody_tried_to_install_me_but_failed_halfway_through";
+        String newContent = "i_am_the_real_certificate";
+        File existingLogDir =
+                new File(
+                        mTestDir,
+                        CertificateTransparencyInstaller.LOGS_DIR_PREFIX + existingVersion);
+        assertThat(mTestDir.mkdir()).isTrue();
+        assertThat(existingLogDir.mkdir()).isTrue();
+        File logsListFile =
+                new File(existingLogDir, CertificateTransparencyInstaller.LOGS_LIST_FILE_NAME);
+        logsListFile.createNewFile();
+        writeToFile(logsListFile, existingContent);
+        boolean success = false;
+
+        try (InputStream inputStream = asStream(newContent)) {
+            success = mCertificateTransparencyInstaller.install(inputStream, existingVersion);
+        }
+
+        assertThat(success).isTrue();
+        assertThat(mTestSymlink.getCanonicalPath()).isEqualTo(existingLogDir.getCanonicalPath());
+        assertThat(readAsString(logsListFile)).isEqualTo(newContent);
+    }
+
+    private static InputStream asStream(String string) throws IOException {
+        return new ByteArrayInputStream(string.getBytes());
+    }
+
+    private static String readAsString(File file) throws IOException {
+        return new String(new FileInputStream(file).readAllBytes());
+    }
+
+    private static void writeToFile(File file, String string) throws IOException {
+        try (OutputStream out = new FileOutputStream(file)) {
+            out.write(string.getBytes());
+        }
+    }
+}
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/DataStoreTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/DataStoreTest.java
new file mode 100644
index 0000000..3e670d4
--- /dev/null
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/DataStoreTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.io.IOException;
+
+/** Tests for the {@link DataStore}. */
+@RunWith(JUnit4.class)
+public class DataStoreTest {
+
+    private File mTempFile;
+    private DataStore mDataStore;
+
+    @Before
+    public void setUp() throws IOException {
+        mTempFile = File.createTempFile("datastore-test", ".properties");
+        mDataStore = new DataStore(mTempFile);
+    }
+
+    @After
+    public void tearDown() {
+        mTempFile.delete();
+    }
+
+    @Test
+    public void testDataStore_propertyFileCreatedSuccessfully() {
+        assertThat(mTempFile.exists()).isTrue();
+        assertThat(mDataStore.isEmpty()).isTrue();
+    }
+
+    @Test
+    public void testDataStore_propertySet() {
+        String stringProperty = "prop1";
+        String stringValue = "i_am_a_string";
+        String longProperty = "prop3";
+        long longValue = 9000;
+
+        assertThat(mDataStore.getProperty(stringProperty)).isNull();
+        assertThat(mDataStore.getPropertyLong(longProperty, -1)).isEqualTo(-1);
+
+        mDataStore.setProperty(stringProperty, stringValue);
+        mDataStore.setPropertyLong(longProperty, longValue);
+
+        assertThat(mDataStore.getProperty(stringProperty)).isEqualTo(stringValue);
+        assertThat(mDataStore.getPropertyLong(longProperty, -1)).isEqualTo(longValue);
+    }
+
+    @Test
+    public void testDataStore_propertyStore() {
+        String stringProperty = "prop1";
+        String stringValue = "i_am_a_string";
+        String longProperty = "prop3";
+        long longValue = 9000;
+
+        mDataStore.setProperty(stringProperty, stringValue);
+        mDataStore.setPropertyLong(longProperty, longValue);
+        mDataStore.store();
+
+        mDataStore.clear();
+        assertThat(mDataStore.getProperty(stringProperty)).isNull();
+        assertThat(mDataStore.getPropertyLong(longProperty, -1)).isEqualTo(-1);
+
+        mDataStore.load();
+        assertThat(mDataStore.getProperty(stringProperty)).isEqualTo(stringValue);
+        assertThat(mDataStore.getPropertyLong(longProperty, -1)).isEqualTo(longValue);
+    }
+}
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/DownloadHelperTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/DownloadHelperTest.java
new file mode 100644
index 0000000..0b65e3c
--- /dev/null
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/DownloadHelperTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import android.app.DownloadManager;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/** Tests for the {@link DownloadHelper}. */
+@RunWith(JUnit4.class)
+public class DownloadHelperTest {
+
+    @Mock private DownloadManager mDownloadManager;
+
+    private DownloadHelper mDownloadHelper;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mDownloadHelper = new DownloadHelper(mDownloadManager);
+    }
+
+    @Test
+    public void testDownloadHelper_scheduleDownload() {
+        long downloadId = 666;
+        when(mDownloadManager.enqueue(any())).thenReturn(downloadId);
+
+        assertThat(mDownloadHelper.startDownload("http://test.org")).isEqualTo(downloadId);
+    }
+
+    @Test
+    public void testDownloadHelper_wrongUri() {
+        when(mDownloadManager.enqueue(any())).thenReturn(666L);
+
+        assertThrows(
+                IllegalArgumentException.class, () -> mDownloadHelper.startDownload("not_a_uri"));
+    }
+}
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index 0fe24a2..53b1eb2 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -6002,7 +6002,7 @@
             // TODO : The only way out of this is to diff old defaults and new defaults, and only
             // remove ranges for those requests that won't have a replacement
             final NetworkAgentInfo satisfier = nri.getSatisfier();
-            if (null != satisfier) {
+            if (null != satisfier && !satisfier.isDestroyed()) {
                 try {
                     mNetd.networkRemoveUidRangesParcel(new NativeUidRangeConfig(
                             satisfier.network.getNetId(),
diff --git a/service/src/com/android/server/connectivity/DnsManager.java b/service/src/com/android/server/connectivity/DnsManager.java
index b95e3b1..c940eec 100644
--- a/service/src/com/android/server/connectivity/DnsManager.java
+++ b/service/src/com/android/server/connectivity/DnsManager.java
@@ -29,6 +29,7 @@
 import static android.net.resolv.aidl.IDnsResolverUnsolicitedEventListener.VALIDATION_RESULT_SUCCESS;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
@@ -404,22 +405,11 @@
             mPrivateDnsValidationMap.remove(netId);
         }
 
-        Log.d(TAG, String.format("sendDnsConfigurationForNetwork(%d, %s, %s, %d, %d, %d, %d, "
-                + "%d, %d, %s, %s, %s, %b, %s, %s, %s, %s, %d)", paramsParcel.netId,
-                Arrays.toString(paramsParcel.servers), Arrays.toString(paramsParcel.domains),
-                paramsParcel.sampleValiditySeconds, paramsParcel.successThreshold,
-                paramsParcel.minSamples, paramsParcel.maxSamples, paramsParcel.baseTimeoutMsec,
-                paramsParcel.retryCount, paramsParcel.tlsName,
-                Arrays.toString(paramsParcel.tlsServers),
-                Arrays.toString(paramsParcel.transportTypes), paramsParcel.meteredNetwork,
-                Arrays.toString(paramsParcel.interfaceNames),
-                paramsParcel.dohParams.name, Arrays.toString(paramsParcel.dohParams.ips),
-                paramsParcel.dohParams.dohpath, paramsParcel.dohParams.port));
+        Log.d(TAG, "sendDnsConfigurationForNetwork(" + paramsParcel + ")");
         try {
             mDnsResolver.setResolverConfiguration(paramsParcel);
         } catch (RemoteException | ServiceSpecificException e) {
             Log.e(TAG, "Error setting DNS configuration: " + e);
-            return;
         }
     }
 
@@ -509,9 +499,12 @@
         return out;
     }
 
-    @NonNull
+    @Nullable
     private DohParamsParcel makeDohParamsParcel(@NonNull PrivateDnsConfig cfg,
             @NonNull LinkProperties lp) {
+        if (!cfg.ddrEnabled) {
+            return null;
+        }
         if (cfg.mode == PRIVATE_DNS_MODE_OFF) {
             return new DohParamsParcel.Builder().build();
         }
diff --git a/staticlibs/device/com/android/net/module/util/Struct.java b/staticlibs/device/com/android/net/module/util/Struct.java
index ff7a711..69ca678 100644
--- a/staticlibs/device/com/android/net/module/util/Struct.java
+++ b/staticlibs/device/com/android/net/module/util/Struct.java
@@ -105,6 +105,7 @@
  */
 public class Struct {
     public enum Type {
+        Bool,        // bool,           size = 1 byte
         U8,          // unsigned byte,  size = 1 byte
         U16,         // unsigned short, size = 2 bytes
         U32,         // unsigned int,   size = 4 bytes
@@ -169,6 +170,9 @@
 
     private static void checkAnnotationType(final Field annotation, final Class fieldType) {
         switch (annotation.type()) {
+            case Bool:
+                if (fieldType == Boolean.TYPE) return;
+                break;
             case U8:
             case S16:
                 if (fieldType == Short.TYPE) return;
@@ -218,6 +222,7 @@
     private static int getFieldLength(final Field annotation) {
         int length = 0;
         switch (annotation.type()) {
+            case Bool:
             case U8:
             case S8:
                 length = 1;
@@ -357,6 +362,9 @@
         final Object value;
         checkAnnotationType(fieldInfo.annotation, fieldInfo.field.getType());
         switch (fieldInfo.annotation.type()) {
+            case Bool:
+                value = buf.get() != 0;
+                break;
             case U8:
                 value = (short) (buf.get() & 0xFF);
                 break;
@@ -457,6 +465,9 @@
     private static void putFieldValue(final ByteBuffer output, final FieldInfo fieldInfo,
             final Object value) throws BufferUnderflowException {
         switch (fieldInfo.annotation.type()) {
+            case Bool:
+                output.put((byte) (value != null && (boolean) value ? 1 : 0));
+                break;
             case U8:
                 output.put((byte) (((short) value) & 0xFF));
                 break;
@@ -748,6 +759,16 @@
         return sb.toString();
     }
 
+    /** A simple Struct which only contains a bool field. */
+    public static class Bool extends Struct {
+        @Struct.Field(order = 0, type = Struct.Type.Bool)
+        public final boolean val;
+
+        public Bool(final boolean val) {
+            this.val = val;
+        }
+    }
+
     /** A simple Struct which only contains a u8 field. */
     public static class U8 extends Struct {
         @Struct.Field(order = 0, type = Struct.Type.U8)
diff --git a/staticlibs/framework/com/android/net/module/util/NetworkStatsUtils.java b/staticlibs/framework/com/android/net/module/util/NetworkStatsUtils.java
index 41a9428..28ff770 100644
--- a/staticlibs/framework/com/android/net/module/util/NetworkStatsUtils.java
+++ b/staticlibs/framework/com/android/net/module/util/NetworkStatsUtils.java
@@ -16,23 +16,12 @@
 
 package com.android.net.module.util;
 
-import android.app.usage.NetworkStats;
-
-import com.android.internal.annotations.VisibleForTesting;
-
 /**
  * Various utilities used for NetworkStats related code.
  *
  * @hide
  */
 public class NetworkStatsUtils {
-    // These constants must be synced with the definition in android.net.NetworkStats.
-    // TODO: update to formal APIs once all downstreams have these APIs.
-    private static final int SET_ALL = -1;
-    private static final int METERED_ALL = -1;
-    private static final int ROAMING_ALL = -1;
-    private static final int DEFAULT_NETWORK_ALL = -1;
-
     /**
      * Safely multiple a value by a rational.
      * <p>
@@ -99,77 +88,4 @@
         if (low > high) throw new IllegalArgumentException("low(" + low + ") > high(" + high + ")");
         return amount < low ? low : (amount > high ? high : amount);
     }
-
-    /**
-     * Convert structure from android.app.usage.NetworkStats to android.net.NetworkStats.
-     */
-    public static android.net.NetworkStats fromPublicNetworkStats(
-            NetworkStats publiceNetworkStats) {
-        android.net.NetworkStats stats = new android.net.NetworkStats(0L, 0);
-        while (publiceNetworkStats.hasNextBucket()) {
-            NetworkStats.Bucket bucket = new NetworkStats.Bucket();
-            publiceNetworkStats.getNextBucket(bucket);
-            final android.net.NetworkStats.Entry entry = fromBucket(bucket);
-            stats = stats.addEntry(entry);
-        }
-        return stats;
-    }
-
-    @VisibleForTesting
-    public static android.net.NetworkStats.Entry fromBucket(NetworkStats.Bucket bucket) {
-        return new android.net.NetworkStats.Entry(
-                null /* IFACE_ALL */, bucket.getUid(), convertBucketState(bucket.getState()),
-                convertBucketTag(bucket.getTag()), convertBucketMetered(bucket.getMetered()),
-                convertBucketRoaming(bucket.getRoaming()),
-                convertBucketDefaultNetworkStatus(bucket.getDefaultNetworkStatus()),
-                bucket.getRxBytes(), bucket.getRxPackets(),
-                bucket.getTxBytes(), bucket.getTxPackets(), 0 /* operations */);
-    }
-
-    private static int convertBucketState(int networkStatsSet) {
-        switch (networkStatsSet) {
-            case NetworkStats.Bucket.STATE_ALL: return SET_ALL;
-            case NetworkStats.Bucket.STATE_DEFAULT: return android.net.NetworkStats.SET_DEFAULT;
-            case NetworkStats.Bucket.STATE_FOREGROUND:
-                return android.net.NetworkStats.SET_FOREGROUND;
-        }
-        return 0;
-    }
-
-    private static int convertBucketTag(int tag) {
-        switch (tag) {
-            case NetworkStats.Bucket.TAG_NONE: return android.net.NetworkStats.TAG_NONE;
-        }
-        return tag;
-    }
-
-    private static int convertBucketMetered(int metered) {
-        switch (metered) {
-            case NetworkStats.Bucket.METERED_ALL: return METERED_ALL;
-            case NetworkStats.Bucket.METERED_NO: return android.net.NetworkStats.METERED_NO;
-            case NetworkStats.Bucket.METERED_YES: return android.net.NetworkStats.METERED_YES;
-        }
-        return 0;
-    }
-
-    private static int convertBucketRoaming(int roaming) {
-        switch (roaming) {
-            case NetworkStats.Bucket.ROAMING_ALL: return ROAMING_ALL;
-            case NetworkStats.Bucket.ROAMING_NO: return android.net.NetworkStats.ROAMING_NO;
-            case NetworkStats.Bucket.ROAMING_YES: return android.net.NetworkStats.ROAMING_YES;
-        }
-        return 0;
-    }
-
-    private static int convertBucketDefaultNetworkStatus(int defaultNetworkStatus) {
-        switch (defaultNetworkStatus) {
-            case NetworkStats.Bucket.DEFAULT_NETWORK_ALL:
-                return DEFAULT_NETWORK_ALL;
-            case NetworkStats.Bucket.DEFAULT_NETWORK_NO:
-                return android.net.NetworkStats.DEFAULT_NETWORK_NO;
-            case NetworkStats.Bucket.DEFAULT_NETWORK_YES:
-                return android.net.NetworkStats.DEFAULT_NETWORK_YES;
-        }
-        return 0;
-    }
 }
diff --git a/staticlibs/native/README.md b/staticlibs/native/README.md
index 1f505c4..7e0e963 100644
--- a/staticlibs/native/README.md
+++ b/staticlibs/native/README.md
@@ -27,4 +27,4 @@
   library (`.so`) file, and different versions of the library loaded in the same process by
   different modules will in general have different versions. It's important that each of these
   libraries loads the common function from its own library. Static linkage should guarantee this
-  because static linkage resolves symbols at build time, not runtime.
\ No newline at end of file
+  because static linkage resolves symbols at build time, not runtime.
diff --git a/staticlibs/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index 91f94b5..61f41f7 100644
--- a/staticlibs/tests/unit/Android.bp
+++ b/staticlibs/tests/unit/Android.bp
@@ -70,6 +70,7 @@
     ],
     main: "host/python/run_tests.py",
     libs: [
+        "absl-py",
         "mobly",
         "net-tests-utils-host-python-common",
     ],
@@ -81,10 +82,4 @@
     test_options: {
         unit_test: false,
     },
-    // Needed for applying VirtualEnv.
-    version: {
-        py3: {
-            embedded_launcher: false,
-        },
-    },
 }
diff --git a/staticlibs/tests/unit/host/python/test_config.xml b/staticlibs/tests/unit/host/python/test_config.xml
index d3b200a..fed9d11 100644
--- a/staticlibs/tests/unit/host/python/test_config.xml
+++ b/staticlibs/tests/unit/host/python/test_config.xml
@@ -14,9 +14,6 @@
      limitations under the License.
 -->
 <configuration description="Config for NetworkStaticLibHostPythonTests">
-    <target_preparer class="com.android.tradefed.targetprep.PythonVirtualenvPreparer">
-        <option name="dep-module" value="absl-py" />
-    </target_preparer>
     <test class="com.android.tradefed.testtype.mobly.MoblyBinaryHostTest" >
         <option name="mobly-par-file-name" value="NetworkStaticLibHostPythonTests" />
         <option name="mobly-test-timeout" value="3m" />
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/NetworkStatsUtilsTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/NetworkStatsUtilsTest.kt
index baadad0..9981b6a 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/NetworkStatsUtilsTest.kt
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/NetworkStatsUtilsTest.kt
@@ -16,16 +16,12 @@
 
 package com.android.net.module.util
 
-import android.net.NetworkStats
 import androidx.test.filters.SmallTest
 import androidx.test.runner.AndroidJUnit4
-import com.android.testutils.assertEntryEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import org.junit.Test
 import org.junit.runner.RunWith
-import org.mockito.Mockito.doReturn
-import org.mockito.Mockito.mock
 
 @RunWith(AndroidJUnit4::class)
 @SmallTest
@@ -75,48 +71,4 @@
         assertEquals(11, NetworkStatsUtils.constrain(11, 11, 11))
         assertEquals(11, NetworkStatsUtils.constrain(1, 11, 11))
     }
-
-    @Test
-    fun testBucketToEntry() {
-        val bucket = makeMockBucket(android.app.usage.NetworkStats.Bucket.UID_ALL,
-                android.app.usage.NetworkStats.Bucket.TAG_NONE,
-                android.app.usage.NetworkStats.Bucket.STATE_DEFAULT,
-                android.app.usage.NetworkStats.Bucket.METERED_YES,
-                android.app.usage.NetworkStats.Bucket.ROAMING_NO,
-                android.app.usage.NetworkStats.Bucket.DEFAULT_NETWORK_ALL, 1024, 8, 2048, 12)
-        val entry = NetworkStatsUtils.fromBucket(bucket)
-        val expectedEntry = NetworkStats.Entry(null /* IFACE_ALL */, NetworkStats.UID_ALL,
-            NetworkStats.SET_DEFAULT, NetworkStats.TAG_NONE, NetworkStats.METERED_YES,
-            NetworkStats.ROAMING_NO, NetworkStats.DEFAULT_NETWORK_ALL, 1024, 8, 2048, 12,
-            0 /* operations */)
-
-        assertEntryEquals(expectedEntry, entry)
-    }
-
-    private fun makeMockBucket(
-        uid: Int,
-        tag: Int,
-        state: Int,
-        metered: Int,
-        roaming: Int,
-        defaultNetwork: Int,
-        rxBytes: Long,
-        rxPackets: Long,
-        txBytes: Long,
-        txPackets: Long
-    ): android.app.usage.NetworkStats.Bucket {
-        val ret: android.app.usage.NetworkStats.Bucket =
-                mock(android.app.usage.NetworkStats.Bucket::class.java)
-        doReturn(uid).`when`(ret).getUid()
-        doReturn(tag).`when`(ret).getTag()
-        doReturn(state).`when`(ret).getState()
-        doReturn(metered).`when`(ret).getMetered()
-        doReturn(roaming).`when`(ret).getRoaming()
-        doReturn(defaultNetwork).`when`(ret).getDefaultNetworkStatus()
-        doReturn(rxBytes).`when`(ret).getRxBytes()
-        doReturn(rxPackets).`when`(ret).getRxPackets()
-        doReturn(txBytes).`when`(ret).getTxBytes()
-        doReturn(txPackets).`when`(ret).getTxPackets()
-        return ret
-    }
 }
\ No newline at end of file
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java
index a39b7a3..0c2605f 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java
@@ -32,6 +32,7 @@
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.net.module.util.Struct.Bool;
 import com.android.net.module.util.Struct.Field;
 import com.android.net.module.util.Struct.Type;
 
@@ -133,6 +134,29 @@
         verifyHeaderParsing(msg);
     }
 
+    @Test
+    public void testBoolStruct() {
+        assertEquals(1, Struct.getSize(Bool.class));
+
+        assertEquals(false, Struct.parse(Bool.class, toByteBuffer("00")).val);
+        assertEquals(true,  Struct.parse(Bool.class, toByteBuffer("01")).val);
+        // maybe these should throw instead, but currently only 0 is false...
+        assertEquals(true,  Struct.parse(Bool.class, toByteBuffer("02")).val);
+        assertEquals(true,  Struct.parse(Bool.class, toByteBuffer("7F")).val);
+        assertEquals(true,  Struct.parse(Bool.class, toByteBuffer("80")).val);
+        assertEquals(true,  Struct.parse(Bool.class, toByteBuffer("FF")).val);
+
+        final var f = new Bool(false);
+        final var t = new Bool(true);
+        assertEquals(f.val, false);
+        assertEquals(t.val, true);
+
+        assertArrayEquals(toByteBuffer("00").array(), f.writeToBytes(ByteOrder.BIG_ENDIAN));
+        assertArrayEquals(toByteBuffer("00").array(), f.writeToBytes(ByteOrder.LITTLE_ENDIAN));
+        assertArrayEquals(toByteBuffer("01").array(), t.writeToBytes(ByteOrder.BIG_ENDIAN));
+        assertArrayEquals(toByteBuffer("01").array(), t.writeToBytes(ByteOrder.LITTLE_ENDIAN));
+    }
+
     public static class HeaderMsgWithoutConstructor extends Struct {
         static int sType;
         static int sLength;
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
index a014834..d00ae52 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
@@ -61,8 +61,10 @@
     private val shouldThreadLeakFailTest = klass.isAnnotationPresent(MonitorThreadLeak::class.java)
     private val restoreDefaultNetworkDesc =
             Description.createTestDescription(klass, "RestoreDefaultNetwork")
-    private val restoreDefaultNetwork = klass.isAnnotationPresent(RestoreDefaultNetwork::class.java)
     val ctx = ApplicationProvider.getApplicationContext<Context>()
+    private val restoreDefaultNetwork =
+            klass.isAnnotationPresent(RestoreDefaultNetwork::class.java) &&
+            !ctx.applicationInfo.isInstantApp()
 
     // Inference correctly infers Runner & Filterable & Sortable for |baseRunner|, but the
     // Java bytecode doesn't have a way to express this. Give this type a name by wrapping it.
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/NetworkStatsUtils.kt b/staticlibs/testutils/devicetests/com/android/testutils/NetworkStatsUtils.kt
index f2b14f5..26bdb49 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/NetworkStatsUtils.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/NetworkStatsUtils.kt
@@ -16,7 +16,13 @@
 
 package com.android.testutils
 
+import android.app.usage.NetworkStatsManager
+import android.content.Context
+import android.net.INetworkStatsService
+import android.net.INetworkStatsSession
 import android.net.NetworkStats
+import android.net.NetworkTemplate
+import android.net.NetworkTemplate.MATCH_MOBILE
 import android.text.TextUtils
 import com.android.modules.utils.build.SdkLevel
 import kotlin.test.assertTrue
@@ -112,3 +118,32 @@
 fun assertParcelingIsLossless(stats: NetworkStats) {
     assertParcelingIsLossless(stats) { a, b -> orderInsensitiveEquals(a, b) }
 }
+
+/**
+ * Make a {@link android.app.usage.NetworkStats} instance from
+ * a {@link android.net.NetworkStats} instance.
+ */
+// It's not possible to directly create a mocked `NetworkStats` instance
+// because of limitations with `NetworkStats#getNextBucket`.
+// As a workaround for testing, create a mock by controlling the return values
+// from the mocked service that provides the `NetworkStats` data.
+// Notes:
+//   1. The order of records in the final `NetworkStats` object might change or
+//      some records might be merged if there are items with duplicate keys.
+//   2. The interface and operations fields will be empty since there is
+//      no such field in the {@link android.app.usage.NetworkStats}.
+fun makePublicStatsFromAndroidNetStats(androidNetStats: NetworkStats):
+        android.app.usage.NetworkStats {
+    val mockService = Mockito.mock(INetworkStatsService::class.java)
+    val manager = NetworkStatsManager(Mockito.mock(Context::class.java), mockService)
+    val mockStatsSession = Mockito.mock(INetworkStatsSession::class.java)
+
+    Mockito.doReturn(mockStatsSession).`when`(mockService)
+            .openSessionForUsageStats(anyInt(), any())
+    Mockito.doReturn(androidNetStats).`when`(mockStatsSession).getSummaryForAllUid(
+            any(NetworkTemplate::class.java), anyLong(), anyLong(), anyBoolean())
+    return manager.querySummary(
+            NetworkTemplate.Builder(MATCH_MOBILE).build(),
+            Long.MIN_VALUE, Long.MAX_VALUE
+    )
+}
diff --git a/tests/cts/hostside/app/Android.bp b/tests/cts/hostside/app/Android.bp
index 919e025..798cf98 100644
--- a/tests/cts/hostside/app/Android.bp
+++ b/tests/cts/hostside/app/Android.bp
@@ -44,6 +44,7 @@
         "general-tests",
         "sts",
     ],
+    min_sdk_version: "31",
 }
 
 android_test_helper_app {
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
index 88c2d5a..b62db04 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
@@ -1055,7 +1055,7 @@
 
     @AppModeFull(reason = "WRITE_SECURE_SETTINGS permission can't be granted to instant apps")
     @Test @IgnoreUpTo(Build.VERSION_CODES.Q)
-    public void testIsPrivateDnsBroken() throws InterruptedException {
+    public void testIsPrivateDnsBroken() throws Exception {
         final String invalidPrivateDnsServer = "invalidhostname.example.com";
         final String goodPrivateDnsServer = "dns.google";
         mCtsNetUtils.storePrivateDnsSetting();
@@ -1077,11 +1077,9 @@
                     .isPrivateDnsBroken()) && networkForPrivateDns.equals(entry.getNetwork()));
         } finally {
             mCtsNetUtils.restorePrivateDnsSetting();
-            // Toggle network to make sure it is re-validated
-            mCm.reportNetworkConnectivity(networkForPrivateDns, true);
-            cb.eventuallyExpect(CallbackEntry.NETWORK_CAPS_UPDATED, NETWORK_CALLBACK_TIMEOUT_MS,
-                    entry -> !(((CallbackEntry.CapabilitiesChanged) entry).getCaps()
-                    .isPrivateDnsBroken()) && networkForPrivateDns.equals(entry.getNetwork()));
+            // Toggle networks to make sure they are re-validated
+            mCtsNetUtils.reconnectWifiIfSupported();
+            mCtsNetUtils.reconnectCellIfSupported();
         }
     }
 
diff --git a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
index 8794847..1454d9a 100644
--- a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
+++ b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
@@ -22,6 +22,9 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
+import static android.net.TetheringManager.TETHERING_BLUETOOTH;
+import static android.net.TetheringManager.TETHERING_ETHERNET;
+import static android.net.TetheringManager.TETHERING_NCM;
 import static android.net.TetheringManager.TETHERING_USB;
 import static android.net.TetheringManager.TETHERING_WIFI;
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
@@ -34,6 +37,7 @@
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
@@ -60,7 +64,9 @@
 import android.net.cts.util.CtsTetheringUtils;
 import android.net.cts.util.CtsTetheringUtils.StartTetheringCallback;
 import android.net.cts.util.CtsTetheringUtils.TestTetheringEventCallback;
+import android.net.wifi.SoftApConfiguration;
 import android.net.wifi.WifiManager;
+import android.net.wifi.WifiSsid;
 import android.os.Bundle;
 import android.os.PersistableBundle;
 import android.os.ResultReceiver;
@@ -71,6 +77,7 @@
 import androidx.test.InstrumentationRegistry;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.modules.utils.build.SdkLevel;
 import com.android.testutils.ParcelUtils;
 
 import org.junit.After;
@@ -78,6 +85,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -216,12 +224,26 @@
 
     @Test
     public void testTetheringRequest() {
-        final TetheringRequest tr = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        SoftApConfiguration softApConfiguration;
+        if (SdkLevel.isAtLeastT()) {
+            softApConfiguration = new SoftApConfiguration.Builder()
+                    .setWifiSsid(WifiSsid.fromBytes(
+                            "This is an SSID!".getBytes(StandardCharsets.UTF_8)))
+                    .build();
+        } else {
+            softApConfiguration = new SoftApConfiguration.Builder()
+                    .setSsid("This is an SSID!")
+                    .build();
+        }
+        final TetheringRequest tr = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfiguration)
+                .build();
         assertEquals(TETHERING_WIFI, tr.getTetheringType());
         assertNull(tr.getLocalIpv4Address());
         assertNull(tr.getClientStaticIpv4Address());
         assertFalse(tr.isExemptFromEntitlementCheck());
         assertTrue(tr.getShouldShowEntitlementUi());
+        assertEquals(softApConfiguration, tr.getSoftApConfiguration());
 
         final LinkAddress localAddr = new LinkAddress("192.168.24.5/24");
         final LinkAddress clientAddr = new LinkAddress("192.168.24.100/24");
@@ -244,15 +266,57 @@
     }
 
     @Test
+    public void testTetheringRequestSetSoftApConfigurationFailsWhenNotWifi() {
+        final SoftApConfiguration softApConfiguration;
+        if (SdkLevel.isAtLeastT()) {
+            softApConfiguration = new SoftApConfiguration.Builder()
+                    .setWifiSsid(WifiSsid.fromBytes(
+                            "This is an SSID!".getBytes(StandardCharsets.UTF_8)))
+                    .build();
+        } else {
+            softApConfiguration = new SoftApConfiguration.Builder()
+                    .setSsid("This is an SSID!")
+                    .build();
+        }
+        for (int type : List.of(TETHERING_USB, TETHERING_BLUETOOTH, TETHERING_WIFI_P2P,
+                TETHERING_NCM, TETHERING_ETHERNET)) {
+            try {
+                new TetheringRequest.Builder(type).setSoftApConfiguration(softApConfiguration);
+                fail("Was able to set SoftApConfiguration for tethering type " + type);
+            } catch (IllegalArgumentException e) {
+                // Success
+            }
+        }
+    }
+
+    @Test
     public void testTetheringRequestParcelable() {
+        final SoftApConfiguration softApConfiguration;
+        if (SdkLevel.isAtLeastT()) {
+            softApConfiguration = new SoftApConfiguration.Builder()
+                    .setWifiSsid(WifiSsid.fromBytes(
+                            "This is an SSID!".getBytes(StandardCharsets.UTF_8)))
+                    .build();
+        } else {
+            softApConfiguration = new SoftApConfiguration.Builder()
+                    .setSsid("This is an SSID!")
+                    .build();
+        }
         final LinkAddress localAddr = new LinkAddress("192.168.24.5/24");
         final LinkAddress clientAddr = new LinkAddress("192.168.24.100/24");
-        final TetheringRequest unparceled = new TetheringRequest.Builder(TETHERING_USB)
+        final TetheringRequest withConfig = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfiguration)
                 .setStaticIpv4Addresses(localAddr, clientAddr)
                 .setExemptFromEntitlementCheck(true)
                 .setShouldShowEntitlementUi(false).build();
-        final TetheringRequest parceled = ParcelUtils.parcelingRoundTrip(unparceled);
-        assertEquals(unparceled, parceled);
+        final TetheringRequest withoutConfig = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setStaticIpv4Addresses(localAddr, clientAddr)
+                .setExemptFromEntitlementCheck(true)
+                .setShouldShowEntitlementUi(false).build();
+        assertEquals(withConfig, ParcelUtils.parcelingRoundTrip(withConfig));
+        assertEquals(withoutConfig, ParcelUtils.parcelingRoundTrip(withoutConfig));
+        assertNotEquals(withConfig, ParcelUtils.parcelingRoundTrip(withoutConfig));
+        assertNotEquals(withoutConfig, ParcelUtils.parcelingRoundTrip(withConfig));
     }
 
     @Test
diff --git a/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java b/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java
index b47b97d..fb3004a3 100644
--- a/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java
+++ b/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java
@@ -325,18 +325,9 @@
         assertEquals(new InetAddress[0], cfgStrict.ips);
     }
 
-    @Test
-    public void testSendDnsConfiguration() throws Exception {
+    private void doTestSendDnsConfiguration(PrivateDnsConfig cfg, DohParamsParcel expectedDohParams)
+            throws Exception {
         reset(mMockDnsResolver);
-        final PrivateDnsConfig cfg = new PrivateDnsConfig(
-                PRIVATE_DNS_MODE_OPPORTUNISTIC /* mode */,
-                null /* hostname */,
-                null /* ips */,
-                "doh.com" /* dohName */,
-                null /* dohIps */,
-                "/some-path{?dns}" /* dohPath */,
-                5353 /* dohPort */);
-
         mDnsManager.updatePrivateDns(new Network(TEST_NETID), cfg);
         final LinkProperties lp = new LinkProperties();
         lp.setInterfaceName(TEST_IFACENAME);
@@ -361,13 +352,60 @@
         expectedParams.transportTypes = TEST_TRANSPORT_TYPES;
         expectedParams.resolverOptions = null;
         expectedParams.meteredNetwork = true;
-        expectedParams.dohParams = new DohParamsParcel.Builder()
+        expectedParams.dohParams = expectedDohParams;
+        expectedParams.interfaceNames = new String[]{TEST_IFACENAME};
+        verify(mMockDnsResolver, times(1)).setResolverConfiguration(eq(expectedParams));
+    }
+
+    @Test
+    public void testSendDnsConfiguration_ddrDisabled() throws Exception {
+        final PrivateDnsConfig cfg = new PrivateDnsConfig(
+                PRIVATE_DNS_MODE_OPPORTUNISTIC /* mode */,
+                null /* hostname */,
+                null /* ips */,
+                false /* ddrEnabled */,
+                null /* dohName */,
+                null /* dohIps */,
+                null /* dohPath */,
+                -1 /* dohPort */);
+        doTestSendDnsConfiguration(cfg, null /* expectedDohParams */);
+    }
+
+    @Test
+    public void testSendDnsConfiguration_ddrEnabledEmpty() throws Exception {
+        final PrivateDnsConfig cfg = new PrivateDnsConfig(
+                PRIVATE_DNS_MODE_OPPORTUNISTIC /* mode */,
+                null /* hostname */,
+                null /* ips */,
+                true /* ddrEnabled */,
+                null /* dohName */,
+                null /* dohIps */,
+                null /* dohPath */,
+                -1 /* dohPort */);
+
+        final DohParamsParcel params = new DohParamsParcel.Builder().build();
+        doTestSendDnsConfiguration(cfg, params);
+    }
+
+    @Test
+    public void testSendDnsConfiguration_ddrEnabled() throws Exception {
+        final PrivateDnsConfig cfg = new PrivateDnsConfig(
+                PRIVATE_DNS_MODE_OPPORTUNISTIC /* mode */,
+                null /* hostname */,
+                null /* ips */,
+                true /* ddrEnabled */,
+                "doh.com" /* dohName */,
+                null /* dohIps */,
+                "/some-path{?dns}" /* dohPath */,
+                5353 /* dohPort */);
+
+        final DohParamsParcel params = new DohParamsParcel.Builder()
                 .setName("doh.com")
                 .setDohpath("/some-path{?dns}")
                 .setPort(5353)
                 .build();
-        expectedParams.interfaceNames = new String[]{TEST_IFACENAME};
-        verify(mMockDnsResolver, times(1)).setResolverConfiguration(eq(expectedParams));
+
+        doTestSendDnsConfiguration(cfg, params);
     }
 
     @Test
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSSatelliteNetworkTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSSatelliteNetworkTest.kt
index 88c2738..5ca7fcc 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSSatelliteNetworkTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSSatelliteNetworkTest.kt
@@ -54,6 +54,7 @@
 import org.mockito.ArgumentMatchers.any
 import org.mockito.Mockito.inOrder
 import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 
@@ -162,6 +163,43 @@
         doTestSatelliteNeverBecomeDefaultNetwork(restricted = false)
     }
 
+    private fun doTestUnregisterAfterReplacementSatisfier(destroyed: Boolean) {
+        val satelliteAgent = createSatelliteAgent("satellite0")
+        satelliteAgent.connect()
+
+        val uids = setOf(TEST_PACKAGE_UID)
+        updateSatelliteNetworkFallbackUids(uids)
+
+        if (destroyed) {
+            satelliteAgent.unregisterAfterReplacement(timeoutMs = 5000)
+        }
+
+        updateSatelliteNetworkFallbackUids(setOf())
+        if (destroyed) {
+            // If the network is already destroyed, networkRemoveUidRangesParcel should not be
+            // called.
+            verify(netd, never()).networkRemoveUidRangesParcel(any())
+        } else {
+            verify(netd).networkRemoveUidRangesParcel(
+                    NativeUidRangeConfig(
+                            satelliteAgent.network.netId,
+                            toUidRangeStableParcels(uidRangesForUids(uids)),
+                            PREFERENCE_ORDER_SATELLITE_FALLBACK
+                    )
+            )
+        }
+    }
+
+    @Test
+    fun testUnregisterAfterReplacementSatisfier_destroyed() {
+        doTestUnregisterAfterReplacementSatisfier(destroyed = true)
+    }
+
+    @Test
+    fun testUnregisterAfterReplacementSatisfier_notDestroyed() {
+        doTestUnregisterAfterReplacementSatisfier(destroyed = false)
+    }
+
     private fun assertCreateMultiLayerNrisFromSatelliteNetworkPreferredUids(uids: Set<Int>) {
         val nris: Set<ConnectivityService.NetworkRequestInfo> =
             service.createMultiLayerNrisFromSatelliteNetworkFallbackUids(uids)
diff --git a/thread/README.md b/thread/README.md
index 41b73ac..f2bd3b2 100644
--- a/thread/README.md
+++ b/thread/README.md
@@ -16,3 +16,7 @@
 
 Open `https://localhost:8443/` in your web browser, you can find the Thread
 demoapp (with the Thread logo) in the cuttlefish instance. Open it and have fun with Thread!
+
+## More docs
+
+- [Make your Android Border Router](./docs/make-your-android-border-router.md)
diff --git a/thread/docs/android-thread-arch.png b/thread/docs/android-thread-arch.png
new file mode 100644
index 0000000..ea408fa
--- /dev/null
+++ b/thread/docs/android-thread-arch.png
Binary files differ
diff --git a/thread/docs/build-an-android-border-router.md b/thread/docs/build-an-android-border-router.md
new file mode 100644
index 0000000..257999b
--- /dev/null
+++ b/thread/docs/build-an-android-border-router.md
@@ -0,0 +1,526 @@
+# Build an Android Border Router
+
+If you are not an Android device or Thread chip vendor, you can stop reading
+now.
+
+This document walks you through the steps to build a new Android-based Thread
+Border Router device with the latest AOSP source code. By following this
+document, you will learn:
+
+1. [the overall architecture and status of Thread support in Android](#architecture)
+2. [how to create your own Thread HAL service](#build-your-thread-hal-service)
+3. [how to make your device compatible with Google Home](#be-compatible-with-google-home)
+4. [how to test your Thread Border Router](#testing)
+
+If you need support, file an issue in
+[GitHub](https://github.com/openthread/ot-br-posix/issues) or open a
+[Dicussion](https://github.com/orgs/openthread/discussions) if you have any
+questions.
+
+Note: Before creating an issue or discussion, search to see if it has already
+been reported or asked.
+
+## Overview
+
+The Android Thread stack is based on OpenThread and `ot-br-posix` which are
+open-sourced by Google in GitHub. The same way OpenThread is developed in a
+public GitHub repository, so the Android Thread stack is developed in the
+public [AOSP codebase](https://cs.android.com/). All features and bug fixes
+are submitted first in AOSP. This allows vendors to start adopting the latest
+Thread versions without waiting for regular Android releases.
+
+### Architecture
+
+The whole Android Thread stack consists of two major components: the core
+Thread stack in a generic system partition and the Thread HAL service in a
+vendor partition. Device vendors typically need only to take care and build the
+HAL service.
+
+![android-thread-arch](./android-thread-arch.png)
+
+Here is a brief summary of how the Android Thread stack works:
+- There is a Java Thread system service in the system server which manages the
+  whole stack - provides the Thread system API, creates the `thread-wpan`
+  tunnel interface, registers the Thread network to the
+  [Connectivity service](https://developer.android.com/reference/android/content/Context#CONNECTIVITY_SERVICE)
+  and implements the Border Routing and Advertising Proxy functionalities.
+- The core Thread / OpenThread stack is hosted in a non-privileged standalone
+  native process which is named `ot-daemon`. `ot-daemon` is directly managed
+  by the Java system service via private AIDL APIs and it accesses the Thread
+  hardware radio through the Thread HAL API.
+- A vendor-provided Thread HAL service MUST implement the Thread HAL API. It
+  typically works as an
+  [RCP](https://openthread.io/platforms/co-processor#radio_co-processor_rcp)
+  and implements the
+  [spinel](https://openthread.io/platforms/co-processor#spinel_protocol)
+  protocol.
+
+Note: Both the Java system service and ot-daemon are delivered in a
+[Tethering](https://source.android.com/docs/core/ota/modular-system/tethering#overview)
+mainline module. For mobile devices, the binary is managed by Google and
+delivered to devices via Google Play monthly. For non-mobile devices such as
+TV, vendors are free to build from source or use a prebuilt.
+
+### Where is the code?
+
+- The Android Thread framework / API and service: https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Connectivity/thread/
+- The Thread HAL API and default service implementation: https://cs.android.com/android/platform/superproject/main/+/main:hardware/interfaces/threadnetwork/
+- Imported OpenThread repo: https://cs.android.com/android/platform/superproject/main/+/main:external/openthread/
+- Imported ot-br-posix repo: https://cs.android.com/android/platform/superproject/main/+/main:external/ot-br-posix/
+
+## Set up development environment
+
+Android device vendors who have already established an Android development
+environment for the device can skip this section.
+
+If you are new to Android ecosystem or you are a silicon vendor who wants to
+make your Thread chip compatible with Android and provide support for device
+vendors, keep reading.
+
+### Follow the Android developer codelab
+
+To set up your Android development environment for the first time, use the
+following codelab: https://source.android.com/docs/setup/start. At the end of
+this codelab, you will be able to build and run a simulated Cuttlefish device
+from source code.
+
+## Build your Thread HAL service
+
+### Try Thread in Cuttlefish
+
+[Cuttlefish](https://source.android.com/docs/devices/Cuttlefish) is the virtual
+Android device. Before starting building your own HAL service, it's better to
+try Thread in Cuttlefish to understand how HAL works.
+
+A default Thread HAL service is provided in Cuttlefish and it's implemented
+with the [simulated RCP](https://github.com/openthread/openthread/tree/main/examples/platforms/simulation)
+which transceives packets via UDP socket to and from a simulated
+Thread (802.15.4) radio.
+
+In the Cuttlefish instance, a "ThreadNetworkDemoApp" is pre-installed. Open
+that app to join the Cuttlefish device into a default Thread network.
+
+![demoapp-screenshot](./demoapp-screenshot.png)
+
+Note: The Border Router functionalities will be started (and OMR address will
+be created) only when the Cuttlefish device is connected to a virtual Wi-Fi
+network. You can connect the Wi-Fi network in Settings.
+
+There are also the `ot-ctl` and `ot-cli-ftd` command line tools provided to
+configure your Thread network for testing. Those tools support all the
+OpenThread CLI commands that you may be familiar with already.
+
+You can grep for logs of the Cuttlefish Thread HAL service by:
+
+```
+$ adb logcat | egrep -i threadnetwork-service
+
+07-21 10:43:05.048     0     0 I init    : Parsing file /apex/com.android.hardware.threadnetwork/etc/threadnetwork-service.rc...
+07-21 10:59:27.233   580   580 W android.hardware.threadnetwork-service: ThreadChip binder is unlinked
+07-21 10:59:27.233   580   580 I android.hardware.threadnetwork-service: Close IThreadChip successfully
+07-21 10:59:27.385   580   580 I android.hardware.threadnetwork-service: Open IThreadChip successfully
+```
+
+Or grep for ot-daemon logs by:
+
+```
+$ adb logcat | egrep -i ot-daemon
+07-21 10:43:48.741     0     0 I init    : starting service 'ot-daemon'...
+07-21 10:43:48.742     0     0 I init    : Created socket '/dev/socket/ot-daemon/thread-wpan.sock', mode 660, user 1084, group 1084
+07-21 10:43:48.762     0     0 I init    : ... started service 'ot-daemon' has pid 2473
+07-21 10:46:26.320  2473  2473 I ot-daemon: [I] P-Daemon------: Session socket is ready
+07-21 10:46:30.290  2473  2473 W ot-daemon: [W] P-Daemon------: Daemon read: Connection reset by peer
+07-21 10:48:07.264  2473  2473 I ot-daemon: [INFO]-BINDER--: Start joining...
+07-21 10:48:07.267  2473  2473 I ot-daemon: [I] Settings------: Saved ActiveDataset
+07-21 10:48:07.267  2473  2473 I ot-daemon: [I] DatasetManager: Active dataset set
+07-21 10:48:07.273  2473  2473 I ot-daemon: [I] DnssdServer---: Started
+07-21 10:48:07.273  2473  2473 I ot-daemon: [N] Mle-----------: Role disabled -> detached
+07-21 10:48:07.273  2473  2473 I ot-daemon: [I] Mle-----------: AttachState Idle -> Start
+07-21 10:48:07.273  2473  2473 I ot-daemon: [I] Notifier------: StateChanged (0x111fd11d) [Ip6+ Role LLAddr MLAddr KeySeqCntr Ip6Mult+ Channel PanId NetName ExtPanId ...
+07-21 10:48:07.273  2473  2473 I ot-daemon: [I] Notifier------: StateChanged (0x111fd11d) ... NetworkKey PSKc SecPolicy NetifState ActDset]
+```
+
+Note: You can also capture Thread system server log with the tag
+"ThreadNetworkService".
+
+The Cuttlefish Thread HAL service uses the default Thread HAL service plus the
+OpenThread simulated RCP binary, see the next section for how it works.
+
+### The default HAL service
+
+A [default HAL service](https://cs.android.com/android/platform/superproject/main/+/main:hardware/interfaces/threadnetwork/aidl/default/)
+is included along with the Thread HAL API. The default HAL service supports
+both simulated and real RCP devices. It receives an optional RCP device URL and
+if the URL is not provided, it defaults to the simulated RCP device.
+
+In file `hardware/interfaces/threadnetwork/aidl/default/threadnetwork-service.rc`:
+
+```
+service vendor.threadnetwork_hal /apex/com.android.hardware.threadnetwork/bin/hw/android.hardware.threadnetwork-service
+    class hal
+    user thread_network
+```
+
+This is equivalent to:
+
+```
+service vendor.threadnetwork_hal /apex/com.android.hardware.threadnetwork/bin/hw/android.hardware.threadnetwork-service spinel+hdlc+forkpty:///apex/com.android.hardware.threadnetwork/bin/ot-rcp?forkpty-arg=1
+    class hal
+    user thread_network
+```
+
+For real RCP devices, it supports both SPI and UART interace and you can
+specify the device with the schema `spinel+spi://`, `spinel+hdlc+uart://` and
+`spinel+socket://` respectively.
+
+Note: `spinel+socket://` is a new spinel interface added in the default Thread
+HAL, it supports transmitting spinel frame via an Unix socket. A full
+socket-based radio URL may be like
+`spinel+socket:///data/vendor/threadnetwork/thread_spinel_socket`.
+
+#### Understand the vendor APEX
+
+Similar to the Thread stack in the Tethering mainline module, the default Thread
+HAL service in Cuttlefish is packaged in an APEX module as well. But it's a
+vendor APEX module which will be installed to `/vendor/apex/` (The artifacts in
+the module will be unzipped to `/apex/com.android.hardware.threadnetwork/`).
+
+```aidl
+apex {
+    name: "com.android.hardware.threadnetwork",
+    manifest: "manifest.json",
+    file_contexts: "file_contexts",
+    key: "com.android.hardware.key",
+    certificate: ":com.android.hardware.certificate",
+    updatable: false,
+    vendor: true,
+
+    binaries: [
+        "android.hardware.threadnetwork-service",
+        "ot-rcp",
+    ],
+
+    prebuilts: [
+        "threadnetwork-default.xml", // vintf_fragment
+        "threadnetwork-service.rc", // init_rc
+        "android.hardware.thread_network.prebuilt.xml", // permission
+    ],
+}
+```
+
+There are a few important configurations that you will need to pay attention or
+make changes to when building your own HAL APEX module:
+
+- `file_contexts`: This describes the binary / data files delivered in this
+  APEX module or files the HAL service need to access (for example, the RCP
+  device). This allows you to specify specific sepolicy rules for your HAL
+  service to access the hardware RCP device.
+- `binaries`: The binary file delivered in this APEX module
+- `threadnetwork-service.rc`: How the HAL service will be started. You need to
+  specify the RCP device path here.
+- `android.hardware.thread_network.prebuilt.xml`: Defines the
+  `android.hardware.thread_network` hardware feature. This is required for the
+  Android system to know that your device does have Thread hardware support.
+  Otherwise, the Android Thread stack won't be enabled.
+
+### Create your HAL service
+
+Whether you are an Android device developer or a silicon vendor, you should be
+familiar with building OT RCP firmware for your Thread chip. The following
+instructions assume that the hardware chip is correctly wired and
+validated.
+
+The simplest way to build your HAL APEX is to create a new APEX with the
+binaries and prebuilts of the default HAL APEX. For example, if your company is
+Banana and the RCP device on your device is `/dev/ttyACM0`, your Thread HAL
+APEX will look like this:
+
+- `Android.bp`:
+  ```
+  prebuilt_etc {
+    name: "banana-threadnetwork-service.rc",
+    src: "banana-threadnetwork-service.rc",
+    installable: false,
+  }
+
+  apex {
+    name: "com.banana.android.hardware.threadnetwork",
+    manifest: "manifest.json",
+    file_contexts: "file_contexts",
+    key: "com.android.hardware.key",
+    certificate: ":com.android.hardware.certificate",
+    updatable: false,
+    vendor: true,
+
+    binaries: [
+        "android.hardware.threadnetwork-service",
+    ],
+
+    prebuilts: [
+        "banana-threadnetwork-service.rc",
+        "threadnetwork-default.xml",
+        "android.hardware.thread_network.prebuilt.xml",
+    ],
+  }
+  ```
+- `file_contexts`:
+  ```
+  (/.*)?                                                      u:object_r:vendor_file:s0
+  /etc(/.*)?                                                  u:object_r:vendor_configs_file:s0
+  /bin/hw/android\.hardware\.threadnetwork-service            u:object_r:hal_threadnetwork_default_exec:s0
+  /dev/ttyACM0                                                u:object_r:threadnetwork_rcp_device:s0
+  ```
+  The file paths in the first column are related to `/apex/com.android.hardware.threadnetwork/`.
+- `threadnetwork-service.rc`:
+  ```
+  service vendor.threadnetwork_hal /apex/com.android.hardware.threadnetwork/bin/hw/android.hardware.threadnetwork-service spinel+hdlc+uart:///dev/ttyACM0?uart-baudrate=115200
+    class hal
+    user root
+  ```
+- `manifest.json`:
+  ```
+  {
+    "name": "com.android.hardware.threadnetwork",
+    "version": 1
+  }
+  ```
+
+Note: The default Thread HAL service is not designed to be a generic system
+component which works for all Android devices. If the default implementation
+can't support your device, you are free to make a copy and change it for your
+needs. In this case, it's just simpler to create a new APEX module without
+overriding the default one.
+
+Assuming you are making a new device named Orange, your device specific
+configuration directory will be like:
+
+```
+device/banana/orange/threadnetwork/
+    sepolicy/
+    Android.bp
+    file_contexts
+    manifest.json
+    threadnetwork-default.xml
+    threadnetwork-service.rc
+```
+
+See the next section for what sepolicy rules should be added in the `sepolicy/`
+sub-directory.
+
+#### Sepolicy rules for RCP device
+
+By default, your Thread HAL service doesn't have access to the RCP device (for
+example `/dev/ttyACM0`), custom sepolicy rules need to be added to the
+`sepolicy/` directory.
+
+Create a new `sepolicy/threadnetwork_hal.te` file with below content:
+
+```
+type threadnetwork_rcp_device, dev_type;
+
+# Allows the Thread HAL service to read / write the Thread RCP device
+allow hal_threadnetwork_default threadnetwork_rcp_device:chr_file rw_file_perms;
+```
+
+#### Put together
+
+Now you have finished almost all the code needs for adding Thread, the last
+step is to add the Thread HAL APEX and sepolicy rules to your device's image.
+
+You can do this by adding below code to your device's `Makefile` (for example,
+`device.mk`):
+
+```
+PRODUCT_PACKAGES += com.banana.hardware.threadnetwork
+BOARD_SEPOLICY_DIRS += device/banana/orange/threadnetwork/sepolicy
+```
+
+Note: Unfortunately, APEX module doesn't support sepolicy rules, so you need
+to explicitly specify the sepolicy directory separately.
+
+If everything works, now you will be able to see the Thread HAL service log similar to:
+
+```
+$ adb logcat | egrep -i threadnetwork-service
+08-13 13:26:41.751   477   477 I android.hardware.threadnetwork-service: ServiceName: android.hardware.threadnetwork.IThreadChip/chip0, Url: spinel+spi
+08-13 13:26:41.751   477   477 I android.hardware.threadnetwork-service: Thread Network HAL is running
+08-13 13:26:55.165   477   477 I android.hardware.threadnetwork-service: Open IThreadChip successfully
+```
+
+And the `ot-daemon` log will be like:
+
+```
+$ adb logcat -s ot-daemon
+08-13 13:26:55.157  1019  1019 I ot-daemon: [NOTE]-AGENT---: Running OTBR_AGENT/Unknown
+08-13 13:26:55.157  1019  1019 I ot-daemon: [NOTE]-AGENT---: Thread version: 1.3.0
+08-13 13:26:55.157  1019  1019 I ot-daemon: [NOTE]-AGENT---: Thread interface: thread-wpan
+08-13 13:26:55.157  1019  1019 I ot-daemon: [NOTE]-AGENT---: Backbone interface is not specified
+08-13 13:26:55.157  1019  1019 I ot-daemon: [NOTE]-AGENT---: Radio URL: threadnetwork_hal://binder?none
+08-13 13:26:55.157  1019  1019 I ot-daemon: [NOTE]-ILS-----: Infra link selected:
+08-13 13:26:55.160  1019  1019 I ot-daemon: [I] Platform------: [HAL] Wait for getting the service android.hardware.threadnetwork.IThreadChip/chip0 ...
+08-13 13:26:55.165  1019  1019 I ot-daemon: [I] Platform------: [HAL] Successfully got the service android.hardware.threadnetwork.IThreadChip/chip0
+08-13 13:26:55.275  1019  1019 I ot-daemon: [I] P-RadioSpinel-: RCP reset: RESET_UNKNOWN
+08-13 13:26:55.276  1019  1019 I ot-daemon: [I] P-RadioSpinel-: Software reset RCP successfully
+08-13 13:26:55.277  1019  1019 I ot-daemon: [I] P-RadioSpinel-: RCP reset: RESET_POWER_ON
+08-13 13:26:55.322  1019  1019 I ot-daemon: [I] ChildSupervsn-: Timeout: 0 -> 190
+08-13 13:26:55.324  1019  1019 I ot-daemon: [I] RoutingManager: Initializing - InfraIfIndex:0
+08-13 13:26:55.324  1019  1019 I ot-daemon: [I] InfraIf-------: Init infra netif 0
+08-13 13:26:55.324  1019  1019 I ot-daemon: [I] Settings------: Read BrUlaPrefix fd7b:cc45:ff06::/48
+08-13 13:26:55.324  1019  1019 I ot-daemon: [N] RoutingManager: BR ULA prefix: fd7b:cc45:ff06::/48 (loaded)
+08-13 13:26:55.324  1019  1019 I ot-daemon: [I] RoutingManager: Generated local OMR prefix: fd7b:cc45:ff06:1::/64
+08-13 13:26:55.324  1019  1019 I ot-daemon: [N] RoutingManager: Local on-link prefix: fdde:ad00:beef:cafe::/64
+08-13 13:26:55.324  1019  1019 I ot-daemon: [I] RoutingManager: Enabling
+```
+
+## Customization
+
+The Thread mainline module (it's actually a part of the "Tethering" module)
+provides a few [overlayable configurations](https://source.android.com/docs/core/runtime/rros)
+which can be specified by vendors to customize the stack behavior. See
+[config_thread.xml](https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Connectivity/service/ServiceConnectivityResources/res/values/config_thread.xml)
+for the full list.
+
+Typically, you must change the `config_thread_vendor_name`,
+`config_thread_vendor_oui` and `config_thread_model_name` to your vendor or
+product values. Those values will be included in the `_meshcop._udp` mDNS
+service which is always advertised by a Thread Border Router.
+
+To add the overlay, you need to create a new `ConnectivityOverlayOrange`
+runtime_resource_overlay target for your Orange device. Create a new
+`ConnectivityOverlay/` directory under `device/banana/orange/rro_overlays` and
+create below contents in it:
+
+```
+device/banana/orange/rro_overlays/ConnectivityOverlay/
+  res
+    values
+      config_thread.xml
+  Android.bp
+  AndroidManifest.xml
+```
+
+- `Android.bp`:
+  ```
+  package {
+      default_applicable_licenses: ["Android-Apache-2.0"],
+  }
+
+  runtime_resource_overlay {
+      name: "ConnectivityOverlayOrange",
+      manifest: "AndroidManifest.xml",
+      resource_dirs: ["res"],
+      certificate: "platform",
+      product_specific: true,
+      sdk_version: "current",
+  }
+  ```
+- `AndroidManifest.xml`:
+  ```
+  <!-- Orange overlays for the Connectivity module -->
+  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
+      package="com.banana.android.connectivity.resources.orange"
+      android:versionCode="1"
+      android:versionName="1.0">
+      <application android:hasCode="false" />
+
+      <!-- If your device uses google-signed mainline modules, the targetPackage
+      needs to be "com.google.android.connectivity.resources", otherise, it
+      should be "com.android.connectivity.resources"
+      -->
+      <overlay
+          android:targetPackage="com.google.android.connectivity.resources"
+          android:targetName="ServiceConnectivityResourcesConfig"
+          android:isStatic="true"
+          android:priority="1"/>
+  </manifest>
+  ```
+- `config_thread.xml`:
+  ```
+  <string translatable="false" name="config_thread_vendor_name">Banana Inc.</string>
+  <string translatable="false" name="config_thread_vendor_oui">AC:DE:48</string>
+  <string translatable="false" name="config_thread_model_name">Orange</string>
+  ```
+
+Similar to the HAL APEX, you need to add the overlay app to your `device.mk`
+file:
+
+```
+PRODUCT_PACKAGES += \
+    ConnectivityOverlayOrange
+```
+
+If everything works, you will see that `ot-daemon` logs the vendor and model name
+at the very beginning of the log:
+```
+$ adb logcat -s ot-daemon
+07-22 15:31:37.693  1472  1472 I ot-daemon: [I] P-Daemon------: Session socket is ready
+07-22 15:31:37.693  1472  1472 I ot-daemon: [I] Cli-----------: Input: state
+07-22 15:31:37.693  1472  1472 I ot-daemon: [I] Cli-----------: Output: disabled
+07-22 15:31:37.693  1472  1472 I ot-daemon: [I] Cli-----------: Output: Done
+07-22 15:31:37.693  1472  1472 W ot-daemon: [W] P-Daemon------: Daemon read: Connection reset by peer
+07-22 15:31:50.091  1472  1472 I ot-daemon: [I] P-Daemon------: Session socket is ready
+07-22 15:31:50.091  1472  1472 I ot-daemon: [I] Cli-----------: Input: factoryreset
+07-22 15:31:50.092  1472  1472 I ot-daemon: [I] Settings------: Wiped all info
+07-22 15:31:50.092  1472  1472 I ot-daemon: [INFO]-ADPROXY-: Stopped
+07-22 15:31:50.092  1472  1472 I ot-daemon: [INFO]-DPROXY--: Stopped
+07-22 15:31:50.092  1472  1472 I ot-daemon: [INFO]-BA------: Stop Thread Border Agent
+07-22 15:31:50.092  1472  1472 I ot-daemon: [INFO]-BA------: Unpublish meshcop service Banana Inc. Orange #4833._meshcop._udp.local
+07-22 15:31:50.092  1472  1472 I ot-daemon: [INFO]-MDNS----: Removing service Banana Inc. Orange #4833._meshcop._udp
+07-22 15:31:50.092  1472  1472 I ot-daemon: [INFO]-MDNS----: Unpublishing service Banana Inc. Orange #4833._meshcop._udp listener ID = 0
+```
+
+Note: In case the overlay doesn't work, check https://source.android.com/docs/core/runtime/rro-troubleshoot
+for troubleshooting instructions.
+
+### Be compatible with Google Home
+
+Additionally, if you want to make your Border Router be used by the Google Home
+ecosystem, you can specify this configuration in `config_thread.xml`:
+
+```
+<string-array name="config_thread_mdns_vendor_specific_txts">
+  <item>vgh=1</item>
+</string-array>
+```
+
+## Testing
+
+Your device should be compatible with the Thread 1.3+ Border Router
+specification now. Before sending it to the Thread certification program, there
+are a few Android xTS tests should be exercised to ensure the compatibility.
+
+- The VTS test makes sure Thread HAL service work as expected on your device.
+  You can run the tests with command
+  ```
+  atest VtsHalThreadNetworkTargetTest
+  ```
+- The CTS test makes sure Thread APIs work as expected on your device. You can
+  run the tests with command
+  ```
+  atest CtsThreadNetworkTestCases
+  ```
+- The integration test provides more quality guarantee of how the Thread
+  mainline code works on your device. You can run the tests with command
+  ```
+  atest ThreadNetworkIntegrationTests
+  ```
+
+You can also find more instructions of how to run VTS/CTS/MTS tests with those
+released test suites:
+
+- https://source.android.com/docs/core/tests/vts
+- https://source.android.com/docs/compatibility/cts/run
+- https://docs.partner.android.com/mainline/test/mts (you need to be a partner to access this link)
+
+### Test with the Thread demo app
+
+Similar to the Cuttlefish device, you can add the Thread demo app to your system image:
+
+```
+# ThreadNetworkDemoApp for testing
+PRODUCT_PACKAGES_DEBUG += ThreadNetworkDemoApp
+```
+
+Note that you should add it to only the debug / eng variant (for example,
+`PRODUCT_PACKAGES_DEBUG`) given this is not supposed to be included in user
+build for end consumers.
diff --git a/thread/docs/demoapp-screenshot.png b/thread/docs/demoapp-screenshot.png
new file mode 100644
index 0000000..fa7f079
--- /dev/null
+++ b/thread/docs/demoapp-screenshot.png
Binary files differ
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 452fab9..61fa5d0 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -120,7 +120,6 @@
 import com.android.server.ServiceManagerWrapper;
 import com.android.server.connectivity.ConnectivityResources;
 import com.android.server.thread.openthread.BackboneRouterState;
-import com.android.server.thread.openthread.BorderRouterConfiguration;
 import com.android.server.thread.openthread.DnsTxtAttribute;
 import com.android.server.thread.openthread.IChannelMasksReceiver;
 import com.android.server.thread.openthread.IOtDaemon;
@@ -129,6 +128,7 @@
 import com.android.server.thread.openthread.Ipv6AddressInfo;
 import com.android.server.thread.openthread.MeshcopTxtAttributes;
 import com.android.server.thread.openthread.OnMeshPrefixConfig;
+import com.android.server.thread.openthread.OtDaemonConfiguration;
 import com.android.server.thread.openthread.OtDaemonState;
 
 import libcore.util.HexEncoding;
@@ -213,7 +213,7 @@
     private boolean mUserRestricted;
     private boolean mForceStopOtDaemonEnabled;
 
-    private BorderRouterConfiguration mBorderRouterConfig;
+    private OtDaemonConfiguration mOtDaemonConfig;
 
     @VisibleForTesting
     ThreadNetworkControllerService(
@@ -238,8 +238,8 @@
         mInfraIfController = infraIfController;
         mUpstreamNetworkRequest = newUpstreamNetworkRequest();
         mNetworkToInterface = new HashMap<Network, String>();
-        mBorderRouterConfig =
-                new BorderRouterConfiguration.Builder()
+        mOtDaemonConfig =
+                new OtDaemonConfiguration.Builder()
                         .setIsBorderRoutingEnabled(true)
                         .setInfraInterfaceName(null)
                         .build();
@@ -1232,54 +1232,51 @@
         }
     }
 
-    private void configureBorderRouter(BorderRouterConfiguration borderRouterConfig) {
-        if (mBorderRouterConfig.equals(borderRouterConfig)) {
+    private void configureBorderRouter(OtDaemonConfiguration otDaemonConfig) {
+        if (mOtDaemonConfig.equals(otDaemonConfig)) {
             return;
         }
-        Log.i(
-                TAG,
-                "Configuring Border Router: " + mBorderRouterConfig + " -> " + borderRouterConfig);
-        mBorderRouterConfig = borderRouterConfig;
+        Log.i(TAG, "Configuring Border Router: " + mOtDaemonConfig + " -> " + otDaemonConfig);
+        mOtDaemonConfig = otDaemonConfig;
         ParcelFileDescriptor infraIcmp6Socket = null;
-        if (mBorderRouterConfig.infraInterfaceName != null) {
+        if (mOtDaemonConfig.infraInterfaceName != null) {
             try {
                 infraIcmp6Socket =
-                        mInfraIfController.createIcmp6Socket(
-                                mBorderRouterConfig.infraInterfaceName);
+                        mInfraIfController.createIcmp6Socket(mOtDaemonConfig.infraInterfaceName);
             } catch (IOException e) {
                 Log.i(TAG, "Failed to create ICMPv6 socket on infra network interface", e);
             }
         }
         try {
             getOtDaemon()
-                    .configureBorderRouter(
-                            mBorderRouterConfig,
+                    .setConfiguration(
+                            mOtDaemonConfig,
                             infraIcmp6Socket,
                             new ConfigureBorderRouterStatusReceiver());
         } catch (RemoteException | ThreadNetworkException e) {
-            Log.w(TAG, "Failed to configure border router " + mBorderRouterConfig, e);
+            Log.w(TAG, "Failed to configure border router " + mOtDaemonConfig, e);
         }
     }
 
     private void enableBorderRouting(String infraIfName) {
-        BorderRouterConfiguration borderRouterConfig =
-                newBorderRouterConfigBuilder(mBorderRouterConfig)
+        OtDaemonConfiguration otDaemonConfig =
+                newOtDaemonConfigBuilder(mOtDaemonConfig)
                         .setIsBorderRoutingEnabled(true)
                         .setInfraInterfaceName(infraIfName)
                         .build();
         Log.i(TAG, "Enable border routing on AIL: " + infraIfName);
-        configureBorderRouter(borderRouterConfig);
+        configureBorderRouter(otDaemonConfig);
     }
 
     private void disableBorderRouting() {
         mUpstreamNetwork = null;
-        BorderRouterConfiguration borderRouterConfig =
-                newBorderRouterConfigBuilder(mBorderRouterConfig)
+        OtDaemonConfiguration otDaemonConfig =
+                newOtDaemonConfigBuilder(mOtDaemonConfig)
                         .setIsBorderRoutingEnabled(false)
                         .setInfraInterfaceName(null)
                         .build();
         Log.i(TAG, "Disabling border routing");
-        configureBorderRouter(borderRouterConfig);
+        configureBorderRouter(otDaemonConfig);
     }
 
     private void handleThreadInterfaceStateChanged(boolean isUp) {
@@ -1380,9 +1377,9 @@
         return builder.build();
     }
 
-    private static BorderRouterConfiguration.Builder newBorderRouterConfigBuilder(
-            BorderRouterConfiguration brConfig) {
-        return new BorderRouterConfiguration.Builder()
+    private static OtDaemonConfiguration.Builder newOtDaemonConfigBuilder(
+            OtDaemonConfiguration brConfig) {
+        return new OtDaemonConfiguration.Builder()
                 .setIsBorderRoutingEnabled(brConfig.isBorderRoutingEnabled)
                 .setInfraInterfaceName(brConfig.infraInterfaceName);
     }