Merge pull request #4845 from element-hq/feature/bma/batteryOptimization

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-16 11:19:15 +02:00
committed by GitHub
39 changed files with 776 additions and 36 deletions

View File

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
fun aBatteryOptimizationState(
shouldDisplayBanner: Boolean = false,
eventSink: (BatteryOptimizationEvents) -> Unit = {},
) = BatteryOptimizationState(
shouldDisplayBanner = shouldDisplayBanner,
eventSink = eventSink,
)

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

View File

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

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(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_SHOW)
}
override suspend fun onOptimizationBannerDismissed() {
defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED)
}
}

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

View File

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

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

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

View File

@@ -14,4 +14,5 @@ android {
dependencies {
api(projects.services.toolbox.api)
implementation(projects.tests.testutils)
}

View File

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

View File

@@ -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_.*",