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()
+ }
}