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:
commit
72247a3047
39 changed files with 776 additions and 36 deletions
|
|
@ -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.ListItem
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
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.matrix.api.room.RoomNotificationMode
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
@ -149,7 +150,7 @@ private fun NotificationSettingsContentView(
|
||||||
Text(stringResource(R.string.full_screen_intent_banner_message))
|
Text(stringResource(R.string.full_screen_intent_banner_message))
|
||||||
},
|
},
|
||||||
onClick = {
|
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.FullScreenIntentPermissionsState
|
||||||
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
|
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toPersistentSet
|
import kotlinx.collections.immutable.toPersistentSet
|
||||||
|
|
@ -31,10 +33,12 @@ internal fun aRoomsContentState(
|
||||||
securityBannerState: SecurityBannerState = SecurityBannerState.None,
|
securityBannerState: SecurityBannerState = SecurityBannerState.None,
|
||||||
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
|
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
|
||||||
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
|
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
|
||||||
|
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
|
||||||
seenRoomInvites: Set<RoomId> = emptySet(),
|
seenRoomInvites: Set<RoomId> = emptySet(),
|
||||||
) = RoomListContentState.Rooms(
|
) = RoomListContentState.Rooms(
|
||||||
securityBannerState = securityBannerState,
|
securityBannerState = securityBannerState,
|
||||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
|
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
|
||||||
|
batteryOptimizationState = batteryOptimizationState,
|
||||||
summaries = summaries,
|
summaries = summaries,
|
||||||
seenRoomInvites = seenRoomInvites.toPersistentSet(),
|
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.matrix.api.timeline.ReceiptType
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
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.libraries.push.api.notifications.NotificationCleaner
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||||
|
|
@ -88,6 +89,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||||
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
|
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
|
||||||
|
private val batteryOptimizationPresenter: Presenter<BatteryOptimizationState>,
|
||||||
private val notificationCleaner: NotificationCleaner,
|
private val notificationCleaner: NotificationCleaner,
|
||||||
private val logoutPresenter: Presenter<DirectLogoutState>,
|
private val logoutPresenter: Presenter<DirectLogoutState>,
|
||||||
private val appPreferencesStore: AppPreferencesStore,
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
|
@ -248,6 +250,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
RoomListContentState.Rooms(
|
RoomListContentState.Rooms(
|
||||||
securityBannerState = securityBannerState,
|
securityBannerState = securityBannerState,
|
||||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
||||||
|
batteryOptimizationState = batteryOptimizationPresenter.present(),
|
||||||
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
|
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
|
||||||
seenRoomInvites = seenRoomInvites.toPersistentSet(),
|
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.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
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.ImmutableList
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
|
|
||||||
|
|
@ -78,6 +79,7 @@ sealed interface RoomListContentState {
|
||||||
data class Rooms(
|
data class Rooms(
|
||||||
val securityBannerState: SecurityBannerState,
|
val securityBannerState: SecurityBannerState,
|
||||||
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
|
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
|
||||||
|
val batteryOptimizationState: BatteryOptimizationState,
|
||||||
val summaries: ImmutableList<RoomListRoomSummary>,
|
val summaries: ImmutableList<RoomListRoomSummary>,
|
||||||
val seenRoomInvites: ImmutableSet<RoomId>,
|
val seenRoomInvites: ImmutableSet<RoomId>,
|
||||||
) : RoomListContentState
|
) : 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.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
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 io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -45,6 +46,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||||
aRoomListState(contentState = aSkeletonContentState()),
|
aRoomListState(contentState = aSkeletonContentState()),
|
||||||
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
|
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
|
||||||
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
|
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.components.AnnouncementType
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.FullScreenIntentPermissionsState
|
||||||
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
|
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -29,8 +30,8 @@ fun FullScreenIntentPermissionBanner(
|
||||||
description = stringResource(R.string.full_screen_intent_banner_message),
|
description = stringResource(R.string.full_screen_intent_banner_message),
|
||||||
type = AnnouncementType.Actionable(
|
type = AnnouncementType.Actionable(
|
||||||
actionText = stringResource(CommonStrings.action_continue),
|
actionText = stringResource(CommonStrings.action_continue),
|
||||||
onDismissClick = state.dismissFullScreenIntentBanner,
|
onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvents.Dismiss) },
|
||||||
onActionClick = state.openFullScreenIntentSettings,
|
onActionClick = { state.eventSink(FullScreenIntentPermissionsEvents.OpenSettings) },
|
||||||
),
|
),
|
||||||
modifier = modifier.roomListBannerPadding(),
|
modifier = modifier.roomListBannerPadding(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ private fun EmptyView(
|
||||||
onDismissClick = { eventSink(RoomListEvents.DismissBanner) },
|
onDismissClick = { eventSink(RoomListEvents.DismissBanner) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> Unit
|
SecurityBannerState.None -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -234,6 +234,10 @@ private fun RoomsViewList(
|
||||||
item {
|
item {
|
||||||
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<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_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_submit">"Set up recovery"</string>
|
||||||
<string name="banner_set_up_recovery_title">"Set up recovery to protect your account"</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.api.store.SessionPreferencesStore
|
||||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
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.api.notifications.NotificationCleaner
|
||||||
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
|
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -712,6 +713,7 @@ class RoomListPresenterTest {
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||||
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
|
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
|
||||||
|
batteryOptimizationPresenter = { aBatteryOptimizationState() },
|
||||||
notificationCleaner = notificationCleaner,
|
notificationCleaner = notificationCleaner,
|
||||||
logoutPresenter = { aDirectLogoutState() },
|
logoutPresenter = { aDirectLogoutState() },
|
||||||
appPreferencesStore = appPreferencesStore,
|
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(
|
data class FullScreenIntentPermissionsState(
|
||||||
val permissionGranted: Boolean,
|
val permissionGranted: Boolean,
|
||||||
val shouldDisplayBanner: Boolean,
|
val shouldDisplayBanner: Boolean,
|
||||||
val dismissFullScreenIntentBanner: () -> Unit,
|
val eventSink: (FullScreenIntentPermissionsEvents) -> Unit,
|
||||||
val openFullScreenIntentSettings: () -> Unit,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,9 @@ package io.element.android.libraries.fullscreenintent.api
|
||||||
fun aFullScreenIntentPermissionsState(
|
fun aFullScreenIntentPermissionsState(
|
||||||
permissionGranted: Boolean = true,
|
permissionGranted: Boolean = true,
|
||||||
shouldDisplay: Boolean = false,
|
shouldDisplay: Boolean = false,
|
||||||
openFullScreenIntentSettings: () -> Unit = {},
|
eventSink: (FullScreenIntentPermissionsEvents) -> Unit = {},
|
||||||
dismissFullScreenIntentBanner: () -> Unit = {},
|
|
||||||
) = FullScreenIntentPermissionsState(
|
) = FullScreenIntentPermissionsState(
|
||||||
permissionGranted = permissionGranted,
|
permissionGranted = permissionGranted,
|
||||||
shouldDisplayBanner = shouldDisplay,
|
shouldDisplayBanner = shouldDisplay,
|
||||||
openFullScreenIntentSettings = openFullScreenIntentSettings,
|
eventSink = eventSink,
|
||||||
dismissFullScreenIntentBanner = dismissFullScreenIntentBanner,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.SingleIn
|
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.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||||
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||||
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
|
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
|
||||||
|
|
@ -60,15 +61,20 @@ class FullScreenIntentPermissionsPresenter @Inject constructor(
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val isGranted = notificationManagerCompat.canUseFullScreenIntent()
|
val isGranted = notificationManagerCompat.canUseFullScreenIntent()
|
||||||
val isBannerDismissed by isFullScreenIntentBannerDismissed.collectAsState(initial = true)
|
val isBannerDismissed by isFullScreenIntentBannerDismissed.collectAsState(initial = true)
|
||||||
|
|
||||||
|
fun handleEvents(event: FullScreenIntentPermissionsEvents) {
|
||||||
|
when (event) {
|
||||||
|
FullScreenIntentPermissionsEvents.Dismiss -> coroutineScope.launch {
|
||||||
|
dismissFullScreenIntentBanner()
|
||||||
|
}
|
||||||
|
FullScreenIntentPermissionsEvents.OpenSettings -> openFullScreenIntentSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return FullScreenIntentPermissionsState(
|
return FullScreenIntentPermissionsState(
|
||||||
permissionGranted = isGranted,
|
permissionGranted = isGranted,
|
||||||
shouldDisplayBanner = !isBannerDismissed && !isGranted,
|
shouldDisplayBanner = !isBannerDismissed && !isGranted,
|
||||||
dismissFullScreenIntentBanner = {
|
eventSink = ::handleEvents,
|
||||||
coroutineScope.launch {
|
|
||||||
dismissFullScreenIntentBanner()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
openFullScreenIntentSettings = ::openFullScreenIntentSettings,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
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.fullscreenintent.impl.FullScreenIntentPermissionsPresenter
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||||
|
|
@ -76,10 +77,8 @@ class FullScreenIntentPermissionsPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedItem = awaitItem()
|
val loadedItem = awaitItem()
|
||||||
loadedItem.dismissFullScreenIntentBanner()
|
loadedItem.eventSink(FullScreenIntentPermissionsEvents.Dismiss)
|
||||||
|
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
assertThat(awaitItem().shouldDisplayBanner).isFalse()
|
assertThat(awaitItem().shouldDisplayBanner).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,10 +93,8 @@ class FullScreenIntentPermissionsPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedItem = awaitItem()
|
val loadedItem = awaitItem()
|
||||||
loadedItem.openFullScreenIntentSettings()
|
loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
|
||||||
|
|
||||||
launchLambda.assertions().isCalledOnce()
|
launchLambda.assertions().isCalledOnce()
|
||||||
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,10 +112,8 @@ class FullScreenIntentPermissionsPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedItem = awaitItem()
|
val loadedItem = awaitItem()
|
||||||
loadedItem.openFullScreenIntentSettings()
|
loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
|
||||||
|
|
||||||
launchLambda.assertions().isNeverCalled()
|
launchLambda.assertions().isNeverCalled()
|
||||||
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".notifications.TestNotificationReceiver"
|
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 android.content.Context
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
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
|
@Module
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(AppScope::class)
|
||||||
object PushModule {
|
interface PushModule {
|
||||||
@Provides
|
companion object {
|
||||||
fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat {
|
@Provides
|
||||||
return NotificationManagerCompat.from(context)
|
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 onNotifiableEventReceived: OnNotifiableEventReceived,
|
||||||
private val onRedactedEventReceived: OnRedactedEventReceived,
|
private val onRedactedEventReceived: OnRedactedEventReceived,
|
||||||
private val incrementPushDataStore: IncrementPushDataStore,
|
private val incrementPushDataStore: IncrementPushDataStore,
|
||||||
|
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
|
||||||
private val userPushStoreFactory: UserPushStoreFactory,
|
private val userPushStoreFactory: UserPushStoreFactory,
|
||||||
private val pushClientSecret: PushClientSecret,
|
private val pushClientSecret: PushClientSecret,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
|
@ -102,6 +103,7 @@ class DefaultPushHandler @Inject constructor(
|
||||||
sessionId = request.sessionId,
|
sessionId = request.sessionId,
|
||||||
reason = exception.message ?: exception.javaClass.simpleName,
|
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 {
|
) : PushDataStore {
|
||||||
private val pushCounter = intPreferencesKey("push_counter")
|
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 ->
|
override val pushCounterFlow: Flow<Int> = context.dataStore.data.map { preferences ->
|
||||||
preferences[pushCounter] ?: 0
|
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() {
|
suspend fun incrementPushCounter() {
|
||||||
context.dataStore.edit { settings ->
|
context.dataStore.edit { settings ->
|
||||||
val currentCounterValue = settings[pushCounter] ?: 0
|
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>> {
|
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
|
||||||
return pushDatabase.pushHistoryQueries.selectAll()
|
return pushDatabase.pushHistoryQueries.selectAll()
|
||||||
.asFlow()
|
.asFlow()
|
||||||
|
|
@ -84,4 +110,10 @@ class DefaultPushDataStore @Inject constructor(
|
||||||
it.clear()
|
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
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface PushDataStore {
|
interface PushDataStore {
|
||||||
|
val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean>
|
||||||
val pushCounterFlow: Flow<Int>
|
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 {
|
fun `when classical PushData is received, the notification drawer is informed`() = runTest {
|
||||||
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
||||||
val notifiableEventResult =
|
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)
|
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))))
|
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
||||||
}
|
}
|
||||||
|
|
@ -268,11 +268,35 @@ class DefaultPushHandlerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 {
|
runTest {
|
||||||
val notifiableEventResult =
|
val notifiableEventResult =
|
||||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, requests ->
|
||||||
Result.failure(ResolvingException("Unable to resolve"))
|
notificationResolveResult(requests)
|
||||||
}
|
}
|
||||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||||
|
|
@ -286,6 +310,7 @@ class DefaultPushHandlerTest {
|
||||||
val pushHistoryService = FakePushHistoryService(
|
val pushHistoryService = FakePushHistoryService(
|
||||||
onPushReceivedResult = onPushReceivedResult,
|
onPushReceivedResult = onPushReceivedResult,
|
||||||
)
|
)
|
||||||
|
val showBatteryOptimizationBannerResult = lambdaRecorder<Unit> {}
|
||||||
val defaultPushHandler = createDefaultPushHandler(
|
val defaultPushHandler = createDefaultPushHandler(
|
||||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||||
notifiableEventsResult = notifiableEventResult,
|
notifiableEventsResult = notifiableEventResult,
|
||||||
|
|
@ -297,6 +322,9 @@ class DefaultPushHandlerTest {
|
||||||
getUserIdFromSecretResult = { A_USER_ID }
|
getUserIdFromSecretResult = { A_USER_ID }
|
||||||
),
|
),
|
||||||
incrementPushCounterResult = incrementPushCounterResult,
|
incrementPushCounterResult = incrementPushCounterResult,
|
||||||
|
mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
|
||||||
|
showBatteryOptimizationBannerResult = showBatteryOptimizationBannerResult,
|
||||||
|
),
|
||||||
pushHistoryService = pushHistoryService,
|
pushHistoryService = pushHistoryService,
|
||||||
)
|
)
|
||||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||||
|
|
@ -313,7 +341,15 @@ class DefaultPushHandlerTest {
|
||||||
onPushReceivedResult.assertions()
|
onPushReceivedResult.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any())
|
.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
|
@Test
|
||||||
fun `when ringing call PushData is received, the incoming call will be handled`() = runTest {
|
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 {
|
fun `when receiving several push notifications at the same time, those are batched before being processed`() = runTest {
|
||||||
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
||||||
val notifiableEventResult =
|
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)
|
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))))
|
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
||||||
}
|
}
|
||||||
|
|
@ -595,8 +631,9 @@ class DefaultPushHandlerTest {
|
||||||
onNotifiableEventsReceived: (List<NotifiableEvent>) -> Unit = { lambdaError() },
|
onNotifiableEventsReceived: (List<NotifiableEvent>) -> Unit = { lambdaError() },
|
||||||
onRedactedEventsReceived: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
|
onRedactedEventsReceived: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
|
||||||
notifiableEventsResult: (SessionId, List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> =
|
notifiableEventsResult: (SessionId, List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> =
|
||||||
{ _, _, -> lambdaError() },
|
{ _, _ -> lambdaError() },
|
||||||
incrementPushCounterResult: () -> Unit = { lambdaError() },
|
incrementPushCounterResult: () -> Unit = { lambdaError() },
|
||||||
|
mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
|
||||||
userPushStore: UserPushStore = FakeUserPushStore(),
|
userPushStore: UserPushStore = FakeUserPushStore(),
|
||||||
pushClientSecret: PushClientSecret = FakePushClientSecret(),
|
pushClientSecret: PushClientSecret = FakePushClientSecret(),
|
||||||
buildMeta: BuildMeta = aBuildMeta(),
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
|
|
@ -614,6 +651,7 @@ class DefaultPushHandlerTest {
|
||||||
incrementPushCounterResult()
|
incrementPushCounterResult()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mutableBatteryOptimizationStore = mutableBatteryOptimizationStore,
|
||||||
userPushStoreFactory = FakeUserPushStoreFactory { userPushStore },
|
userPushStoreFactory = FakeUserPushStoreFactory { userPushStore },
|
||||||
pushClientSecret = pushClientSecret,
|
pushClientSecret = pushClientSecret,
|
||||||
buildMeta = buildMeta,
|
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(
|
class InMemoryPushDataStore(
|
||||||
initialPushCounter: Int = 0,
|
initialPushCounter: Int = 0,
|
||||||
|
initialShouldDisplayBatteryOptimizationBanner: Boolean = false,
|
||||||
initialPushHistoryItems: List<PushHistoryItem> = emptyList(),
|
initialPushHistoryItems: List<PushHistoryItem> = emptyList(),
|
||||||
private val resetResult: () -> Unit = { lambdaError() }
|
private val resetResult: () -> Unit = { lambdaError() }
|
||||||
) : PushDataStore {
|
) : PushDataStore {
|
||||||
private val mutablePushCounterFlow = MutableStateFlow(initialPushCounter)
|
private val mutablePushCounterFlow = MutableStateFlow(initialPushCounter)
|
||||||
override val pushCounterFlow: Flow<Int> = mutablePushCounterFlow.asStateFlow()
|
override val pushCounterFlow: Flow<Int> = mutablePushCounterFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val mutableShouldDisplayBatteryOptimizationBannerFlow = MutableStateFlow(initialShouldDisplayBatteryOptimizationBanner)
|
||||||
|
override val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean> = mutableShouldDisplayBatteryOptimizationBannerFlow.asStateFlow()
|
||||||
|
|
||||||
private val mutablePushHistoryItemsFlow = MutableStateFlow(initialPushHistoryItems)
|
private val mutablePushHistoryItemsFlow = MutableStateFlow(initialPushHistoryItems)
|
||||||
|
|
||||||
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
|
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,5 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.services.toolbox.api)
|
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 android.content.Intent
|
||||||
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
|
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
class FakeExternalIntentLauncher(
|
class FakeExternalIntentLauncher(
|
||||||
var launchLambda: (Intent) -> Unit = {},
|
var launchLambda: (Intent) -> Unit = { lambdaError() },
|
||||||
) : ExternalIntentLauncher {
|
) : ExternalIntentLauncher {
|
||||||
override fun launch(intent: Intent) {
|
override fun launch(intent: Intent) {
|
||||||
launchLambda(intent)
|
launchLambda(intent)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ef31a93af4f24ebada1e1436812c01d949e75d09f1259fabbe0d7a2a7eae3400
|
||||||
|
size 26278
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9bf4b769592e06e707a727bae111a6ebf27ced4c272c4aaace3368049873182b
|
||||||
|
size 25324
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6041a7693307a30183606e7f8f2b605ba4c9bfb8c7ad5d8122837749813d6e2c
|
||||||
|
size 99928
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7563629f9df4012d7cc35b2805e1ac381ccdb171935b35cad0d1ea60186e3e7d
|
||||||
|
size 105496
|
||||||
|
|
@ -168,6 +168,7 @@
|
||||||
"session_verification_banner_.*",
|
"session_verification_banner_.*",
|
||||||
"confirm_recovery_key_banner_.*",
|
"confirm_recovery_key_banner_.*",
|
||||||
"banner\\.set_up_recovery\\..*",
|
"banner\\.set_up_recovery\\..*",
|
||||||
|
"banner\\.battery_optimization\\..*",
|
||||||
"full_screen_intent_banner_.*",
|
"full_screen_intent_banner_.*",
|
||||||
"screen_migration_.*",
|
"screen_migration_.*",
|
||||||
"screen_invites_.*",
|
"screen_invites_.*",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue