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

Add a banner to ask the user to disable battery optimization when Event cannot be resolved from Push
This commit is contained in:
Benoit Marty 2025-06-16 11:19:15 +02:00 committed by GitHub
commit 8f94b4cd0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 776 additions and 36 deletions

View file

@ -7,6 +7,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application>
<receiver
android:name=".notifications.TestNotificationReceiver"

View file

@ -0,0 +1,85 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.battery
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.PowerManager
import android.provider.Settings
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
import timber.log.Timber
import javax.inject.Inject
interface BatteryOptimization {
/**
* Tells if the application ignores battery optimizations.
*
* Ignoring them allows the app to run in background to make background sync with the homeserver.
* This user option appears on Android M but Android O enforces its usage and kills apps not
* authorised by the user to run in background.
*
* @return true if battery optimisations are ignored
*/
fun isIgnoringBatteryOptimizations(): Boolean
/**
* Request the user to disable battery optimizations for this app.
* This will open the system settings where the user can disable battery optimizations.
* See https://developer.android.com/training/monitoring-device-state/doze-standby#exemption-cases
*
* @return true if the intent was successfully started, false if the activity was not found
*/
fun requestDisablingBatteryOptimization(): Boolean
}
@ContributesBinding(AppScope::class)
class AndroidBatteryOptimization @Inject constructor(
@ApplicationContext
private val context: Context,
private val externalIntentLauncher: ExternalIntentLauncher,
) : BatteryOptimization {
override fun isIgnoringBatteryOptimizations(): Boolean {
return context.getSystemService<PowerManager>()
?.isIgnoringBatteryOptimizations(context.packageName) == true
}
@SuppressLint("BatteryLife")
override fun requestDisablingBatteryOptimization(): Boolean {
val ignoreBatteryOptimizationsResult = launchAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, withData = true)
if (ignoreBatteryOptimizationsResult) {
return true
}
// Open settings as a fallback if the first attempt fails
return launchAction(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, withData = false)
}
private fun launchAction(
action: String,
withData: Boolean,
): Boolean {
val intent = Intent()
intent.action = action
if (withData) {
intent.data = ("package:" + context.packageName).toUri()
}
return try {
externalIntentLauncher.launch(intent)
true
} catch (exception: ActivityNotFoundException) {
Timber.w(exception, "Cannot launch intent with action $action.")
false
}
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.battery
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.LifecycleResumeEffect
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.store.PushDataStore
import kotlinx.coroutines.launch
import javax.inject.Inject
class BatteryOptimizationPresenter @Inject constructor(
private val pushDataStore: PushDataStore,
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
private val batteryOptimization: BatteryOptimization,
) : Presenter<BatteryOptimizationState> {
@Composable
override fun present(): BatteryOptimizationState {
val coroutineScope = rememberCoroutineScope()
var isRequestSent by remember { mutableStateOf(false) }
var localShouldDisplayBanner by remember { mutableStateOf(true) }
val storeShouldDisplayBanner by pushDataStore.shouldDisplayBatteryOptimizationBannerFlow.collectAsState(initial = false)
var isSystemIgnoringBatteryOptimizations by remember {
mutableStateOf(batteryOptimization.isIgnoringBatteryOptimizations())
}
LifecycleResumeEffect(Unit) {
isSystemIgnoringBatteryOptimizations = batteryOptimization.isIgnoringBatteryOptimizations()
if (isRequestSent) {
localShouldDisplayBanner = false
}
onPauseOrDispose {}
}
fun handleEvents(event: BatteryOptimizationEvents) {
when (event) {
BatteryOptimizationEvents.Dismiss -> coroutineScope.launch {
mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
}
BatteryOptimizationEvents.RequestDisableOptimizations -> {
isRequestSent = true
if (batteryOptimization.requestDisablingBatteryOptimization().not()) {
// If not able to perform the request, ensure that we do not display the banner again
coroutineScope.launch {
mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
}
}
}
}
}
return BatteryOptimizationState(
shouldDisplayBanner = localShouldDisplayBanner && storeShouldDisplayBanner && !isSystemIgnoringBatteryOptimizations,
eventSink = ::handleEvents,
)
}
}

View file

@ -10,16 +10,25 @@ package io.element.android.libraries.push.impl.di
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import dagger.Provides
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.impl.battery.BatteryOptimizationPresenter
@Module
@ContributesTo(AppScope::class)
object PushModule {
@Provides
fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat {
return NotificationManagerCompat.from(context)
interface PushModule {
companion object {
@Provides
fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat {
return NotificationManagerCompat.from(context)
}
}
@Binds
fun bindBatteryOptimizationPresenter(presenter: BatteryOptimizationPresenter): Presenter<BatteryOptimizationState>
}

View file

@ -50,6 +50,7 @@ class DefaultPushHandler @Inject constructor(
private val onNotifiableEventReceived: OnNotifiableEventReceived,
private val onRedactedEventReceived: OnRedactedEventReceived,
private val incrementPushDataStore: IncrementPushDataStore,
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
private val userPushStoreFactory: UserPushStoreFactory,
private val pushClientSecret: PushClientSecret,
private val buildMeta: BuildMeta,
@ -102,6 +103,7 @@ class DefaultPushHandler @Inject constructor(
sessionId = request.sessionId,
reason = exception.message ?: exception.javaClass.simpleName,
)
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
}
)
}

View file

@ -0,0 +1,31 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.push
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.impl.store.DefaultPushDataStore
import javax.inject.Inject
interface MutableBatteryOptimizationStore {
suspend fun showBatteryOptimizationBanner()
suspend fun onOptimizationBannerDismissed()
}
@ContributesBinding(AppScope::class)
class DefaultMutableBatteryOptimizationStore @Inject constructor(
private val defaultPushDataStore: DefaultPushDataStore,
) : MutableBatteryOptimizationStore {
override suspend fun showBatteryOptimizationBanner() {
defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_SHOW)
}
override suspend fun onOptimizationBannerDismissed() {
defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED)
}
}

View file

@ -43,10 +43,24 @@ class DefaultPushDataStore @Inject constructor(
) : PushDataStore {
private val pushCounter = intPreferencesKey("push_counter")
/**
* Integer preference to track the state of the battery optimization banner.
* Possible values:
* [BATTERY_OPTIMIZATION_BANNER_STATE_INIT]: Should not show the banner
* [BATTERY_OPTIMIZATION_BANNER_STATE_SHOW]: Should show the banner
* [BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED]: Banner has been shown and user has dismissed it
*/
private val batteryOptimizationBannerState = intPreferencesKey("battery_optimization_banner_state")
override val pushCounterFlow: Flow<Int> = context.dataStore.data.map { preferences ->
preferences[pushCounter] ?: 0
}
@Suppress("UnnecessaryParentheses")
override val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean> = context.dataStore.data.map { preferences ->
(preferences[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT) == BATTERY_OPTIMIZATION_BANNER_STATE_SHOW
}
suspend fun incrementPushCounter() {
context.dataStore.edit { settings ->
val currentCounterValue = settings[pushCounter] ?: 0
@ -54,6 +68,18 @@ class DefaultPushDataStore @Inject constructor(
}
}
suspend fun setBatteryOptimizationBannerState(newState: Int) {
context.dataStore.edit { settings ->
val currentValue = settings[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT
settings[batteryOptimizationBannerState] = when (currentValue) {
BATTERY_OPTIMIZATION_BANNER_STATE_INIT,
BATTERY_OPTIMIZATION_BANNER_STATE_SHOW -> newState
BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED -> currentValue
else -> error("Invalid value for showBatteryOptimizationBanner: $currentValue")
}
}
}
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
return pushDatabase.pushHistoryQueries.selectAll()
.asFlow()
@ -84,4 +110,10 @@ class DefaultPushDataStore @Inject constructor(
it.clear()
}
}
companion object {
const val BATTERY_OPTIMIZATION_BANNER_STATE_INIT = 0
const val BATTERY_OPTIMIZATION_BANNER_STATE_SHOW = 1
const val BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED = 2
}
}

View file

@ -11,6 +11,7 @@ import io.element.android.libraries.push.api.history.PushHistoryItem
import kotlinx.coroutines.flow.Flow
interface PushDataStore {
val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean>
val pushCounterFlow: Flow<Int>
/**