Disable advertisement on suspend and re-enable on resume

This is to reduce the power consumption during suspend.

Additionally, this tries to selectively filter out the advertisement
enabled/disabled event generated by suspend activity so that it's not
propagated to any upper layer apps.

Please refer to the design doc in b/433136730.

Bug: 413129616
Bug: 433136730
Test: m com.google.android.bt
Flag: com.android.bluetooth.flags.adapter_suspend_advertisement

Change-Id: I635d05f5d1a1fb48e775dba7dbc888b00644b73b
diff --git a/android/app/src/com/android/bluetooth/gatt/AdvertiseManager.java b/android/app/src/com/android/bluetooth/gatt/AdvertiseManager.java
index c80dd8f..61e8f82 100644
--- a/android/app/src/com/android/bluetooth/gatt/AdvertiseManager.java
+++ b/android/app/src/com/android/bluetooth/gatt/AdvertiseManager.java
@@ -106,6 +106,7 @@
                     mAdvertiseBinder.cleanup();
                     mNativeInterface.cleanup();
                     mAdvertisers.clear();
+                    mAdvertiseSuspendManager.cleanup();
                 });
     }
 
@@ -162,6 +163,7 @@
         if (entry == null) {
             Log.i(TAG, "onAdvertisingSetStarted() - no callback found for regId " + regId);
             // Advertising set was stopped before it was properly registered.
+            mAdvertiseSuspendManager.onAdvertisingSetStarted(regId, advertiserId, status);
             mNativeInterface.stopAdvertisingSet(advertiserId);
             return;
         }
@@ -169,6 +171,7 @@
         final var advertiserInfo = entry.getValue();
         final var deathRecipient = advertiserInfo.deathRecipient;
         final var callback = advertiserInfo.callback;
+
         if (status == 0) {
             entry.setValue(new AdvertiserInfo(advertiserId, deathRecipient, callback));
             mAdvertiserMap.setAdvertiserIdByRegId(regId, advertiserId);
@@ -185,6 +188,8 @@
             mAdvertiserMap.removeAppAdvertiseStats(regId);
         }
 
+        mAdvertiseSuspendManager.onAdvertisingSetStarted(regId, advertiserId, status);
+
         callbackToApp(
                 () ->
                         callback.onAdvertisingSetStarted(
@@ -211,15 +216,18 @@
             return;
         }
 
-        final var callback = entry.getValue().callback;
-        callbackToApp(() -> callback.onAdvertisingEnabled(advertiserId, enable, status));
-
         if (!enable && status != 0) {
             final var appAdvertiseStats = mAdvertiserMap.getAppAdvertiseStatsById(advertiserId);
             if (appAdvertiseStats != null) {
                 appAdvertiseStats.recordAdvertiseStop(mAdvertisers.size());
             }
         }
+
+        mAdvertiseSuspendManager.onAdvertisingEnabled(advertiserId, enable, status);
+        if (!mAdvertiseSuspendManager.shouldSkipCallback()) {
+            final var callback = entry.getValue().callback;
+            callbackToApp(() -> callback.onAdvertisingEnabled(advertiserId, enable, status));
+        }
     }
 
     private void fetchAppForegroundState(int id) {
@@ -322,6 +330,7 @@
 
             final int cbId = --mTempRegistrationId;
             mAdvertisers.put(binder, new AdvertiserInfo(cbId, deathRecipient, callback));
+            mAdvertiseSuspendManager.onStartAdvertisingSet(cbId, duration, maxExtAdvEvents);
 
             Log.d(TAG, "startAdvertisingSet() - reg_id=" + cbId + ", callback: " + binder);
 
@@ -418,6 +427,7 @@
             return;
         }
 
+        mAdvertiseSuspendManager.onStopAdvertisingSet(advertiserId);
         mNativeInterface.stopAdvertisingSet(advertiserId);
 
         try {
@@ -443,6 +453,9 @@
             Log.w(TAG, "enableAdvertisingSet() - bad advertiserId " + advertiserId);
             return;
         }
+
+        mAdvertiseSuspendManager.onEnableAdvertisingSet(advertiserId);
+
         fetchAppForegroundState(advertiserId);
         mNativeInterface.enableAdvertisingSet(advertiserId, enable, duration, maxExtAdvEvents);
         mAdvertiserMap.enableAdvertisingSet(advertiserId, enable, duration, maxExtAdvEvents);
diff --git a/android/app/src/com/android/bluetooth/gatt/AdvertiseSuspendManager.kt b/android/app/src/com/android/bluetooth/gatt/AdvertiseSuspendManager.kt
index 2d07cc1..b239c3c 100644
--- a/android/app/src/com/android/bluetooth/gatt/AdvertiseSuspendManager.kt
+++ b/android/app/src/com/android/bluetooth/gatt/AdvertiseSuspendManager.kt
@@ -22,12 +22,15 @@
 import android.bluetooth.le.IAdvertisingSetCallback
 import android.bluetooth.le.PeriodicAdvertisingParameters
 import android.content.AttributionSource
+import android.util.Log
 import com.android.bluetooth.btservice.AdapterService
+import com.android.bluetooth.flags.Flags
 
 /**
  * Manages the queueing of advertisement commands during Bluetooth suspend state. This class is
  * responsible for holding commands when the adapter is suspending or suspended, and processing them
- * upon resume.
+ * upon resume. All methods in this class are run inside the advertisement thread of
+ * AdvertiseManager.
  */
 class AdvertiseSuspendManager(
     private val advertiseManager: AdvertiseManager,
@@ -44,9 +47,27 @@
         RESUMING, // Enable all paused advertisements.
     }
 
+    class AdvertiserSuspendInfo(var mDuration: Int, var mMaxExtAdvEvents: Int) {
+        var currentlyEnabled: Boolean = false
+        var needEnableOnResume: Boolean = false
+        // The number of ongoing start/enable/disable operations for this advertiser.
+        // Initially, this advertiser is waiting to be started.
+        var numOfOngoingOperations: Int = 1
+    }
+
     private var suspendState = SuspendState.NORMAL
     private val pendingCommands = mutableListOf<PendingAdvertiseCommand>()
 
+    private val suspendInfoMap = mutableMapOf<Int, AdvertiserSuspendInfo>()
+    // The number of advertisers still in transition state. This indicates #adv with ongoing request
+    // on RESOLVING, #adv not yet paused on PAUSING, and #adv not yet resumed on RESUMING.
+    private var suspendAdvCounter = 0
+    // To skip the shouldQueue check - used when en/disabling advertisements internally
+    private var forceNoQueue = false
+    // Indicates whether onAdvertisingEnabled callback should be skipped. We should skip it if the
+    // enablement event is purely due to suspend activity.
+    private var skipCallback = false
+
     sealed interface PendingAdvertiseCommand
 
     data class StartAdvertisingSetCommand(
@@ -140,7 +161,7 @@
 
     /** Returns whether advertising commands should be queued, which is true during suspend. */
     fun shouldQueueCommand(): Boolean {
-        return suspendState != SuspendState.NORMAL
+        return suspendState != SuspendState.NORMAL && !forceNoQueue
     }
 
     /** Queue a Start Advertising Set command (during suspend). */
@@ -227,11 +248,70 @@
         pendingCommands.add(SetPeriodicAdvertisingEnableCommand(advertiserId, enable))
     }
 
+    private fun enableAdvertisingSet(
+        advertiserId: Int,
+        enable: Boolean,
+        duration: Int,
+        maxExtAdvEvents: Int,
+    ) {
+        // skip the state check when en/disabling advertisement internally.
+        forceNoQueue = true
+        advertiseManager.enableAdvertisingSet(advertiserId, enable, duration, maxExtAdvEvents)
+        forceNoQueue = false
+    }
+
     /** Initiates suspend sequence. Resolve ongoing operations then pause all advertisements. */
     fun enterSuspend() {
+        Log.i(TAG, "Enter suspend. Current state = $suspendState")
+
+        if (suspendState == SuspendState.NORMAL || suspendState == SuspendState.RESUMING) {
+            // Here (re)start the suspend flow from the beginning.
+            waitAdvertisementsToResolve()
+        } else if (suspendState == SuspendState.SUSPENDED) {
+            // We're told to suspend but we're already suspended. Just report ready.
+            finalizeSuspend()
+        } // Otherwise just continue the ongoing suspend flow.
+    }
+
+    private fun waitAdvertisementsToResolve() {
+        // Wait for all ongoing start/enable/disable operations to complete before proceeding to
+        // pause the advertisements.
+        suspendState = SuspendState.RESOLVING
+        suspendAdvCounter = 0
+
+        for (entry in suspendInfoMap.entries) {
+            val suspendInfo = entry.value
+            if (suspendInfo.numOfOngoingOperations > 0) {
+                suspendAdvCounter += 1
+            }
+        }
+
+        if (suspendAdvCounter == 0) {
+            pauseAdvertisements()
+        }
+    }
+
+    private fun pauseAdvertisements() {
         suspendState = SuspendState.PAUSING
-        // later we pause the advertisements here then call finalizeSuspend
-        finalizeSuspend()
+        if (suspendAdvCounter != 0) {
+            Log.w(TAG, "Suspend state is PAUSING but counter isn't zero")
+            suspendAdvCounter = 0
+        }
+
+        for ((advertiserId, suspendInfo) in suspendInfoMap) {
+            // In case of a quick suspend -> resume -> suspend, it's possible to have
+            // needEnableOnResume flag still on and currentlyEnabled is off.
+            suspendInfo.needEnableOnResume =
+                suspendInfo.needEnableOnResume or suspendInfo.currentlyEnabled
+            if (suspendInfo.needEnableOnResume) {
+                suspendAdvCounter += 1
+                enableAdvertisingSet(advertiserId, false, 0, 0)
+            }
+        }
+
+        if (suspendAdvCounter == 0) {
+            finalizeSuspend()
+        }
     }
 
     private fun finalizeSuspend() {
@@ -241,16 +321,166 @@
 
     /** Initiates resume sequence. Enable all paused advertisements. */
     fun exitSuspend() {
+        Log.i(TAG, "Exit suspend. Current state = $suspendState")
+        resumeAdvertisements()
+    }
+
+    private fun resumeAdvertisements() {
         suspendState = SuspendState.RESUMING
-        // later we reenable the advertisements here then call finalizeResume
-        finalizeResume()
+        suspendAdvCounter = 0
+
+        for ((advertiserId, suspendInfo) in suspendInfoMap) {
+            if (suspendInfo.needEnableOnResume) {
+                suspendAdvCounter += 1
+                enableAdvertisingSet(
+                    advertiserId,
+                    true,
+                    suspendInfo.mDuration,
+                    suspendInfo.mMaxExtAdvEvents,
+                )
+            }
+        }
+
+        if (suspendAdvCounter == 0) {
+            finalizeResume()
+        }
     }
 
     private fun finalizeResume() {
         suspendState = SuspendState.NORMAL
+
         for (command in pendingCommands) {
             runPendingCommand(command)
         }
         pendingCommands.clear()
     }
+
+    /** To be called from AdvertiseManager when starting an advertising set. */
+    fun onStartAdvertisingSet(regId: Int, duration: Int, maxExtAdvEvents: Int) {
+        if (!Flags.adapterSuspendAdvertisement()) {
+            return
+        }
+        suspendInfoMap[regId] = AdvertiserSuspendInfo(duration, maxExtAdvEvents)
+    }
+
+    /** To be called from AdvertiseManager when stopping an advertising set. */
+    fun onStopAdvertisingSet(advertiserId: Int) {
+        if (!Flags.adapterSuspendAdvertisement()) {
+            return
+        }
+        suspendInfoMap.remove(advertiserId)
+    }
+
+    /** To be called from AdvertiseManager when enabling an advertising set. */
+    fun onEnableAdvertisingSet(advertiserId: Int) {
+        if (!Flags.adapterSuspendAdvertisement()) {
+            return
+        }
+        val suspendInfo = suspendInfoMap[advertiserId]
+        if (suspendInfo == null) {
+            Log.wtf(TAG, "onEnableAdvertisingSet: suspendInfo is null for id $advertiserId")
+            return
+        }
+        suspendInfo.numOfOngoingOperations += 1
+    }
+
+    /** To be called from AdvertiseManager when an advertising set is started. */
+    fun onAdvertisingSetStarted(regId: Int, advertiserId: Int, status: Int) {
+        if (!Flags.adapterSuspendAdvertisement()) {
+            return
+        }
+        val suspendInfo = suspendInfoMap.remove(regId)
+        if (suspendInfo == null) {
+            Log.wtf(TAG, "onAdvertisingSetStarted: suspendInfo is null for id $regId")
+            return
+        }
+        if (status == 0) {
+            suspendInfo.numOfOngoingOperations -= 1
+            suspendInfo.currentlyEnabled = true
+            suspendInfoMap[advertiserId] = suspendInfo
+        }
+        if (suspendState == SuspendState.RESOLVING) {
+            suspendAdvCounter -= 1
+            if (suspendAdvCounter == 0) {
+                Log.i(TAG, "All ongoing operations resolved, pausing advertisements.")
+                pauseAdvertisements()
+            }
+        }
+    }
+
+    /** To be called from AdvertiseManager when an advertising set is enabled. */
+    fun onAdvertisingEnabled(advertiserId: Int, enable: Boolean, status: Int) {
+        if (!Flags.adapterSuspendAdvertisement()) {
+            return
+        }
+
+        val suspendInfo = suspendInfoMap[advertiserId]
+        if (suspendInfo == null) {
+            Log.wtf(TAG, "onAdvertisingEnabled: suspendInfo is null for id $advertiserId")
+            return
+        }
+        val wasEnabled = suspendInfo.currentlyEnabled
+        var skipCallback = false
+
+        if (suspendState == SuspendState.PAUSING) {
+            if (wasEnabled && !enable) {
+                // Normal disablement - don't invoke callback
+                suspendAdvCounter -= 1
+                skipCallback = true
+                if (suspendAdvCounter == 0) {
+                    finalizeSuspend()
+                }
+            } else {
+                Log.w(TAG, "Unexpected event when pausing: was $wasEnabled now $enable")
+            }
+        } else if (suspendState == SuspendState.RESUMING) {
+            val needEnable = suspendInfo.needEnableOnResume
+            if (!wasEnabled && enable && needEnable) {
+                // Normal re-enablement - don't invoke callback.
+                suspendAdvCounter -= 1
+                suspendInfo.needEnableOnResume = false
+                skipCallback = true
+            } else if (!wasEnabled && !enable && status != 0 && needEnable) {
+                // Re-enablement failed! Let's invoke callback to let the app know.
+                suspendAdvCounter -= 1
+                suspendInfo.needEnableOnResume = false
+            } else {
+                Log.w(
+                    TAG,
+                    "Unexpected event when resuming: need " +
+                        needEnable +
+                        " was " +
+                        wasEnabled +
+                        " now " +
+                        enable,
+                )
+            }
+
+            if (suspendAdvCounter == 0) {
+                finalizeResume()
+            }
+        }
+
+        suspendInfo.currentlyEnabled = enable
+        suspendInfo.numOfOngoingOperations -= 1
+        if (suspendState == SuspendState.RESOLVING && suspendInfo.numOfOngoingOperations == 0) {
+            suspendAdvCounter -= 1
+            if (suspendAdvCounter == 0) {
+                pauseAdvertisements()
+            }
+        }
+
+        skipCallback = skipCallback
+    }
+
+    /** Returns whether we should call the callback of AdvertiseManager's onAdvertisingEnabled. */
+    fun shouldSkipCallback(): Boolean {
+        return skipCallback
+    }
+
+    /** Frees structures. */
+    fun cleanup() {
+        suspendInfoMap.clear()
+        pendingCommands.clear()
+    }
 }