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-06 17:59:52 +02:00
parent 11c2467577
commit 7deed4cc86
22 changed files with 583 additions and 11 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,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
}
}
}

View file

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

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(1)
}
override suspend fun onOptimizationBannerDismissed() {
defaultPushDataStore.setBatteryOptimizationBannerState(2)
}
}

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>
/**