Support MirrorLink DHCPDECLINE.

Bug: 130741856
Test: atest NetworkStackTests NetworkStackNextTests
Merged-In: I9f558ce0838c87d9bb0ef519ee7c91e16f3bb066
Change-Id: I9f558ce0838c87d9bb0ef519ee7c91e16f3bb066
diff --git a/src/android/net/dhcp/DhcpLeaseRepository.java b/src/android/net/dhcp/DhcpLeaseRepository.java
index 3639a2d..b7a2572 100644
--- a/src/android/net/dhcp/DhcpLeaseRepository.java
+++ b/src/android/net/dhcp/DhcpLeaseRepository.java
@@ -37,6 +37,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 
 import java.net.Inet4Address;
 import java.util.ArrayList;
@@ -158,7 +159,7 @@
     /**
      * From a map keyed by {@link Inet4Address}, remove entries where the key is invalid (as
      * specified by {@link #isValidAddress(Inet4Address)}), or is a reserved address.
-     * @return true iff at least one entry was removed.
+     * @return true if and only if at least one entry was removed.
      */
     private <T> boolean cleanMap(Map<Inet4Address, T> map) {
         final Iterator<Entry<Inet4Address, T>> it = map.entrySet().iterator();
@@ -397,7 +398,8 @@
         mEventCallbacks.finishBroadcast();
     }
 
-    public void markLeaseDeclined(@NonNull Inet4Address addr) {
+    @VisibleForTesting
+    void markLeaseDeclined(@NonNull Inet4Address addr) {
         if (mDeclinedAddrs.containsKey(addr) || !isValidAddress(addr)) {
             mLog.logf("Not marking %s as declined: already declined or not assignable",
                     inet4AddrToString(addr));
@@ -410,6 +412,22 @@
     }
 
     /**
+     * Mark a committed lease matching the passed in clientId and hardware address parameters to be
+     * declined, and delete it from the repository.
+     *
+     * @param clientId Client identifier option if specified, or {@link #CLIENTID_UNSPEC}
+     * @param hwAddr client's mac address
+     * @param Addr IPv4 address to be declined
+     * @return true if a lease matching parameters was removed from committed repository.
+     */
+    public boolean markAndReleaseDeclinedLease(@Nullable byte[] clientId,
+            @NonNull MacAddress hwAddr, @NonNull Inet4Address addr) {
+        if (!releaseLease(clientId, hwAddr, addr)) return false;
+        markLeaseDeclined(addr);
+        return true;
+    }
+
+    /**
      * Get the list of currently valid committed leases in the repository.
      */
     @NonNull
diff --git a/src/android/net/dhcp/DhcpServer.java b/src/android/net/dhcp/DhcpServer.java
index 212e609..55b1f28 100644
--- a/src/android/net/dhcp/DhcpServer.java
+++ b/src/android/net/dhcp/DhcpServer.java
@@ -45,6 +45,7 @@
 
 import android.content.Context;
 import android.net.INetworkStackStatusCallback;
+import android.net.IpPrefix;
 import android.net.MacAddress;
 import android.net.TrafficStats;
 import android.net.util.NetworkStackUtils;
@@ -96,7 +97,8 @@
     private static final int CMD_START_DHCP_SERVER = 1;
     private static final int CMD_STOP_DHCP_SERVER = 2;
     private static final int CMD_UPDATE_PARAMS = 3;
-    private static final int CMD_SUSPEND_DHCP_SERVER = 4;
+    @VisibleForTesting
+    protected static final int CMD_RECEIVE_PACKET = 4;
 
     @NonNull
     private final Context mContext;
@@ -126,6 +128,8 @@
     private final StoppedState mStoppedState = new StoppedState();
     private final StartedState mStartedState = new StartedState();
     private final RunningState mRunningState = new RunningState();
+    private final WaitBeforeRetrievalState mWaitBeforeRetrievalState =
+            new WaitBeforeRetrievalState();
 
     /**
      * Clock to be used by DhcpServer to track time for lease expiration.
@@ -262,6 +266,7 @@
         addState(mStoppedState);
         addState(mStartedState);
             addState(mRunningState, mStartedState);
+            addState(mWaitBeforeRetrievalState, mStartedState);
         // CHECKSTYLE:ON IndentationCheck
 
         setInitialState(mStoppedState);
@@ -372,6 +377,17 @@
         }
     }
 
+    private void handleUpdateServingParams(@NonNull DhcpServingParams params,
+            @Nullable INetworkStackStatusCallback cb) {
+        mServingParams = params;
+        mLeaseRepo.updateParams(
+                DhcpServingParams.makeIpPrefix(params.serverAddr),
+                params.excludedAddrs,
+                params.dhcpLeaseTimeSecs * 1000,
+                params.singleClientAddr);
+        maybeNotifyStatus(cb, STATUS_SUCCESS);
+    }
+
     class StoppedState extends State {
         private INetworkStackStatusCallback mOnStopCallback;
 
@@ -422,6 +438,8 @@
                 mLeaseRepo.addLeaseCallbacks(mEventCallbacks);
             }
             maybeNotifyStatus(mOnStartCallback, STATUS_SUCCESS);
+            // Clear INetworkStackStatusCallback binder token, so that it's freed
+            // on the other side.
             mOnStartCallback = null;
         }
 
@@ -431,14 +449,7 @@
                 case CMD_UPDATE_PARAMS:
                     final Pair<DhcpServingParams, INetworkStackStatusCallback> pair =
                             (Pair<DhcpServingParams, INetworkStackStatusCallback>) msg.obj;
-                    final DhcpServingParams params = pair.first;
-                    mServingParams = params;
-                    mLeaseRepo.updateParams(
-                            DhcpServingParams.makeIpPrefix(params.serverAddr),
-                            params.excludedAddrs,
-                            params.dhcpLeaseTimeSecs * 1000,
-                            params.singleClientAddr);
-                    maybeNotifyStatus(pair.second, STATUS_SUCCESS);
+                    handleUpdateServingParams(pair.first, pair.second);
                     return HANDLED;
 
                 case CMD_START_DHCP_SERVER:
@@ -466,100 +477,158 @@
         @Override
         public boolean processMessage(Message msg) {
             switch (msg.what) {
-                case CMD_SUSPEND_DHCP_SERVER:
-                    // TODO: transition to the state which waits for IpServer to reconfigure the
-                    // new selected prefix.
+                case CMD_RECEIVE_PACKET:
+                    processPacket((DhcpPacket) msg.obj);
                     return HANDLED;
+
                 default:
                     // Fall through to StartedState.
                     return NOT_HANDLED;
             }
         }
-    }
 
-    @VisibleForTesting
-    void processPacket(@NonNull DhcpPacket packet, int srcPort) {
-        final String packetType = packet.getClass().getSimpleName();
-        if (srcPort != DHCP_CLIENT) {
-            mLog.logf("Ignored packet of type %s sent from client port %d", packetType, srcPort);
-            return;
-        }
+        private void processPacket(@NonNull DhcpPacket packet) {
+            mLog.log("Received packet of type " + packet.getClass().getSimpleName());
 
-        mLog.log("Received packet of type " + packetType);
-        final Inet4Address sid = packet.mServerIdentifier;
-        if (sid != null && !sid.equals(mServingParams.serverAddr.getAddress())) {
-            mLog.log("Packet ignored due to wrong server identifier: " + sid);
-            return;
-        }
-
-        try {
-            if (packet instanceof DhcpDiscoverPacket) {
-                processDiscover((DhcpDiscoverPacket) packet);
-            } else if (packet instanceof DhcpRequestPacket) {
-                processRequest((DhcpRequestPacket) packet);
-            } else if (packet instanceof DhcpReleasePacket) {
-                processRelease((DhcpReleasePacket) packet);
-            } else {
-                mLog.e("Unknown packet type: " + packet.getClass().getSimpleName());
+            final Inet4Address sid = packet.mServerIdentifier;
+            if (sid != null && !sid.equals(mServingParams.serverAddr.getAddress())) {
+                mLog.log("Packet ignored due to wrong server identifier: " + sid);
+                return;
             }
-        } catch (MalformedPacketException e) {
+
+            try {
+                if (packet instanceof DhcpDiscoverPacket) {
+                    processDiscover((DhcpDiscoverPacket) packet);
+                } else if (packet instanceof DhcpRequestPacket) {
+                    processRequest((DhcpRequestPacket) packet);
+                } else if (packet instanceof DhcpReleasePacket) {
+                    processRelease((DhcpReleasePacket) packet);
+                } else if (packet instanceof DhcpDeclinePacket) {
+                    processDecline((DhcpDeclinePacket) packet);
+                } else {
+                    mLog.e("Unknown packet type: " + packet.getClass().getSimpleName());
+                }
+            } catch (MalformedPacketException e) {
+                // Not an internal error: only logging exception message, not stacktrace
+                mLog.e("Ignored malformed packet: " + e.getMessage());
+            }
+        }
+
+        private void logIgnoredPacketInvalidSubnet(DhcpLeaseRepository.InvalidSubnetException e) {
             // Not an internal error: only logging exception message, not stacktrace
-            mLog.e("Ignored malformed packet: " + e.getMessage());
+            mLog.e("Ignored packet from invalid subnet: " + e.getMessage());
         }
-    }
 
-    private void logIgnoredPacketInvalidSubnet(DhcpLeaseRepository.InvalidSubnetException e) {
-        // Not an internal error: only logging exception message, not stacktrace
-        mLog.e("Ignored packet from invalid subnet: " + e.getMessage());
-    }
-
-    private void processDiscover(@NonNull DhcpDiscoverPacket packet)
-            throws MalformedPacketException {
-        final DhcpLease lease;
-        final MacAddress clientMac = getMacAddr(packet);
-        try {
-            if (mDhcpRapidCommitEnabled && packet.mRapidCommit) {
-                lease = mLeaseRepo.getCommittedLease(packet.getExplicitClientIdOrNull(), clientMac,
-                        packet.mRelayIp, packet.mHostName);
-                transmitAck(packet, lease, clientMac);
-            } else {
-                lease = mLeaseRepo.getOffer(packet.getExplicitClientIdOrNull(), clientMac,
-                        packet.mRelayIp, packet.mRequestedIp, packet.mHostName);
-                transmitOffer(packet, lease, clientMac);
+        private void processDiscover(@NonNull DhcpDiscoverPacket packet)
+                throws MalformedPacketException {
+            final DhcpLease lease;
+            final MacAddress clientMac = getMacAddr(packet);
+            try {
+                if (mDhcpRapidCommitEnabled && packet.mRapidCommit) {
+                    lease = mLeaseRepo.getCommittedLease(packet.getExplicitClientIdOrNull(),
+                            clientMac, packet.mRelayIp, packet.mHostName);
+                    transmitAck(packet, lease, clientMac);
+                } else {
+                    lease = mLeaseRepo.getOffer(packet.getExplicitClientIdOrNull(), clientMac,
+                            packet.mRelayIp, packet.mRequestedIp, packet.mHostName);
+                    transmitOffer(packet, lease, clientMac);
+                }
+            } catch (DhcpLeaseRepository.OutOfAddressesException e) {
+                transmitNak(packet, "Out of addresses to offer");
+            } catch (DhcpLeaseRepository.InvalidSubnetException e) {
+                logIgnoredPacketInvalidSubnet(e);
             }
-        } catch (DhcpLeaseRepository.OutOfAddressesException e) {
-            transmitNak(packet, "Out of addresses to offer");
-        } catch (DhcpLeaseRepository.InvalidSubnetException e) {
-            logIgnoredPacketInvalidSubnet(e);
+        }
+
+        private void processRequest(@NonNull DhcpRequestPacket packet)
+                throws MalformedPacketException {
+            // If set, packet SID matches with this server's ID as checked in processPacket().
+            final boolean sidSet = packet.mServerIdentifier != null;
+            final DhcpLease lease;
+            final MacAddress clientMac = getMacAddr(packet);
+            try {
+                lease = mLeaseRepo.requestLease(packet.getExplicitClientIdOrNull(), clientMac,
+                        packet.mClientIp, packet.mRelayIp, packet.mRequestedIp, sidSet,
+                        packet.mHostName);
+            } catch (DhcpLeaseRepository.InvalidAddressException e) {
+                transmitNak(packet, "Invalid requested address");
+                return;
+            } catch (DhcpLeaseRepository.InvalidSubnetException e) {
+                logIgnoredPacketInvalidSubnet(e);
+                return;
+            }
+
+            transmitAck(packet, lease, clientMac);
+        }
+
+        private void processRelease(@NonNull DhcpReleasePacket packet)
+                throws MalformedPacketException {
+            final byte[] clientId = packet.getExplicitClientIdOrNull();
+            final MacAddress macAddr = getMacAddr(packet);
+            // Don't care about success (there is no ACK/NAK); logging is already done
+            // in the repository.
+            mLeaseRepo.releaseLease(clientId, macAddr, packet.mClientIp);
+        }
+
+        private void processDecline(@NonNull DhcpDeclinePacket packet)
+                throws MalformedPacketException {
+            final byte[] clientId = packet.getExplicitClientIdOrNull();
+            final MacAddress macAddr = getMacAddr(packet);
+            int committedLeasesCount = mLeaseRepo.getCommittedLeases().size();
+
+            // If peer's clientID and macAddr doesn't match with any issued lease, nothing to do.
+            if (!mLeaseRepo.markAndReleaseDeclinedLease(clientId, macAddr, packet.mRequestedIp)) {
+                return;
+            }
+
+            // Check whether the boolean flag which requests a new prefix is enabled, and if
+            // it's enabled, make sure the issued lease count should be only one, otherwise,
+            // changing a different prefix will cause other exist host(s) configured with the
+            // current prefix lose appropriate route.
+            if (!mServingParams.changePrefixOnDecline || committedLeasesCount > 1) return;
+
+            if (mEventCallbacks == null) {
+                mLog.e("changePrefixOnDecline enabled but caller didn't pass a valid"
+                        + "IDhcpEventCallbacks callback.");
+                return;
+            }
+
+            try {
+                mEventCallbacks.onNewPrefixRequest(
+                        DhcpServingParams.makeIpPrefix(mServingParams.serverAddr));
+                transitionTo(mWaitBeforeRetrievalState);
+            } catch (RemoteException e) {
+                mLog.e("could not request a new prefix to caller", e);
+            }
         }
     }
 
-    private void processRequest(@NonNull DhcpRequestPacket packet) throws MalformedPacketException {
-        // If set, packet SID matches with this server's ID as checked in processPacket().
-        final boolean sidSet = packet.mServerIdentifier != null;
-        final DhcpLease lease;
-        final MacAddress clientMac = getMacAddr(packet);
-        try {
-            lease = mLeaseRepo.requestLease(packet.getExplicitClientIdOrNull(), clientMac,
-                    packet.mClientIp, packet.mRelayIp, packet.mRequestedIp, sidSet,
-                    packet.mHostName);
-        } catch (DhcpLeaseRepository.InvalidAddressException e) {
-            transmitNak(packet, "Invalid requested address");
-            return;
-        } catch (DhcpLeaseRepository.InvalidSubnetException e) {
-            logIgnoredPacketInvalidSubnet(e);
-            return;
+    class WaitBeforeRetrievalState extends State {
+        @Override
+        public boolean processMessage(Message msg) {
+            switch (msg.what) {
+                case CMD_UPDATE_PARAMS:
+                    final Pair<DhcpServingParams, INetworkStackStatusCallback> pair =
+                            (Pair<DhcpServingParams, INetworkStackStatusCallback>) msg.obj;
+                    final IpPrefix currentPrefix =
+                            DhcpServingParams.makeIpPrefix(mServingParams.serverAddr);
+                    final IpPrefix newPrefix =
+                            DhcpServingParams.makeIpPrefix(pair.first.serverAddr);
+                    handleUpdateServingParams(pair.first, pair.second);
+                    if (currentPrefix != null && !currentPrefix.equals(newPrefix)) {
+                        transitionTo(mRunningState);
+                    }
+                    return HANDLED;
+
+                case CMD_RECEIVE_PACKET:
+                    deferMessage(msg);
+                    return HANDLED;
+
+                default:
+                    // Fall through to StartedState.
+                    return NOT_HANDLED;
+            }
         }
-
-        transmitAck(packet, lease, clientMac);
-    }
-
-    private void processRelease(@NonNull DhcpReleasePacket packet)
-            throws MalformedPacketException {
-        final byte[] clientId = packet.getExplicitClientIdOrNull();
-        final MacAddress macAddr = getMacAddr(packet);
-        // Don't care about success (there is no ACK/NAK); logging is already done in the repository
-        mLeaseRepo.releaseLease(clientId, macAddr, packet.mClientIp);
     }
 
     private Inet4Address getAckOrOfferDst(@NonNull DhcpPacket request, @NonNull DhcpLease lease,
@@ -748,7 +817,13 @@
         @Override
         protected void onReceive(@NonNull DhcpPacket packet, @NonNull Inet4Address srcAddr,
                 int srcPort) {
-            processPacket(packet, srcPort);
+            if (srcPort != DHCP_CLIENT) {
+                final String packetType = packet.getClass().getSimpleName();
+                mLog.logf("Ignored packet of type %s sent from client port %d",
+                        packetType, srcPort);
+                return;
+            }
+            sendMessage(CMD_RECEIVE_PACKET, packet);
         }
 
         @Override
diff --git a/tests/unit/src/android/net/dhcp/DhcpServerTest.java b/tests/unit/src/android/net/dhcp/DhcpServerTest.java
index c925789..2e8a3c2 100644
--- a/tests/unit/src/android/net/dhcp/DhcpServerTest.java
+++ b/tests/unit/src/android/net/dhcp/DhcpServerTest.java
@@ -17,12 +17,13 @@
 package android.net.dhcp;
 
 import static android.net.InetAddresses.parseNumericAddress;
-import static android.net.dhcp.DhcpPacket.DHCP_CLIENT;
 import static android.net.dhcp.DhcpPacket.DHCP_HOST_NAME;
 import static android.net.dhcp.DhcpPacket.ENCAP_BOOTP;
 import static android.net.dhcp.DhcpPacket.INADDR_ANY;
 import static android.net.dhcp.DhcpPacket.INADDR_BROADCAST;
+import static android.net.dhcp.DhcpServer.CMD_RECEIVE_PACKET;
 import static android.net.dhcp.IDhcpServer.STATUS_SUCCESS;
+import static android.net.shared.Inet4AddressUtils.inet4AddressToIntHTH;
 import static android.net.util.NetworkStackUtils.DHCP_RAPID_COMMIT_VERSION;
 
 import static junit.framework.Assert.assertEquals;
@@ -31,7 +32,6 @@
 import static junit.framework.Assert.assertTrue;
 
 import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.doNothing;
@@ -44,12 +44,14 @@
 import android.annotation.Nullable;
 import android.content.Context;
 import android.net.INetworkStackStatusCallback;
+import android.net.IpPrefix;
 import android.net.LinkAddress;
 import android.net.MacAddress;
 import android.net.dhcp.DhcpLeaseRepository.InvalidAddressException;
 import android.net.dhcp.DhcpLeaseRepository.OutOfAddressesException;
 import android.net.dhcp.DhcpServer.Clock;
 import android.net.dhcp.DhcpServer.Dependencies;
+import android.net.shared.Inet4AddressUtils;
 import android.net.util.SharedLog;
 import android.testing.AndroidTestingRunner;
 
@@ -69,6 +71,8 @@
 import java.net.Inet4Address;
 import java.nio.ByteBuffer;
 import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -156,6 +160,7 @@
                 .setServerAddr(TEST_SERVER_LINKADDR)
                 .setLinkMtu(TEST_MTU)
                 .setExcludedAddrs(TEST_EXCLUDED_ADDRS)
+                .setChangePrefixOnDecline(false)
                 .build();
     }
 
@@ -215,7 +220,8 @@
         final DhcpDiscoverPacket discover = new DhcpDiscoverPacket(TEST_TRANSACTION_ID,
                 (short) 0 /* secs */, INADDR_ANY /* relayIp */, TEST_CLIENT_MAC_BYTES,
                 false /* broadcast */, INADDR_ANY /* srcIp */, false /* rapidCommit */);
-        mServer.processPacket(discover, DHCP_CLIENT);
+        mServer.sendMessage(CMD_RECEIVE_PACKET, discover);
+        HandlerUtilsKt.waitForIdle(mServer.getHandler(), TEST_TIMEOUT_MS);
 
         assertResponseSentTo(TEST_CLIENT_ADDR);
         final DhcpOfferPacket packet = assertOffer(getPacket());
@@ -233,7 +239,8 @@
         final DhcpDiscoverPacket discover = new DhcpDiscoverPacket(TEST_TRANSACTION_ID,
                 (short) 0 /* secs */, INADDR_ANY /* relayIp */, TEST_CLIENT_MAC_BYTES,
                 false /* broadcast */, INADDR_ANY /* srcIp */, true /* rapidCommit */);
-        mServer.processPacket(discover, DHCP_CLIENT);
+        mServer.sendMessage(CMD_RECEIVE_PACKET, discover);
+        HandlerUtilsKt.waitForIdle(mServer.getHandler(), TEST_TIMEOUT_MS);
 
         assertResponseSentTo(TEST_CLIENT_ADDR);
         final DhcpAckPacket packet = assertAck(getPacket());
@@ -251,7 +258,8 @@
         final DhcpDiscoverPacket discover = new DhcpDiscoverPacket(TEST_TRANSACTION_ID,
                 (short) 0 /* secs */, INADDR_ANY /* relayIp */, TEST_CLIENT_MAC_BYTES,
                 false /* broadcast */, INADDR_ANY /* srcIp */, false /* rapidCommit */);
-        mServer.processPacket(discover, DHCP_CLIENT);
+        mServer.sendMessage(CMD_RECEIVE_PACKET, discover);
+        HandlerUtilsKt.waitForIdle(mServer.getHandler(), TEST_TIMEOUT_MS);
 
         assertResponseSentTo(INADDR_BROADCAST);
         final DhcpNakPacket packet = assertNak(getPacket());
@@ -279,7 +287,8 @@
         final DhcpRequestPacket request = makeRequestSelectingPacket();
         request.mHostName = TEST_HOSTNAME;
         request.mRequestedParams = new byte[] { DHCP_HOST_NAME };
-        mServer.processPacket(request, DHCP_CLIENT);
+        mServer.sendMessage(CMD_RECEIVE_PACKET, request);
+        HandlerUtilsKt.waitForIdle(mServer.getHandler(), TEST_TIMEOUT_MS);
 
         assertResponseSentTo(TEST_CLIENT_ADDR);
         final DhcpAckPacket packet = assertAck(getPacket());
@@ -296,7 +305,8 @@
                 .thenThrow(new InvalidAddressException("Test error"));
 
         final DhcpRequestPacket request = makeRequestSelectingPacket();
-        mServer.processPacket(request, DHCP_CLIENT);
+        mServer.sendMessage(CMD_RECEIVE_PACKET, request);
+        HandlerUtilsKt.waitForIdle(mServer.getHandler(), TEST_TIMEOUT_MS);
 
         assertResponseSentTo(INADDR_BROADCAST);
         final DhcpNakPacket packet = assertNak(getPacket());
@@ -304,46 +314,134 @@
     }
 
     @Test
-    public void testRequest_Selecting_WrongClientPort() throws Exception {
-        startServer();
-
-        final DhcpRequestPacket request = makeRequestSelectingPacket();
-        mServer.processPacket(request, 50000);
-
-        verify(mRepository, never())
-                .requestLease(any(), any(), any(), any(), any(), anyBoolean(), any());
-        verify(mDeps, never()).sendPacket(any(), any(), any());
-    }
-
-    @Test
     public void testRelease() throws Exception {
         startServer();
 
         final DhcpReleasePacket release = new DhcpReleasePacket(TEST_TRANSACTION_ID,
                 TEST_SERVER_ADDR, TEST_CLIENT_ADDR,
                 INADDR_ANY /* relayIp */, TEST_CLIENT_MAC_BYTES);
-        mServer.processPacket(release, DHCP_CLIENT);
+        mServer.sendMessage(CMD_RECEIVE_PACKET, release);
+        HandlerUtilsKt.waitForIdle(mServer.getHandler(), TEST_TIMEOUT_MS);
 
         verify(mRepository, times(1))
                 .releaseLease(isNull(), eq(TEST_CLIENT_MAC), eq(TEST_CLIENT_ADDR));
     }
 
+    @Test
+    public void testDecline_LeaseDoesNotExist() throws Exception {
+        when(mRepository.markAndReleaseDeclinedLease(isNull(), eq(TEST_CLIENT_MAC),
+                eq(TEST_CLIENT_ADDR))).thenReturn(false);
+
+        startServer();
+        runOnReceivedDeclinePacket();
+        verify(mEventCallbacks, never()).onNewPrefixRequest(any());
+    }
+
+    private void runOnReceivedDeclinePacket() throws Exception {
+        when(mRepository.getCommittedLeases()).thenReturn(
+                Arrays.asList(new DhcpLease(null, TEST_CLIENT_MAC, TEST_CLIENT_ADDR,
+                        TEST_PREFIX_LENGTH, TEST_LEASE_EXPTIME_SECS * 1000L + TEST_CLOCK_TIME,
+                        TEST_HOSTNAME)));
+        final DhcpDeclinePacket decline = new DhcpDeclinePacket(TEST_TRANSACTION_ID,
+                (short) 0 /* secs */, INADDR_ANY /* clientIp */, INADDR_ANY /* yourIp */,
+                INADDR_ANY /* nextIp */, INADDR_ANY /* relayIp */, TEST_CLIENT_MAC_BYTES,
+                TEST_CLIENT_ADDR /* requestedIp */, TEST_SERVER_ADDR /* serverIdentifier */);
+        mServer.sendMessage(CMD_RECEIVE_PACKET, decline);
+        HandlerUtilsKt.waitForIdle(mServer.getHandler(), TEST_TIMEOUT_MS);
+
+        verify(mRepository).markAndReleaseDeclinedLease(isNull(), eq(TEST_CLIENT_MAC),
+                eq(TEST_CLIENT_ADDR));
+    }
+
+    private int[] toIntArray(@NonNull Collection<Inet4Address> addrs) {
+        return addrs.stream().mapToInt(Inet4AddressUtils::inet4AddressToIntHTH).toArray();
+    }
+
+    private void updateServingParams(Set<Inet4Address> defaultRouters,
+            Set<Inet4Address> dnsServers, Set<Inet4Address> excludedAddrs, LinkAddress serverAddr,
+            boolean changePrefixOnDecline) throws Exception {
+        final DhcpServingParamsParcel params = new DhcpServingParamsParcel();
+        params.serverAddr = inet4AddressToIntHTH((Inet4Address) serverAddr.getAddress());
+        params.serverAddrPrefixLength = serverAddr.getPrefixLength();
+        params.defaultRouters = toIntArray(defaultRouters);
+        params.dnsServers = toIntArray(dnsServers);
+        params.excludedAddrs = toIntArray(excludedAddrs);
+        params.dhcpLeaseTimeSecs = TEST_LEASE_EXPTIME_SECS;
+        params.linkMtu = TEST_MTU;
+        params.metered = true;
+        params.changePrefixOnDecline = changePrefixOnDecline;
+
+        mServer.updateParams(params, mAssertSuccessCallback);
+        HandlerUtilsKt.waitForIdle(mServer.getHandler(), TEST_TIMEOUT_MS);
+    }
+
+    @Test
+    public void testChangePrefixOnDecline() throws Exception {
+        when(mRepository.markAndReleaseDeclinedLease(isNull(), eq(TEST_CLIENT_MAC),
+                eq(TEST_CLIENT_ADDR))).thenReturn(true);
+
+        mServer.start(mAssertSuccessCallback, mEventCallbacks);
+        HandlerUtilsKt.waitForIdle(mServer.getHandler(), TEST_TIMEOUT_MS);
+        verify(mRepository).addLeaseCallbacks(eq(mEventCallbacks));
+
+        // Enable changePrefixOnDecline
+        updateServingParams(TEST_DEFAULT_ROUTERS, TEST_DNS_SERVERS, TEST_EXCLUDED_ADDRS,
+                TEST_SERVER_LINKADDR, true /* changePrefixOnDecline */);
+
+        runOnReceivedDeclinePacket();
+        final IpPrefix servingPrefix = DhcpServingParams.makeIpPrefix(TEST_SERVER_LINKADDR);
+        verify(mEventCallbacks).onNewPrefixRequest(eq(servingPrefix));
+
+        final Inet4Address serverAddr = parseAddr("192.168.51.129");
+        final LinkAddress srvLinkAddr = new LinkAddress(serverAddr, 24);
+        final Set<Inet4Address> srvAddr = new HashSet<>(Collections.singletonList(serverAddr));
+        final Set<Inet4Address> excludedAddrs = new HashSet<>(
+                Arrays.asList(parseAddr("192.168.51.200"), parseAddr("192.168.51.201")));
+
+        // Simulate IpServer updates the serving params with a new prefix.
+        updateServingParams(srvAddr, srvAddr, excludedAddrs, srvLinkAddr,
+                true /* changePrefixOnDecline */);
+
+        final Inet4Address clientAddr = parseAddr("192.168.51.42");
+        final DhcpLease lease = new DhcpLease(null, TEST_CLIENT_MAC,
+                clientAddr, 24 /*prefixLen*/, TEST_LEASE_EXPTIME_SECS * 1000L + TEST_CLOCK_TIME,
+                null /* hostname */);
+        when(mRepository.getOffer(isNull() /* clientId */, eq(TEST_CLIENT_MAC),
+                eq(INADDR_ANY) /* relayAddr */, isNull() /* reqAddr */, isNull() /* hostname */))
+                .thenReturn(lease);
+
+        // Test discover packet
+        final DhcpDiscoverPacket discover = new DhcpDiscoverPacket(TEST_TRANSACTION_ID,
+                (short) 0 /* secs */, INADDR_ANY /* relayIp */, TEST_CLIENT_MAC_BYTES,
+                false /* broadcast */, INADDR_ANY /* srcIp */, false /* rapidCommit */);
+        mServer.sendMessage(CMD_RECEIVE_PACKET, discover);
+        HandlerUtilsKt.waitForIdle(mServer.getHandler(), TEST_TIMEOUT_MS);
+        assertResponseSentTo(clientAddr);
+        final DhcpOfferPacket packet = assertOffer(getPacket());
+        assertMatchesLease(packet, serverAddr, clientAddr, null);
+    }
+
     /* TODO: add more tests once packet construction is refactored, including:
      *  - usage of giaddr
      *  - usage of broadcast bit
      *  - other request states (init-reboot/renewing/rebinding)
      */
 
-    private void assertMatchesTestLease(@NonNull DhcpPacket packet, @Nullable String hostname) {
+    private void assertMatchesLease(@NonNull DhcpPacket packet, @NonNull Inet4Address srvAddr,
+            @NonNull Inet4Address clientAddr, @Nullable String hostname) {
         assertMatchesClient(packet);
         assertFalse(packet.hasExplicitClientId());
-        assertEquals(TEST_SERVER_ADDR, packet.mServerIdentifier);
-        assertEquals(TEST_CLIENT_ADDR, packet.mYourIp);
+        assertEquals(srvAddr, packet.mServerIdentifier);
+        assertEquals(clientAddr, packet.mYourIp);
         assertNotNull(packet.mLeaseTime);
         assertEquals(TEST_LEASE_EXPTIME_SECS, (int) packet.mLeaseTime);
         assertEquals(hostname, packet.mHostName);
     }
 
+    private void assertMatchesTestLease(@NonNull DhcpPacket packet, @Nullable String hostname) {
+        assertMatchesLease(packet, TEST_SERVER_ADDR, TEST_CLIENT_ADDR, hostname);
+    }
+
     private void assertMatchesTestLease(@NonNull DhcpPacket packet) {
         assertMatchesTestLease(packet, null);
     }