Add a banner to ask the user to disable battery optimization when Event cannot be resolved from Push.

This commit is contained in:
Benoit Marty 2025-06-06 17:59:52 +02:00
parent 11c2467577
commit 7deed4cc86
22 changed files with 583 additions and 11 deletions

View file

@ -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(),
)

View file

@ -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(),
)

View file

@ -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

View file

@ -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))),
)
}

View file

@ -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(),
)
}

View file

@ -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)
}
}
}

View file

@ -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,

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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"

View file

@ -0,0 +1,72 @@
/*
* 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.app.Activity
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 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
*
* @param activity The activity from which to start the settings intent.
* @return true if the intent was successfully started, false if the activity was not found
*/
fun requestDisablingBatteryOptimization(activity: Activity?): Boolean
}
@ContributesBinding(AppScope::class)
class AndroidBatteryOptimization @Inject constructor(
@ApplicationContext
private val context: Context,
) : BatteryOptimization {
override fun isIgnoringBatteryOptimizations(): Boolean {
return context.getSystemService<PowerManager>()
?.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
}
}
}

View file

@ -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<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 {}
}
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()
}
}
}
)
}
}

View file

@ -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>
}

View file

@ -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()
}
)
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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>
/**

View file

@ -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<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.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.openSettings(null)
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.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
)
}

View file

@ -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()
}
}

View file

@ -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,

View file

@ -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()
}
}

View file

@ -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>> {