From a14fb8e1c314b9dfd41c578c0757c3b53f7f9923 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 13 Oct 2026 19:29:20 +1300 Subject: [PATCH 1/2] fix(push): report restricted background usage as best effort With background usage set to Restricted in the app's battery settings (RUN_ANY_IN_BACKGROUND ignore), Android may hold the app's alarms, jobs and foreground service while it is closed, so messages can wait until the app is opened. backgroundStatus() only looked at exact alarms and foreground mode, so it still reported bestEffort=false and apps had no signal to ask the user to lift it. PushBackgroundStatus gains backgroundRestricted, the raw ActivityManager.isBackgroundRestricted() on Android 9+ and false below, and the React Native and Flutter bridges carry it as "backgroundRestricted". bestEffort is true when it is set and the app is not exempt from battery optimisation: AppStateTracker lets alarms and jobs of power-allowlisted apps run before it checks the app-op, and the exemption request allowlists the app without resetting the op, so isBackgroundRestricted() stays true while the scheduled wake-ups work again. Foreground mode doesn't count while it is set, exemption or not: ActiveServices only lets a restricted app hold a foreground service while it is on screen, stops the service once the app leaves it, and refuses startForeground() from the background, so a restricted, exempt app's bestEffort follows exact alarms alone. The README and KDoc tell apps to ask for Unrestricted, or for the exemption, since neither exact alarms nor foreground mode lift it. The field is appended last with a false default, and the constructor is @JvmOverloads so Java callers of the three-argument constructor keep compiling; Kotlin callers keep compiling through the default. The Robolectric lanes run Tests.kt at API 23, where only the guard is reachable, so the printed check covers that. PushBackgroundRestrictedTest at API 28 drives ShadowActivityManager and ShadowPowerManager to cover the restricted and exempt cases, and checks that foreground mode doesn't lift the restriction, and PushBridgeBackgroundStatusTest in the React Native Android harness checks the same through the bridge's JSON, since PushBridge is only generated into React Native and Flutter. Co-Authored-By: Claude Opus 5.5 --- templates/android/README.md.twig | 7 ++ .../java/io/package/services/Push.kt.twig | 12 ++-- .../package/services/PushBackground.kt.twig | 2 + .../io/package/services/PushBridge.kt.twig | 3 +- .../java/io/package/services/PushCore.kt.twig | 25 +++++-- tests/e2e/Base.php | 5 +- tests/e2e/languages/android/Tests.kt | 71 +++++++++++++++++++ .../react-native/android-test/Tests.kt | 60 ++++++++++++++++ 8 files changed, 174 insertions(+), 11 deletions(-) diff --git a/templates/android/README.md.twig b/templates/android/README.md.twig index 6ed9518511..2be1b1e0ed 100644 --- a/templates/android/README.md.twig +++ b/templates/android/README.md.twig @@ -127,6 +127,13 @@ The SDK uses exact alarms on its own whenever the app may schedule them. To allo nothing to ask, including when the permission is not declared. The SDK never opens these screens on its own. +If `backgroundRestricted` is true, the user restricted the app's background usage and messages can +wait until the app is opened. Exact alarms don't lift it, and neither does foreground mode: Android +stops a restricted app's foreground service once it leaves the screen. Ask the user to choose +Unrestricted in the app's battery settings (Settings > Apps > your app > Battery); granting the +battery optimisation exemption also lets the SDK's alarms and jobs run, and `bestEffort` then +reflects only the exact-alarm state. + If the user force-stops the app (Settings > Force stop, and on some devices swiping it away from recents), Android cancels its alarms and jobs: nothing is delivered until the app is opened again, and the broker then replays what was sent meanwhile. diff --git a/templates/android/library/src/main/java/io/package/services/Push.kt.twig b/templates/android/library/src/main/java/io/package/services/Push.kt.twig index e2dc82857a..f0818a1494 100644 --- a/templates/android/library/src/main/java/io/package/services/Push.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Push.kt.twig @@ -828,10 +828,14 @@ class Push( const val EXTRA_PAYLOAD = PushBackground.EXTRA_PAYLOAD /** - * What background delivery can rely on: exact alarms, battery-optimisation exemption and - * foreground mode. When [PushBackgroundStatus.bestEffort] is true, wake-ups are inexact and - * messages can arrive late while the app is closed; explain why before asking with - * [requestExactAlarms] or [requestIgnoreBatteryOptimizations]. + * What background delivery can rely on: exact alarms, battery-optimisation exemption, + * foreground mode and restricted background usage. When [PushBackgroundStatus.bestEffort] + * is true, messages can arrive late while the app is closed; explain why before asking with + * [requestExactAlarms] or [requestIgnoreBatteryOptimizations]. When + * [PushBackgroundStatus.backgroundRestricted] is true, neither exact alarms nor foreground + * mode lift it: ask the user to choose Unrestricted in the app's battery settings + * (Settings > Apps > your app > Battery), or for the battery-optimisation exemption, which + * also lets the SDK's alarms and jobs run. */ @JvmStatic fun backgroundStatus(context: Context): PushBackgroundStatus = PushBackground.backgroundStatus(context) diff --git a/templates/android/library/src/main/java/io/package/services/PushBackground.kt.twig b/templates/android/library/src/main/java/io/package/services/PushBackground.kt.twig index 6713b75d5d..a8533e2527 100644 --- a/templates/android/library/src/main/java/io/package/services/PushBackground.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/PushBackground.kt.twig @@ -1202,6 +1202,8 @@ internal object PushBackground { app.getSystemService(AlarmManager::class.java)?.canScheduleExactAlarms() == true, ignoringBatteryOptimizations = app.getSystemService(PowerManager::class.java)?.isIgnoringBatteryOptimizations(app.packageName) == true, foregroundService = PushStore.foreground(app), + backgroundRestricted = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && + app.getSystemService(ActivityManager::class.java)?.isBackgroundRestricted == true, ) } diff --git a/templates/android/library/src/main/java/io/package/services/PushBridge.kt.twig b/templates/android/library/src/main/java/io/package/services/PushBridge.kt.twig index 6465c91393..614fa55b0f 100644 --- a/templates/android/library/src/main/java/io/package/services/PushBridge.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/PushBridge.kt.twig @@ -170,12 +170,13 @@ class PushBridge(context: Context, private val events: Events) { return if (registered) PushStore.takeStoppedError(appContext) else null } - /** What background delivery can rely on, as `{"exactAlarms", "ignoringBatteryOptimizations", "foregroundService", "bestEffort"}`. */ + /** What background delivery can rely on, as `{"exactAlarms", "ignoringBatteryOptimizations", "foregroundService", "backgroundRestricted", "bestEffort"}`. */ fun backgroundStatus(): String = PushBackground.backgroundStatus(appContext).let { JSONObject() .put("exactAlarms", it.exactAlarms) .put("ignoringBatteryOptimizations", it.ignoringBatteryOptimizations) .put("foregroundService", it.foregroundService) + .put("backgroundRestricted", it.backgroundRestricted) .put("bestEffort", it.bestEffort) .toString() } diff --git a/templates/android/library/src/main/java/io/package/services/PushCore.kt.twig b/templates/android/library/src/main/java/io/package/services/PushCore.kt.twig index 0467f531f4..aa737441ec 100644 --- a/templates/android/library/src/main/java/io/package/services/PushCore.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/PushCore.kt.twig @@ -55,19 +55,36 @@ data class PushMessage( /** * What background delivery can rely on. Without exact alarms, the scheduled wake-ups are inexact * and Doze can defer them, so delivery is [bestEffort] unless foreground mode keeps the - * connection open. + * connection open. When the user restricted background usage, delivery is [bestEffort] unless the + * app is also exempt from battery optimisation, and foreground mode no longer counts: Android stops + * a restricted app's foreground service once it leaves the screen, exemption or not. */ -data class PushBackgroundStatus( +data class PushBackgroundStatus @JvmOverloads constructor( /** The app may schedule exact alarms (`SCHEDULE_EXACT_ALARM`, granted). */ val exactAlarms: Boolean, /** The app is exempt from battery optimisation. */ val ignoringBatteryOptimizations: Boolean, /** Foreground mode (`setForeground(true)`) keeps the connection open in a service. */ val foregroundService: Boolean, + /** + * The user restricted the app's background usage in its battery settings (Android 9+). Android + * may then hold its alarms, jobs and foreground service while it is closed, so messages can + * wait until the app is opened. The battery-optimisation exemption lets the alarms and jobs + * run again, but foreground mode still stops once the app leaves the screen. + */ + val backgroundRestricted: Boolean = false, ) { - /** Wake-ups may be deferred by Doze, so messages can arrive late while the app is closed. */ + /** + * Messages can arrive late, or not until the app is opened, while the app is closed: background + * usage is restricted without the battery-optimisation exemption, or wake-ups may be deferred + * by Doze. + */ val bestEffort: Boolean - get() = !exactAlarms && !foregroundService + get() { + val held = backgroundRestricted && !ignoringBatteryOptimizations + val foreground = foregroundService && !backgroundRestricted + return held || (!exactAlarms && !foreground) + } } /** Connection parameters shared between the in-process client and the foreground service. */ diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index b9dcd38539..2b2bdd1721 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -322,8 +322,8 @@ abstract class Base extends TestCase 'Push background drain:passed', ]; - // Android only: no notification while the app is on screen, the watchdog job, and resume with - // the current credential. + // Android only: no notification while the app is on screen, the watchdog job, resume with the + // current credential, and restricted background usage. protected const ANDROID_PUSH_BACKGROUND_RESPONSES = [ 'Push background message:passed', 'Push foreground no notification:passed', @@ -332,6 +332,7 @@ abstract class Base extends TestCase 'Push background resume credential:passed', 'Push background close:passed', 'Push background refused:passed', + 'Push background restricted:passed', ]; protected const PUSH_BACKGROUND_RESPONSES = [ diff --git a/tests/e2e/languages/android/Tests.kt b/tests/e2e/languages/android/Tests.kt index 25e9cc1031..4359e56a16 100644 --- a/tests/e2e/languages/android/Tests.kt +++ b/tests/e2e/languages/android/Tests.kt @@ -25,6 +25,7 @@ import io.appwrite.services.Foo import io.appwrite.services.General import io.appwrite.services.Plaintext import io.appwrite.services.Push +import io.appwrite.services.PushBackgroundStatus import io.appwrite.services.Realtime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -981,6 +982,26 @@ class ServiceTest { ) refusedPush.close() + // Restricted background usage (the app's battery settings) makes delivery best effort. Android + // reports it from 9 on; PushBackgroundRestrictedTest covers it there. + val shadow = org.robolectric.Shadows.shadowOf(context.getSystemService(android.app.ActivityManager::class.java)) + val unrestrictedStatus = Push.backgroundStatus(context) + shadow.setBackgroundRestricted(true) + val restrictedStatus = Push.backgroundStatus(context) + shadow.setBackgroundRestricted(false) + val resetStatus = Push.backgroundStatus(context) + val restrictionReported = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P + writeToFile( + if (restrictedStatus == unrestrictedStatus.copy(backgroundRestricted = restrictionReported) && + !unrestrictedStatus.backgroundRestricted && + resetStatus == unrestrictedStatus + ) { + "Push background restricted:passed" + } else { + "Push background restricted:failed (sdk: ${android.os.Build.VERSION.SDK_INT}, restricted: $restrictedStatus, unrestricted: $unrestrictedStatus, reset: $resetStatus)" + }, + ) + // A message from createPush: the notification shows the server's title and body, not the // raw JSON, and still posts when its image cannot be fetched. notifications.cancelAll() @@ -1158,6 +1179,56 @@ class ServiceTest { } } +@Config(manifest=Config.NONE, sdk=[android.os.Build.VERSION_CODES.P]) +@RunWith(AndroidJUnit4::class) +class PushBackgroundRestrictedTest { + private val context = ApplicationProvider.getApplicationContext() + private val shadow = org.robolectric.Shadows.shadowOf(context.getSystemService(android.app.ActivityManager::class.java)) + private val power = org.robolectric.Shadows.shadowOf(context.getSystemService(android.os.PowerManager::class.java)) + + @Test + fun testRestrictedBackgroundUsageMakesDeliveryBestEffort() { + val unrestricted = Push.backgroundStatus(context) + shadow.setBackgroundRestricted(true) + val restricted = Push.backgroundStatus(context) + shadow.setBackgroundRestricted(false) + val reset = Push.backgroundStatus(context) + + assertFalse("unrestricted: $unrestricted", unrestricted.backgroundRestricted) + assertFalse("unrestricted: $unrestricted", unrestricted.bestEffort) + assertTrue("restricted: $restricted", restricted.backgroundRestricted) + assertTrue("restricted: $restricted", restricted.bestEffort) + assertTrue("reset: $reset, unrestricted: $unrestricted", reset == unrestricted) + } + + @Test + fun testBatteryOptimisationExemptionLiftsRestrictedBackgroundUsage() { + shadow.setBackgroundRestricted(true) + power.setIgnoringBatteryOptimizations(context.packageName, true) + val exempt = Push.backgroundStatus(context) + power.setIgnoringBatteryOptimizations(context.packageName, false) + shadow.setBackgroundRestricted(false) + + assertTrue("exempt: $exempt", exempt.exactAlarms) + assertTrue("exempt: $exempt", exempt.ignoringBatteryOptimizations) + assertTrue("exempt: $exempt", exempt.backgroundRestricted) + assertFalse("exempt: $exempt", exempt.bestEffort) + } + + @Test + fun testForegroundModeDoesNotLiftRestrictedBackgroundUsage() { + assertTrue( + PushBackgroundStatus(exactAlarms = false, ignoringBatteryOptimizations = true, foregroundService = true, backgroundRestricted = true).bestEffort, + ) + assertTrue( + PushBackgroundStatus(exactAlarms = false, ignoringBatteryOptimizations = false, foregroundService = true, backgroundRestricted = true).bestEffort, + ) + assertFalse( + PushBackgroundStatus(exactAlarms = true, ignoringBatteryOptimizations = true, foregroundService = true, backgroundRestricted = true).bestEffort, + ) + } +} + @Config(manifest=Config.NONE) @RunWith(AndroidJUnit4::class) class RequestNullEncodingTest { diff --git a/tests/e2e/languages/react-native/android-test/Tests.kt b/tests/e2e/languages/react-native/android-test/Tests.kt index 40abb05c06..6a7d32e8d7 100644 --- a/tests/e2e/languages/react-native/android-test/Tests.kt +++ b/tests/e2e/languages/react-native/android-test/Tests.kt @@ -12,6 +12,7 @@ import android.content.pm.ResolveInfo import android.os.Build import android.net.Uri import android.os.Looper +import android.os.PowerManager import android.util.Base64 import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -22,12 +23,15 @@ import com.facebook.react.modules.network.ForwardingCookieHandler import io.appwrite.reactnative.AppwriteCookiesModule import io.appwrite.reactnative.AppwritePushModule import io.appwrite.services.PushBackground +import io.appwrite.services.PushBridge import io.appwrite.services.PushMessage import io.appwrite.services.PushOpenActivity import io.appwrite.services.PushReceiver import io.appwrite.services.PushStore import org.json.JSONArray import org.json.JSONObject +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric @@ -439,3 +443,59 @@ class Tests { const val SESSION = "eyJpZCI6ImUyZS1zZXNzaW9uLXVzZXIiLCJzZWNyZXQiOiJlMmUtc2VjcmV0In0=" } } + +/** + * The status the SDKs read through the bridge on Android 9, where restricted background usage is + * reported: best effort unless the app is also exempt from battery optimisation. + */ +@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.P]) +@RunWith(AndroidJUnit4::class) +class PushBridgeBackgroundStatusTest { + private val context = ApplicationProvider.getApplicationContext() + private val shadow = shadowOf(context.getSystemService(android.app.ActivityManager::class.java)) + private val power = shadowOf(context.getSystemService(PowerManager::class.java)) + private val bridge = PushBridge( + context, + object : PushBridge.Events { + override fun onMessage(subscriptionId: String, message: PushMessage, ackToken: String) = Unit + + override fun onError(message: String) = Unit + + override fun onConnection(connected: Boolean) = Unit + }, + ) + + @Test + fun testRestrictedBackgroundUsageIsBestEffort() { + val unrestricted = JSONObject(bridge.backgroundStatus()) + shadow.setBackgroundRestricted(true) + val restricted = JSONObject(bridge.backgroundStatus()) + shadow.setBackgroundRestricted(false) + + assertFalse("unrestricted: $unrestricted", unrestricted.getBoolean(BACKGROUND_RESTRICTED)) + assertFalse("unrestricted: $unrestricted", unrestricted.getBoolean(BEST_EFFORT)) + assertTrue("restricted: $restricted", restricted.getBoolean(BACKGROUND_RESTRICTED)) + assertTrue("restricted: $restricted", restricted.getBoolean(BEST_EFFORT)) + } + + @Test + fun testBatteryOptimisationExemptionLiftsRestrictedBackgroundUsage() { + shadow.setBackgroundRestricted(true) + power.setIgnoringBatteryOptimizations(context.packageName, true) + val exempt = JSONObject(bridge.backgroundStatus()) + power.setIgnoringBatteryOptimizations(context.packageName, false) + shadow.setBackgroundRestricted(false) + + assertTrue("exempt: $exempt", exempt.getBoolean(EXACT_ALARMS)) + assertTrue("exempt: $exempt", exempt.getBoolean(IGNORING_BATTERY_OPTIMIZATIONS)) + assertTrue("exempt: $exempt", exempt.getBoolean(BACKGROUND_RESTRICTED)) + assertFalse("exempt: $exempt", exempt.getBoolean(BEST_EFFORT)) + } + + private companion object { + const val EXACT_ALARMS = "exactAlarms" + const val IGNORING_BATTERY_OPTIMIZATIONS = "ignoringBatteryOptimizations" + const val BACKGROUND_RESTRICTED = "backgroundRestricted" + const val BEST_EFFORT = "bestEffort" + } +} From b25c935f81aa5e7b2124dfab55bdddf6cb29ecf7 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 13 Oct 2026 18:23:24 +1300 Subject: [PATCH 2/2] fix(push): expose backgroundRestricted in react native and flutter When a user sets an app's Android battery usage to Restricted, Android may hold its alarms, jobs and foreground service while it is closed, so messages can wait until the app is opened. backgroundStatus() still reported bestEffort=false on such devices, giving apps no way to notice or explain it. The native status now carries backgroundRestricted and folds it into bestEffort unless the app is power-allowlisted, and stops counting foreground mode while it is set, since Android stops a restricted app's foreground service once it leaves the screen, exemption or not. Surface the field in the React Native PushBackgroundStatus type and the Flutter model so apps can read it, and document the two ways out: the user choosing Unrestricted in the app's battery settings, or granting the battery optimisation exemption, after which bestEffort reflects only the exact-alarm state. Neither exact alarms nor foreground mode lift it. The Dart field defaults to false so callers of the public const constructor keep compiling, and fromMap treats a missing key as false. A Flutter unit test covers the parsed value and both defaults. Co-Authored-By: Claude Opus 5.5 --- src/SDK/Language/Flutter.php | 5 +++ templates/flutter/README.md.twig | 7 +++ templates/flutter/lib/src/mqtt.dart.twig | 22 +++++++-- .../src/push_background_status_test.dart.twig | 45 +++++++++++++++++++ templates/react-native/README.md.twig | 11 ++++- .../react-native/src/services/push.ts.twig | 23 ++++++++-- tests/e2e/languages/react-native/push.node.js | 4 +- 7 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 templates/flutter/test/src/push_background_status_test.dart.twig diff --git a/src/SDK/Language/Flutter.php b/src/SDK/Language/Flutter.php index 518cc85ed3..739af31fee 100644 --- a/src/SDK/Language/Flutter.php +++ b/src/SDK/Language/Flutter.php @@ -443,6 +443,11 @@ public function getFiles(): array 'destination' => '/test/src/interceptor_test.dart', 'template' => 'flutter/test/src/interceptor_test.dart.twig', ], + [ + 'scope' => 'default', + 'destination' => '/test/src/push_background_status_test.dart', + 'template' => 'flutter/test/src/push_background_status_test.dart.twig', + ], [ 'scope' => 'default', 'destination' => '/test/src/realtime_response_test.dart', diff --git a/templates/flutter/README.md.twig b/templates/flutter/README.md.twig index 86889179c1..54ebb8f85a 100644 --- a/templates/flutter/README.md.twig +++ b/templates/flutter/README.md.twig @@ -117,6 +117,13 @@ The SDK uses exact alarms on its own whenever the app may schedule them. To allo nothing to ask, including when the permission is not declared. The SDK never opens these screens on its own. +If `backgroundRestricted` is true, the user restricted the app's background usage and messages can +wait until the app is opened. Exact alarms don't lift it, and neither does foreground mode: Android +stops a restricted app's foreground service once it leaves the screen. Ask the user to choose +Unrestricted in the app's battery settings (Settings > Apps > your app > Battery); granting the +battery optimisation exemption also lets the SDK's alarms and jobs run, and `bestEffort` then +reflects only the exact-alarm state. It is always false below Android 9. + If the user force-stops the app (Settings > Force stop, and on some devices swiping it away from recents), Android cancels its alarms and jobs: nothing is delivered until the app is opened again, and the broker then replays what was sent meanwhile. diff --git a/templates/flutter/lib/src/mqtt.dart.twig b/templates/flutter/lib/src/mqtt.dart.twig index 4382c967e7..2f90323bbb 100644 --- a/templates/flutter/lib/src/mqtt.dart.twig +++ b/templates/flutter/lib/src/mqtt.dart.twig @@ -40,14 +40,24 @@ class PushBackgroundStatus { /// Foreground mode ([Push.setForeground]) keeps the connection open in a service. final bool foregroundService; - /// Wake-ups may be deferred by Doze, so messages can arrive late while the app is closed. + /// Messages can arrive late, or not until the app is opened, while the app is closed: background + /// usage is restricted without a battery optimisation exemption, or wake-ups may be deferred by + /// Doze without exact alarms or foreground mode. final bool bestEffort; + /// The user restricted the app's background usage in the app's battery settings (Settings > + /// Apps > your app > Battery), Android 9+. Android may then hold its alarms, jobs and foreground + /// service while it is closed, so messages can wait until the app is opened. The battery + /// optimisation exemption lets the alarms and jobs run again, but foreground mode still stops + /// once the app leaves the screen. False below Android 9. + final bool backgroundRestricted; + const PushBackgroundStatus({ required this.exactAlarms, required this.ignoringBatteryOptimizations, required this.foregroundService, required this.bestEffort, + this.backgroundRestricted = false, }); factory PushBackgroundStatus.fromMap(Map map) => @@ -57,6 +67,7 @@ class PushBackgroundStatus { map['ignoringBatteryOptimizations'] == true, foregroundService: map['foregroundService'] == true, bestEffort: map['bestEffort'] == true, + backgroundRestricted: map['backgroundRestricted'] == true, ); } @@ -189,9 +200,12 @@ abstract class Push extends Service { /// background. It only affects [background] subscriptions. A no-op elsewhere. Future setForeground(bool enabled); - /// Android: what background delivery can rely on. When `bestEffort` is true the scheduled - /// wake-ups are inexact and Doze can defer them, so messages may arrive late while the app is - /// closed; explain why, then ask with [requestExactAlarms] or + /// Android: what background delivery can rely on. When `bestEffort` is true messages may arrive + /// late, or not until the app is opened, while the app is closed, so explain why, then ask. If + /// `backgroundRestricted` is true and the app is not `ignoringBatteryOptimizations`, ask the user + /// to choose Unrestricted in the app's battery settings (Settings > Apps > your app > Battery), + /// or ask with [requestIgnoreBatteryOptimizations]; otherwise the scheduled wake-ups are inexact + /// and Doze can defer them, so ask with [requestExactAlarms] or /// [requestIgnoreBatteryOptimizations]. Null elsewhere. Future backgroundStatus(); diff --git a/templates/flutter/test/src/push_background_status_test.dart.twig b/templates/flutter/test/src/push_background_status_test.dart.twig new file mode 100644 index 0000000000..aee5e69b3b --- /dev/null +++ b/templates/flutter/test/src/push_background_status_test.dart.twig @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:{{language.params.packageName}}/src/mqtt.dart'; + +void main() { + group('PushBackgroundStatus', () { + test('fromMap should read backgroundRestricted', () { + final status = PushBackgroundStatus.fromMap({ + 'exactAlarms': true, + 'ignoringBatteryOptimizations': true, + 'foregroundService': true, + 'bestEffort': true, + 'backgroundRestricted': true, + }); + + expect(status.backgroundRestricted, isTrue); + expect(status.bestEffort, isTrue); + expect(status.exactAlarms, isTrue); + expect(status.ignoringBatteryOptimizations, isTrue); + expect(status.foregroundService, isTrue); + }); + + test('fromMap should default backgroundRestricted to false', () { + final status = PushBackgroundStatus.fromMap({ + 'exactAlarms': false, + 'ignoringBatteryOptimizations': false, + 'foregroundService': false, + 'bestEffort': true, + }); + + expect(status.backgroundRestricted, isFalse); + expect(status.bestEffort, isTrue); + }); + + test('constructor should default backgroundRestricted to false', () { + const status = PushBackgroundStatus( + exactAlarms: true, + ignoringBatteryOptimizations: false, + foregroundService: false, + bestEffort: false, + ); + + expect(status.backgroundRestricted, isFalse); + }); + }); +} diff --git a/templates/react-native/README.md.twig b/templates/react-native/README.md.twig index d3bccbe1d6..35e6a1dc87 100644 --- a/templates/react-native/README.md.twig +++ b/templates/react-native/README.md.twig @@ -101,6 +101,13 @@ With Expo, list them under `android.permissions` in `app.json` instead. nothing to ask, including when the permission is not declared. The SDK never opens these screens on its own. +If `backgroundRestricted` is true, the user restricted the app's background usage and messages can +wait until the app is opened. Exact alarms don't lift it, and neither does foreground mode: Android +stops a restricted app's foreground service once it leaves the screen. Ask the user to choose +Unrestricted in the app's battery settings (Settings > Apps > your app > Battery); granting the +battery optimisation exemption also lets the SDK's alarms and jobs run, and `bestEffort` then +reflects only the exact-alarm state. It is always false below Android 9. + If the user force-stops the app (Settings > Force stop, and on some devices swiping it away from recents), Android cancels its alarms and jobs: nothing is delivered until the app is opened again, and the broker then replays what was sent meanwhile. @@ -111,7 +118,9 @@ Set the notification icon with ```js const status = await push.backgroundStatus(); // null outside Android -if (status?.bestEffort) { +if (status?.backgroundRestricted && !status.ignoringBatteryOptimizations) { + // Ask the user to choose Unrestricted in the app's battery settings. +} else if (status?.bestEffort) { // Explain why, then from a button press: await push.requestExactAlarms(); } diff --git a/templates/react-native/src/services/push.ts.twig b/templates/react-native/src/services/push.ts.twig index a7e28ddea8..6e12efa836 100644 --- a/templates/react-native/src/services/push.ts.twig +++ b/templates/react-native/src/services/push.ts.twig @@ -44,8 +44,20 @@ export interface PushBackgroundStatus { ignoringBatteryOptimizations: boolean; /** Foreground mode ({@link Push.setForeground}) keeps the connection open in a service. */ foregroundService: boolean; - /** Wake-ups may be deferred by Doze, so messages can arrive late while the app is closed. */ + /** + * Messages can arrive late, or not until the app is opened, while the app is closed: background + * usage is restricted without a battery optimisation exemption, or wake-ups may be deferred by + * Doze without exact alarms or foreground mode. + */ bestEffort: boolean; + /** + * The user restricted the app's background usage in the app's battery settings (Settings > + * Apps > your app > Battery), Android 9+. Android may then hold its alarms, jobs and foreground + * service while it is closed, so messages can wait until the app is opened. The battery + * optimisation exemption lets the alarms and jobs run again, but foreground mode still stops + * once the app leaves the screen. False below Android 9. + */ + backgroundRestricted: boolean; } /** A background notification the user tapped. */ @@ -293,9 +305,12 @@ export class Push extends Service { } /** - * Android: what background delivery can rely on. When `bestEffort` is true the scheduled - * wake-ups are inexact and Doze can defer them, so messages may arrive late while the app is - * closed; explain why, then ask with {@link requestExactAlarms} or + * Android: what background delivery can rely on. When `bestEffort` is true messages may arrive + * late, or not until the app is opened, while the app is closed, so explain why, then ask. If + * `backgroundRestricted` is true and the app is not `ignoringBatteryOptimizations`, ask the user + * to choose Unrestricted in the app's battery settings (Settings > Apps > your app > Battery), + * or ask with {@link requestIgnoreBatteryOptimizations}; otherwise the scheduled wake-ups are + * inexact and Doze can defer them, so ask with {@link requestExactAlarms} or * {@link requestIgnoreBatteryOptimizations}. Null elsewhere. */ async backgroundStatus(): Promise { diff --git a/tests/e2e/languages/react-native/push.node.js b/tests/e2e/languages/react-native/push.node.js index df8a7c6d23..df21e8ce38 100644 --- a/tests/e2e/languages/react-native/push.node.js +++ b/tests/e2e/languages/react-native/push.node.js @@ -197,7 +197,7 @@ async function main() { NativeModules.AppwritePush.resumed.push(args); }, backgroundStatus: async () => - JSON.stringify({ exactAlarms: false, ignoringBatteryOptimizations: false, foregroundService: false, bestEffort: true }), + JSON.stringify({ exactAlarms: false, ignoringBatteryOptimizations: false, foregroundService: false, bestEffort: true, backgroundRestricted: true }), requestExactAlarms: async () => true, requestIgnoreBatteryOptimizations: async () => false, setErrorCallback: async () => null, @@ -260,7 +260,7 @@ async function main() { const askedBattery = await firstUser.requestIgnoreBatteryOptimizations(); const resumed = NativeModules.AppwritePush.resumed.find((args) => args[0] === 'appwrite-session'); console.log(nativeConnection); - console.log(status?.bestEffort === true && askedExact === true && askedBattery === false && resumed?.[1] === e2eSession && resumed?.[2] === false ? 'Push background status JS:passed' : 'Push background status JS:failed'); + console.log(status?.bestEffort === true && status?.backgroundRestricted === true && askedExact === true && askedBattery === false && resumed?.[1] === e2eSession && resumed?.[2] === false ? 'Push background status JS:passed' : 'Push background status JS:failed'); console.log(launched?.topic === 'news' && launched.data.saleId === '42' && tapped.length === 1 && tapped[0].data.saleId === '7' && NativeModules.AppwritePush.listening.join() === 'true,false' ? 'Push notification opened JS:passed' : 'Push notification opened JS:failed'); // The same taps outside Android, where expo-notifications posted the notification: the launching