From 2fb30cab46d5823e8e1ee8420fd84484f44e6e03 Mon Sep 17 00:00:00 2001 From: Sadaqat Hussain Date: Thu, 21 May 2026 16:13:22 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(android):=20defer=20wakelock=20toggle?= =?UTF-8?q?=20when=20no=20activity=20is=20attached=20=20=20=20Wakelock.tog?= =?UTF-8?q?gle()/isEnabled()=20threw=20NoActivityException=20whenever=20th?= =?UTF-8?q?ey=20=20=20were=20called=20with=20no=20foreground=20activity=20?= =?UTF-8?q?attached=20(e.g.=20the=20app=20is=20=20=20backgrounded=20or=20m?= =?UTF-8?q?id=20lifecycle=20transition),=20surfacing=20as=20non-fatal=20?= =?UTF-8?q?=20=20crashes=20in=20apps=20that=20toggle=20the=20wakelock=20of?= =?UTF-8?q?f=20the=20foreground=20path.=20=20=20=20Instead=20of=20throwing?= =?UTF-8?q?,=20the=20requested=20wakelock=20state=20is=20now=20tracked=20i?= =?UTF-8?q?n=20=20=20Kotlin=20and=20re-applied=20once=20an=20activity=20(r?= =?UTF-8?q?e)attaches=20=E2=80=94=20matching=20how=20the=20=20=20Windows/L?= =?UTF-8?q?inux/web=20implementations=20track=20state.=20The=20activity=20?= =?UTF-8?q?setter=20only=20=20=20re-asserts=20FLAG=5FKEEP=5FSCREEN=5FON=20?= =?UTF-8?q?when=20wakelock=20was=20actually=20enabled,=20so=20it=20=20=20w?= =?UTF-8?q?on't=20clear=20the=20flag=20for=20apps=20that=20set=20it=20on?= =?UTF-8?q?=20their=20own=20window.=20The=20=20=20now-unused=20NoActivityE?= =?UTF-8?q?xception=20class=20is=20removed.=20=20=20=20Bumps=20wakelock=5F?= =?UTF-8?q?plus=20to=201.6.2=20and=20updates=20the=20CHANGELOG.=20=20=20?= =?UTF-8?q?=20Fixes=20#131,=20#102,=20#6.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(android): defer wakelock toggle when no activity is attached Wakelock.toggle()/isEnabled() threw NoActivityException whenever they were called with no foreground activity attached (e.g. the app is backgrounded or mid lifecycle transition), surfacing as non-fatal crashes in apps that toggle the wakelock off the foreground path. Instead of throwing, the requested wakelock state is now tracked in Kotlin and re-applied once an activity (re)attaches — matching how the Windows/Linux/web implementations track state. The activity setter only re-asserts FLAG_KEEP_SCREEN_ON when wakelock was actually enabled, so it won't clear the flag for apps that set it on their own window. The now-unused NoActivityException class is removed. Bumps wakelock_plus to 1.6.2 and updates the CHANGELOG. Fixes #131, #102, #6. --- .gitignore | 3 +- wakelock_plus/CHANGELOG.md | 3 ++ .../plus/wakelock/Wakelock.kt | 47 ++++++++++--------- wakelock_plus/pubspec.yaml | 2 +- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 88efa4b..eeb418a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ .buildlog/ .history .svn/ -.metadata \ No newline at end of file +.metadata +CLAUDE.md diff --git a/wakelock_plus/CHANGELOG.md b/wakelock_plus/CHANGELOG.md index da5027c..5713e1d 100644 --- a/wakelock_plus/CHANGELOG.md +++ b/wakelock_plus/CHANGELOG.md @@ -1,3 +1,6 @@ +## [1.6.2] +* Android: Fixed `NoActivityException` ("wakelock requires a foreground activity") being thrown when `toggle`/`enabled` were called with no foreground activity attached (e.g. while the app is backgrounded or during a lifecycle transition). The requested wakelock state is now remembered and re-applied once an activity (re)attaches instead of throwing. + ## [1.6.1] * [#133](https://github.com/fluttercommunity/wakelock_plus/pull/133): wakelock_plus Flutter 3.38 downgrade. Thanks [diegotori](https://github.com/diegotori). - Library now requires Dart version `3.10` or higher, restoring previous compatibility. diff --git a/wakelock_plus/android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/Wakelock.kt b/wakelock_plus/android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/Wakelock.kt index 8ba445a..36e5027 100644 --- a/wakelock_plus/android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/Wakelock.kt +++ b/wakelock_plus/android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/Wakelock.kt @@ -6,34 +6,35 @@ import android.app.Activity import android.view.WindowManager internal class Wakelock { - var activity: Activity? = null - - private val enabled - get() = activity!!.window.attributes.flags and - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON != 0 + // The desired wakelock state. Tracked independently of [activity] so that a + // toggle requested while no activity is attached (e.g. the app is in the + // background or mid lifecycle transition) is remembered and re-applied once an + // activity (re)attaches, instead of throwing a NoActivityException. + private var enableWakelock = false - fun toggle(message: ToggleMessage) { - if (activity == null) { - throw NoActivityException() + var activity: Activity? = null + set(value) { + field = value + // Re-assert the wakelock on the newly attached activity's window, but only + // when it was actually requested. If the user never enabled it (or last + // disabled it), leave the flag alone — the activity may keep the screen on + // for its own reasons. + if (enableWakelock) applyWakelock() } - val activity = this.activity!! - val enabled = this.enabled - - if (message.enable!!) { - if (!enabled) activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else if (enabled) { - activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + private fun applyWakelock() { + val window = activity?.window ?: return + if (enableWakelock) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } - fun isEnabled(): IsEnabledMessage { - if (activity == null) { - throw NoActivityException() - } - - return IsEnabledMessage(enabled = enabled) + fun toggle(message: ToggleMessage) { + enableWakelock = message.enable!! + applyWakelock() } -} -class NoActivityException : Exception("wakelock requires a foreground activity") + fun isEnabled(): IsEnabledMessage = IsEnabledMessage(enabled = enableWakelock) +} diff --git a/wakelock_plus/pubspec.yaml b/wakelock_plus/pubspec.yaml index a555046..7a4ea90 100644 --- a/wakelock_plus/pubspec.yaml +++ b/wakelock_plus/pubspec.yaml @@ -2,7 +2,7 @@ name: wakelock_plus description: >-2 Plugin that allows you to keep the device screen awake, i.e. prevent the screen from sleeping on Android, iOS, macOS, Windows, Linux, and web. -version: 1.6.1 +version: 1.6.2 repository: https://github.com/fluttercommunity/wakelock_plus/tree/main/wakelock_plus environment: From 8ca3e547691860cdcb28f7ca21778aa212aea7f5 Mon Sep 17 00:00:00 2001 From: Sadaqat Hussain Date: Wed, 10 Jun 2026 11:38:25 +0500 Subject: [PATCH 2/2] test(android): add Robolectric unit tests for deferred wakelock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred-wakelock fix (2fb30ca) moved the activity-attach logic into Kotlin, but that branch had no JVM test coverage — its behaviour was only exercised by the example app's on-device integration tests. Add a Robolectric suite that asserts the FLAG_KEEP_SCREEN_ON window flag directly, without a device: - toggle()/isEnabled() no longer throw when no activity is attached - isEnabled() defaults to false with no activity - enabling before an activity attaches applies the flag once it does - attaching an activity while disabled leaves the flag untouched - disabling clears the flag on the attached activity - enabled state is re-applied to a new activity after detach/reattach Wire the JUnit4/Robolectric/vintage-engine test dependencies into the plugin's build.gradle and run :wakelock_plus:testDebugUnitTest as a new CI step (reusing the Gradle wrapper generated by the example APK build). No version bump or CHANGELOG entry — test/CI-only, no change to the published plugin surface. --- .github/workflows/checks.yml | 6 ++ wakelock_plus/android/build.gradle | 7 ++ .../plus/wakelock/WakelockTest.kt | 97 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 wakelock_plus/android/src/test/kotlin/dev/fluttercommunity/plus/wakelock/WakelockTest.kt diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 3f89df1..ffe4740 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -139,6 +139,12 @@ jobs: cd wakelock_plus/example flutter pub get flutter build apk --debug --target=./lib/main.dart + - name: Android plugin unit tests (Robolectric) + # The APK build above generates the (gitignored) Gradle wrapper that this + # step reuses to run the plugin's JVM/Robolectric unit tests. + run: | + cd wakelock_plus/example/android + ./gradlew :wakelock_plus:testDebugUnitTest android_integration_test: needs: setup_matrix diff --git a/wakelock_plus/android/build.gradle b/wakelock_plus/android/build.gradle index d2a1ffe..e060d37 100644 --- a/wakelock_plus/android/build.gradle +++ b/wakelock_plus/android/build.gradle @@ -54,6 +54,13 @@ android { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.mockito:mockito-core:5.0.0' + // Robolectric runs the JVM unit tests against a simulated Android + // framework so the wakelock window flags can be asserted without a + // device. It is JUnit4-based, so the vintage engine bridges it onto the + // JUnit Platform configured below (useJUnitPlatform()). + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.14.1' + testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.11.4' } testOptions { diff --git a/wakelock_plus/android/src/test/kotlin/dev/fluttercommunity/plus/wakelock/WakelockTest.kt b/wakelock_plus/android/src/test/kotlin/dev/fluttercommunity/plus/wakelock/WakelockTest.kt new file mode 100644 index 0000000..07f2a9a --- /dev/null +++ b/wakelock_plus/android/src/test/kotlin/dev/fluttercommunity/plus/wakelock/WakelockTest.kt @@ -0,0 +1,97 @@ +package dev.fluttercommunity.plus.wakelock + +import ToggleMessage +import android.app.Activity +import android.os.Build +import android.view.WindowManager +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [Wakelock] covering the "no foreground activity attached" + * behaviour introduced to stop [toggle]/[isEnabled] from throwing when the app + * is backgrounded or mid lifecycle transition. The desired state is tracked in + * Kotlin and (re)applied to whichever activity window is attached. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) +class WakelockTest { + + private fun buildActivity(): Activity = + Robolectric.buildActivity(Activity::class.java).setup().get() + + private val Activity.keepScreenOn: Boolean + get() = window.attributes.flags and + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON != 0 + + @Test + fun `toggle enable with no activity attached does not throw`() { + val wakelock = Wakelock() + + wakelock.toggle(ToggleMessage(enable = true)) + + assertTrue(wakelock.isEnabled().enabled == true) + } + + @Test + fun `isEnabled with no activity attached does not throw and defaults to false`() { + assertFalse(Wakelock().isEnabled().enabled == true) + } + + @Test + fun `enabling before an activity attaches applies the flag once it does`() { + val wakelock = Wakelock() + + wakelock.toggle(ToggleMessage(enable = true)) + val activity = buildActivity() + wakelock.activity = activity + + assertTrue(activity.keepScreenOn) + } + + @Test + fun `attaching an activity while disabled leaves the flag untouched`() { + val wakelock = Wakelock() + val activity = buildActivity() + + wakelock.activity = activity + + assertFalse(activity.keepScreenOn) + } + + @Test + fun `disabling clears the flag on the attached activity`() { + val wakelock = Wakelock() + val activity = buildActivity() + wakelock.activity = activity + + wakelock.toggle(ToggleMessage(enable = true)) + assertTrue(activity.keepScreenOn) + + wakelock.toggle(ToggleMessage(enable = false)) + assertFalse(activity.keepScreenOn) + } + + @Test + fun `enabled state is re-applied to a new activity after detach`() { + val wakelock = Wakelock() + val first = buildActivity() + wakelock.activity = first + wakelock.toggle(ToggleMessage(enable = true)) + assertTrue(first.keepScreenOn) + + // The activity goes away (e.g. the app is backgrounded). This used to throw. + wakelock.activity = null + assertTrue(wakelock.isEnabled().enabled == true) + + // A fresh activity attaches; the requested state must be re-asserted on it. + val second = buildActivity() + wakelock.activity = second + assertTrue(second.keepScreenOn) + } +}