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:
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>> {
|
||||
|
||||
@@ -14,4 +14,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
api(projects.services.toolbox.api)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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_.*",
|
||||
|
||||
Reference in New Issue
Block a user