From 559a8c6e8483265bb6c5e84e801ce3c6c96efb46 Mon Sep 17 00:00:00 2001 From: Oleksandr Karpovich Date: Wed, 3 Jun 2026 14:56:41 +0000 Subject: [PATCH] Add user activation retry for Safari wake lock requests Co-Authored-By: Codex --- .../compose/ui/platform/WebWakeLockManager.kt | 36 ++++++--- .../ui/platform/WebWakeLockManagerTests.kt | 80 ++++++++++++++++++- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/WebWakeLockManager.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/WebWakeLockManager.kt index 7253428984812..567b218f70dc7 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/WebWakeLockManager.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/WebWakeLockManager.kt @@ -46,10 +46,25 @@ internal object WebWakeLockManager { private val requests = mutableSetOf() + @VisibleForTesting + internal var requestWakeLock: () -> Promise = ::requestScreenWakeLock + init { document.addEventListener("visibilitychange") { - if (documentIsVisible() && enoughRequestsForLock() && webWakeLockSupported) { - requestWakeLock() + retryWakeLockRequest() + } + + // Safari requires Screen Wake Lock requests to run during transient user activation. + listOf( + "click", + "keydown", + "mousedown", + "pointerdown", + "pointerup", + "touchend" + ).forEach { eventName -> + document.addEventListener(eventName) { + retryWakeLockRequest() } } } @@ -68,21 +83,26 @@ internal object WebWakeLockManager { requests.remove(client) } if (enoughRequestsForLock()) { - requestWakeLock() + acquireWakeLock() } else { releaseWakeLock() } } + private fun retryWakeLockRequest() { + if (documentIsVisible() && enoughRequestsForLock() && webWakeLockSupported) { + acquireWakeLock() + } + } - private fun requestWakeLock() { + private fun acquireWakeLock() { if (wakeLockSentinel != null || requestingLock) { //A lock is already active or a request is in progress return } requestingLock = true - requestScreenWakeLock() + requestWakeLock() .then { sentinel -> //Prevents race condition where a release requestLock could come in before the lock is granted if (requestingLock) { @@ -134,6 +154,7 @@ internal object WebWakeLockManager { requestingLock = false requests.clear() releaseWakeLock() + requestWakeLock = ::requestScreenWakeLock } } @@ -156,7 +177,7 @@ internal fun isFullWakeLockApiSupported(): Boolean = """ ) -private external interface WakeLockSentinel : JsAny { +internal external interface WakeLockSentinel : JsAny { @Suppress("unused") val released: Boolean @@ -165,6 +186,3 @@ private external interface WakeLockSentinel : JsAny { fun release(): Promise fun addEventListener(type: String, listener: () -> Unit) } - - - diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebWakeLockManagerTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebWakeLockManagerTests.kt index a94048fa6202c..9b9e04e5828e5 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebWakeLockManagerTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/WebWakeLockManagerTests.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.OnCanvasTests import androidx.compose.ui.keepScreenOn -import kotlin.js.JsAny import kotlin.js.Promise import kotlin.js.js import kotlin.js.unsafeCast @@ -33,8 +32,10 @@ import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlinx.browser.document import kotlinx.browser.window -import kotlinx.coroutines.suspendCancellableCoroutine +import org.w3c.dom.pointerevents.PointerEvent +import org.w3c.dom.pointerevents.PointerEventInit class WebWakeLockManagerTests : OnCanvasTests { @@ -75,6 +76,52 @@ class WebWakeLockManagerTests : OnCanvasTests { manager.reset() } + @Test + fun testWakeLockRequestRetriedOnUserActivationAfterInitialFailure() = runApplicationTest { + val manager = WebWakeLockManager + manager.reset() + + var allowRequest = false + val sentinel = fakeWakeLockSentinel() + manager.requestWakeLock = { + if (allowRequest) { + resolvedWakeLockPromise(sentinel) + } else { + rejectedWakeLockPromise() + } + } + + try { + createComposeWindow { + Box(Modifier.keepScreenOn()) + } + + awaitIdle() + + assertFalse( + manager.isWakeLockActive(), + "Wake lock should not be active after the initial request fails" + ) + assertFalse(manager.requestingLock, "Failed request should clear requesting state") + + allowRequest = true + document.dispatchEvent( + PointerEvent( + "pointerdown", + PointerEventInit(button = 0, buttons = 1, pointerType = "mouse") + ) + ) + awaitIdle() + + assertTrue( + manager.isWakeLockActive(), + "Wake lock should be retried on a user activation event" + ) + } finally { + manager.reset() + } + } + @Test fun testWakeLockRemainsWhenOneOfTwoRemoved() = runApplicationTest { var attachFirst by mutableStateOf(true) @@ -264,4 +311,31 @@ class WebWakeLockManagerTests : OnCanvasTests { assertFalse(manager.requestingLock, "Should not be requesting lock after release") manager.reset() } -} \ No newline at end of file +} + +private fun fakeWakeLockSentinel(): WakeLockSentinel = js( + """({ + released: false, + type: "screen", + release: function() { + this.released = true; + if (this.releaseListener) { + this.releaseListener(); + } + return Promise.resolve(null); + }, + addEventListener: function(type, listener) { + if (type === "release") { + this.releaseListener = listener; + } + } + })""" +).unsafeCast() + +private fun resolvedWakeLockPromise(sentinel: WakeLockSentinel): Promise = js( + "Promise.resolve(sentinel)" +) + +private fun rejectedWakeLockPromise(): Promise = js( + "Promise.reject(new Error('Transient activation is required'))" +)