Add battery saver warning (#542)

* Show battery saver warning in account list and debug info

* Move app warnings to model class

* Debug info: more verbose text

* Restore previous strings for sync enqueued/started
This commit is contained in:
Ricki Hirner 2024-01-30 15:50:03 +01:00 committed by GitHub
parent fbed5c7d67
commit dd036b91fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 188 additions and 129 deletions

View file

@ -14,6 +14,7 @@ import android.content.pm.ShortcutManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.viewModels
@ -49,7 +50,12 @@ import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.DataSaverOn
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.SignalCellularOff
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -82,6 +88,7 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.ui.account.AppWarningsModel
import at.bitfire.davdroid.ui.intro.IntroActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import at.bitfire.davdroid.ui.widget.ActionCard
@ -105,6 +112,7 @@ class AccountsActivity: AppCompatActivity() {
@Inject lateinit var accountsDrawerHandler: AccountsDrawerHandler
private val model by viewModels<Model>()
private val warnings by viewModels<AppWarningsModel>()
private val introActivityLauncher = registerForActivityResult(IntroActivity.Contract) { cancelled ->
if (cancelled)
@ -165,8 +173,6 @@ class AccountsActivity: AppCompatActivity() {
)
Column {
val warnings = model.warnings
val notificationsPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(
permission = Manifest.permission.POST_NOTIFICATIONS
@ -183,13 +189,7 @@ class AccountsActivity: AppCompatActivity() {
},
internetWarning = warnings.networkAvailable.observeAsState().value == false,
onManageConnections = {
val intent = Intent(android.provider.Settings.ACTION_WIRELESS_SETTINGS)
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
lowStorageWarning = warnings.storageLow.observeAsState().value == true,
onManageStorage = {
val intent = Intent(android.provider.Settings.ACTION_INTERNAL_STORAGE_SETTINGS)
val intent = Intent(Settings.ACTION_WIRELESS_SETTINGS)
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
@ -198,6 +198,18 @@ class AccountsActivity: AppCompatActivity() {
val intent = Intent(android.provider.Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS, Uri.parse("package:" + packageName))
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
batterySaverActive = warnings.batterySaverActive.observeAsState().value == true,
onManageBatterySaver = {
val intent = Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
lowStorageWarning = warnings.storageLow.observeAsState().value == true,
onManageStorage = {
val intent = Intent(android.provider.Settings.ACTION_INTERNAL_STORAGE_SETTINGS)
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
}
)
@ -244,12 +256,19 @@ class AccountsActivity: AppCompatActivity() {
scope: CoroutineScope
): @Composable (SnackbarHostState) -> Unit = {
SnackbarHost(snackbarHostState)
model.feedback.observeAsState().value?.let { msg ->
scope.launch {
snackbarHostState.showSnackbar(msg)
}
model.syncEnqueued.observeAsState().value?.let { enqueued ->
if (enqueued)
scope.launch {
val msg = getString(
if (warnings.networkAvailable.value == true)
R.string.sync_started
else
R.string.no_internet_sync_scheduled
)
snackbarHostState.showSnackbar(msg)
}
// reset feedback
model.feedback.value = null
model.syncEnqueued.value = null
}
}
@ -343,11 +362,10 @@ class AccountsActivity: AppCompatActivity() {
@HiltViewModel
class Model @Inject constructor(
application: Application,
val db: AppDatabase,
val warnings: AppWarningsManager
val db: AppDatabase
): AndroidViewModel(application), OnAccountsUpdateListener {
val feedback = MutableLiveData<String>()
val syncEnqueued = MutableLiveData<Boolean>()
val accountManager = AccountManager.get(application)
private val accountType = application.getString(R.string.account_type)
@ -394,8 +412,6 @@ class AccountsActivity: AppCompatActivity() {
}
}
val networkAvailable = warnings.networkAvailable
val showAddAccount = MutableLiveData(true)
init {
@ -416,12 +432,7 @@ class AccountsActivity: AppCompatActivity() {
if (Build.VERSION.SDK_INT >= 25)
context.getSystemService<ShortcutManager>()?.reportShortcutUsed(UiUtils.SHORTCUT_SYNC_ALL)
feedback.value = context.getString(
if (networkAvailable.value == false)
R.string.no_internet_sync_scheduled
else
R.string.sync_started
)
syncEnqueued.value = true
// Enqueue sync worker for all accounts and authorities. Will sync once internet is available
for (account in allAccounts())
@ -539,15 +550,17 @@ fun SyncWarnings(
onClickPermissions: () -> Unit = {},
internetWarning: Boolean,
onManageConnections: () -> Unit = {},
lowStorageWarning: Boolean,
onManageStorage: () -> Unit = {},
batterySaverActive: Boolean,
onManageBatterySaver: () -> Unit = {},
dataSaverActive: Boolean,
onManageDataSaver: () -> Unit = {}
onManageDataSaver: () -> Unit = {},
lowStorageWarning: Boolean,
onManageStorage: () -> Unit = {}
) {
Column(Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
if (notificationsWarning)
ActionCard(
icon = painterResource(R.drawable.ic_notifications_off),
icon = Icons.Default.NotificationsOff,
actionText = stringResource(R.string.account_permissions_action),
onAction = onClickPermissions
) {
@ -556,30 +569,39 @@ fun SyncWarnings(
if (internetWarning)
ActionCard(
icon = painterResource(R.drawable.ic_signal_cellular_off),
icon = Icons.Default.SignalCellularOff,
actionText = stringResource(R.string.account_list_manage_connections),
onAction = onManageConnections
) {
Text(stringResource(R.string.account_list_no_internet))
}
if (lowStorageWarning)
if (batterySaverActive)
ActionCard(
icon = painterResource(R.drawable.ic_storage),
actionText = stringResource(R.string.account_list_manage_storage),
onAction = onManageStorage
icon = Icons.Default.BatterySaver,
actionText = stringResource(R.string.account_list_manage_battery_saver),
onAction = onManageBatterySaver
) {
Text(stringResource(R.string.account_list_low_storage))
Text(stringResource(R.string.account_list_battery_saver_enabled))
}
if (dataSaverActive)
ActionCard(
icon = painterResource(R.drawable.ic_datasaver_on),
icon = Icons.Default.DataSaverOn,
actionText = stringResource(R.string.account_list_manage_datasaver),
onAction = onManageDataSaver
) {
Text(stringResource(R.string.account_list_datasaver_enabled))
}
if (lowStorageWarning)
ActionCard(
icon = Icons.Default.Storage,
actionText = stringResource(R.string.account_list_manage_storage),
onAction = onManageStorage
) {
Text(stringResource(R.string.account_list_low_storage))
}
}
}
@ -589,7 +611,8 @@ fun SyncWarnings_Preview() {
SyncWarnings(
notificationsWarning = true,
internetWarning = true,
lowStorageWarning = true,
dataSaverActive = true
batterySaverActive = true,
dataSaverActive = true,
lowStorageWarning = true
)
}
}

View file

@ -557,10 +557,10 @@ class DebugInfoActivity : AppCompatActivity() {
val locales: Any = LocaleList.getAdjustedDefault()
writer.append(
"\nSYSTEM INFORMATION\n\n" +
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" +
"Locale(s): $locales\n" +
"Time zone: ${TimeZone.getDefault().id}\n"
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" +
"Locale(s): $locales\n" +
"Time zone: ${TimeZone.getDefault().id}\n"
)
val filesPath = Environment.getDataDirectory()
val statFs = StatFs(filesPath.path)
@ -570,6 +570,38 @@ class DebugInfoActivity : AppCompatActivity() {
.append(FileUtils.byteCountToDisplaySize(statFs.totalBytes))
.append("\n\n")
// power saving
if (Build.VERSION.SDK_INT >= 28)
context.getSystemService<UsageStatsManager>()?.let { statsManager ->
val bucket = statsManager.appStandbyBucket
writer
.append("App standby bucket: ")
.append(
when {
bucket <= 5 -> "exempted (very good)"
bucket <= UsageStatsManager.STANDBY_BUCKET_ACTIVE -> "active (good)"
bucket <= UsageStatsManager.STANDBY_BUCKET_WORKING_SET -> "working set (bad: job restrictions apply)"
bucket <= UsageStatsManager.STANDBY_BUCKET_FREQUENT -> "frequent (bad: job restrictions apply)"
bucket <= UsageStatsManager.STANDBY_BUCKET_RARE -> "rare (very bad: job and network restrictions apply)"
bucket <= UsageStatsManager.STANDBY_BUCKET_RESTRICTED -> "restricted (very bad: job and network restrictions apply)"
else -> "$bucket"
}
)
writer.append('\n')
}
context.getSystemService<PowerManager>()?.let { powerManager ->
writer.append("App exempted from power saving: ")
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes (good)" else "no (bad)")
.append('\n')
.append("System in power-save mode: ")
.append(if (powerManager.isPowerSaveMode) "yes (restrictions apply!)" else "no")
.append('\n')
}
// system-wide sync
writer.append("System-wide synchronization: ")
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
.append("\n\n")
// connectivity
context.getSystemService<ConnectivityManager>()?.let { connectivityManager ->
writer.append("\nCONNECTIVITY\n\n")
@ -608,24 +640,6 @@ class DebugInfoActivity : AppCompatActivity() {
}
writer.append("\nCONFIGURATION\n\n")
// power saving
if (Build.VERSION.SDK_INT >= 28)
context.getSystemService<UsageStatsManager>()?.let { statsManager ->
val bucket = statsManager.appStandbyBucket
writer.append("App standby bucket: $bucket")
if (bucket > UsageStatsManager.STANDBY_BUCKET_ACTIVE)
writer.append(" (RESTRICTED!)")
writer.append('\n')
}
context.getSystemService<PowerManager>()?.let { powerManager ->
writer.append("Power saving disabled: ")
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no")
.append('\n')
}
// system-wide sync
writer.append("System-wide synchronization: ")
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
.append('\n')
// notifications
val nm = NotificationManagerCompat.from(context)
writer.append("\nNotifications")

View file

@ -20,7 +20,11 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.*
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityAccountBinding
@ -30,7 +34,6 @@ import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.ui.AppWarningsManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
@ -38,7 +41,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import java.util.logging.Level
import javax.inject.Inject
@ -60,6 +65,8 @@ class AccountActivity: AppCompatActivity() {
}
}
private val warningsModel by viewModels<AppWarningsModel>()
private lateinit var binding: ActivityAccountBinding
@ -94,14 +101,18 @@ class AccountActivity: AppCompatActivity() {
// "Sync now" fab
TooltipCompat.setTooltipText(binding.sync, binding.sync.contentDescription)
model.networkAvailable.observe(this) { networkAvailable ->
warningsModel.networkAvailable.observe(this) { networkAvailable ->
binding.sync.setOnClickListener {
if (!networkAvailable)
Snackbar.make(
binding.sync,
R.string.no_internet_sync_scheduled,
Snackbar.LENGTH_LONG
).show()
val msgId =
if (warningsModel.networkAvailable.value == true)
R.string.sync_started
else
R.string.no_internet_sync_scheduled
Snackbar.make(
binding.sync,
msgId,
Snackbar.LENGTH_SHORT
).show()
SyncWorker.enqueueAllAuthorities(this, model.account)
}
}
@ -276,8 +287,7 @@ class AccountActivity: AppCompatActivity() {
class Model @AssistedInject constructor(
application: Application,
val db: AppDatabase,
@Assisted val account: Account,
warnings: AppWarningsManager
@Assisted val account: Account
): AndroidViewModel(application), OnAccountsUpdateListener {
@AssistedFactory
@ -294,8 +304,6 @@ class AccountActivity: AppCompatActivity() {
val showOnlyPersonal = MutableLiveData<Boolean>()
val showOnlyPersonalWritable = MutableLiveData<Boolean>()
val networkAvailable = warnings.networkAvailable
init {
accountManager.addOnAccountsUpdatedListener(this, null, true)

View file

@ -2,8 +2,9 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui
package at.bitfire.davdroid.ui.account
import android.app.Application
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context
@ -14,12 +15,12 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.PowerManager
import androidx.core.content.getSystemService
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import at.bitfire.davdroid.StorageLowReceiver
import at.bitfire.davdroid.log.Logger
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
@ -34,31 +35,32 @@ import javax.inject.Inject
* - whether a network connection is available [networkAvailable]
* - whether data saver is turned on -> [dataSaverEnabled]
*/
class AppWarningsManager @Inject constructor(
@ApplicationContext private val context: Context,
@HiltViewModel
class AppWarningsModel @Inject constructor(
context: Application,
storageLowReceiver: StorageLowReceiver
) : AutoCloseable, SyncStatusObserver {
): AndroidViewModel(context), SyncStatusObserver {
/** whether storage is low (prevents sync framework from running synchronization) */
val storageLow = storageLowReceiver.storageLow
/** whether global sync is disabled (sync framework won't run automatic synchronization in this case) */
val globalSyncDisabled = MutableLiveData(false)
val globalSyncDisabled = MutableLiveData<Boolean>()
private var syncStatusObserver: Any? = null
/** whether a usable network connection is available (sync framework won't run synchronization otherwise) */
val networkAvailable = MutableLiveData<Boolean>()
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var networkReceiver: BroadcastReceiver? = null
private lateinit var networkCallback: ConnectivityManager.NetworkCallback
private val connectivityManager = context.getSystemService<ConnectivityManager>()!!
val batterySaverActive = MutableLiveData<Boolean>()
private val batterySaverListener: BroadcastReceiver
/** whether data saver is restricting background synchronization ([ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED]) */
val dataSaverEnabled = MutableLiveData<Boolean>()
var dataSaverChangedListener: BroadcastReceiver? = null
private val dataSaverChangedListener: BroadcastReceiver
init {
Logger.log.fine("Watching for warning conditions")
// Automatic Sync
syncStatusObserver = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
onStatusChanged(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS)
@ -66,19 +68,57 @@ class AppWarningsManager @Inject constructor(
// Network
watchConnectivity()
// Battery saver
batterySaverListener = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
checkBatterySaver()
}
}
val batterySaverListenerFilter = IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
context.registerReceiver(batterySaverListener, batterySaverListenerFilter)
checkBatterySaver()
// Data saver
val listener = object: BroadcastReceiver() {
dataSaverChangedListener = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
checkDataSaver()
}
}
val dataSaverChangedFilter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED)
context.registerReceiver(listener, dataSaverChangedFilter)
dataSaverChangedListener = listener
context.registerReceiver(dataSaverChangedListener, dataSaverChangedFilter)
checkDataSaver()
}
private fun checkBatterySaver() {
batterySaverActive.postValue(
getApplication<Application>().getSystemService<PowerManager>()?.isPowerSaveMode
)
}
private fun checkDataSaver() {
dataSaverEnabled.postValue(
getApplication<Application>().getSystemService<ConnectivityManager>()?.let { connectivityManager ->
connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
}
)
}
override fun onCleared() {
val context = getApplication<Application>()
// Automatic sync
ContentResolver.removeStatusChangeListener(syncStatusObserver)
// Network
connectivityManager.unregisterNetworkCallback(networkCallback)
// Battery saver
context.unregisterReceiver(batterySaverListener)
// Data Saver
context.unregisterReceiver(dataSaverChangedListener)
}
override fun onStatusChanged(which: Int) {
globalSyncDisabled.postValue(!ContentResolver.getMasterSyncAutomatically())
}
@ -91,7 +131,7 @@ class AppWarningsManager @Inject constructor(
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
val callback = object: ConnectivityManager.NetworkCallback() {
networkCallback = object: ConnectivityManager.NetworkCallback() {
val availableNetworks = hashSetOf<Network>()
override fun onAvailable(network: Network) {
@ -108,37 +148,7 @@ class AppWarningsManager @Inject constructor(
networkAvailable.postValue(availableNetworks.isNotEmpty())
}
}
connectivityManager.registerNetworkCallback(networkRequest, callback)
networkCallback = callback
}
private fun checkDataSaver() {
dataSaverEnabled.postValue(
context.getSystemService<ConnectivityManager>()?.let { connectivityManager ->
connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
}
)
}
override fun close() {
Logger.log.fine("Stopping watching for warning conditions")
// Automatic sync
ContentResolver.removeStatusChangeListener(syncStatusObserver)
// Network
networkReceiver?.let {
context.unregisterReceiver(it)
}
networkCallback?.let {
connectivityManager.unregisterNetworkCallback(it)
}
// Data Saver
dataSaverChangedListener?.let { listener ->
context.unregisterReceiver(listener)
dataSaverChangedListener = null
}
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
}
}

View file

@ -13,10 +13,12 @@ import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationAdd
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -24,7 +26,7 @@ import at.bitfire.davdroid.R
@Composable
fun ActionCard(
icon: Painter? = null,
icon: ImageVector? = null,
actionText: String? = null,
onAction: () -> Unit = {},
content: @Composable () -> Unit
@ -59,7 +61,7 @@ fun ActionCard(
@Preview
fun ActionCard_Sample() {
ActionCard(
icon = painterResource(R.drawable.ic_notifications_off),
icon = Icons.Default.NotificationAdd,
actionText = "Some Action"
) {
Text("Some Content")

View file

@ -145,10 +145,12 @@
<string name="account_list_no_notification_permission">Notifications disabled. You won\'t be notified about sync errors.</string>
<string name="account_list_no_internet">No validated Internet connectivity. Synchronization may not run.</string>
<string name="account_list_manage_connections">Manage connections</string>
<string name="account_list_low_storage">Storage space low. Android will not sync local changes immediately, but during the next regular sync.</string>
<string name="account_list_manage_storage">Manage storage</string>
<string name="account_list_datasaver_enabled">Data saver enabled. Background synchronization is restricted.</string>
<string name="account_list_manage_datasaver">Manage data saver</string>
<string name="account_list_battery_saver_enabled">Battery saver enabled. Synchronization may be restricted.</string>
<string name="account_list_manage_battery_saver">Manage battery saver</string>
<string name="account_list_low_storage">Storage space low. Android will not sync local changes immediately, but during the next regular sync.</string>
<string name="account_list_manage_storage">Manage storage</string>
<string name="account_list_empty">Welcome to DAVx⁵!\n\nYou can add a CalDAV/CardDAV account now.</string>
<string name="accounts_global_sync_disabled">System-wide automatic synchronization is disabled</string>
<string name="accounts_global_sync_enable">Enable</string>