Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))),
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.features.roomlist.impl.components

import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState

@Composable
internal fun BatteryOptimizationBanner(
state: BatteryOptimizationState,
modifier: Modifier = Modifier,
) {
val activity = LocalActivity.current
Announcement(
modifier = modifier.roomListBannerPadding(),
// TODO Localazy
title = "Notification tip",
description = "To be sure to receive all the notifications, it can help to disable the battery optimization for this application.",
type = AnnouncementType.Actionable(
actionText = "Yes, disable",
onActionClick = { state.openSettings(activity) },
onDismissClick = state.dismiss,
),
)
}

@PreviewsDayNight
@Composable
internal fun BatteryOptimizationBannerPreview() = ElementPreview {
BatteryOptimizationBanner(
state = aBatteryOptimizationState(),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ private fun EmptyView(
onDismissClick = { eventSink(RoomListEvents.DismissBanner) },
)
}
else -> Unit
SecurityBannerState.None -> Unit
}
}
}
Expand Down Expand Up @@ -234,6 +234,10 @@ private fun RoomsViewList(
item {
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
}
} else if (state.batteryOptimizationState.shouldDisplayBanner) {
item {
BatteryOptimizationBanner(state = state.batteryOptimizationState)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -712,6 +713,7 @@ class RoomListPresenterTest {
analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
batteryOptimizationPresenter = { aBatteryOptimizationState() },
notificationCleaner = notificationCleaner,
logoutPresenter = { aDirectLogoutState() },
appPreferencesStore = appPreferencesStore,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.libraries.push.api.battery

import android.app.Activity

data class BatteryOptimizationState(
val shouldDisplayBanner: Boolean,
val dismiss: () -> Unit,
val openSettings: (Activity?) -> Unit,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.libraries.push.api.battery

import android.app.Activity

fun aBatteryOptimizationState(
shouldDisplayBanner: Boolean = false,
dismiss: () -> Unit = {},
openSettings: (Activity?) -> Unit = {},
) = BatteryOptimizationState(
shouldDisplayBanner = shouldDisplayBanner,
dismiss = dismiss,
openSettings = openSettings,
)
3 changes: 3 additions & 0 deletions libraries/push/impl/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.libraries.push.impl.battery

import android.annotation.SuppressLint
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.PowerManager
import android.provider.Settings
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
import javax.inject.Inject

interface BatteryOptimization {
/**
* Tells if the application ignores battery optimizations.
*
* Ignoring them allows the app to run in background to make background sync with the homeserver.
* This user option appears on Android M but Android O enforces its usage and kills apps not
* authorised by the user to run in background.
*
* @return true if battery optimisations are ignored
*/
fun isIgnoringBatteryOptimizations(): Boolean

/**
* Request the user to disable battery optimizations for this app.
* This will open the system settings where the user can disable battery optimizations.
* See https://developer.android.com/training/monitoring-device-state/doze-standby#exemption-cases
*
* @param activity The activity from which to start the settings intent.
* @return true if the intent was successfully started, false if the activity was not found
*/
fun requestDisablingBatteryOptimization(activity: Activity?): Boolean
}

@ContributesBinding(AppScope::class)
class AndroidBatteryOptimization @Inject constructor(
@ApplicationContext
private val context: Context,
) : BatteryOptimization {
override fun isIgnoringBatteryOptimizations(): Boolean {
return context.getSystemService<PowerManager>()
?.isIgnoringBatteryOptimizations(context.packageName) == true
}

@SuppressLint("BatteryLife")
override fun requestDisablingBatteryOptimization(activity: Activity?): Boolean {
activity ?: return false
val intent = Intent()
intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
intent.data = ("package:" + context.packageName).toUri()
return try {
activity.startActivity(intent)
true
} catch (exception: ActivityNotFoundException) {
Timber.w(exception, "Cannot request ignoring battery optimizations.")
false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.libraries.push.impl.battery

import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.LifecycleResumeEffect
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.store.PushDataStore
import kotlinx.coroutines.launch
import javax.inject.Inject

class BatteryOptimizationPresenter @Inject constructor(
private val pushDataStore: PushDataStore,
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
private val batteryOptimization: BatteryOptimization,
) : Presenter<BatteryOptimizationState> {
@Composable
override fun present(): BatteryOptimizationState {
val coroutineScope = rememberCoroutineScope()
var isRequestSent by remember { mutableStateOf(false) }
var localShouldDisplayBanner by remember { mutableStateOf(true) }
val storeShouldDisplayBanner by pushDataStore.shouldDisplayBatteryOptimizationBannerFlow.collectAsState(initial = false)
var isSystemIgnoringBatteryOptimizations by remember {
mutableStateOf(batteryOptimization.isIgnoringBatteryOptimizations())
}

LifecycleResumeEffect(Unit) {
isSystemIgnoringBatteryOptimizations = batteryOptimization.isIgnoringBatteryOptimizations()
if (isRequestSent) {
localShouldDisplayBanner = false
}
onPauseOrDispose {}
}

return BatteryOptimizationState(
shouldDisplayBanner = localShouldDisplayBanner && storeShouldDisplayBanner && !isSystemIgnoringBatteryOptimizations,
dismiss = {
coroutineScope.launch {
mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
}
},
openSettings = { activity ->
isRequestSent = true
if (batteryOptimization.requestDisablingBatteryOptimization(activity).not()) {
// If not able to perform the request, ensure that we do not display the banner again
coroutineScope.launch {
mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
}
}
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -102,6 +103,7 @@ class DefaultPushHandler @Inject constructor(
sessionId = request.sessionId,
reason = exception.message ?: exception.javaClass.simpleName,
)
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
}
)
}
Expand Down
Loading