Add settings to disable battery optimizations and to make the player service unkillable

This commit is contained in:
vfsfitvnm
2022-07-11 20:05:24 +02:00
parent 3acde3a3ba
commit 742e8702e5
9 changed files with 354 additions and 49 deletions

View File

@@ -6,12 +6,8 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalAnimationApi
@@ -27,12 +23,10 @@ import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material.ripple.RippleTheme
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.core.content.getSystemService
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.valentinilk.shimmer.LocalShimmerTheme
import com.valentinilk.shimmer.defaultShimmerTheme
@@ -41,10 +35,12 @@ import it.vfsfitvnm.vimusic.ui.components.BottomSheetMenu
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
import it.vfsfitvnm.vimusic.ui.components.rememberMenuState
import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog
import it.vfsfitvnm.vimusic.ui.screens.HomeScreen
import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen
import it.vfsfitvnm.vimusic.ui.styling.*
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.ui.styling.rememberTypography
import it.vfsfitvnm.vimusic.ui.views.PlayerView
import it.vfsfitvnm.vimusic.utils.LocalPreferences
import it.vfsfitvnm.vimusic.utils.intent
@@ -146,43 +142,6 @@ class MainActivity : ComponentActivity() {
.fillMaxSize()
.background(colorPalette.background)
) {
var isIgnoringBatteryOptimizations by rememberSaveable {
mutableStateOf(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
getSystemService<PowerManager>()?.isIgnoringBatteryOptimizations(packageName) ?: true
} else {
true
})
}
if (!isIgnoringBatteryOptimizations) {
ConfirmationDialog(
text = "(Temporary) ViMusic needs to ignore battery optimizations to avoid being killed when the playback is paused.",
confirmText = "Grant",
onDismiss = {
isIgnoringBatteryOptimizations = true
},
onConfirm = {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return@ConfirmationDialog
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
if (intent.resolveActivity(packageManager) != null) {
startActivity(intent)
} else {
val fallbackIntent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
if (fallbackIntent.resolveActivity(packageManager) != null) {
startActivity(fallbackIntent)
} else {
Toast.makeText(this@MainActivity, "Couldn't find battery optimization settings, please whitelist ViMusic manually", Toast.LENGTH_SHORT).show()
}
}
}
)
}
when (val uri = uri) {
null -> {
HomeScreen()

View File

@@ -54,7 +54,7 @@ import android.os.Binder as AndroidBinder
@Suppress("DEPRECATION")
class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback,
class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListener.Callback,
SharedPreferences.OnSharedPreferenceChangeListener {
private lateinit var mediaSession: MediaSession
private lateinit var cache: SimpleCache
@@ -86,14 +86,19 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
private var isVolumeNormalizationEnabled = false
private var isPersistentQueueEnabled = false
override var isInvincibilityEnabled = false
private val binder = Binder()
private var isNotificationStarted = false
override val notificationId: Int
get() = NotificationId
private lateinit var notificationActionReceiver: NotificationActionReceiver
override fun onBind(intent: Intent?): AndroidBinder {
super.onBind(intent)
return binder
}
@@ -117,6 +122,7 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
val preferences = Preferences()
isPersistentQueueEnabled = preferences.persistentQueue
isVolumeNormalizationEnabled = preferences.volumeNormalization
isInvincibilityEnabled = preferences.isInvincibilityEnabled
val cacheEvictor = LeastRecentlyUsedCacheEvictor(preferences.exoPlayerDiskCacheMaxSizeBytes)
cache = SimpleCache(cacheDir, cacheEvictor, StandaloneDatabaseProvider(this))
@@ -190,6 +196,10 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
super.onDestroy()
}
override fun shouldBeInvincible(): Boolean {
return !player.shouldBePlaying
}
override fun onConfigurationChanged(newConfig: Configuration) {
if (bitmapProvider.setDefaultBitmap() && player.currentMediaItem != null) {
notificationManager?.notify(NotificationId, notification())
@@ -332,10 +342,12 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
isNotificationStarted = true
startForegroundService(this@PlayerService, intent<PlayerService>())
startForeground(NotificationId, notification())
makeInvincible(false)
} else {
if (!player.shouldBePlaying) {
isNotificationStarted = false
stopForeground(false)
makeInvincible(true)
}
notificationManager?.notify(NotificationId, notification)
}
@@ -348,10 +360,12 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
sharedPreferences.getBoolean(key, isPersistentQueueEnabled)
Preferences.Keys.volumeNormalization -> isVolumeNormalizationEnabled =
sharedPreferences.getBoolean(key, isVolumeNormalizationEnabled)
Preferences.Keys.isInvincibilityEnabled -> isInvincibilityEnabled =
sharedPreferences.getBoolean(key, isInvincibilityEnabled)
}
}
private fun notification(): Notification? {
override fun notification(): Notification? {
if (player.currentMediaItem == null) return null
val playIntent = Action.play.pendingIntent

View File

@@ -35,6 +35,7 @@ fun SettingsScreen() {
val playerSettingsRoute = rememberPlayerSettingsRoute()
val backupAndRestoreRoute = rememberBackupAndRestoreRoute()
val cacheSettingsRoute = rememberCacheSettingsRoute()
val otherSettingsRoute = rememberOtherSettingsRoute()
val aboutRoute = rememberAboutRoute()
val scrollState = rememberScrollState()
@@ -80,6 +81,10 @@ fun SettingsScreen() {
CacheSettingsScreen()
}
otherSettingsRoute {
OtherSettingsScreen()
}
aboutRoute {
AboutScreen()
}
@@ -207,6 +212,14 @@ fun SettingsScreen() {
Entry(
color = colorPalette.green,
icon = R.drawable.shapes,
title = "Other",
description = "Advanced settings",
route = otherSettingsRoute
)
Entry(
color = colorPalette.magenta,
icon = R.drawable.information,
title = "About",
description = "App version and social links",

View File

@@ -0,0 +1,168 @@
package it.vfsfitvnm.vimusic.ui.screens.settings
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.TextCard
import it.vfsfitvnm.vimusic.ui.screens.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.LocalPreferences
import it.vfsfitvnm.vimusic.utils.isIgnoringBatteryOptimizations
import it.vfsfitvnm.vimusic.utils.semiBold
@ExperimentalAnimationApi
@Composable
fun OtherSettingsScreen() {
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
val scrollState = rememberScrollState()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
host {
val context = LocalContext.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val preferences = LocalPreferences.current
var isIgnoringBatteryOptimizations by remember {
mutableStateOf(context.isIgnoringBatteryOptimizations)
}
val activityResultLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
isIgnoringBatteryOptimizations = context.isIgnoringBatteryOptimizations
}
Column(
modifier = Modifier
.background(colorPalette.background)
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = 72.dp)
) {
TopAppBar(
modifier = Modifier
.height(52.dp)
) {
Image(
painter = painterResource(R.drawable.chevron_back),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = pop)
.padding(horizontal = 16.dp, vertical = 8.dp)
.size(24.dp)
)
BasicText(
text = "Other",
style = typography.m.semiBold
)
Spacer(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.size(24.dp)
)
}
SettingsEntryGroupText(title = "SERVICE LIFETIME")
SettingsEntry(
title = "Ignore battery optimizations",
isEnabled = !isIgnoringBatteryOptimizations,
text = if (isIgnoringBatteryOptimizations) {
"Already unrestricted"
} else {
"Disable background restrictions"
},
onClick = {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return@SettingsEntry
@SuppressLint("BatteryLife")
val intent =
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
}
if (intent.resolveActivity(context.packageManager) != null) {
activityResultLauncher.launch(intent)
} else {
val fallbackIntent =
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
if (fallbackIntent.resolveActivity(context.packageManager) != null) {
activityResultLauncher.launch(fallbackIntent)
} else {
Toast.makeText(
context,
"Couldn't find battery optimization settings, please whitelist ViMusic manually",
Toast.LENGTH_SHORT
).show()
}
}
}
)
SwitchSettingEntry(
title = "Invincible service",
text = "When turning off battery optimizations is not enough",
isChecked = preferences.isInvincibilityEnabled,
onCheckedChange = {
preferences.isInvincibilityEnabled = it
}
)
TextCard(icon = R.drawable.alert_circle) {
Title(text = "Service lifetime")
Text(text = "Some device manufacturers may have an aggressive policy against stopped foreground services - the media notification can disappear suddenly when paused.\nThe gentle approach consists in disabling battery optimizations - this is enough for some devices and ROMs.\nHowever, if it's not, you can make the service \"invincible\" - which should keep the service alive.")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Spacer(
modifier = Modifier
.height(32.dp)
)
Title(text = "Invincible service")
Text(text = "Since Android 12, this option works ONLY if battery optimizations are disabled for this application.")
}
}
}
}
}
}

View File

@@ -32,6 +32,13 @@ fun rememberCacheSettingsRoute(): Route0 {
}
}
@Composable
fun rememberOtherSettingsRoute(): Route0 {
return remember {
Route0("OtherSettingsRoute")
}
}
@Composable
fun rememberAboutRoute(): Route0 {
return remember {

View File

@@ -6,6 +6,9 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.PowerManager
import androidx.core.content.getSystemService
inline fun <reified T> Context.intent(): Intent =
Intent(this@Context, T::class.java)
@@ -20,4 +23,11 @@ inline fun <reified T: Activity> Context.activityPendingIntent(
requestCode: Int = 0,
flags: Int = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0,
): PendingIntent =
PendingIntent.getActivity(this, requestCode, intent<T>(), flags)
PendingIntent.getActivity(this, requestCode, intent<T>(), flags)
val Context.isIgnoringBatteryOptimizations: Boolean
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
getSystemService<PowerManager>()?.isIgnoringBatteryOptimizations(packageName) ?: true
} else {
true
}

View File

@@ -0,0 +1,116 @@
package it.vfsfitvnm.vimusic.utils
import android.app.Notification
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.Looper
// https://stackoverflow.com/q/53502244/16885569
// I found four ways to make the system not kill the stopped foreground service: e.g. when
// the player is paused:
// 1 - Use the solution below - hacky;
// 2 - Do not call stopForeground but provide a button to dismiss the notification - bad UX;
// 3 - Lower the targetSdk (e.g. to 23) - security concerns;
// 4 - Host the service in a separate process - overkill and pathetic.
abstract class InvincibleService : Service() {
protected val handler = Handler(Looper.getMainLooper())
protected abstract val isInvincibilityEnabled: Boolean
protected abstract val notificationId: Int
private var invincibility: Invincibility? = null
private val isAllowedToStartForegroundServices: Boolean
get() = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || isIgnoringBatteryOptimizations
override fun onBind(intent: Intent?): Binder? {
invincibility?.stop()
invincibility = null
return null
}
override fun onRebind(intent: Intent?) {
invincibility?.stop()
invincibility = null
super.onRebind(intent)
}
override fun onUnbind(intent: Intent?): Boolean {
if (isInvincibilityEnabled && isAllowedToStartForegroundServices) {
invincibility = Invincibility()
}
return true
}
override fun onDestroy() {
invincibility?.stop()
invincibility = null
super.onDestroy()
}
protected fun makeInvincible(isInvincible: Boolean = true) {
if (isInvincible) {
invincibility?.start()
} else {
invincibility?.stop()
}
}
protected abstract fun shouldBeInvincible(): Boolean
protected abstract fun notification(): Notification?
private inner class Invincibility : BroadcastReceiver(), Runnable {
private var isStarted = false
private val intervalMs = 30_000L
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_ON -> handler.post(this)
Intent.ACTION_SCREEN_OFF -> notification()?.let { notification ->
handler.removeCallbacks(this)
startForeground(notificationId, notification)
}
}
}
@Synchronized
fun start() {
if (!isStarted) {
isStarted = true
handler.postDelayed(this, intervalMs)
registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
})
}
}
@Synchronized
fun stop() {
if (isStarted) {
handler.removeCallbacks(this)
unregisterReceiver(this)
isStarted = false
}
}
override fun run() {
if (shouldBeInvincible() && isAllowedToStartForegroundServices) {
notification()?.let { notification ->
startForeground(notificationId, notification)
stopForeground(false)
handler.postDelayed(this, intervalMs)
}
}
}
}
}

View File

@@ -24,6 +24,7 @@ class Preferences(
initialSkipSilence: Boolean,
initialVolumeNormalization: Boolean,
initialPersistentQueue: Boolean,
initialIsInvincibilityEnabled: Boolean,
) {
constructor(preferences: SharedPreferences) : this(
edit = { action: SharedPreferences.Editor.() -> Unit ->
@@ -39,7 +40,8 @@ class Preferences(
initialExoPlayerDiskCacheMaxSizeBytes = preferences.getLong(Keys.exoPlayerDiskCacheMaxSizeBytes, 512L * 1024 * 1024),
initialSkipSilence = preferences.getBoolean(Keys.skipSilence, false),
initialVolumeNormalization = preferences.getBoolean(Keys.volumeNormalization, false),
initialPersistentQueue = preferences.getBoolean(Keys.persistentQueue, false)
initialPersistentQueue = preferences.getBoolean(Keys.persistentQueue, false),
initialIsInvincibilityEnabled = preferences.getBoolean(Keys.isInvincibilityEnabled, false),
)
var songSortBy = initialSongSortBy
@@ -75,6 +77,9 @@ class Preferences(
var persistentQueue = initialPersistentQueue
set(value) = edit { putBoolean(Keys.persistentQueue, value) }
var isInvincibilityEnabled = initialIsInvincibilityEnabled
set(value) = edit { putBoolean(Keys.isInvincibilityEnabled, value) }
object Keys {
const val songSortOrder = "songSortOrder"
const val songSortBy = "songSortBy"
@@ -87,6 +92,7 @@ class Preferences(
const val skipSilence = "skipSilence"
const val volumeNormalization = "volumeNormalization"
const val persistentQueue = "persistentQueue"
const val isInvincibilityEnabled = "isInvincibilityEnabled"
}
companion object {

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M336,336H32a16,16 0,0 1,-14 -23.81l152,-272a16,16 0,0 1,27.94 0l152,272A16,16 0,0 1,336 336Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M336,160a161.07,161.07 0,0 0,-32.57 3.32L377.9,296.59A48,48 0,0 1,336 368H183.33A160,160 0,1 0,336 160Z"/>
</vector>