Adjust query frequency based on remaining TTL

After numOfQueriesBeforeBackoff query, the mDNS discovery logic will
enter backoff mode. In backoff mode,  the query frequency will be
updated to max(20, 0.8 * shortest remaining TTL) seconds. It will help
to reduce mDNS query frequency in certain use cases.

Bug: 284480315
Test: atest CtsNetTest FrameworksNetTests
Change-Id: Iac8baaaf58cf9b3b8e67e1cd80402fdecde1d3d4
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsSearchOptions.java b/service-t/src/com/android/server/connectivity/mdns/MdnsSearchOptions.java
index 98c80ee..f09596d 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSearchOptions.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSearchOptions.java
@@ -50,7 +50,8 @@
                             source.readBoolean(),
                             source.readParcelable(null),
                             source.readString(),
-                            (source.dataAvail() > 0) ? source.readBoolean() : false);
+                            source.readBoolean(),
+                            source.readInt());
                 }
 
                 @Override
@@ -62,9 +63,9 @@
     private final List<String> subtypes;
     @Nullable
     private final String resolveInstanceName;
-
     private final boolean isPassiveMode;
     private final boolean onlyUseIpv6OnIpv6OnlyNetworks;
+    private final int numOfQueriesBeforeBackoff;
     private final boolean removeExpiredService;
     // The target network for searching. Null network means search on all possible interfaces.
     @Nullable private final Network mNetwork;
@@ -76,13 +77,15 @@
             boolean removeExpiredService,
             @Nullable Network network,
             @Nullable String resolveInstanceName,
-            boolean onlyUseIpv6OnIpv6OnlyNetworks) {
+            boolean onlyUseIpv6OnIpv6OnlyNetworks,
+            int numOfQueriesBeforeBackoff) {
         this.subtypes = new ArrayList<>();
         if (subtypes != null) {
             this.subtypes.addAll(subtypes);
         }
         this.isPassiveMode = isPassiveMode;
         this.onlyUseIpv6OnIpv6OnlyNetworks = onlyUseIpv6OnIpv6OnlyNetworks;
+        this.numOfQueriesBeforeBackoff = numOfQueriesBeforeBackoff;
         this.removeExpiredService = removeExpiredService;
         mNetwork = network;
         this.resolveInstanceName = resolveInstanceName;
@@ -122,6 +125,14 @@
         return onlyUseIpv6OnIpv6OnlyNetworks;
     }
 
+    /**
+     *  Returns number of queries should be executed before backoff mode is enabled.
+     *  The default number is 3 if it is not set.
+     */
+    public int numOfQueriesBeforeBackoff() {
+        return numOfQueriesBeforeBackoff;
+    }
+
     /** Returns {@code true} if service will be removed after its TTL expires. */
     public boolean removeExpiredService() {
         return removeExpiredService;
@@ -159,6 +170,7 @@
         out.writeParcelable(mNetwork, 0);
         out.writeString(resolveInstanceName);
         out.writeBoolean(onlyUseIpv6OnIpv6OnlyNetworks);
+        out.writeInt(numOfQueriesBeforeBackoff);
     }
 
     /** A builder to create {@link MdnsSearchOptions}. */
@@ -166,6 +178,7 @@
         private final Set<String> subtypes;
         private boolean isPassiveMode = true;
         private boolean onlyUseIpv6OnIpv6OnlyNetworks = false;
+        private int numOfQueriesBeforeBackoff = 3;
         private boolean removeExpiredService;
         private Network mNetwork;
         private String resolveInstanceName;
@@ -219,6 +232,14 @@
         }
 
         /**
+         * Sets if the query backoff mode should be turned on.
+         */
+        public Builder setNumOfQueriesBeforeBackoff(int numOfQueriesBeforeBackoff) {
+            this.numOfQueriesBeforeBackoff = numOfQueriesBeforeBackoff;
+            return this;
+        }
+
+        /**
          * Sets if the service should be removed after TTL.
          *
          * @param removeExpiredService If set to {@code true}, the service will be removed after TTL
@@ -258,7 +279,8 @@
                     removeExpiredService,
                     mNetwork,
                     resolveInstanceName,
-                    onlyUseIpv6OnIpv6OnlyNetworks);
+                    onlyUseIpv6OnIpv6OnlyNetworks,
+                    numOfQueriesBeforeBackoff);
         }
     }
 }
\ No newline at end of file
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index 48e4724..8d5949c 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -50,6 +50,7 @@
  */
 public class MdnsServiceTypeClient {
 
+    private static final String TAG = MdnsServiceTypeClient.class.getSimpleName();
     private static final int DEFAULT_MTU = 1500;
 
     private final String serviceType;
@@ -63,6 +64,7 @@
     private final ArrayMap<MdnsServiceBrowserListener, MdnsSearchOptions> listeners =
             new ArrayMap<>();
     // TODO: change instanceNameToResponse to TreeMap with case insensitive comparator.
+    @GuardedBy("lock")
     private final Map<String, MdnsResponse> instanceNameToResponse = new HashMap<>();
     private final boolean removeServiceAfterTtlExpires =
             MdnsConfigs.removeServiceAfterTtlExpires();
@@ -77,7 +79,14 @@
 
     @GuardedBy("lock")
     @Nullable
-    private Future<?> requestTaskFuture;
+    private Future<?> nextQueryTaskFuture;
+
+    @GuardedBy("lock")
+    @Nullable
+    private QueryTask lastScheduledTask;
+
+    @GuardedBy("lock")
+    private long lastSentTime;
 
     /**
      * Constructor of {@link MdnsServiceTypeClient}.
@@ -189,7 +198,7 @@
                 }
             }
             // Cancel the next scheduled periodical task.
-            if (requestTaskFuture != null) {
+            if (nextQueryTaskFuture != null) {
                 cancelRequestTaskLocked();
             }
             // Keep tracking the ScheduledFuture for the task so we can cancel it if caller is not
@@ -198,21 +207,40 @@
                     searchOptions.getSubtypes(),
                     searchOptions.isPassiveMode(),
                     searchOptions.onlyUseIpv6OnIpv6OnlyNetworks(),
-                    currentSessionId,
+                    searchOptions.numOfQueriesBeforeBackoff(),
                     socketKey);
+            final long now = clock.elapsedRealtime();
+            if (lastSentTime == 0) {
+                lastSentTime = now;
+            }
             if (hadReply) {
-                requestTaskFuture = scheduleNextRunLocked(taskConfig);
+                final QueryTaskConfig queryTaskConfig = taskConfig.getConfigForNextRun();
+                final long minRemainingTtl = getMinRemainingTtlLocked(now);
+                final long timeToRun = now + queryTaskConfig.delayUntilNextTaskWithoutBackoffMs;
+                nextQueryTaskFuture = scheduleNextRunLocked(queryTaskConfig,
+                        minRemainingTtl, now, timeToRun, currentSessionId);
             } else {
-                requestTaskFuture = executor.submit(new QueryTask(taskConfig));
+                lastScheduledTask = new QueryTask(taskConfig,
+                        now /* timeToRun */,
+                        now + getMinRemainingTtlLocked(now)/* minTtlExpirationTimeWhenScheduled */,
+                        currentSessionId);
+                nextQueryTaskFuture = executor.submit(lastScheduledTask);
             }
         }
     }
 
     @GuardedBy("lock")
     private void cancelRequestTaskLocked() {
-        requestTaskFuture.cancel(true);
+        final boolean canceled = nextQueryTaskFuture.cancel(true);
+        sharedLog.log("task canceled:" + canceled + ", current session: " + currentSessionId
+                + " task hashcode: " + getHexString(nextQueryTaskFuture));
         ++currentSessionId;
-        requestTaskFuture = null;
+        nextQueryTaskFuture = null;
+        lastScheduledTask = null;
+    }
+
+    private static String getHexString(Object o) {
+        return Integer.toHexString(System.identityHashCode(o));
     }
 
     private boolean responseMatchesOptions(@NonNull MdnsResponse response,
@@ -247,7 +275,7 @@
             if (listeners.remove(listener) == null) {
                 return listeners.isEmpty();
             }
-            if (listeners.isEmpty() && requestTaskFuture != null) {
+            if (listeners.isEmpty() && nextQueryTaskFuture != null) {
                 cancelRequestTaskLocked();
             }
             return listeners.isEmpty();
@@ -284,9 +312,9 @@
             for (MdnsResponse response : allResponses) {
                 if (modifiedResponse.contains(response)) {
                     if (response.isGoodbye()) {
-                        onGoodbyeReceived(response.getServiceInstanceName());
+                        onGoodbyeReceivedLocked(response.getServiceInstanceName());
                     } else {
-                        onResponseModified(response);
+                        onResponseModifiedLocked(response);
                     }
                 } else if (instanceNameToResponse.containsKey(response.getServiceInstanceName())) {
                     // If the response is not modified and already in the cache. The cache will
@@ -294,6 +322,20 @@
                     instanceNameToResponse.put(response.getServiceInstanceName(), response);
                 }
             }
+            if (nextQueryTaskFuture != null && lastScheduledTask != null
+                    && lastScheduledTask.config.shouldUseQueryBackoff()) {
+                final long now = clock.elapsedRealtime();
+                final long minRemainingTtl = getMinRemainingTtlLocked(now);
+                final long timeToRun = calculateTimeToRun(lastScheduledTask,
+                        lastScheduledTask.config, now,
+                        minRemainingTtl, lastSentTime);
+                if (timeToRun > lastScheduledTask.timeToRun) {
+                    QueryTaskConfig lastTaskConfig = lastScheduledTask.config;
+                    cancelRequestTaskLocked();
+                    nextQueryTaskFuture = scheduleNextRunLocked(lastTaskConfig, minRemainingTtl,
+                            now, timeToRun, currentSessionId);
+                }
+            }
         }
     }
 
@@ -323,13 +365,14 @@
                 }
             }
 
-            if (requestTaskFuture != null) {
+            if (nextQueryTaskFuture != null) {
                 cancelRequestTaskLocked();
             }
         }
     }
 
-    private void onResponseModified(@NonNull MdnsResponse response) {
+    @GuardedBy("lock")
+    private void onResponseModifiedLocked(@NonNull MdnsResponse response) {
         final String serviceInstanceName = response.getServiceInstanceName();
         final MdnsResponse currentResponse =
                 instanceNameToResponse.get(serviceInstanceName);
@@ -375,7 +418,8 @@
         }
     }
 
-    private void onGoodbyeReceived(@Nullable String serviceInstanceName) {
+    @GuardedBy("lock")
+    private void onGoodbyeReceivedLocked(@Nullable String serviceInstanceName) {
         final MdnsResponse response = instanceNameToResponse.remove(serviceInstanceName);
         if (response == null) {
             return;
@@ -427,32 +471,52 @@
                 MdnsConfigs.alwaysAskForUnicastResponseInEachBurst();
         private final boolean usePassiveMode;
         private final boolean onlyUseIpv6OnIpv6OnlyNetworks;
-        private final long sessionId;
+        private final int numOfQueriesBeforeBackoff;
         @VisibleForTesting
-        int transactionId;
+        final int transactionId;
         @VisibleForTesting
-        boolean expectUnicastResponse;
-        private int queriesPerBurst;
-        private int timeBetweenBurstsInMs;
-        private int burstCounter;
-        private int timeToRunNextTaskInMs;
-        private boolean isFirstBurst;
+        final boolean expectUnicastResponse;
+        private final int queriesPerBurst;
+        private final int timeBetweenBurstsInMs;
+        private final int burstCounter;
+        private final long delayUntilNextTaskWithoutBackoffMs;
+        private final boolean isFirstBurst;
+        private final long queryCount;
         @NonNull private final SocketKey socketKey;
 
+
+        QueryTaskConfig(@NonNull QueryTaskConfig other, long queryCount, int transactionId,
+                boolean expectUnicastResponse, boolean isFirstBurst, int burstCounter,
+                int queriesPerBurst, int timeBetweenBurstsInMs,
+                long delayUntilNextTaskWithoutBackoffMs) {
+            this.subtypes = new ArrayList<>(other.subtypes);
+            this.usePassiveMode = other.usePassiveMode;
+            this.onlyUseIpv6OnIpv6OnlyNetworks = other.onlyUseIpv6OnIpv6OnlyNetworks;
+            this.numOfQueriesBeforeBackoff = other.numOfQueriesBeforeBackoff;
+            this.transactionId = transactionId;
+            this.expectUnicastResponse = expectUnicastResponse;
+            this.queriesPerBurst = queriesPerBurst;
+            this.timeBetweenBurstsInMs = timeBetweenBurstsInMs;
+            this.burstCounter = burstCounter;
+            this.delayUntilNextTaskWithoutBackoffMs = delayUntilNextTaskWithoutBackoffMs;
+            this.isFirstBurst = isFirstBurst;
+            this.queryCount = queryCount;
+            this.socketKey = other.socketKey;
+        }
         QueryTaskConfig(@NonNull Collection<String> subtypes,
                 boolean usePassiveMode,
                 boolean onlyUseIpv6OnIpv6OnlyNetworks,
-                long sessionId,
+                int numOfQueriesBeforeBackoff,
                 @Nullable SocketKey socketKey) {
             this.usePassiveMode = usePassiveMode;
             this.onlyUseIpv6OnIpv6OnlyNetworks = onlyUseIpv6OnIpv6OnlyNetworks;
+            this.numOfQueriesBeforeBackoff = numOfQueriesBeforeBackoff;
             this.subtypes = new ArrayList<>(subtypes);
             this.queriesPerBurst = QUERIES_PER_BURST;
             this.burstCounter = 0;
             this.transactionId = 1;
             this.expectUnicastResponse = true;
             this.isFirstBurst = true;
-            this.sessionId = sessionId;
             // Config the scan frequency based on the scan mode.
             if (this.usePassiveMode) {
                 // In passive scan mode, sends a single burst of QUERIES_PER_BURST queries, and then
@@ -467,42 +531,61 @@
                 this.timeBetweenBurstsInMs = INITIAL_TIME_BETWEEN_BURSTS_MS;
             }
             this.socketKey = socketKey;
+            this.queryCount = 0;
+            this.delayUntilNextTaskWithoutBackoffMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
         }
 
         QueryTaskConfig getConfigForNextRun() {
-            if (++transactionId > UNSIGNED_SHORT_MAX_VALUE) {
-                transactionId = 1;
+            long newQueryCount = queryCount + 1;
+            int newTransactionId = transactionId + 1;
+            if (newTransactionId > UNSIGNED_SHORT_MAX_VALUE) {
+                newTransactionId = 1;
             }
+            boolean newExpectUnicastResponse = false;
+            boolean newIsFirstBurst = isFirstBurst;
+            int newQueriesPerBurst = queriesPerBurst;
+            int newBurstCounter = burstCounter + 1;
+            long newDelayUntilNextTaskWithoutBackoffMs = delayUntilNextTaskWithoutBackoffMs;
+            int newTimeBetweenBurstsInMs = timeBetweenBurstsInMs;
             // Only the first query expects uni-cast response.
-            expectUnicastResponse = false;
-            if (++burstCounter == queriesPerBurst) {
-                burstCounter = 0;
+            if (newBurstCounter == queriesPerBurst) {
+                newBurstCounter = 0;
 
                 if (alwaysAskForUnicastResponse) {
-                    expectUnicastResponse = true;
+                    newExpectUnicastResponse = true;
                 }
                 // In passive scan mode, sends a single burst of QUERIES_PER_BURST queries, and
                 // then in each TIME_BETWEEN_BURSTS interval, sends QUERIES_PER_BURST_PASSIVE_MODE
                 // queries.
                 if (isFirstBurst) {
-                    isFirstBurst = false;
+                    newIsFirstBurst = false;
                     if (usePassiveMode) {
-                        queriesPerBurst = QUERIES_PER_BURST_PASSIVE_MODE;
+                        newQueriesPerBurst = QUERIES_PER_BURST_PASSIVE_MODE;
                     }
                 }
                 // In active scan mode, sends a burst of QUERIES_PER_BURST queries,
                 // TIME_BETWEEN_QUERIES_IN_BURST_MS apart, then waits for the scan interval, and
                 // then repeats. The scan interval starts as INITIAL_TIME_BETWEEN_BURSTS_MS and
                 // doubles until it maxes out at TIME_BETWEEN_BURSTS_MS.
-                timeToRunNextTaskInMs = timeBetweenBurstsInMs;
+                newDelayUntilNextTaskWithoutBackoffMs = timeBetweenBurstsInMs;
                 if (timeBetweenBurstsInMs < TIME_BETWEEN_BURSTS_MS) {
-                    timeBetweenBurstsInMs = Math.min(timeBetweenBurstsInMs * 2,
+                    newTimeBetweenBurstsInMs = Math.min(timeBetweenBurstsInMs * 2,
                             TIME_BETWEEN_BURSTS_MS);
                 }
             } else {
-                timeToRunNextTaskInMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
+                newDelayUntilNextTaskWithoutBackoffMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
             }
-            return this;
+            return new QueryTaskConfig(this, newQueryCount, newTransactionId,
+                    newExpectUnicastResponse, newIsFirstBurst, newBurstCounter, newQueriesPerBurst,
+                    newTimeBetweenBurstsInMs, newDelayUntilNextTaskWithoutBackoffMs);
+        }
+
+        private boolean shouldUseQueryBackoff() {
+            // Don't enable backoff mode during the burst or in the first burst
+            if (burstCounter != 0 || isFirstBurst) {
+                return false;
+            }
+            return queryCount > numOfQueriesBeforeBackoff;
         }
     }
 
@@ -532,9 +615,17 @@
     private class QueryTask implements Runnable {
 
         private final QueryTaskConfig config;
+        private final long timeToRun;
+        private final long minTtlExpirationTimeWhenScheduled;
+        private final long sessionId;
 
-        QueryTask(@NonNull QueryTaskConfig config) {
+        QueryTask(@NonNull QueryTaskConfig config, long timeToRun,
+                long minTtlExpirationTimeWhenScheduled,
+                long sessionId) {
             this.config = config;
+            this.timeToRun = timeToRun;
+            this.minTtlExpirationTimeWhenScheduled = minTtlExpirationTimeWhenScheduled;
+            this.sessionId = sessionId;
         }
 
         @Override
@@ -573,13 +664,13 @@
                 if (MdnsConfigs.useSessionIdToScheduleMdnsTask()) {
                     // In case that the task is not canceled successfully, use session ID to check
                     // if this task should continue to schedule more.
-                    if (config.sessionId != currentSessionId) {
+                    if (sessionId != currentSessionId) {
                         return;
                     }
                 }
 
                 if (MdnsConfigs.shouldCancelScanTaskWhenFutureIsNull()) {
-                    if (requestTaskFuture == null) {
+                    if (nextQueryTaskFuture == null) {
                         // If requestTaskFuture is set to null, the task is cancelled. We can't use
                         // isCancelled() here because this QueryTask is different from the future
                         // that is returned from executor.schedule(). See b/71646910.
@@ -624,14 +715,72 @@
                         }
                     }
                 }
-                requestTaskFuture = scheduleNextRunLocked(this.config);
+                QueryTaskConfig nextRunConfig = this.config.getConfigForNextRun();
+                final long now = clock.elapsedRealtime();
+                lastSentTime = now;
+                final long minRemainingTtl = getMinRemainingTtlLocked(now);
+                final long timeToRun = calculateTimeToRun(this, nextRunConfig, now,
+                        minRemainingTtl, lastSentTime);
+                nextQueryTaskFuture = scheduleNextRunLocked(nextRunConfig,
+                        minRemainingTtl, now, timeToRun, lastScheduledTask.sessionId);
             }
         }
     }
 
+    private static long calculateTimeToRun(@NonNull QueryTask lastScheduledTask,
+            QueryTaskConfig queryTaskConfig, long now, long minRemainingTtl, long lastSentTime) {
+        final long baseDelayInMs = queryTaskConfig.delayUntilNextTaskWithoutBackoffMs;
+        if (!queryTaskConfig.shouldUseQueryBackoff()) {
+            return lastSentTime + baseDelayInMs;
+        }
+        if (minRemainingTtl <= 0) {
+            // There's no service, or there is an expired service. In any case, schedule for the
+            // minimum time, which is the base delay.
+            return lastSentTime + baseDelayInMs;
+        }
+        // If the next TTL expiration time hasn't changed, then use previous calculated timeToRun.
+        if (lastSentTime < now
+                && lastScheduledTask.minTtlExpirationTimeWhenScheduled == now + minRemainingTtl) {
+            // Use the original scheduling time if the TTL has not changed, to avoid continuously
+            // rescheduling to 80% of the remaining TTL as time passes
+            return lastScheduledTask.timeToRun;
+        }
+        return Math.max(now + (long) (0.8 * minRemainingTtl), lastSentTime + baseDelayInMs);
+    }
+
+    @GuardedBy("lock")
+    private long getMinRemainingTtlLocked(long now) {
+        long minRemainingTtl = Long.MAX_VALUE;
+        for (MdnsResponse response : instanceNameToResponse.values()) {
+            if (!response.isComplete()) {
+                continue;
+            }
+            long remainingTtl =
+                    response.getServiceRecord().getRemainingTTL(now);
+            // remainingTtl is <= 0 means the service expired.
+            if (remainingTtl <= 0) {
+                return 0;
+            }
+            if (remainingTtl < minRemainingTtl) {
+                minRemainingTtl = remainingTtl;
+            }
+        }
+        return minRemainingTtl == Long.MAX_VALUE ? 0 : minRemainingTtl;
+    }
+
+    @GuardedBy("lock")
     @NonNull
-    private Future<?> scheduleNextRunLocked(@NonNull QueryTaskConfig lastRunConfig) {
-        QueryTaskConfig config = lastRunConfig.getConfigForNextRun();
-        return executor.schedule(new QueryTask(config), config.timeToRunNextTaskInMs, MILLISECONDS);
+    private Future<?> scheduleNextRunLocked(@NonNull QueryTaskConfig nextRunConfig,
+            long minRemainingTtl,
+            long timeWhenScheduled, long timeToRun, long sessionId) {
+        lastScheduledTask = new QueryTask(nextRunConfig, timeToRun,
+                minRemainingTtl + timeWhenScheduled, sessionId);
+        // The timeWhenScheduled could be greater than the timeToRun if the Runnable is delayed.
+        long timeToNextTasksWithBackoffInMs = Math.max(timeToRun - timeWhenScheduled, 0);
+        sharedLog.log(
+                String.format("Next run: sessionId: %d, in %d ms", lastScheduledTask.sessionId,
+                        timeToNextTasksWithBackoffInMs));
+        return executor.schedule(lastScheduledTask, timeToNextTasksWithBackoffInMs,
+                MILLISECONDS);
     }
 }
\ No newline at end of file
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
index 03e893f..0a299bf 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
@@ -289,6 +289,110 @@
     }
 
     @Test
+    public void sendQueries_activeScanWithQueryBackoff() {
+        MdnsSearchOptions searchOptions =
+                MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(
+                        false).setNumOfQueriesBeforeBackoff(11).build();
+        client.startSendAndReceive(mockListenerOne, searchOptions);
+
+        // First burst, 3 queries.
+        verifyAndSendQuery(0, 0, /* expectsUnicastResponse= */ true);
+        verifyAndSendQuery(
+                1, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+        verifyAndSendQuery(
+                2, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+        // Second burst will be sent after initialTimeBetweenBurstsMs, 3 queries.
+        verifyAndSendQuery(
+                3, MdnsConfigs.initialTimeBetweenBurstsMs(), /* expectsUnicastResponse= */ false);
+        verifyAndSendQuery(
+                4, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+        verifyAndSendQuery(
+                5, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+        // Third burst will be sent after initialTimeBetweenBurstsMs * 2, 3 queries.
+        verifyAndSendQuery(
+                6, MdnsConfigs.initialTimeBetweenBurstsMs() * 2, /* expectsUnicastResponse= */
+                false);
+        verifyAndSendQuery(
+                7, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+        verifyAndSendQuery(
+                8, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+        // Forth burst will be sent after initialTimeBetweenBurstsMs * 4, 3 queries.
+        verifyAndSendQuery(
+                9, MdnsConfigs.initialTimeBetweenBurstsMs() * 4, /* expectsUnicastResponse= */
+                false);
+        verifyAndSendQuery(
+                10, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+        verifyAndSendQuery(
+                11, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+        // In backoff mode, the current scheduled task will be canceled and reschedule if the
+        // 0.8 * smallestRemainingTtl is larger than time to next run.
+        long currentTime = TEST_TTL / 2 + TEST_ELAPSED_REALTIME;
+        doReturn(currentTime).when(mockDecoderClock).elapsedRealtime();
+        client.processResponse(createResponse(
+                "service-instance-1", "192.0.2.123", 5353,
+                SERVICE_TYPE_LABELS,
+                Collections.emptyMap(), TEST_TTL), socketKey);
+        verifyAndSendQuery(12, (long) (TEST_TTL / 2 * 0.8), /* expectsUnicastResponse= */
+                false);
+        currentTime += (long) (TEST_TTL / 2 * 0.8);
+        doReturn(currentTime).when(mockDecoderClock).elapsedRealtime();
+        verifyAndSendQuery(
+                13, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+    }
+
+    @Test
+    public void sendQueries_passiveScanWithQueryBackoff() {
+        MdnsSearchOptions searchOptions =
+                MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(
+                        true).setNumOfQueriesBeforeBackoff(3).build();
+        client.startSendAndReceive(mockListenerOne, searchOptions);
+        verifyAndSendQuery(0, 0, /* expectsUnicastResponse= */ true);
+        verifyAndSendQuery(
+                1, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+        verifyAndSendQuery(
+                2, MdnsConfigs.timeBetweenQueriesInBurstMs(), /* expectsUnicastResponse= */ false);
+        verifyAndSendQuery(3, MdnsConfigs.timeBetweenBurstsMs(), /* expectsUnicastResponse= */
+                false);
+        assertEquals(4, currentThreadExecutor.getNumOfScheduledFuture());
+
+        // In backoff mode, the current scheduled task will be canceled and reschedule if the
+        // 0.8 * smallestRemainingTtl is larger than time to next run.
+        doReturn(TEST_ELAPSED_REALTIME + 20000).when(mockDecoderClock).elapsedRealtime();
+        client.processResponse(createResponse(
+                "service-instance-1", "192.0.2.123", 5353,
+                SERVICE_TYPE_LABELS,
+                Collections.emptyMap(), TEST_TTL), socketKey);
+        verify(expectedSendFutures[4]).cancel(true);
+        assertEquals(5, currentThreadExecutor.getNumOfScheduledFuture());
+        verifyAndSendQuery(4, 80000 /* timeInMs */, false /* expectsUnicastResponse */);
+        assertEquals(6, currentThreadExecutor.getNumOfScheduledFuture());
+        // Next run should also be scheduled in 0.8 * smallestRemainingTtl
+        verifyAndSendQuery(5, 80000 /* timeInMs */, false /* expectsUnicastResponse */);
+        assertEquals(7, currentThreadExecutor.getNumOfScheduledFuture());
+
+        // If the records is not refreshed, the current scheduled task will not be canceled.
+        doReturn(TEST_ELAPSED_REALTIME + 20001).when(mockDecoderClock).elapsedRealtime();
+        client.processResponse(createResponse(
+                "service-instance-1", "192.0.2.123", 5353,
+                SERVICE_TYPE_LABELS,
+                Collections.emptyMap(), TEST_TTL,
+                TEST_ELAPSED_REALTIME - 1), socketKey);
+        verify(expectedSendFutures[7], never()).cancel(true);
+
+        // In backoff mode, the current scheduled task will not be canceled if the
+        // 0.8 * smallestRemainingTtl is smaller than time to next run.
+        doReturn(TEST_ELAPSED_REALTIME).when(mockDecoderClock).elapsedRealtime();
+        client.processResponse(createResponse(
+                "service-instance-1", "192.0.2.123", 5353,
+                SERVICE_TYPE_LABELS,
+                Collections.emptyMap(), TEST_TTL), socketKey);
+        verify(expectedSendFutures[7], never()).cancel(true);
+
+        client.stopSendAndReceive(mockListenerOne);
+        verify(expectedSendFutures[7]).cancel(true);
+    }
+
+    @Test
     public void sendQueries_reentry_passiveScanMode() {
         MdnsSearchOptions searchOptions =
                 MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(true).build();
@@ -328,7 +432,8 @@
                 MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(false).build();
         QueryTaskConfig config = new QueryTaskConfig(
                 searchOptions.getSubtypes(), searchOptions.isPassiveMode(),
-                false /* onlyUseIpv6OnIpv6OnlyNetworks */, 1, socketKey);
+                false /* onlyUseIpv6OnIpv6OnlyNetworks */, 3 /* numOfQueriesBeforeBackoff */,
+                socketKey);
 
         // This is the first query. We will ask for unicast response.
         assertTrue(config.expectUnicastResponse);
@@ -358,7 +463,8 @@
                 MdnsSearchOptions.newBuilder().addSubtype("12345").setIsPassiveMode(false).build();
         QueryTaskConfig config = new QueryTaskConfig(
                 searchOptions.getSubtypes(), searchOptions.isPassiveMode(),
-                false /* onlyUseIpv6OnIpv6OnlyNetworks */, 1, socketKey);
+                false /* onlyUseIpv6OnIpv6OnlyNetworks */, 3 /* numOfQueriesBeforeBackoff */,
+                socketKey);
 
         // This is the first query. We will ask for unicast response.
         assertTrue(config.expectUnicastResponse);
@@ -714,8 +820,10 @@
                         return mockPacketWriter;
                     }
                 };
-        MdnsSearchOptions searchOptions = MdnsSearchOptions.newBuilder().setRemoveExpiredService(
-                true).build();
+        MdnsSearchOptions searchOptions = MdnsSearchOptions.newBuilder()
+                .setRemoveExpiredService(true)
+                .setNumOfQueriesBeforeBackoff(Integer.MAX_VALUE)
+                .build();
         client.startSendAndReceive(mockListenerOne, searchOptions);
         Runnable firstMdnsTask = currentThreadExecutor.getAndClearSubmittedRunnable();
 
@@ -1248,13 +1356,16 @@
         final String ipV4Address = "192.0.2.0";
 
         final MdnsSearchOptions resolveOptions = MdnsSearchOptions.newBuilder()
+                .setNumOfQueriesBeforeBackoff(Integer.MAX_VALUE)
                 .setResolveInstanceName("instance1").build();
 
         client.startSendAndReceive(mockListenerOne, resolveOptions);
         // Ensure the first task is executed so it schedules a future task
         currentThreadExecutor.getAndClearSubmittedFuture().get(
                 TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
-        client.startSendAndReceive(mockListenerTwo, MdnsSearchOptions.getDefaultOptions());
+        client.startSendAndReceive(mockListenerTwo,
+                MdnsSearchOptions.newBuilder().setNumOfQueriesBeforeBackoff(
+                        Integer.MAX_VALUE).build());
 
         // Filing the second request cancels the first future
         verify(expectedSendFutures[0]).cancel(true);
@@ -1317,7 +1428,7 @@
 
     private void verifyAndSendQuery(int index, long timeInMs, boolean expectsUnicastResponse,
             boolean multipleSocketDiscovery) {
-        assertEquals(currentThreadExecutor.getAndClearLastScheduledDelayInMs(), timeInMs);
+        assertEquals(timeInMs, currentThreadExecutor.getAndClearLastScheduledDelayInMs());
         currentThreadExecutor.getAndClearLastScheduledRunnable().run();
         if (expectsUnicastResponse) {
             verify(mockSocketClient).sendPacketRequestingUnicastResponse(
@@ -1406,6 +1517,10 @@
             lastSubmittedFuture = null;
             return val;
         }
+
+        public int getNumOfScheduledFuture() {
+            return futureIndex - 1;
+        }
     }
 
     private MdnsPacket createResponse(
@@ -1424,7 +1539,7 @@
                 textAttributes, ptrTtlMillis);
     }
 
-    // Creates a mDNS response.
+
     private MdnsPacket createResponse(
             @NonNull String serviceInstanceName,
             @Nullable String host,
@@ -1432,6 +1547,19 @@
             @NonNull String[] type,
             @NonNull Map<String, String> textAttributes,
             long ptrTtlMillis) {
+        return createResponse(serviceInstanceName, host, port, type, textAttributes, ptrTtlMillis,
+                TEST_ELAPSED_REALTIME);
+    }
+
+    // Creates a mDNS response.
+    private MdnsPacket createResponse(
+            @NonNull String serviceInstanceName,
+            @Nullable String host,
+            int port,
+            @NonNull String[] type,
+            @NonNull Map<String, String> textAttributes,
+            long ptrTtlMillis,
+            long receiptTimeMillis) {
 
         final ArrayList<MdnsRecord> answerRecords = new ArrayList<>();
 
@@ -1442,7 +1570,7 @@
         final String[] serviceName = serviceNameList.toArray(new String[0]);
         final MdnsPointerRecord pointerRecord = new MdnsPointerRecord(
                 type,
-                TEST_ELAPSED_REALTIME /* receiptTimeMillis */,
+                receiptTimeMillis,
                 false /* cacheFlush */,
                 ptrTtlMillis,
                 serviceName);
@@ -1451,7 +1579,7 @@
         // Set SRV record.
         final MdnsServiceRecord serviceRecord = new MdnsServiceRecord(
                 serviceName,
-                TEST_ELAPSED_REALTIME /* receiptTimeMillis */,
+                receiptTimeMillis,
                 false /* cacheFlush */,
                 TEST_TTL,
                 0 /* servicePriority */,
@@ -1465,7 +1593,7 @@
             final InetAddress addr = InetAddresses.parseNumericAddress(host);
             final MdnsInetAddressRecord inetAddressRecord = new MdnsInetAddressRecord(
                     new String[] {"hostname"} /* name */,
-                    TEST_ELAPSED_REALTIME /* receiptTimeMillis */,
+                    receiptTimeMillis,
                     false /* cacheFlush */,
                     TEST_TTL,
                     addr);
@@ -1479,7 +1607,7 @@
         }
         final MdnsTextRecord textRecord = new MdnsTextRecord(
                 serviceName,
-                TEST_ELAPSED_REALTIME /* receiptTimeMillis */,
+                receiptTimeMillis,
                 false /* cacheFlush */,
                 TEST_TTL,
                 textEntries);