From 7deed4cc8633bb607fd43f8201ed4f29fb8252db Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 6 Jun 2025 17:59:52 +0200 Subject: [PATCH] Add a banner to ask the user to disable battery optimization when Event cannot be resolved from Push. --- .../impl/RoomListContentStateProvider.kt | 4 + .../roomlist/impl/RoomListPresenter.kt | 3 + .../features/roomlist/impl/RoomListState.kt | 2 + .../roomlist/impl/RoomListStateProvider.kt | 2 + .../components/BatteryOptimizationBanner.kt | 45 +++++ .../impl/components/RoomListContentView.kt | 6 +- .../roomlist/impl/RoomListPresenterTest.kt | 2 + .../api/battery/BatteryOptimizationState.kt | 16 ++ .../BatteryOptimizationStateProvider.kt | 20 +++ .../push/impl/src/main/AndroidManifest.xml | 3 + .../push/impl/battery/BatteryOptimization.kt | 72 ++++++++ .../battery/BatteryOptimizationPresenter.kt | 66 +++++++ .../libraries/push/impl/di/PushModule.kt | 17 +- .../push/impl/push/DefaultPushHandler.kt | 2 + .../push/MutableBatteryOptimizationStore.kt | 31 ++++ .../push/impl/store/DefaultPushDataStore.kt | 32 ++++ .../push/impl/store/PushDataStore.kt | 1 + .../BatteryOptimizationPresenterTest.kt | 169 ++++++++++++++++++ .../impl/battery/FakeBatteryOptimization.kt | 24 +++ .../push/impl/push/DefaultPushHandlerTest.kt | 50 +++++- .../FakeMutableBatteryOptimizationStore.kt | 23 +++ .../push/impl/store/InMemoryPushDataStore.kt | 4 + 22 files changed, 583 insertions(+), 11 deletions(-) create mode 100644 features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/BatteryOptimizationBanner.kt create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationState.kt create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationStateProvider.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimization.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenter.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/MutableBatteryOptimizationStore.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenterTest.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/FakeBatteryOptimization.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeMutableBatteryOptimizationStore.kt 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 7435424b15..5825c0a1e5 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 = aRoomListRoomSummaryList(), fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), + batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(), seenRoomInvites: Set = 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 7746f98204..9e8199cd80 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, private val fullScreenIntentPermissionsPresenter: Presenter, + private val batteryOptimizationPresenter: Presenter, private val notificationCleaner: NotificationCleaner, private val logoutPresenter: Presenter, 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 f685d5f379..0cf6147df7 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, val seenRoomInvites: ImmutableSet, ) : 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 4a221d3c97..cb4818ddd1 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 { 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 0000000000..19f63cf20d --- /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.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.BatteryOptimizationState +import io.element.android.libraries.push.api.battery.aBatteryOptimizationState + +@Composable +internal fun BatteryOptimizationBanner( + state: BatteryOptimizationState, + modifier: Modifier = Modifier, +) { + val activity = LocalActivity.current + Announcement( + modifier = modifier.roomListBannerPadding(), + // TODO Localazy + title = "Notification tip", + description = "To be sure to receive all the notifications, it can help to disable the battery optimization for this application.", + type = AnnouncementType.Actionable( + actionText = "Yes, disable", + onActionClick = { state.openSettings(activity) }, + onDismissClick = state.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/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt index 0118a4fb81..b27f21c7f6 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/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 446a864f0b..3184a44a1c 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/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 0000000000..42e65c2bcb --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationState.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 + +import android.app.Activity + +data class BatteryOptimizationState( + val shouldDisplayBanner: Boolean, + val dismiss: () -> Unit, + val openSettings: (Activity?) -> 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 0000000000..da1639ed7a --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationStateProvider.kt @@ -0,0 +1,20 @@ +/* + * 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 + +import android.app.Activity + +fun aBatteryOptimizationState( + shouldDisplayBanner: Boolean = false, + dismiss: () -> Unit = {}, + openSettings: (Activity?) -> Unit = {}, +) = BatteryOptimizationState( + shouldDisplayBanner = shouldDisplayBanner, + dismiss = dismiss, + openSettings = openSettings, +) diff --git a/libraries/push/impl/src/main/AndroidManifest.xml b/libraries/push/impl/src/main/AndroidManifest.xml index 49ca55decc..c08c16ed5f 100644 --- a/libraries/push/impl/src/main/AndroidManifest.xml +++ b/libraries/push/impl/src/main/AndroidManifest.xml @@ -7,6 +7,9 @@ + + + () + ?.isIgnoringBatteryOptimizations(context.packageName) == true + } + + @SuppressLint("BatteryLife") + override fun requestDisablingBatteryOptimization(activity: Activity?): Boolean { + activity ?: return false + val intent = Intent() + intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + intent.data = ("package:" + context.packageName).toUri() + return try { + activity.startActivity(intent) + true + } catch (exception: ActivityNotFoundException) { + Timber.w(exception, "Cannot request ignoring battery optimizations.") + 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 0000000000..46bd00ab3a --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenter.kt @@ -0,0 +1,66 @@ +/* + * 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.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 { + @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 {} + } + + return BatteryOptimizationState( + shouldDisplayBanner = localShouldDisplayBanner && storeShouldDisplayBanner && !isSystemIgnoringBatteryOptimizations, + dismiss = { + coroutineScope.launch { + mutableBatteryOptimizationStore.onOptimizationBannerDismissed() + } + }, + openSettings = { activity -> + isRequestSent = true + if (batteryOptimization.requestDisablingBatteryOptimization(activity).not()) { + // If not able to perform the request, ensure that we do not display the banner again + coroutineScope.launch { + mutableBatteryOptimizationStore.onOptimizationBannerDismissed() + } + } + } + ) + } +} 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 3e7c7ddae3..bb8b7e1624 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 } 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 a426cdcf42..d42ca28c88 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 0000000000..c5d52c55a2 --- /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(1) + } + + override suspend fun onOptimizationBannerDismissed() { + defaultPushDataStore.setBatteryOptimizationBannerState(2) + } +} 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 2130e91c72..a4d9413cb1 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 = context.dataStore.data.map { preferences -> preferences[pushCounter] ?: 0 } + @Suppress("UnnecessaryParentheses") + override val shouldDisplayBatteryOptimizationBannerFlow: Flow = 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> { 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 13c4e06347..de8f6dbbc1 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 val pushCounterFlow: Flow /** 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 0000000000..6a31b399f3 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenterTest.kt @@ -0,0 +1,169 @@ +/* + * 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.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 { } + 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.dismiss() + onOptimizationBannerDismissedResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - should display banner, user continue, error case`() = runTest { + val onOptimizationBannerDismissedResult = lambdaRecorder { } + val requestDisablingBatteryOptimizationResult = lambdaRecorder { 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.openSettings(null) + requestDisablingBatteryOptimizationResult.assertions().isCalledOnce() + onOptimizationBannerDismissedResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - should display banner, user continue, nominal case`() = runTest { + val requestDisablingBatteryOptimizationResult = lambdaRecorder { 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.openSettings(null) + 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 0000000000..7bf4564bb8 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/FakeBatteryOptimization.kt @@ -0,0 +1,24 @@ +/* + * 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.app.Activity +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(activity: Activity?): 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 933c830525..64a3b87aa7 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, Result>>> { _, _, -> + lambdaRecorder, Result>>> { _, _ -> 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 -> + Result.success( + requests.associateWith { Result.failure(ResolvingException("Unable to resolve event")) } + ) + }, + shouldSetOptimizationBatteryBanner = true, + ) + } + + private fun `test notification resolver failure`( + notificationResolveResult: (List) -> Result>>, + shouldSetOptimizationBatteryBanner: Boolean, + ) { runTest { val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - Result.failure(ResolvingException("Unable to resolve")) + lambdaRecorder, Result>>> { _, requests -> + notificationResolveResult(requests) } val onNotifiableEventsReceived = lambdaRecorder, Unit> {} val incrementPushCounterResult = lambdaRecorder {} @@ -286,6 +310,7 @@ class DefaultPushHandlerTest { val pushHistoryService = FakePushHistoryService( onPushReceivedResult = onPushReceivedResult, ) + val showBatteryOptimizationBannerResult = lambdaRecorder {} 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, Result>>> { _, _, -> + lambdaRecorder, Result>>> { _, _ -> 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) -> Unit = { lambdaError() }, onRedactedEventsReceived: (List) -> Unit = { lambdaError() }, notifiableEventsResult: (SessionId, List) -> Result>> = - { _, _, -> 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 0000000000..9e526debbb --- /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 7ea038791b..4f710e09b6 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 = emptyList(), private val resetResult: () -> Unit = { lambdaError() } ) : PushDataStore { private val mutablePushCounterFlow = MutableStateFlow(initialPushCounter) override val pushCounterFlow: Flow = mutablePushCounterFlow.asStateFlow() + private val mutableShouldDisplayBatteryOptimizationBannerFlow = MutableStateFlow(initialShouldDisplayBatteryOptimizationBanner) + override val shouldDisplayBatteryOptimizationBannerFlow: Flow = mutableShouldDisplayBatteryOptimizationBannerFlow.asStateFlow() + private val mutablePushHistoryItemsFlow = MutableStateFlow(initialPushHistoryItems) override fun getPushHistoryItemsFlow(): Flow> {