Add captive portal info to DhcpClient output

Requesting the captive portal option is flagged off by default.
The URL it provides will be used to support the captive portal API; see
RFC7710bis.

Bug: 139269711
Test: atest NetworkStackTests NetworkStackNextTests
Test: atest NetworkStackIntegrationTests NetworkStackNextIntegrationTests

Change-Id: I783466e0e60f364e79cd76af3fe43a7862d35cf2
diff --git a/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java b/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java
index 135aa63..10df01b 100644
--- a/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java
+++ b/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java
@@ -23,7 +23,6 @@
 import android.os.RemoteException;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.VisibleForTesting;
 
 import org.json.JSONException;
 import org.json.JSONObject;
@@ -70,7 +69,6 @@
                 .build());
     }
 
-    @VisibleForTesting
     public static boolean isSupported() {
         return ShimUtils.isReleaseOrDevelopmentApiAbove(Build.VERSION_CODES.Q);
     }
diff --git a/src/android/net/dhcp/DhcpClient.java b/src/android/net/dhcp/DhcpClient.java
index fbcd0f6..41e54a4 100644
--- a/src/android/net/dhcp/DhcpClient.java
+++ b/src/android/net/dhcp/DhcpClient.java
@@ -17,6 +17,7 @@
 package android.net.dhcp;
 
 import static android.net.dhcp.DhcpPacket.DHCP_BROADCAST_ADDRESS;
+import static android.net.dhcp.DhcpPacket.DHCP_CAPTIVE_PORTAL;
 import static android.net.dhcp.DhcpPacket.DHCP_DNS_SERVER;
 import static android.net.dhcp.DhcpPacket.DHCP_DOMAIN_NAME;
 import static android.net.dhcp.DhcpPacket.DHCP_LEASE_TIME;
@@ -95,6 +96,7 @@
 import com.android.internal.util.TrafficStatsConstants;
 import com.android.internal.util.WakeupMessage;
 import com.android.networkstack.R;
+import com.android.networkstack.apishim.CaptivePortalDataShimImpl;
 import com.android.networkstack.apishim.SocketUtilsShimImpl;
 import com.android.networkstack.arp.ArpPacket;
 
@@ -260,8 +262,9 @@
     private static final SparseArray<String> sMessageNames =
             MessageUtils.findMessageNames(sMessageClasses);
 
-    // DHCP parameters that we request.
-    /* package */ static final byte[] REQUESTED_PARAMS = new byte[] {
+    // DHCP parameters that we request by default.
+    @VisibleForTesting
+    /* package */ static final byte[] DEFAULT_REQUESTED_PARAMS = new byte[] {
         DHCP_SUBNET_MASK,
         DHCP_ROUTER,
         DHCP_DNS_SERVER,
@@ -274,6 +277,21 @@
         DHCP_VENDOR_INFO,
     };
 
+    @NonNull
+    private byte[] getRequestedParams() {
+        if (isCapportApiEnabled()) {
+            final byte[] params = Arrays.copyOf(DEFAULT_REQUESTED_PARAMS,
+                    DEFAULT_REQUESTED_PARAMS.length + 1);
+            params[params.length - 1] = DHCP_CAPTIVE_PORTAL;
+            return params;
+        }
+        return DEFAULT_REQUESTED_PARAMS;
+    }
+
+    private static boolean isCapportApiEnabled() {
+        return CaptivePortalDataShimImpl.isSupported();
+    }
+
     // DHCP flag that means "yes, we support unicast."
     private static final boolean DO_UNICAST   = false;
 
@@ -556,8 +574,10 @@
         @Override
         protected void handlePacket(byte[] recvbuf, int length) {
             try {
+                final byte[] optionsToSkip =
+                        isCapportApiEnabled() ? new byte[0] : new byte[] { DHCP_CAPTIVE_PORTAL };
                 final DhcpPacket packet = DhcpPacket.decodeFullPacket(recvbuf, length,
-                        DhcpPacket.ENCAP_L2);
+                        DhcpPacket.ENCAP_L2, optionsToSkip);
                 if (DBG) Log.d(TAG, "Received packet: " + packet);
                 sendMessage(CMD_RECEIVED_PACKET, packet);
             } catch (DhcpPacket.ParseException e) {
@@ -649,7 +669,7 @@
     private boolean sendDiscoverPacket() {
         final ByteBuffer packet = DhcpPacket.buildDiscoverPacket(
                 DhcpPacket.ENCAP_L2, mTransactionId, getSecs(), mHwAddr,
-                DO_UNICAST, REQUESTED_PARAMS, isDhcpRapidCommitEnabled(), mHostname);
+                DO_UNICAST, getRequestedParams(), isDhcpRapidCommitEnabled(), mHostname);
         return transmitPacket(packet, "DHCPDISCOVER", DhcpPacket.ENCAP_L2, INADDR_BROADCAST);
     }
 
@@ -663,7 +683,7 @@
         final ByteBuffer packet = DhcpPacket.buildRequestPacket(
                 encap, mTransactionId, getSecs(), clientAddress,
                 DO_UNICAST, mHwAddr, requestedAddress,
-                serverAddress, REQUESTED_PARAMS, mHostname);
+                serverAddress, getRequestedParams(), mHostname);
         String serverStr = (serverAddress != null) ? serverAddress.getHostAddress() : null;
         String description = "DHCPREQUEST ciaddr=" + clientAddress.getHostAddress() +
                              " request=" + requestedAddress.getHostAddress() +
@@ -1267,7 +1287,7 @@
             final Layer2PacketParcelable l2Packet = new Layer2PacketParcelable();
             final ByteBuffer packet = DhcpPacket.buildDiscoverPacket(
                     DhcpPacket.ENCAP_L2, mTransactionId, getSecs(), mHwAddr,
-                    DO_UNICAST, REQUESTED_PARAMS, true /* rapid commit */, mHostname);
+                    DO_UNICAST, getRequestedParams(), true /* rapid commit */, mHostname);
 
             l2Packet.dstMacAddress = MacAddress.fromBytes(DhcpPacket.ETHER_BROADCAST);
             l2Packet.payload = Arrays.copyOf(packet.array(), packet.limit());
diff --git a/src/android/net/dhcp/DhcpPacket.java b/src/android/net/dhcp/DhcpPacket.java
index 9eca0b5..19af74c 100644
--- a/src/android/net/dhcp/DhcpPacket.java
+++ b/src/android/net/dhcp/DhcpPacket.java
@@ -307,6 +307,10 @@
     protected static final byte DHCP_RAPID_COMMIT = 80;
     protected boolean mRapidCommit;
 
+    @VisibleForTesting
+    public static final byte DHCP_CAPTIVE_PORTAL = (byte) 114;
+    protected String mCaptivePortalUrl;
+
     /**
      * DHCP zero-length option code: pad
      */
@@ -785,6 +789,7 @@
         if (mMtu != null && Short.toUnsignedInt(mMtu) >= IPV4_MIN_MTU) {
             addTlv(buf, DHCP_MTU, mMtu);
         }
+        addTlv(buf, DHCP_CAPTIVE_PORTAL, mCaptivePortalUrl);
     }
 
     /**
@@ -871,6 +876,23 @@
         }
     }
 
+    private static int skipOption(ByteBuffer packet, int optionLen)
+            throws BufferUnderflowException {
+        int expectedLen = 0;
+        for (int i = 0; i < optionLen; i++) {
+            expectedLen++;
+            packet.get();
+        }
+        return expectedLen;
+    }
+
+    private static boolean shouldSkipOption(byte optionType, byte[] optionsToSkip) {
+        for (byte option : optionsToSkip) {
+            if (option == optionType) return true;
+        }
+        return false;
+    }
+
     /**
      * Creates a concrete DhcpPacket from the supplied ByteBuffer.  The
      * buffer may have an L2 encapsulation (which is the full EthernetII
@@ -881,8 +903,8 @@
      * in object fields.
      */
     @VisibleForTesting
-    static DhcpPacket decodeFullPacket(ByteBuffer packet, int pktType) throws ParseException
-    {
+    static DhcpPacket decodeFullPacket(ByteBuffer packet, int pktType, byte[] optionsToSkip)
+            throws ParseException {
         // bootp parameters
         int transactionId;
         short secs;
@@ -900,6 +922,7 @@
         String vendorId = null;
         String vendorInfo = null;
         boolean rapidCommit = false;
+        String captivePortalUrl = null;
         byte[] expectedParams = null;
         String hostName = null;
         String domainName = null;
@@ -1080,6 +1103,11 @@
                     int optionLen = packet.get() & 0xFF;
                     int expectedLen = 0;
 
+                    if (shouldSkipOption(optionType, optionsToSkip)) {
+                        skipOption(packet, optionLen);
+                        continue;
+                    }
+
                     switch(optionType) {
                         case DHCP_SUBNET_MASK:
                             netMask = readIpAddress(packet);
@@ -1172,12 +1200,12 @@
                             expectedLen = 0;
                             rapidCommit = true;
                             break;
+                        case DHCP_CAPTIVE_PORTAL:
+                            expectedLen = optionLen;
+                            captivePortalUrl = readAsciiString(packet, optionLen, true);
+                            break;
                         default:
-                            // ignore any other parameters
-                            for (int i = 0; i < optionLen; i++) {
-                                expectedLen++;
-                                byte throwaway = packet.get();
-                            }
+                            expectedLen = skipOption(packet, optionLen);
                     }
 
                     if (expectedLen != optionLen) {
@@ -1263,6 +1291,7 @@
         newPacket.mT2 = T2;
         newPacket.mVendorId = vendorId;
         newPacket.mVendorInfo = vendorInfo;
+        newPacket.mCaptivePortalUrl = captivePortalUrl;
         if ((optionOverload & OPTION_OVERLOAD_SNAME) == 0) {
             newPacket.mServerHostName = serverHostName;
         } else {
@@ -1274,11 +1303,11 @@
     /**
      * Parse a packet from an array of bytes, stopping at the given length.
      */
-    public static DhcpPacket decodeFullPacket(byte[] packet, int length, int pktType)
-            throws ParseException {
+    public static DhcpPacket decodeFullPacket(byte[] packet, int length, int pktType,
+            byte[] optionsToSkip) throws ParseException {
         ByteBuffer buffer = ByteBuffer.wrap(packet, 0, length).order(ByteOrder.BIG_ENDIAN);
         try {
-            return decodeFullPacket(buffer, pktType);
+            return decodeFullPacket(buffer, pktType, optionsToSkip);
         } catch (ParseException e) {
             throw e;
         } catch (Exception e) {
@@ -1287,6 +1316,14 @@
     }
 
     /**
+     * Parse a packet from an array of bytes, stopping at the given length.
+     */
+    public static DhcpPacket decodeFullPacket(byte[] packet, int length, int pktType)
+            throws ParseException {
+        return decodeFullPacket(packet, length, pktType, new byte[0]);
+    }
+
+    /**
      *  Construct a DhcpResults object from a DHCP reply packet.
      */
     public DhcpResults toDhcpResults() {
@@ -1328,6 +1365,7 @@
         results.leaseDuration = (mLeaseTime != null) ? mLeaseTime : INFINITE_LEASE;
         results.mtu = (mMtu != null && MIN_MTU <= mMtu && mMtu <= MAX_MTU) ? mMtu : 0;
         results.serverHostName = mServerHostName;
+        results.captivePortalApiUrl = mCaptivePortalUrl;
 
         return results;
     }
@@ -1369,7 +1407,7 @@
             Inet4Address yourIp, byte[] mac, Integer timeout, Inet4Address netMask,
             Inet4Address bcAddr, List<Inet4Address> gateways, List<Inet4Address> dnsServers,
             Inet4Address dhcpServerIdentifier, String domainName, String hostname, boolean metered,
-            short mtu) {
+            short mtu, String captivePortalUrl) {
         DhcpPacket pkt = new DhcpOfferPacket(
                 transactionId, (short) 0, broadcast, serverIpAddr, relayIp,
                 INADDR_ANY /* clientIp */, yourIp, mac);
@@ -1382,6 +1420,7 @@
         pkt.mSubnetMask = netMask;
         pkt.mBroadcastAddress = bcAddr;
         pkt.mMtu = mtu;
+        pkt.mCaptivePortalUrl = captivePortalUrl;
         if (metered) {
             pkt.mVendorInfo = VENDOR_INFO_ANDROID_METERED;
         }
@@ -1396,7 +1435,7 @@
             Inet4Address requestClientIp, byte[] mac, Integer timeout, Inet4Address netMask,
             Inet4Address bcAddr, List<Inet4Address> gateways, List<Inet4Address> dnsServers,
             Inet4Address dhcpServerIdentifier, String domainName, String hostname, boolean metered,
-            short mtu, boolean rapidCommit) {
+            short mtu, boolean rapidCommit, String captivePortalUrl) {
         DhcpPacket pkt = new DhcpAckPacket(
                 transactionId, (short) 0, broadcast, serverIpAddr, relayIp, requestClientIp, yourIp,
                 mac, rapidCommit);
@@ -1409,6 +1448,7 @@
         pkt.mServerIdentifier = dhcpServerIdentifier;
         pkt.mBroadcastAddress = bcAddr;
         pkt.mMtu = mtu;
+        pkt.mCaptivePortalUrl = captivePortalUrl;
         if (metered) {
             pkt.mVendorInfo = VENDOR_INFO_ANDROID_METERED;
         }
diff --git a/src/android/net/dhcp/DhcpServer.java b/src/android/net/dhcp/DhcpServer.java
index 9e54a69..9c5b3c6 100644
--- a/src/android/net/dhcp/DhcpServer.java
+++ b/src/android/net/dhcp/DhcpServer.java
@@ -529,7 +529,9 @@
                 broadcastAddr, new ArrayList<>(mServingParams.defaultRouters),
                 new ArrayList<>(mServingParams.dnsServers),
                 mServingParams.getServerInet4Addr(), null /* domainName */, hostname,
-                mServingParams.metered, (short) mServingParams.linkMtu);
+                mServingParams.metered, (short) mServingParams.linkMtu,
+                // TODO (b/144402437): advertise the URL if known
+                null /* captivePortalApiUrl */);
 
         return transmitOfferOrAckPacket(offerPacket, request, lease, clientMac, broadcastFlag);
     }
@@ -549,7 +551,8 @@
                 new ArrayList<>(mServingParams.dnsServers),
                 mServingParams.getServerInet4Addr(), null /* domainName */, hostname,
                 mServingParams.metered, (short) mServingParams.linkMtu,
-                packet.mRapidCommit && mDhcpRapidCommitEnabled);
+                // TODO (b/144402437): advertise the URL if known
+                packet.mRapidCommit && mDhcpRapidCommitEnabled, null /* captivePortalApiUrl */);
 
         return transmitOfferOrAckPacket(ackPacket, packet, lease, clientMac, broadcastFlag);
     }
diff --git a/src/android/net/ip/IpClient.java b/src/android/net/ip/IpClient.java
index 47f955a..173f136 100644
--- a/src/android/net/ip/IpClient.java
+++ b/src/android/net/ip/IpClient.java
@@ -36,6 +36,7 @@
 import android.net.ProxyInfo;
 import android.net.RouteInfo;
 import android.net.TcpKeepalivePacketDataParcelable;
+import android.net.Uri;
 import android.net.apf.ApfCapabilities;
 import android.net.apf.ApfFilter;
 import android.net.dhcp.DhcpClient;
@@ -57,6 +58,7 @@
 import android.util.LocalLog;
 import android.util.Log;
 import android.util.Pair;
+import android.util.Patterns;
 import android.util.SparseArray;
 
 import androidx.annotation.NonNull;
@@ -1192,6 +1194,15 @@
             if (mDhcpResults.mtu != 0) {
                 newLp.setMtu(mDhcpResults.mtu);
             }
+
+            final String capportUrl = mDhcpResults.captivePortalApiUrl;
+            // Uri.parse does no syntax check; do a simple regex check to eliminate garbage.
+            // If the URL is still incorrect data fetching will fail later, which is fine.
+            if (capportUrl != null && Patterns.WEB_URL.matcher(capportUrl).matches()) {
+                NetworkInformationShimImpl.newInstance()
+                        .setCaptivePortalApiUrl(newLp, Uri.parse(capportUrl));
+            }
+            // TODO: also look at the IPv6 RA (netlink) for captive portal URL
         }
 
         // [4] Add in TCP buffer sizes and HTTP Proxy config, if available.
diff --git a/src/android/net/util/ConnectivityPacketSummary.java b/src/android/net/util/ConnectivityPacketSummary.java
index 08c3f60..4d04911 100644
--- a/src/android/net/util/ConnectivityPacketSummary.java
+++ b/src/android/net/util/ConnectivityPacketSummary.java
@@ -82,12 +82,26 @@
     private final ByteBuffer mPacket;
     private final String mSummary;
 
+    /**
+     * Create a string summary of a received packet.
+     * @param hwaddr MacAddress of the receiving device.
+     * @param buffer Buffer of the packet. Length is assumed to be the buffer length.
+     * @return A summary of the packet.
+     */
     public static String summarize(MacAddress hwaddr, byte[] buffer) {
         return summarize(hwaddr, buffer, buffer.length);
     }
 
     // Methods called herein perform some but by no means all error checking.
     // They may throw runtime exceptions on malformed packets.
+
+    /**
+     * Create a string summary of a received packet.
+     * @param macAddr MacAddress of the receiving device.
+     * @param buffer Buffer of the packet.
+     * @param length Length of the packet.
+     * @return A summary of the packet.
+     */
     public static String summarize(MacAddress macAddr, byte[] buffer, int length) {
         if ((macAddr == null) || (buffer == null)) return null;
         length = Math.min(length, buffer.length);
diff --git a/tests/integration/Android.bp b/tests/integration/Android.bp
index c4f057b..a9eabc8 100644
--- a/tests/integration/Android.bp
+++ b/tests/integration/Android.bp
@@ -27,9 +27,8 @@
     visibility: ["//visibility:private"],
 }
 
-android_library {
-    name: "NetworkStackIntegrationTestsLib",
-    min_sdk_version: "29",
+java_defaults {
+    name: "NetworkStackIntegrationTestsDefaults",
     srcs: ["src/**/*.java"],
     static_libs: [
         "androidx.annotation_annotation",
@@ -37,7 +36,6 @@
         "mockito-target-extended-minus-junit4",
         "net-tests-utils",
         "testables",
-        "NetworkStackApiStableLib",
     ],
     libs: [
         "android.test.runner",
@@ -48,6 +46,15 @@
     visibility: ["//visibility:private"],
 }
 
+android_library {
+    name: "NetworkStackIntegrationTestsLib",
+    defaults: ["NetworkStackIntegrationTestsDefaults"],
+    min_sdk_version: "29",
+    static_libs: [
+        "NetworkStackApiStableLib",
+    ],
+}
+
 // Network stack integration tests.
 android_test {
     name: "NetworkStackIntegrationTests",
@@ -59,6 +66,21 @@
     min_sdk_version: "29",
 }
 
+// Network stack next integration tests.
+android_test {
+    name: "NetworkStackNextIntegrationTests",
+    defaults: [
+        "NetworkStackIntegrationTestsDefaults",
+        "NetworkStackIntegrationTestsJniDefaults",
+    ],
+    static_libs: [
+        "NetworkStackApiCurrentLib",
+    ],
+    certificate: "networkstack",
+    platform_apis: true,
+    test_suites: ["device-tests"],
+}
+
 // Special version of the network stack tests that includes all tests necessary for code coverage
 // purposes. This is currently the union of NetworkStackTests and NetworkStackIntegrationTests.
 android_test {
diff --git a/tests/integration/src/android/net/ip/IpClientIntegrationTest.java b/tests/integration/src/android/net/ip/IpClientIntegrationTest.java
index 4bbee9a..b3ca925 100644
--- a/tests/integration/src/android/net/ip/IpClientIntegrationTest.java
+++ b/tests/integration/src/android/net/ip/IpClientIntegrationTest.java
@@ -56,6 +56,8 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.argThat;
 import static org.mockito.Mockito.doAnswer;
@@ -86,6 +88,7 @@
 import android.net.NetworkStackIpMemoryStore;
 import android.net.TestNetworkInterface;
 import android.net.TestNetworkManager;
+import android.net.Uri;
 import android.net.dhcp.DhcpClient;
 import android.net.dhcp.DhcpDeclinePacket;
 import android.net.dhcp.DhcpDiscoverPacket;
@@ -117,6 +120,7 @@
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.internal.util.StateMachine;
+import com.android.networkstack.apishim.CaptivePortalDataShimImpl;
 import com.android.networkstack.apishim.ShimUtils;
 import com.android.networkstack.arp.ArpPacket;
 import com.android.server.NetworkObserverRegistry;
@@ -146,6 +150,7 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Objects;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.TimeUnit;
 
@@ -219,6 +224,7 @@
     private static final byte[] SERVER_MAC = new byte[] { 0x00, 0x1A, 0x11, 0x22, 0x33, 0x44 };
     private static final String TEST_HOST_NAME = "AOSP on Crosshatch";
     private static final String TEST_HOST_NAME_TRANSLITERATION = "AOSP-on-Crosshatch";
+    private static final String TEST_CAPTIVE_PORTAL_URL = "https://example.com/capportapi";
 
     private static class TapPacketReader extends PacketReader {
         private final ParcelFileDescriptor mTapFd;
@@ -484,7 +490,7 @@
     }
 
     private static ByteBuffer buildDhcpOfferPacket(final DhcpPacket packet,
-            final Integer leaseTimeSec, final short mtu) {
+            final Integer leaseTimeSec, final short mtu, final String captivePortalUrl) {
         return DhcpPacket.buildOfferPacket(DhcpPacket.ENCAP_L2, packet.getTransactionId(),
                 false /* broadcast */, SERVER_ADDR, INADDR_ANY /* relayIp */,
                 CLIENT_ADDR /* yourIp */, packet.getClientMac(), leaseTimeSec,
@@ -492,11 +498,12 @@
                 Collections.singletonList(SERVER_ADDR) /* gateways */,
                 Collections.singletonList(SERVER_ADDR) /* dnsServers */,
                 SERVER_ADDR /* dhcpServerIdentifier */, null /* domainName */, HOSTNAME,
-                false /* metered */, mtu);
+                false /* metered */, mtu, captivePortalUrl);
     }
 
     private static ByteBuffer buildDhcpAckPacket(final DhcpPacket packet,
-            final Integer leaseTimeSec, final short mtu, final boolean rapidCommit) {
+            final Integer leaseTimeSec, final short mtu, final boolean rapidCommit,
+            final String captivePortalApiUrl) {
         return DhcpPacket.buildAckPacket(DhcpPacket.ENCAP_L2, packet.getTransactionId(),
                 false /* broadcast */, SERVER_ADDR, INADDR_ANY /* relayIp */,
                 CLIENT_ADDR /* yourIp */, CLIENT_ADDR /* requestIp */, packet.getClientMac(),
@@ -504,7 +511,7 @@
                 Collections.singletonList(SERVER_ADDR) /* gateways */,
                 Collections.singletonList(SERVER_ADDR) /* dnsServers */,
                 SERVER_ADDR /* dhcpServerIdentifier */, null /* domainName */, HOSTNAME,
-                false /* metered */, mtu, rapidCommit);
+                false /* metered */, mtu, rapidCommit, captivePortalApiUrl);
     }
 
     private static ByteBuffer buildDhcpNakPacket(final DhcpPacket packet) {
@@ -624,27 +631,35 @@
             final Integer leaseTimeSec, final boolean isDhcpLeaseCacheEnabled,
             final boolean shouldReplyRapidCommitAck, final int mtu,
             final boolean isDhcpIpConflictDetectEnabled,
-            final boolean isHostnameConfigurationEnabled, final String hostname)
-            throws Exception {
-        final List<DhcpPacket> packetList = new ArrayList<DhcpPacket>();
+            final boolean isHostnameConfigurationEnabled, final String hostname,
+            final String captivePortalApiUrl) throws Exception {
         startIpClientProvisioning(isDhcpLeaseCacheEnabled, shouldReplyRapidCommitAck,
                 false /* isPreconnectionEnabled */, isDhcpIpConflictDetectEnabled,
                 isHostnameConfigurationEnabled, hostname);
+        return handleDhcpPackets(isSuccessLease, leaseTimeSec, shouldReplyRapidCommitAck, mtu,
+                isDhcpIpConflictDetectEnabled, captivePortalApiUrl);
+    }
 
+    private List<DhcpPacket> handleDhcpPackets(final boolean isSuccessLease,
+            final Integer leaseTimeSec, final boolean shouldReplyRapidCommitAck, final int mtu,
+            final boolean isDhcpIpConflictDetectEnabled, final String captivePortalApiUrl)
+            throws Exception {
+        final List<DhcpPacket> packetList = new ArrayList<>();
         DhcpPacket packet;
         while ((packet = getNextDhcpPacket()) != null) {
             packetList.add(packet);
             if (packet instanceof DhcpDiscoverPacket) {
                 if (shouldReplyRapidCommitAck) {
                     sendResponse(buildDhcpAckPacket(packet, leaseTimeSec, (short) mtu,
-                              true /* rapidCommit */));
+                              true /* rapidCommit */, captivePortalApiUrl));
                 } else {
-                    sendResponse(buildDhcpOfferPacket(packet, leaseTimeSec, (short) mtu));
+                    sendResponse(buildDhcpOfferPacket(packet, leaseTimeSec, (short) mtu,
+                            captivePortalApiUrl));
                 }
             } else if (packet instanceof DhcpRequestPacket) {
                 final ByteBuffer byteBuffer = isSuccessLease
                         ? buildDhcpAckPacket(packet, leaseTimeSec, (short) mtu,
-                                false /* rapidCommit */)
+                                false /* rapidCommit */, captivePortalApiUrl)
                         : buildDhcpNakPacket(packet);
                 sendResponse(byteBuffer);
             } else {
@@ -676,7 +691,8 @@
             final boolean isDhcpIpConflictDetectEnabled) throws Exception {
         return performDhcpHandshake(isSuccessLease, leaseTimeSec, isDhcpLeaseCacheEnabled,
                 isDhcpRapidCommitEnabled, mtu, isDhcpIpConflictDetectEnabled,
-                false /* isHostnameConfigurationEnabled */, null /* hostname */);
+                false /* isHostnameConfigurationEnabled */, null /* hostname */,
+                null /* captivePortalApiUrl */);
     }
 
     private DhcpPacket getNextDhcpPacket() throws ParseException {
@@ -812,12 +828,13 @@
 
         final short mtu = (short) TEST_DEFAULT_MTU;
         if (!shouldReplyRapidCommitAck) {
-            sendResponse(buildDhcpOfferPacket(packet, TEST_LEASE_DURATION_S, mtu));
+            sendResponse(buildDhcpOfferPacket(packet, TEST_LEASE_DURATION_S, mtu,
+                    null /* captivePortalUrl */));
             packet = getNextDhcpPacket();
             assertTrue(packet instanceof DhcpRequestPacket);
         }
         sendResponse(buildDhcpAckPacket(packet, TEST_LEASE_DURATION_S, mtu,
-                shouldReplyRapidCommitAck));
+                shouldReplyRapidCommitAck, null /* captivePortalUrl */));
 
         if (!shouldAbortPreconnection) {
             mIpc.notifyPreconnectionComplete(true /* success */);
@@ -1447,7 +1464,8 @@
                 TEST_LEASE_DURATION_S, true /* isDhcpLeaseCacheEnabled */,
                 false /* isDhcpRapidCommitEnabled */, TEST_DEFAULT_MTU,
                 false /* isDhcpIpConflictDetectEnabled */,
-                true /* isHostnameConfigurationEnabled */, TEST_HOST_NAME /* hostname */);
+                true /* isHostnameConfigurationEnabled */, TEST_HOST_NAME /* hostname */,
+                null /* captivePortalApiUrl */);
         assertEquals(2, sentPackets.size());
         assertHostname(true, TEST_HOST_NAME, TEST_HOST_NAME_TRANSLITERATION, sentPackets);
         assertIpMemoryStoreNetworkAttributes(TEST_LEASE_DURATION_S, currentTime, TEST_DEFAULT_MTU);
@@ -1460,7 +1478,8 @@
                 TEST_LEASE_DURATION_S, true /* isDhcpLeaseCacheEnabled */,
                 false /* isDhcpRapidCommitEnabled */, TEST_DEFAULT_MTU,
                 false /* isDhcpIpConflictDetectEnabled */,
-                false /* isHostnameConfigurationEnabled */, TEST_HOST_NAME);
+                false /* isHostnameConfigurationEnabled */, TEST_HOST_NAME,
+                null /* captivePortalApiUrl */);
         assertEquals(2, sentPackets.size());
         assertHostname(false, TEST_HOST_NAME, TEST_HOST_NAME_TRANSLITERATION, sentPackets);
         assertIpMemoryStoreNetworkAttributes(TEST_LEASE_DURATION_S, currentTime, TEST_DEFAULT_MTU);
@@ -1473,10 +1492,59 @@
                 TEST_LEASE_DURATION_S, true /* isDhcpLeaseCacheEnabled */,
                 false /* isDhcpRapidCommitEnabled */, TEST_DEFAULT_MTU,
                 false /* isDhcpIpConflictDetectEnabled */,
-                true /* isHostnameConfigurationEnabled */, null /* hostname */);
+                true /* isHostnameConfigurationEnabled */, null /* hostname */,
+                null /* captivePortalApiUrl */);
         assertEquals(2, sentPackets.size());
         assertHostname(true, null /* hostname */, null /* hostnameAfterTransliteration */,
                 sentPackets);
         assertIpMemoryStoreNetworkAttributes(TEST_LEASE_DURATION_S, currentTime, TEST_DEFAULT_MTU);
     }
+
+    private void runDhcpClientCaptivePortalApiTest(boolean featureEnabled,
+            boolean serverSendsOption) throws Exception {
+        startIpClientProvisioning(false /* isDhcpLeaseCacheEnabled */,
+                false /* shouldReplyRapidCommitAck */, false /* isPreConnectionEnabled */,
+                false /* isDhcpIpConflictDetectEnabled */);
+        final DhcpPacket discover = getNextDhcpPacket();
+        assertTrue(discover instanceof DhcpDiscoverPacket);
+        assertEquals(featureEnabled, discover.hasRequestedParam(DhcpPacket.DHCP_CAPTIVE_PORTAL));
+
+        // Send Offer and handle Request -> Ack
+        final String serverSentUrl = serverSendsOption ? TEST_CAPTIVE_PORTAL_URL : null;
+        sendResponse(buildDhcpOfferPacket(discover, TEST_LEASE_DURATION_S, (short) TEST_DEFAULT_MTU,
+                serverSentUrl));
+        final int testMtu = 1345;
+        handleDhcpPackets(true /* isSuccessLease */, TEST_LEASE_DURATION_S,
+                false /* isDhcpRapidCommitEnabled */, testMtu,
+                false /* isDhcpIpConflictDetectEnabled */, serverSentUrl);
+
+        final Uri expectedUrl = featureEnabled && serverSendsOption
+                ? Uri.parse(TEST_CAPTIVE_PORTAL_URL) : null;
+        // Wait for LinkProperties containing DHCP-obtained info, such as MTU, and ensure that the
+        // URL is set as expected
+        verify(mCb, timeout(TEST_TIMEOUT_MS)).onLinkPropertiesChange(argThat(lp ->
+                lp.getMtu() == testMtu
+                        && Objects.equals(expectedUrl, lp.getCaptivePortalApiUrl())));
+    }
+
+    @Test
+    public void testDhcpClientCaptivePortalApiEnabled() throws Exception {
+        // Only run the test on platforms / builds where the API is enabled
+        assumeTrue(CaptivePortalDataShimImpl.isSupported());
+        runDhcpClientCaptivePortalApiTest(true /* featureEnabled */, true /* serverSendsOption */);
+    }
+
+    @Test
+    public void testDhcpClientCaptivePortalApiEnabled_NoUrl() throws Exception {
+        // Only run the test on platforms / builds where the API is enabled
+        assumeTrue(CaptivePortalDataShimImpl.isSupported());
+        runDhcpClientCaptivePortalApiTest(true /* featureEnabled */, false /* serverSendsOption */);
+    }
+
+    @Test
+    public void testDhcpClientCaptivePortalApiDisabled() throws Exception {
+        // Only run the test on platforms / builds where the API is disabled
+        assumeFalse(CaptivePortalDataShimImpl.isSupported());
+        runDhcpClientCaptivePortalApiTest(false /* featureEnabled */, true /* serverSendsOption */);
+    }
 }
diff --git a/tests/unit/src/android/net/dhcp/DhcpPacketTest.java b/tests/unit/src/android/net/dhcp/DhcpPacketTest.java
index 090631b..6ce1fdf 100644
--- a/tests/unit/src/android/net/dhcp/DhcpPacketTest.java
+++ b/tests/unit/src/android/net/dhcp/DhcpPacketTest.java
@@ -78,10 +78,12 @@
     private static final Inet4Address BROADCAST_ADDR = getBroadcastAddress(
             SERVER_ADDR, PREFIX_LENGTH);
     private static final String HOSTNAME = "testhostname";
+    private static final String CAPTIVE_PORTAL_API_URL = "https://example.com/capportapi";
     private static final short MTU = 1500;
     // Use our own empty address instead of IPV4_ADDR_ANY or INADDR_ANY to ensure that the code
     // doesn't use == instead of equals when comparing addresses.
     private static final Inet4Address ANY = v4Address("0.0.0.0");
+    private static final byte[] TEST_EMPTY_OPTIONS_SKIP_LIST = new byte[0];
 
     private static final byte[] CLIENT_MAC = new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05 };
 
@@ -169,7 +171,8 @@
                 .setDomainBytes(domainBytes)
                 .setVendorInfoBytes(vendorInfoBytes)
                 .build();
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_BOOTP);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_BOOTP,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertEquals(expectedDomain, offerPacket.mDomainName);
         assertEquals(expectedVendorInfo, offerPacket.mVendorInfo);
     }
@@ -215,14 +218,16 @@
 
         if (!expectValid) {
             try {
-                offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_BOOTP);
+                offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_BOOTP,
+                        TEST_EMPTY_OPTIONS_SKIP_LIST);
                 fail("Invalid packet parsed successfully: " + offerPacket);
             } catch (ParseException expected) {
             }
             return;
         }
 
-        offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_BOOTP);
+        offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_BOOTP,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertNotNull(offerPacket);
         assertEquals(rawLeaseTime, offerPacket.mLeaseTime);
         DhcpResults dhcpResults = offerPacket.toDhcpResults();  // Just check this doesn't crash.
@@ -266,7 +271,8 @@
         ByteBuffer packet = new TestDhcpPacket(type, clientIp, yourIp)
                 .setNetmaskBytes(netmaskBytes)
                 .build();
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_BOOTP);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_BOOTP,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         DhcpResults results = offerPacket.toDhcpResults();
 
         if (expected != null) {
@@ -351,7 +357,8 @@
             "3a0400000e103b040000189cff00000000000000000000"));
         // CHECKSTYLE:ON Generated code
 
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertTrue(offerPacket instanceof DhcpOfferPacket);  // Implicitly checks it's non-null.
         DhcpResults dhcpResults = offerPacket.toDhcpResults();
         assertDhcpResults("192.168.159.247/20", "192.168.159.254", "8.8.8.8,8.8.4.4",
@@ -384,7 +391,8 @@
         // CHECKSTYLE:ON Generated code
 
         assertEquals(337, packet.limit());
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertTrue(offerPacket instanceof DhcpOfferPacket);  // Implicitly checks it's non-null.
         DhcpResults dhcpResults = offerPacket.toDhcpResults();
         assertDhcpResults("192.168.43.247/24", "192.168.43.1", "192.168.43.1",
@@ -393,6 +401,88 @@
         assertTrue(dhcpResults.hasMeteredHint());
     }
 
+    private void runCapportOptionTest(boolean enabled) throws Exception {
+        // CHECKSTYLE:OFF Generated code
+        final ByteBuffer packet = ByteBuffer.wrap(HexDump.hexStringToByteArray(
+                // IP header.
+                "450001518d0600004011144dc0a82b01c0a82bf7" +
+                // UDP header
+                "00430044013d9ac7" +
+                // BOOTP header
+                "02010600dfc23d1f0002000000000000c0a82bf7c0a82b0100000000" +
+                // MAC address.
+                "30766ff2a90c00000000000000000000" +
+                // Server name ("dhcp.android.com" plus invalid "AAAA" after null terminator).
+                "646863702e616e64726f69642e636f6d00000000000000000000000000000000" +
+                "0000000000004141414100000000000000000000000000000000000000000000" +
+                // File.
+                "0000000000000000000000000000000000000000000000000000000000000000" +
+                "0000000000000000000000000000000000000000000000000000000000000000" +
+                "0000000000000000000000000000000000000000000000000000000000000000" +
+                "0000000000000000000000000000000000000000000000000000000000000000" +
+                // Options
+                "638253633501023604c0a82b01330400000e103a04000007083b0400000c4e0104ffffff00" +
+                "1c04c0a82bff0304c0a82b010604c0a82b012b0f414e44524f49445f4d455445524544721d" +
+                "68747470733a2f2f706f7274616c6170692e6578616d706c652e636f6dff"));
+        // CHECKSTYLE:ON Generated code
+
+        final DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3,
+                enabled ? TEST_EMPTY_OPTIONS_SKIP_LIST
+                        : new byte[] { DhcpPacket.DHCP_CAPTIVE_PORTAL });
+        assertTrue(offerPacket instanceof DhcpOfferPacket);  // Implicitly checks it's non-null.
+        final DhcpResults dhcpResults = offerPacket.toDhcpResults();
+        final String testUrl = enabled ? "https://portalapi.example.com" : null;
+        assertEquals(testUrl, dhcpResults.captivePortalApiUrl);
+    }
+
+    @Test
+    public void testCapportOption() throws Exception {
+        runCapportOptionTest(true /* enabled */);
+    }
+
+    @Test
+    public void testCapportOption_Disabled() throws Exception {
+        runCapportOptionTest(false /* enabled */);
+    }
+
+    @Test
+    public void testCapportOption_Invalid() throws Exception {
+        // CHECKSTYLE:OFF Generated code
+        final ByteBuffer packet = ByteBuffer.wrap(HexDump.hexStringToByteArray(
+                // IP header.
+                "450001518d0600004011144dc0a82b01c0a82bf7" +
+                // UDP header
+                "00430044013d9ac7" +
+                // BOOTP header
+                "02010600dfc23d1f0002000000000000c0a82bf7c0a82b0100000000" +
+                // MAC address.
+                "30766ff2a90c00000000000000000000" +
+                // Server name ("dhcp.android.com" plus invalid "AAAA" after null terminator).
+                "646863702e616e64726f69642e636f6d00000000000000000000000000000000" +
+                "0000000000004141414100000000000000000000000000000000000000000000" +
+                // File.
+                "0000000000000000000000000000000000000000000000000000000000000000" +
+                "0000000000000000000000000000000000000000000000000000000000000000" +
+                "0000000000000000000000000000000000000000000000000000000000000000" +
+                "0000000000000000000000000000000000000000000000000000000000000000" +
+                // Options
+                "638253633501023604c0a82b01330400000e103a04000007083b0400000c4e0104ffffff00" +
+                "1c04c0a82bff0304c0a82b010604c0a82b012b0f414e44524f49445f4d455445524544" +
+                // Option 114 (0x72, capport), length 10 (0x0a)
+                "720a" +
+                // バグ-com in UTF-8, plus the ff byte that marks the end of options.
+                "e38390e382b02d636f6dff"));
+        // CHECKSTYLE:ON Generated code
+
+        final DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
+        assertTrue(offerPacket instanceof DhcpOfferPacket);  // Implicitly checks it's non-null.
+        final DhcpResults dhcpResults = offerPacket.toDhcpResults();
+        // Output URL will be garbled because some characters do not exist in the target charset,
+        // but the parser should not crash.
+        assertTrue(dhcpResults.captivePortalApiUrl.length() > 0);
+    }
+
     @Test
     public void testBadIpPacket() throws Exception {
         final byte[] packet = HexDump.hexStringToByteArray(
@@ -400,7 +490,8 @@
             "450001518d0600004011144dc0a82b01c0a82bf7");
 
         try {
-            DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, packet.length, ENCAP_L3);
+            DhcpPacket.decodeFullPacket(packet, packet.length, ENCAP_L3,
+                    TEST_EMPTY_OPTIONS_SKIP_LIST);
         } catch (DhcpPacket.ParseException expected) {
             assertDhcpErrorCodes(DhcpErrorEvent.L3_TOO_SHORT, expected.errorCode);
             return;
@@ -419,7 +510,7 @@
             "02010600dfc23d1f0002000000000000c0a82bf7c0a82b0100000000");
 
         try {
-            DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, packet.length, ENCAP_L3);
+            DhcpPacket.decodeFullPacket(packet, packet.length, ENCAP_L3);
         } catch (DhcpPacket.ParseException expected) {
             assertDhcpErrorCodes(DhcpErrorEvent.L3_TOO_SHORT, expected.errorCode);
             return;
@@ -448,7 +539,7 @@
             "00000000000000000000000000000000000000000000000000000000000000");
 
         try {
-            DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, packet.length, ENCAP_L3);
+            DhcpPacket.decodeFullPacket(packet, packet.length, ENCAP_L3);
         } catch (DhcpPacket.ParseException expected) {
             assertDhcpErrorCodes(DhcpErrorEvent.L3_TOO_SHORT, expected.errorCode);
             return;
@@ -479,7 +570,7 @@
             );
 
         try {
-            DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, packet.length, ENCAP_L3);
+            DhcpPacket.decodeFullPacket(packet, packet.length, ENCAP_L3);
         } catch (DhcpPacket.ParseException expected) {
             assertDhcpErrorCodes(DhcpErrorEvent.DHCP_NO_COOKIE, expected.errorCode);
             return;
@@ -511,7 +602,7 @@
             "1c04c0a82bff0304c0a82b010604c0a82b012b0f414e44524f49445f4d455445524544ff");
 
         try {
-            DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, packet.length, ENCAP_L3);
+            DhcpPacket.decodeFullPacket(packet, packet.length, ENCAP_L3);
         } catch (DhcpPacket.ParseException expected) {
             assertDhcpErrorCodes(DhcpErrorEvent.DHCP_BAD_MAGIC_COOKIE, expected.errorCode);
             return;
@@ -590,7 +681,8 @@
             packet.put(mtuBytes);
             packet.clear();
         }
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertTrue(offerPacket instanceof DhcpOfferPacket);  // Implicitly checks it's non-null.
         DhcpResults dhcpResults = offerPacket.toDhcpResults();
         assertDhcpResults("192.168.159.247/20", "192.168.159.254", "8.8.8.8,8.8.4.4",
@@ -665,7 +757,8 @@
         assertEquals(6, packet.get(hwAddrLenOffset));
 
         // Expect the expected.
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertNotNull(offerPacket);
         assertEquals(6, offerPacket.getClientMac().length);
         assertEquals(expectedClientMac, HexDump.toHexString(offerPacket.getClientMac()));
@@ -673,7 +766,7 @@
         // Reduce the hardware address length and verify that it shortens the client MAC.
         packet.flip();
         packet.put(hwAddrLenOffset, (byte) 5);
-        offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3);
+        offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3, TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertNotNull(offerPacket);
         assertEquals(5, offerPacket.getClientMac().length);
         assertEquals(expectedClientMac.substring(0, 10),
@@ -681,7 +774,7 @@
 
         packet.flip();
         packet.put(hwAddrLenOffset, (byte) 3);
-        offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3);
+        offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3, TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertNotNull(offerPacket);
         assertEquals(3, offerPacket.getClientMac().length);
         assertEquals(expectedClientMac.substring(0, 6),
@@ -691,7 +784,7 @@
         // and crash, and b) hardcode it to 6.
         packet.flip();
         packet.put(hwAddrLenOffset, (byte) -1);
-        offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3);
+        offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3, TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertNotNull(offerPacket);
         assertEquals(6, offerPacket.getClientMac().length);
         assertEquals(expectedClientMac, HexDump.toHexString(offerPacket.getClientMac()));
@@ -700,7 +793,7 @@
         // hardcode it to 6.
         packet.flip();
         packet.put(hwAddrLenOffset, (byte) 17);
-        offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3);
+        offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3, TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertNotNull(offerPacket);
         assertEquals(6, offerPacket.getClientMac().length);
         assertEquals(expectedClientMac, HexDump.toHexString(offerPacket.getClientMac()));
@@ -740,7 +833,8 @@
             "0000000000000000000000000000000000000000000000ff000000"));
         // CHECKSTYLE:ON Generated code
 
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L2);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L2,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertTrue(offerPacket instanceof DhcpOfferPacket);
         DhcpResults dhcpResults = offerPacket.toDhcpResults();
         assertDhcpResults("172.17.152.118/16", "172.17.1.1", "172.17.1.1",
@@ -772,7 +866,8 @@
             "0f0f646f6d61696e3132332e636f2e756b0000000000ff00000000"));
         // CHECKSTYLE:ON Generated code
 
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L3,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertTrue(offerPacket instanceof DhcpOfferPacket);
         DhcpResults dhcpResults = offerPacket.toDhcpResults();
         assertDhcpResults("10.63.93.4/20", "10.63.80.1", "192.0.2.1,192.0.2.2",
@@ -806,7 +901,8 @@
             "0f0b6c616e63732e61632e756b000000000000000000ff00000000"));
         // CHECKSTYLE:ON Generated code
 
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L2);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L2,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertTrue(offerPacket instanceof DhcpOfferPacket);
         assertEquals("BCF5AC000000", HexDump.toHexString(offerPacket.getClientMac()));
         DhcpResults dhcpResults = offerPacket.toDhcpResults();
@@ -842,7 +938,8 @@
             "d18180060f0777766d2e6564751c040a0fffffff000000"));
         // CHECKSTYLE:ON Generated code
 
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L2);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L2,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertTrue(offerPacket instanceof DhcpOfferPacket);
         assertEquals("9CD917000000", HexDump.toHexString(offerPacket.getClientMac()));
         DhcpResults dhcpResults = offerPacket.toDhcpResults();
@@ -880,7 +977,7 @@
         // CHECKSTYLE:ON Generated code
 
         try {
-            DhcpPacket.decodeFullPacket(packet, ENCAP_L2);
+            DhcpPacket.decodeFullPacket(packet, ENCAP_L2, TEST_EMPTY_OPTIONS_SKIP_LIST);
             fail("Packet with invalid dst port did not throw ParseException");
         } catch (ParseException expected) {}
     }
@@ -912,7 +1009,8 @@
             "0308c0a8bd01ffffff0006080808080808080404ff000000000000"));
         // CHECKSTYLE:ON Generated code
 
-        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L2);
+        DhcpPacket offerPacket = DhcpPacket.decodeFullPacket(packet, ENCAP_L2,
+                TEST_EMPTY_OPTIONS_SKIP_LIST);
         assertTrue(offerPacket instanceof DhcpOfferPacket);
         assertEquals("FC3D93000000", HexDump.toHexString(offerPacket.getClientMac()));
         DhcpResults dhcpResults = offerPacket.toDhcpResults();
@@ -931,8 +1029,8 @@
 
         ByteBuffer packet = DhcpPacket.buildDiscoverPacket(
                 DhcpPacket.ENCAP_L2, transactionId, secs, hwaddr,
-                false /* do unicast */, DhcpClient.REQUESTED_PARAMS, false /* rapid commit */,
-                testHostname);
+                false /* do unicast */, DhcpClient.DEFAULT_REQUESTED_PARAMS,
+                false /* rapid commit */, testHostname);
 
         final byte[] headers = new byte[] {
             // Ethernet header.
@@ -1021,7 +1119,7 @@
                 BROADCAST_ADDR /* bcAddr */, Collections.singletonList(SERVER_ADDR) /* gateways */,
                 Collections.singletonList(SERVER_ADDR) /* dnsServers */,
                 SERVER_ADDR /* dhcpServerIdentifier */, null /* domainName */, hostname,
-                false /* metered */, MTU);
+                false /* metered */, MTU, CAPTIVE_PORTAL_API_URL);
 
         ByteArrayOutputStream bos = new ByteArrayOutputStream();
         // BOOTP headers
@@ -1085,6 +1183,9 @@
         // MTU
         bos.write(new byte[] { (byte) 0x1a, (byte) 0x02 });
         bos.write(shortToByteArray(MTU));
+        // capport URL. Option 114 = 0x72
+        bos.write(new byte[] { (byte) 0x72, (byte) CAPTIVE_PORTAL_API_URL.length() });
+        bos.write(CAPTIVE_PORTAL_API_URL.getBytes(Charset.forName("US-ASCII")));
         // End options.
         bos.write(0xff);
 
diff --git a/tests/unit/src/android/net/dhcp/DhcpServerTest.java b/tests/unit/src/android/net/dhcp/DhcpServerTest.java
index a1613c5..a278a88 100644
--- a/tests/unit/src/android/net/dhcp/DhcpServerTest.java
+++ b/tests/unit/src/android/net/dhcp/DhcpServerTest.java
@@ -383,7 +383,8 @@
 
     private DhcpPacket getPacket() throws Exception {
         verify(mDeps, times(1)).sendPacket(any(), any(), any());
-        return DhcpPacket.decodeFullPacket(mSentPacketCaptor.getValue(), ENCAP_BOOTP);
+        return DhcpPacket.decodeFullPacket(mSentPacketCaptor.getValue(), ENCAP_BOOTP,
+                new byte[0] /* optionsToSkip */);
     }
 
     private static Inet4Address parseAddr(@Nullable String inet4Addr) {