Repository: element-hq/element-x-android (language: kotlin)

Issue:
Add check/info to disable battery optimization to F-Droid-build's notification settings/troubleshooter
### Your use case

#### What would you like to do?

Have a reminder or test if battery optimization is set to "unrestricted" in Element X F-Droid build's notification settings/troubleshooter.

#### Why would you like to do it?

Recently, I installed Element X to 2 de-Googled phones, set up unified push using Nextcloud/NextPush and then ntfy, but in both cases notifications worked fine when the phones were unlocked (awake) but kept failing after they were locked for a few minutes.  
(Failing in a sense that they never arrived, were simply skipped/lost).

Initially I kept checking all the battery optimizations and tweaks of NextPush and ntfy, without success.  
Then I realized that maybe also Element needs to be unrestricted.  
And this fixed the issue.

#### How would you like to achieve it?

It would be handy to have hint/check about the battery optimizations also for Element in the notification settings/troubleshooter, as all the green checkmarks kinda create a false sense of "here's everything alright" even tho it's not 😁 

**Thanks for a great app** 😉 ❤ 

![Image](https://github.com/user-attachments/assets/1be8d095-d4af-4cf5-9a17-a9bf0357ddf3)

![Image](https://github.com/user-attachments/assets/26f5093f-befb-4c97-aa23-199f1260af1d)

### Have you considered any alternatives?

_No response_

### Additional context

_No response_

### Are you willing to provide a PR?

No


Discussion hints:
nan

Pull request: Add a banner to ask the user to disable battery optimization when Event cannot be resolved from Push
<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
 
## Content

<!-- Describe shortly what has been changed -->
When an Event cannot be resolved from Push, let the application render a banner in the room list to propose the user to disable battery optimization.

When battery optimization is disabled, it should help to resolve more events when a Push is received. The cost is a higher battery consumption. User always has the ability to revert the change by navigating to the system settings.

Draft as we're waiting for product decision and the wording of the banner may be updated (and must be added to Localazy).

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Improve Notification resolution and limit the risk of not shown notification because not resolve. In a separate PR we will ensure that a notification is always displayed when a not resolved Push is happening.

Closes #4611

## Screenshots / GIFs

<!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.

Providing a video of the change is still very useful for the reviewer and for the history of the project.

You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|

|Before|After|
|-|-|
|||
 -->


https://github.com/user-attachments/assets/5bed12ad-fdce-41db-9942-cf50d435a5a8

https://github.com/user-attachments/assets/3ddf267b-3ef2-4061-8aa2-90b2f4e71251


## Tests

<!-- Explain how you tested your development -->

- Have an unresolved event
- Open the app
- See the banner
- Option 1: Close the banner: it should not be displayed again. From the application, clear the cache to be able to test it again
- Option 2: click on "Yes, disable"
- A system dialog is displayed (can depend on OS / device)
- Option 1: click on "Deny"
- The system dialog is gone and the banner is gone until the application is started again
- Option 2: click on "Accept"
- The system dialog is gone and the banner is gone until the battery optimization setting remains unchanged.

## Tested devices

- [x] Physical
- [ ] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 24
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] You've made a self review of your PR


Test command: ./gradlew :libraries:push:impl:testDebugUnitTest --tests "io.element.android.libraries.push.impl.battery.AndroidBatteryOptimizationTest" --tests "io.element.android.libraries.push.impl.push.DefaultPushHandlerTest" --tests "io.element.android.libraries.push.impl.battery.BatteryOptimizationPresenterTest" :libraries:fullscreenintent:impl:testDebugUnitTest --tests "io.element.android.libraries.fullscreenintent.test.FullScreenIntentPermissionsPresenterTest" :features:roomlist:impl:testDebugUnitTest --tests "io.element.android.features.roomlist.impl.RoomListPresenterTest"

Fix patch:
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
index 8f2543c161f..f73fecaf3c4 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
@@ -38,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.IconSource
 import io.element.android.libraries.designsystem.theme.components.ListItem
 import io.element.android.libraries.designsystem.theme.components.Text
 import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
+import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
 import io.element.android.libraries.matrix.api.room.RoomNotificationMode
 import io.element.android.libraries.ui.strings.CommonStrings
 import kotlinx.collections.immutable.toImmutableList
@@ -149,7 +150,7 @@ private fun NotificationSettingsContentView(
                         Text(stringResource(R.string.full_screen_intent_banner_message))
                     },
                     onClick = {
-                        state.fullScreenIntentPermissionsState.openFullScreenIntentSettings()
+                        state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
                     }
                 )
             }
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
index 7435424b15b..5825c0a1e5d 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
@@ -12,6 +12,8 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
 import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
 import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
 import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.push.api.battery.BatteryOptimizationState
+import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toPersistentSet
@@ -31,10 +33,12 @@ internal fun aRoomsContentState(
     securityBannerState: SecurityBannerState = SecurityBannerState.None,
     summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
     fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
+    batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
     seenRoomInvites: Set<RoomId> = emptySet(),
 ) = RoomListContentState.Rooms(
     securityBannerState = securityBannerState,
     fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
+    batteryOptimizationState = batteryOptimizationState,
     summaries = summaries,
     seenRoomInvites = seenRoomInvites.toPersistentSet(),
 )
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
index 7746f982049..9e8199cd80a 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
@@ -52,6 +52,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
 import io.element.android.libraries.matrix.api.timeline.ReceiptType
 import io.element.android.libraries.preferences.api.store.AppPreferencesStore
 import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
+import io.element.android.libraries.push.api.battery.BatteryOptimizationState
 import io.element.android.libraries.push.api.notifications.NotificationCleaner
 import io.element.android.services.analytics.api.AnalyticsService
 import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -88,6 +89,7 @@ class RoomListPresenter @Inject constructor(
     private val analyticsService: AnalyticsService,
     private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
     private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
+    private val batteryOptimizationPresenter: Presenter<BatteryOptimizationState>,
     private val notificationCleaner: NotificationCleaner,
     private val logoutPresenter: Presenter<DirectLogoutState>,
     private val appPreferencesStore: AppPreferencesStore,
@@ -248,6 +250,7 @@ class RoomListPresenter @Inject constructor(
                 RoomListContentState.Rooms(
                     securityBannerState = securityBannerState,
                     fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
+                    batteryOptimizationState = batteryOptimizationPresenter.present(),
                     summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
                     seenRoomInvites = seenRoomInvites.toPersistentSet(),
                 )
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
index f685d5f3791..0cf6147df7e 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
@@ -18,6 +18,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
 import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
 import io.element.android.libraries.matrix.api.core.RoomId
 import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.push.api.battery.BatteryOptimizationState
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableSet
 
@@ -78,6 +79,7 @@ sealed interface RoomListContentState {
     data class Rooms(
         val securityBannerState: SecurityBannerState,
         val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
+        val batteryOptimizationState: BatteryOptimizationState,
         val summaries: ImmutableList<RoomListRoomSummary>,
         val seenRoomInvites: ImmutableSet<RoomId>,
     ) : RoomListContentState
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
index 4a221d3c973..cb4818ddd10 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
@@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
 import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
 import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
 import io.element.android.libraries.ui.strings.CommonStrings
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
@@ -45,6 +46,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
             aRoomListState(contentState = aSkeletonContentState()),
             aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
             aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
+            aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))),
         )
 }
 
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/BatteryOptimizationBanner.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/BatteryOptimizationBanner.kt
new file mode 100644
index 00000000000..8fde1834d2e
--- /dev/null
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/BatteryOptimizationBanner.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.roomlist.impl.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import io.element.android.features.roomlist.impl.R
+import io.element.android.libraries.designsystem.components.Announcement
+import io.element.android.libraries.designsystem.components.AnnouncementType
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
+import io.element.android.libraries.push.api.battery.BatteryOptimizationState
+import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
+
+@Composable
+internal fun BatteryOptimizationBanner(
+    state: BatteryOptimizationState,
+    modifier: Modifier = Modifier,
+) {
+    Announcement(
+        modifier = modifier.roomListBannerPadding(),
+        title = stringResource(R.string.banner_battery_optimization_title_android),
+        description = stringResource(R.string.banner_battery_optimization_content_android),
+        type = AnnouncementType.Actionable(
+            actionText = stringResource(R.string.banner_battery_optimization_submit_android),
+            onActionClick = { state.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations) },
+            onDismissClick = { state.eventSink(BatteryOptimizationEvents.Dismiss) },
+        ),
+    )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun BatteryOptimizationBannerPreview() = ElementPreview {
+    BatteryOptimizationBanner(
+        state = aBatteryOptimizationState(),
+    )
+}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/FullScreenIntentPermissionBanner.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/FullScreenIntentPermissionBanner.kt
index 7e84a26ab3e..339b5f4fa6a 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/FullScreenIntentPermissionBanner.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/FullScreenIntentPermissionBanner.kt
@@ -15,6 +15,7 @@ import io.element.android.libraries.designsystem.components.Announcement
 import io.element.android.libraries.designsystem.components.AnnouncementType
 import io.element.android.libraries.designsystem.preview.ElementPreview
 import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
 import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
 import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
 import io.element.android.libraries.ui.strings.CommonStrings
@@ -29,8 +30,8 @@ fun FullScreenIntentPermissionBanner(
         description = stringResource(R.string.full_screen_intent_banner_message),
         type = AnnouncementType.Actionable(
             actionText = stringResource(CommonStrings.action_continue),
-            onDismissClick = state.dismissFullScreenIntentBanner,
-            onActionClick = state.openFullScreenIntentSettings,
+            onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvents.Dismiss) },
+            onActionClick = { state.eventSink(FullScreenIntentPermissionsEvents.OpenSettings) },
         ),
         modifier = modifier.roomListBannerPadding(),
     )
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
index 0118a4fb819..b27f21c7f64 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
@@ -149,7 +149,7 @@ private fun EmptyView(
                         onDismissClick = { eventSink(RoomListEvents.DismissBanner) },
                     )
                 }
-                else -> Unit
+                SecurityBannerState.None -> Unit
             }
         }
     }
@@ -234,6 +234,10 @@ private fun RoomsViewList(
                 item {
                     FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
                 }
+            } else if (state.batteryOptimizationState.shouldDisplayBanner) {
+                item {
+                    BatteryOptimizationBanner(state = state.batteryOptimizationState)
+                }
             }
         }
 
diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml
index d72af71f84b..a7b7ce80470 100644
--- a/features/roomlist/impl/src/main/res/values/localazy.xml
+++ b/features/roomlist/impl/src/main/res/values/localazy.xml
@@ -1,5 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+  <string name="banner_battery_optimization_content_android">"Disable battery optimization for this app, to make sure all notifications are received."</string>
+  <string name="banner_battery_optimization_submit_android">"Disable optimization"</string>
+  <string name="banner_battery_optimization_title_android">"Notifications not arriving?"</string>
   <string name="banner_set_up_recovery_content">"Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices."</string>
   <string name="banner_set_up_recovery_submit">"Set up recovery"</string>
   <string name="banner_set_up_recovery_title">"Set up recovery to protect your account"</string>
diff --git a/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsEvents.kt b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsEvents.kt
new file mode 100644
index 00000000000..e0b84337499
--- /dev/null
+++ b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsEvents.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.fullscreenintent.api
+
+sealed interface FullScreenIntentPermissionsEvents {
+    data object Dismiss : FullScreenIntentPermissionsEvents
+    data object OpenSettings : FullScreenIntentPermissionsEvents
+}
diff --git a/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsState.kt b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsState.kt
index 3d77ebb4b57..5b3f84f977c 100644
--- a/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsState.kt
+++ b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsState.kt
@@ -10,6 +10,5 @@ package io.element.android.libraries.fullscreenintent.api
 data class FullScreenIntentPermissionsState(
     val permissionGranted: Boolean,
     val shouldDisplayBanner: Boolean,
-    val dismissFullScreenIntentBanner: () -> Unit,
-    val openFullScreenIntentSettings: () -> Unit,
+    val eventSink: (FullScreenIntentPermissionsEvents) -> Unit,
 )
diff --git a/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsStateProvider.kt b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsStateProvider.kt
index 7dbe340c396..25ab2ab5220 100644
--- a/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsStateProvider.kt
+++ b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsStateProvider.kt
@@ -10,11 +10,9 @@ package io.element.android.libraries.fullscreenintent.api
 fun aFullScreenIntentPermissionsState(
     permissionGranted: Boolean = true,
     shouldDisplay: Boolean = false,
-    openFullScreenIntentSettings: () -> Unit = {},
-    dismissFullScreenIntentBanner: () -> Unit = {},
+    eventSink: (FullScreenIntentPermissionsEvents) -> Unit = {},
 ) = FullScreenIntentPermissionsState(
     permissionGranted = permissionGranted,
     shouldDisplayBanner = shouldDisplay,
-    openFullScreenIntentSettings = openFullScreenIntentSettings,
-    dismissFullScreenIntentBanner = dismissFullScreenIntentBanner,
+    eventSink = eventSink,
 )
diff --git a/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt
index 3e427b6fa62..14cc438f41f 100644
--- a/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt
+++ b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt
@@ -23,6 +23,7 @@ import io.element.android.libraries.architecture.Presenter
 import io.element.android.libraries.core.meta.BuildMeta
 import io.element.android.libraries.di.AppScope
 import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
 import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
 import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
 import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
@@ -60,15 +61,20 @@ class FullScreenIntentPermissionsPresenter @Inject constructor(
         val coroutineScope = rememberCoroutineScope()
         val isGranted = notificationManagerCompat.canUseFullScreenIntent()
         val isBannerDismissed by isFullScreenIntentBannerDismissed.collectAsState(initial = true)
+
+        fun handleEvents(event: FullScreenIntentPermissionsEvents) {
+            when (event) {
+                FullScreenIntentPermissionsEvents.Dismiss -> coroutineScope.launch {
+                    dismissFullScreenIntentBanner()
+                }
+                FullScreenIntentPermissionsEvents.OpenSettings -> openFullScreenIntentSettings()
+            }
+        }
+
         return FullScreenIntentPermissionsState(
             permissionGranted = isGranted,
             shouldDisplayBanner = !isBannerDismissed && !isGranted,
-            dismissFullScreenIntentBanner = {
-                coroutineScope.launch {
-                    dismissFullScreenIntentBanner()
-                }
-            },
-            openFullScreenIntentSettings = ::openFullScreenIntentSettings,
+            eventSink = ::handleEvents,
         )
     }
 
diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationEvents.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationEvents.kt
new file mode 100644
index 00000000000..ab8d9e92e54
--- /dev/null
+++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationEvents.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.push.api.battery
+
+sealed interface BatteryOptimizationEvents {
+    data object Dismiss : BatteryOptimizationEvents
+    data object RequestDisableOptimizations : BatteryOptimizationEvents
+}
diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationState.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationState.kt
new file mode 100644
index 00000000000..7e7c2b2bd6a
--- /dev/null
+++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationState.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.push.api.battery
+
+data class BatteryOptimizationState(
+    val shouldDisplayBanner: Boolean,
+    val eventSink: (BatteryOptimizationEvents) -> Unit,
+)
diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationStateProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationStateProvider.kt
new file mode 100644
index 00000000000..d6d4e5774b3
--- /dev/null
+++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationStateProvider.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.push.api.battery
+
+fun aBatteryOptimizationState(
+    shouldDisplayBanner: Boolean = false,
+    eventSink: (BatteryOptimizationEvents) -> Unit = {},
+) = BatteryOptimizationState(
+    shouldDisplayBanner = shouldDisplayBanner,
+    eventSink = eventSink,
+)
diff --git a/libraries/push/impl/src/main/AndroidManifest.xml b/libraries/push/impl/src/main/AndroidManifest.xml
index 49ca55decc9..c08c16ed5f3 100644
--- a/libraries/push/impl/src/main/AndroidManifest.xml
+++ b/libraries/push/impl/src/main/AndroidManifest.xml
@@ -7,6 +7,9 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
     <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
+    <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
+
     <application>
         <receiver
             android:name=".notifications.TestNotificationReceiver"
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimization.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimization.kt
new file mode 100644
index 00000000000..4087a2e7988
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimization.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.push.impl.battery
+
+import android.annotation.SuppressLint
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.os.PowerManager
+import android.provider.Settings
+import androidx.core.content.getSystemService
+import androidx.core.net.toUri
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
+import timber.log.Timber
+import javax.inject.Inject
+
+interface BatteryOptimization {
+    /**
+     * Tells if the application ignores battery optimizations.
+     *
+     * Ignoring them allows the app to run in background to make background sync with the homeserver.
+     * This user option appears on Android M but Android O enforces its usage and kills apps not
+     * authorised by the user to run in background.
+     *
+     * @return true if battery optimisations are ignored
+     */
+    fun isIgnoringBatteryOptimizations(): Boolean
+
+    /**
+     * Request the user to disable battery optimizations for this app.
+     * This will open the system settings where the user can disable battery optimizations.
+     * See https://developer.android.com/training/monitoring-device-state/doze-standby#exemption-cases
+     *
+     * @return true if the intent was successfully started, false if the activity was not found
+     */
+    fun requestDisablingBatteryOptimization(): Boolean
+}
+
+@ContributesBinding(AppScope::class)
+class AndroidBatteryOptimization @Inject constructor(
+    @ApplicationContext
+    private val context: Context,
+    private val externalIntentLauncher: ExternalIntentLauncher,
+) : BatteryOptimization {
+    override fun isIgnoringBatteryOptimizations(): Boolean {
+        return context.getSystemService<PowerManager>()
+            ?.isIgnoringBatteryOptimizations(context.packageName) == true
+    }
+
+    @SuppressLint("BatteryLife")
+    override fun requestDisablingBatteryOptimization(): Boolean {
+        val ignoreBatteryOptimizationsResult = launchAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, withData = true)
+        if (ignoreBatteryOptimizationsResult) {
+            return true
+        }
+        // Open settings as a fallback if the first attempt fails
+        return launchAction(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, withData = false)
+    }
+
+    private fun launchAction(
+        action: String,
+        withData: Boolean,
+    ): Boolean {
+        val intent = Intent()
+        intent.action = action
+        if (withData) {
+            intent.data = ("package:" + context.packageName).toUri()
+        }
+        return try {
+            externalIntentLauncher.launch(intent)
+            true
+        } catch (exception: ActivityNotFoundException) {
+            Timber.w(exception, "Cannot launch intent with action $action.")
+            false
+        }
+    }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenter.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenter.kt
new file mode 100644
index 00000000000..9fa17f05449
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenter.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.push.impl.battery
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
+import io.element.android.libraries.push.api.battery.BatteryOptimizationState
+import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
+import io.element.android.libraries.push.impl.store.PushDataStore
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class BatteryOptimizationPresenter @Inject constructor(
+    private val pushDataStore: PushDataStore,
+    private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
+    private val batteryOptimization: BatteryOptimization,
+) : Presenter<BatteryOptimizationState> {
+    @Composable
+    override fun present(): BatteryOptimizationState {
+        val coroutineScope = rememberCoroutineScope()
+        var isRequestSent by remember { mutableStateOf(false) }
+        var localShouldDisplayBanner by remember { mutableStateOf(true) }
+        val storeShouldDisplayBanner by pushDataStore.shouldDisplayBatteryOptimizationBannerFlow.collectAsState(initial = false)
+        var isSystemIgnoringBatteryOptimizations by remember {
+            mutableStateOf(batteryOptimization.isIgnoringBatteryOptimizations())
+        }
+
+        LifecycleResumeEffect(Unit) {
+            isSystemIgnoringBatteryOptimizations = batteryOptimization.isIgnoringBatteryOptimizations()
+            if (isRequestSent) {
+                localShouldDisplayBanner = false
+            }
+            onPauseOrDispose {}
+        }
+
+        fun handleEvents(event: BatteryOptimizationEvents) {
+            when (event) {
+                BatteryOptimizationEvents.Dismiss -> coroutineScope.launch {
+                    mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
+                }
+                BatteryOptimizationEvents.RequestDisableOptimizations -> {
+                    isRequestSent = true
+                    if (batteryOptimization.requestDisablingBatteryOptimization().not()) {
+                        // If not able to perform the request, ensure that we do not display the banner again
+                        coroutineScope.launch {
+                            mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
+                        }
+                    }
+                }
+            }
+        }
+
+        return BatteryOptimizationState(
+            shouldDisplayBanner = localShouldDisplayBanner && storeShouldDisplayBanner && !isSystemIgnoringBatteryOptimizations,
+            eventSink = ::handleEvents,
+        )
+    }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt
index 3e7c7ddae3f..bb8b7e1624b 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt
@@ -10,16 +10,25 @@ package io.element.android.libraries.push.impl.di
 import android.content.Context
 import androidx.core.app.NotificationManagerCompat
 import com.squareup.anvil.annotations.ContributesTo
+import dagger.Binds
 import dagger.Module
 import dagger.Provides
+import io.element.android.libraries.architecture.Presenter
 import io.element.android.libraries.di.AppScope
 import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.push.api.battery.BatteryOptimizationState
+import io.element.android.libraries.push.impl.battery.BatteryOptimizationPresenter
 
 @Module
 @ContributesTo(AppScope::class)
-object PushModule {
-    @Provides
-    fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat {
-        return NotificationManagerCompat.from(context)
+interface PushModule {
+    companion object {
+        @Provides
+        fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat {
+            return NotificationManagerCompat.from(context)
+        }
     }
+
+    @Binds
+    fun bindBatteryOptimizationPresenter(presenter: BatteryOptimizationPresenter): Presenter<BatteryOptimizationState>
 }
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt
index a426cdcf426..d42ca28c884 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt
@@ -50,6 +50,7 @@ class DefaultPushHandler @Inject constructor(
     private val onNotifiableEventReceived: OnNotifiableEventReceived,
     private val onRedactedEventReceived: OnRedactedEventReceived,
     private val incrementPushDataStore: IncrementPushDataStore,
+    private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
     private val userPushStoreFactory: UserPushStoreFactory,
     private val pushClientSecret: PushClientSecret,
     private val buildMeta: BuildMeta,
@@ -102,6 +103,7 @@ class DefaultPushHandler @Inject constructor(
                                     sessionId = request.sessionId,
                                     reason = exception.message ?: exception.javaClass.simpleName,
                                 )
+                                mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
                             }
                         )
                     }
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/MutableBatteryOptimizationStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/MutableBatteryOptimizationStore.kt
new file mode 100644
index 00000000000..1c1d9186f68
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/MutableBatteryOptimizationStore.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.push.impl.push
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.push.impl.store.DefaultPushDataStore
+import javax.inject.Inject
+
+interface MutableBatteryOptimizationStore {
+    suspend fun showBatteryOptimizationBanner()
+    suspend fun onOptimizationBannerDismissed()
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultMutableBatteryOptimizationStore @Inject constructor(
+    private val defaultPushDataStore: DefaultPushDataStore,
+) : MutableBatteryOptimizationStore {
+    override suspend fun showBatteryOptimizationBanner() {
+        defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_SHOW)
+    }
+
+    override suspend fun onOptimizationBannerDismissed() {
+        defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED)
+    }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt
index 2130e91c723..a4d9413cb14 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt
@@ -43,10 +43,24 @@ class DefaultPushDataStore @Inject constructor(
 ) : PushDataStore {
     private val pushCounter = intPreferencesKey("push_counter")
 
+    /**
+     * Integer preference to track the state of the battery optimization banner.
+     * Possible values:
+     * [BATTERY_OPTIMIZATION_BANNER_STATE_INIT]: Should not show the banner
+     * [BATTERY_OPTIMIZATION_BANNER_STATE_SHOW]: Should show the banner
+     * [BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED]: Banner has been shown and user has dismissed it
+     */
+    private val batteryOptimizationBannerState = intPreferencesKey("battery_optimization_banner_state")
+
     override val pushCounterFlow: Flow<Int> = context.dataStore.data.map { preferences ->
         preferences[pushCounter] ?: 0
     }
 
+    @Suppress("UnnecessaryParentheses")
+    override val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean> = context.dataStore.data.map { preferences ->
+        (preferences[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT) == BATTERY_OPTIMIZATION_BANNER_STATE_SHOW
+    }
+
     suspend fun incrementPushCounter() {
         context.dataStore.edit { settings ->
             val currentCounterValue = settings[pushCounter] ?: 0
@@ -54,6 +68,18 @@ class DefaultPushDataStore @Inject constructor(
         }
     }
 
+    suspend fun setBatteryOptimizationBannerState(newState: Int) {
+        context.dataStore.edit { settings ->
+            val currentValue = settings[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT
+            settings[batteryOptimizationBannerState] = when (currentValue) {
+                BATTERY_OPTIMIZATION_BANNER_STATE_INIT,
+                BATTERY_OPTIMIZATION_BANNER_STATE_SHOW -> newState
+                BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED -> currentValue
+                else -> error("Invalid value for showBatteryOptimizationBanner: $currentValue")
+            }
+        }
+    }
+
     override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
         return pushDatabase.pushHistoryQueries.selectAll()
             .asFlow()
@@ -84,4 +110,10 @@ class DefaultPushDataStore @Inject constructor(
             it.clear()
         }
     }
+
+    companion object {
+        const val BATTERY_OPTIMIZATION_BANNER_STATE_INIT = 0
+        const val BATTERY_OPTIMIZATION_BANNER_STATE_SHOW = 1
+        const val BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED = 2
+    }
 }
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt
index 13c4e06347d..de8f6dbbc1e 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt
@@ -11,6 +11,7 @@ import io.element.android.libraries.push.api.history.PushHistoryItem
 import kotlinx.coroutines.flow.Flow
 
 interface PushDataStore {
+    val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean>
     val pushCounterFlow: Flow<Int>
 
     /**
diff --git a/tools/localazy/config.json b/tools/localazy/config.json
index b299c39ef71..b4721f8be53 100644
--- a/tools/localazy/config.json
+++ b/tools/localazy/config.json
@@ -168,6 +168,7 @@
         "session_verification_banner_.*",
         "confirm_recovery_key_banner_.*",
         "banner\\.set_up_recovery\\..*",
+        "banner\\.battery_optimization\\..*",
         "full_screen_intent_banner_.*",
         "screen_migration_.*",
         "screen_invites_.*",


Test patch:
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
index 446a864f0b7..3184a44a1cd 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
@@ -75,6 +75,7 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
 import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
 import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
 import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
+import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
 import io.element.android.libraries.push.api.notifications.NotificationCleaner
 import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
 import io.element.android.services.analytics.api.AnalyticsService
@@ -712,6 +713,7 @@ class RoomListPresenterTest {
         analyticsService = analyticsService,
         acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
         fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
+        batteryOptimizationPresenter = { aBatteryOptimizationState() },
         notificationCleaner = notificationCleaner,
         logoutPresenter = { aDirectLogoutState() },
         appPreferencesStore = appPreferencesStore,
diff --git a/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt b/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt
index 8cf508562b9..0d10032da0a 100644
--- a/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt
+++ b/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt
@@ -15,6 +15,7 @@ import app.cash.molecule.moleculeFlow
 import app.cash.turbine.test
 import com.google.common.truth.Truth.assertThat
 import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
 import io.element.android.libraries.fullscreenintent.impl.FullScreenIntentPermissionsPresenter
 import io.element.android.libraries.matrix.test.core.aBuildMeta
 import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
@@ -76,10 +77,8 @@ class FullScreenIntentPermissionsPresenterTest {
         }.test {
             skipItems(1)
             val loadedItem = awaitItem()
-            loadedItem.dismissFullScreenIntentBanner()
-
+            loadedItem.eventSink(FullScreenIntentPermissionsEvents.Dismiss)
             runCurrent()
-
             assertThat(awaitItem().shouldDisplayBanner).isFalse()
         }
     }
@@ -94,10 +93,8 @@ class FullScreenIntentPermissionsPresenterTest {
         }.test {
             skipItems(1)
             val loadedItem = awaitItem()
-            loadedItem.openFullScreenIntentSettings()
-
+            loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
             launchLambda.assertions().isCalledOnce()
-
             cancelAndIgnoreRemainingEvents()
         }
     }
@@ -115,10 +112,8 @@ class FullScreenIntentPermissionsPresenterTest {
         }.test {
             skipItems(1)
             val loadedItem = awaitItem()
-            loadedItem.openFullScreenIntentSettings()
-
+            loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
             launchLambda.assertions().isNeverCalled()
-
             cancelAndIgnoreRemainingEvents()
         }
     }
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/AndroidBatteryOptimizationTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/AndroidBatteryOptimizationTest.kt
new file mode 100644
index 00000000000..e3a28550ca6
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/AndroidBatteryOptimizationTest.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.push.impl.battery
+
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.provider.Settings
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertThat
+import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
+import io.element.android.services.toolbox.test.intent.FakeExternalIntentLauncher
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class AndroidBatteryOptimizationTest {
+    @Test
+    fun `isIgnoringBatteryOptimizations should return false`() {
+        val sut = createAndroidBatteryOptimization()
+        assertThat(sut.isIgnoringBatteryOptimizations()).isFalse()
+    }
+
+    @Test
+    fun `requestDisablingBatteryOptimization is called once with expected intent`() {
+        val launchLambda = lambdaRecorder<Intent, Unit> { intent ->
+            assertThat(intent.action).isEqualTo(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
+            assertThat(intent.data.toString()).isEqualTo("package:${InstrumentationRegistry.getInstrumentation().context.packageName}")
+        }
+        val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda)
+        val sut = createAndroidBatteryOptimization(
+            externalIntentLauncher = externalIntentLauncher,
+        )
+        val result = sut.requestDisablingBatteryOptimization()
+        launchLambda.assertions().isCalledOnce()
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun `in case of 1 error, requestDisablingBatteryOptimization returns true`() {
+        var callNumber = 0
+        val launchLambda = lambdaRecorder<Intent, Unit> { intent ->
+            callNumber++
+            when (callNumber) {
+                1 -> {
+                    assertThat(intent.action).isEqualTo(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
+                    assertThat(intent.data.toString()).isEqualTo("package:${InstrumentationRegistry.getInstrumentation().context.packageName}")
+                    throw ActivityNotFoundException("Test exception")
+                }
+                2 -> {
+                    assertThat(intent.action).isEqualTo(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
+                    assertThat(intent.data).isNull()
+                    // No error
+                }
+                else -> {
+                    throw AssertionError("Unexpected call number: $callNumber")
+                }
+            }
+        }
+        val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda)
+        val sut = createAndroidBatteryOptimization(
+            externalIntentLauncher = externalIntentLauncher,
+        )
+        val result = sut.requestDisablingBatteryOptimization()
+        launchLambda.assertions().isCalledExactly(2)
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun `in case of 2 errors, requestDisablingBatteryOptimization returns false`() {
+        var callNumber = 0
+        val launchLambda = lambdaRecorder<Intent, Unit> { intent ->
+            callNumber++
+            when (callNumber) {
+                1 -> {
+                    assertThat(intent.action).isEqualTo(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
+                    assertThat(intent.data.toString()).isEqualTo("package:${InstrumentationRegistry.getInstrumentation().context.packageName}")
+                    throw ActivityNotFoundException("Test exception")
+                }
+                2 -> {
+                    assertThat(intent.action).isEqualTo(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
+                    assertThat(intent.data).isNull()
+                    throw ActivityNotFoundException("Test exception")
+                }
+                else -> {
+                    throw AssertionError("Unexpected call number: $callNumber")
+                }
+            }
+        }
+        val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda)
+        val sut = createAndroidBatteryOptimization(
+            externalIntentLauncher = externalIntentLauncher,
+        )
+        val result = sut.requestDisablingBatteryOptimization()
+        launchLambda.assertions().isCalledExactly(2)
+        assertThat(result).isFalse()
+    }
+
+    private fun createAndroidBatteryOptimization(
+        externalIntentLauncher: ExternalIntentLauncher = FakeExternalIntentLauncher(),
+    ): AndroidBatteryOptimization {
+        return AndroidBatteryOptimization(
+            context = InstrumentationRegistry.getInstrumentation().context,
+            externalIntentLauncher = externalIntentLauncher,
+        )
+    }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenterTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenterTest.kt
new file mode 100644
index 00000000000..d5dd7c48d45
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenterTest.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.push.impl.battery
+
+import androidx.lifecycle.Lifecycle
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
+import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore
+import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
+import io.element.android.libraries.push.impl.store.InMemoryPushDataStore
+import io.element.android.libraries.push.impl.store.PushDataStore
+import io.element.android.tests.testutils.FakeLifecycleOwner
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.testWithLifecycleOwner
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class BatteryOptimizationPresenterTest {
+    @get:Rule
+    val warmUpRule = WarmUpRule()
+
+    @Test
+    fun `present - initial state`() = runTest {
+        val presenter = createPresenter(
+            pushDataStore = InMemoryPushDataStore(
+                initialShouldDisplayBatteryOptimizationBanner = false,
+            ),
+            batteryOptimization = FakeBatteryOptimization(
+                isIgnoringBatteryOptimizationsResult = false,
+            ),
+        )
+        val lifeCycleOwner = FakeLifecycleOwner()
+        presenter.testWithLifecycleOwner(lifeCycleOwner) {
+            val initialState = awaitItem()
+            assertThat(initialState.shouldDisplayBanner).isFalse()
+            lifeCycleOwner.givenState(Lifecycle.State.RESUMED)
+        }
+    }
+
+    @Test
+    fun `present - should display banner`() = runTest {
+        val presenter = createPresenter(
+            pushDataStore = InMemoryPushDataStore(
+                initialShouldDisplayBatteryOptimizationBanner = true,
+            ),
+            batteryOptimization = FakeBatteryOptimization(
+                isIgnoringBatteryOptimizationsResult = false,
+            ),
+        )
+        presenter.testWithLifecycleOwner {
+            val initialState = awaitItem()
+            assertThat(initialState.shouldDisplayBanner).isFalse()
+            assertThat(awaitItem().shouldDisplayBanner).isTrue()
+        }
+    }
+
+    @Test
+    fun `present - should display banner, but setting already performed`() = runTest {
+        val presenter = createPresenter(
+            pushDataStore = InMemoryPushDataStore(
+                initialShouldDisplayBatteryOptimizationBanner = true,
+            ),
+            batteryOptimization = FakeBatteryOptimization(
+                isIgnoringBatteryOptimizationsResult = true,
+            ),
+        )
+        presenter.testWithLifecycleOwner {
+            val initialState = awaitItem()
+            assertThat(initialState.shouldDisplayBanner).isFalse()
+            assertThat(awaitItem().shouldDisplayBanner).isFalse()
+        }
+    }
+
+    @Test
+    fun `present - should display banner, user dismisses`() = runTest {
+        val onOptimizationBannerDismissedResult = lambdaRecorder<Unit> { }
+        val presenter = createPresenter(
+            pushDataStore = InMemoryPushDataStore(
+                initialShouldDisplayBatteryOptimizationBanner = true,
+            ),
+            batteryOptimization = FakeBatteryOptimization(
+                isIgnoringBatteryOptimizationsResult = false,
+            ),
+            mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
+                onOptimizationBannerDismissedResult = onOptimizationBannerDismissedResult,
+            ),
+        )
+        presenter.testWithLifecycleOwner {
+            val initialState = awaitItem()
+            assertThat(initialState.shouldDisplayBanner).isFalse()
+            val displayedItem = awaitItem()
+            assertThat(displayedItem.shouldDisplayBanner).isTrue()
+            displayedItem.eventSink(BatteryOptimizationEvents.Dismiss)
+            onOptimizationBannerDismissedResult.assertions().isCalledOnce()
+        }
+    }
+
+    @Test
+    fun `present - should display banner, user continue, error case`() = runTest {
+        val onOptimizationBannerDismissedResult = lambdaRecorder<Unit> { }
+        val requestDisablingBatteryOptimizationResult = lambdaRecorder<Boolean> { false }
+        val presenter = createPresenter(
+            pushDataStore = InMemoryPushDataStore(
+                initialShouldDisplayBatteryOptimizationBanner = true,
+            ),
+            batteryOptimization = FakeBatteryOptimization(
+                isIgnoringBatteryOptimizationsResult = false,
+                requestDisablingBatteryOptimizationResult = requestDisablingBatteryOptimizationResult
+            ),
+            mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
+                onOptimizationBannerDismissedResult = onOptimizationBannerDismissedResult,
+            ),
+        )
+        presenter.testWithLifecycleOwner {
+            val initialState = awaitItem()
+            assertThat(initialState.shouldDisplayBanner).isFalse()
+            val displayedItem = awaitItem()
+            assertThat(displayedItem.shouldDisplayBanner).isTrue()
+            displayedItem.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations)
+            requestDisablingBatteryOptimizationResult.assertions().isCalledOnce()
+            onOptimizationBannerDismissedResult.assertions().isCalledOnce()
+        }
+    }
+
+    @Test
+    fun `present - should display banner, user continue, nominal case`() = runTest {
+        val requestDisablingBatteryOptimizationResult = lambdaRecorder<Boolean> { true }
+        val batteryOptimization = FakeBatteryOptimization(
+            isIgnoringBatteryOptimizationsResult = false,
+            requestDisablingBatteryOptimizationResult = requestDisablingBatteryOptimizationResult
+        )
+        val presenter = createPresenter(
+            pushDataStore = InMemoryPushDataStore(
+                initialShouldDisplayBatteryOptimizationBanner = true,
+            ),
+            batteryOptimization = batteryOptimization,
+            mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
+        )
+        val lifeCycleOwner = FakeLifecycleOwner()
+        presenter.testWithLifecycleOwner(lifeCycleOwner) {
+            val initialState = awaitItem()
+            assertThat(initialState.shouldDisplayBanner).isFalse()
+            val displayedItem = awaitItem()
+            assertThat(displayedItem.shouldDisplayBanner).isTrue()
+            displayedItem.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations)
+            requestDisablingBatteryOptimizationResult.assertions().isCalledOnce()
+            batteryOptimization.isIgnoringBatteryOptimizationsResult = true
+            lifeCycleOwner.givenState(Lifecycle.State.RESUMED)
+            assertThat(awaitItem().shouldDisplayBanner).isFalse()
+            assertThat(awaitItem().shouldDisplayBanner).isFalse()
+        }
+    }
+
+    private fun createPresenter(
+        pushDataStore: PushDataStore = InMemoryPushDataStore(),
+        mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
+        batteryOptimization: BatteryOptimization = FakeBatteryOptimization(),
+    ) = BatteryOptimizationPresenter(
+        pushDataStore = pushDataStore,
+        mutableBatteryOptimizationStore = mutableBatteryOptimizationStore,
+        batteryOptimization = batteryOptimization
+    )
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/FakeBatteryOptimization.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/FakeBatteryOptimization.kt
new file mode 100644
index 00000000000..0adb3d25202
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/FakeBatteryOptimization.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.push.impl.battery
+
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeBatteryOptimization(
+    var isIgnoringBatteryOptimizationsResult: Boolean = false,
+    private val requestDisablingBatteryOptimizationResult: () -> Boolean = { lambdaError() }
+) : BatteryOptimization {
+    override fun isIgnoringBatteryOptimizations(): Boolean {
+        return isIgnoringBatteryOptimizationsResult
+    }
+
+    override fun requestDisablingBatteryOptimization(): Boolean {
+        return requestDisablingBatteryOptimizationResult()
+    }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt
index 933c830525b..64a3b87aa73 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt
@@ -86,7 +86,7 @@ class DefaultPushHandlerTest {
     fun `when classical PushData is received, the notification drawer is informed`() = runTest {
         val aNotifiableMessageEvent = aNotifiableMessageEvent()
         val notifiableEventResult =
-            lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _, ->
+            lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
                 val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
                 Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
             }
@@ -268,11 +268,35 @@ class DefaultPushHandlerTest {
         }
 
     @Test
-    fun `when classical PushData is received, but not able to resolve the event, nothing happen`() =
+    fun `when classical PushData is received, but a failure occurs (session not found), nothing happen`() {
+        `test notification resolver failure`(
+            notificationResolveResult = { _ ->
+                Result.failure(ResolvingException("Unable to restore session"))
+            },
+            shouldSetOptimizationBatteryBanner = false,
+        )
+    }
+
+    @Test
+    fun `when classical PushData is received, but not able to resolve the event, the banner to disable battery optimization will be displayed`() {
+        `test notification resolver failure`(
+            notificationResolveResult = { requests: List<NotificationEventRequest> ->
+                Result.success(
+                    requests.associateWith { Result.failure(ResolvingException("Unable to resolve event")) }
+                )
+            },
+            shouldSetOptimizationBatteryBanner = true,
+        )
+    }
+
+    private fun `test notification resolver failure`(
+        notificationResolveResult: (List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>,
+        shouldSetOptimizationBatteryBanner: Boolean,
+    ) {
         runTest {
             val notifiableEventResult =
-                lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
-                    Result.failure(ResolvingException("Unable to resolve"))
+                lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, requests ->
+                    notificationResolveResult(requests)
                 }
             val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
             val incrementPushCounterResult = lambdaRecorder<Unit> {}
@@ -286,6 +310,7 @@ class DefaultPushHandlerTest {
             val pushHistoryService = FakePushHistoryService(
                 onPushReceivedResult = onPushReceivedResult,
             )
+            val showBatteryOptimizationBannerResult = lambdaRecorder<Unit> {}
             val defaultPushHandler = createDefaultPushHandler(
                 onNotifiableEventsReceived = onNotifiableEventsReceived,
                 notifiableEventsResult = notifiableEventResult,
@@ -297,6 +322,9 @@ class DefaultPushHandlerTest {
                     getUserIdFromSecretResult = { A_USER_ID }
                 ),
                 incrementPushCounterResult = incrementPushCounterResult,
+                mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
+                    showBatteryOptimizationBannerResult = showBatteryOptimizationBannerResult,
+                ),
                 pushHistoryService = pushHistoryService,
             )
             defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
@@ -313,7 +341,15 @@ class DefaultPushHandlerTest {
             onPushReceivedResult.assertions()
                 .isCalledOnce()
                 .with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any())
+            showBatteryOptimizationBannerResult.assertions().let {
+                if (shouldSetOptimizationBatteryBanner) {
+                    it.isCalledOnce()
+                } else {
+                    it.isNeverCalled()
+                }
+            }
         }
+    }
 
     @Test
     fun `when ringing call PushData is received, the incoming call will be handled`() = runTest {
@@ -542,7 +578,7 @@ class DefaultPushHandlerTest {
     fun `when receiving several push notifications at the same time, those are batched before being processed`() = runTest {
         val aNotifiableMessageEvent = aNotifiableMessageEvent()
         val notifiableEventResult =
-            lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _, ->
+            lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
                 val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
                 Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
             }
@@ -595,8 +631,9 @@ class DefaultPushHandlerTest {
         onNotifiableEventsReceived: (List<NotifiableEvent>) -> Unit = { lambdaError() },
         onRedactedEventsReceived: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
         notifiableEventsResult: (SessionId, List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> =
-            { _, _, -> lambdaError() },
+            { _, _ -> lambdaError() },
         incrementPushCounterResult: () -> Unit = { lambdaError() },
+        mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
         userPushStore: UserPushStore = FakeUserPushStore(),
         pushClientSecret: PushClientSecret = FakePushClientSecret(),
         buildMeta: BuildMeta = aBuildMeta(),
@@ -614,6 +651,7 @@ class DefaultPushHandlerTest {
                     incrementPushCounterResult()
                 }
             },
+            mutableBatteryOptimizationStore = mutableBatteryOptimizationStore,
             userPushStoreFactory = FakeUserPushStoreFactory { userPushStore },
             pushClientSecret = pushClientSecret,
             buildMeta = buildMeta,
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeMutableBatteryOptimizationStore.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeMutableBatteryOptimizationStore.kt
new file mode 100644
index 00000000000..9e526debbb0
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeMutableBatteryOptimizationStore.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.push.impl.push
+
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeMutableBatteryOptimizationStore(
+    private val showBatteryOptimizationBannerResult: () -> Unit = { lambdaError() },
+    private val onOptimizationBannerDismissedResult: () -> Unit = { lambdaError() },
+) : MutableBatteryOptimizationStore {
+    override suspend fun showBatteryOptimizationBanner() {
+        showBatteryOptimizationBannerResult()
+    }
+
+    override suspend fun onOptimizationBannerDismissed() {
+        onOptimizationBannerDismissedResult()
+    }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt
index 7ea038791b3..4f710e09b6f 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt
@@ -15,12 +15,16 @@ import kotlinx.coroutines.flow.asStateFlow
 
 class InMemoryPushDataStore(
     initialPushCounter: Int = 0,
+    initialShouldDisplayBatteryOptimizationBanner: Boolean = false,
     initialPushHistoryItems: List<PushHistoryItem> = emptyList(),
     private val resetResult: () -> Unit = { lambdaError() }
 ) : PushDataStore {
     private val mutablePushCounterFlow = MutableStateFlow(initialPushCounter)
     override val pushCounterFlow: Flow<Int> = mutablePushCounterFlow.asStateFlow()
 
+    private val mutableShouldDisplayBatteryOptimizationBannerFlow = MutableStateFlow(initialShouldDisplayBatteryOptimizationBanner)
+    override val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean> = mutableShouldDisplayBatteryOptimizationBannerFlow.asStateFlow()
+
     private val mutablePushHistoryItemsFlow = MutableStateFlow(initialPushHistoryItems)
 
     override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
diff --git a/services/toolbox/test/build.gradle.kts b/services/toolbox/test/build.gradle.kts
index 9f3f9bfee3f..8d2f2b8a2d8 100644
--- a/services/toolbox/test/build.gradle.kts
+++ b/services/toolbox/test/build.gradle.kts
@@ -14,4 +14,5 @@ android {
 
 dependencies {
     api(projects.services.toolbox.api)
+    implementation(projects.tests.testutils)
 }
diff --git a/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/intent/FakeExternalIntentLauncher.kt b/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/intent/FakeExternalIntentLauncher.kt
index 721a6ebe1bf..a8e9589b6d8 100644
--- a/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/intent/FakeExternalIntentLauncher.kt
+++ b/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/intent/FakeExternalIntentLauncher.kt
@@ -9,9 +9,10 @@ package io.element.android.services.toolbox.test.intent
 
 import android.content.Intent
 import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
+import io.element.android.tests.testutils.lambda.lambdaError
 
 class FakeExternalIntentLauncher(
-    var launchLambda: (Intent) -> Unit = {},
+    var launchLambda: (Intent) -> Unit = { lambdaError() },
 ) : ExternalIntentLauncher {
     override fun launch(intent: Intent) {
         launchLambda(intent)
diff --git a/tests/uitests/src/test/snapshots/images/features.roomlist.impl.components_BatteryOptimizationBanner_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.roomlist.impl.components_BatteryOptimizationBanner_Day_0_en.png
new file mode 100644
index 00000000000..6b81678de07
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.roomlist.impl.components_BatteryOptimizationBanner_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ef31a93af4f24ebada1e1436812c01d949e75d09f1259fabbe0d7a2a7eae3400
+size 26278
diff --git a/tests/uitests/src/test/snapshots/images/features.roomlist.impl.components_BatteryOptimizationBanner_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.roomlist.impl.components_BatteryOptimizationBanner_Night_0_en.png
new file mode 100644
index 00000000000..64406048487
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.roomlist.impl.components_BatteryOptimizationBanner_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9bf4b769592e06e707a727bae111a6ebf27ced4c272c4aaace3368049873182b
+size 25324
diff --git a/tests/uitests/src/test/snapshots/images/features.roomlist.impl_RoomListView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.roomlist.impl_RoomListView_Day_11_en.png
new file mode 100644
index 00000000000..b7e12785912
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.roomlist.impl_RoomListView_Day_11_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6041a7693307a30183606e7f8f2b605ba4c9bfb8c7ad5d8122837749813d6e2c
+size 99928
diff --git a/tests/uitests/src/test/snapshots/images/features.roomlist.impl_RoomListView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.roomlist.impl_RoomListView_Night_11_en.png
new file mode 100644
index 00000000000..6cafa75e882
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.roomlist.impl_RoomListView_Night_11_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7563629f9df4012d7cc35b2805e1ac381ccdb171935b35cad0d1ea60186e3e7d
+size 105496