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.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalAnimationApi 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.RippleTheme
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.core.content.getSystemService
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.valentinilk.shimmer.LocalShimmerTheme import com.valentinilk.shimmer.LocalShimmerTheme
import com.valentinilk.shimmer.defaultShimmerTheme 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.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
import it.vfsfitvnm.vimusic.ui.components.rememberMenuState 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.HomeScreen
import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen 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.ui.views.PlayerView
import it.vfsfitvnm.vimusic.utils.LocalPreferences import it.vfsfitvnm.vimusic.utils.LocalPreferences
import it.vfsfitvnm.vimusic.utils.intent import it.vfsfitvnm.vimusic.utils.intent
@@ -146,43 +142,6 @@ class MainActivity : ComponentActivity() {
.fillMaxSize() .fillMaxSize()
.background(colorPalette.background) .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) { when (val uri = uri) {
null -> { null -> {
HomeScreen() HomeScreen()

View File

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

View File

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

View File

@@ -6,6 +6,9 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.PowerManager
import androidx.core.content.getSystemService
inline fun <reified T> Context.intent(): Intent = inline fun <reified T> Context.intent(): Intent =
Intent(this@Context, T::class.java) Intent(this@Context, T::class.java)
@@ -21,3 +24,10 @@ inline fun <reified T: Activity> Context.activityPendingIntent(
flags: Int = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0, flags: Int = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0,
): PendingIntent = ): 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, initialSkipSilence: Boolean,
initialVolumeNormalization: Boolean, initialVolumeNormalization: Boolean,
initialPersistentQueue: Boolean, initialPersistentQueue: Boolean,
initialIsInvincibilityEnabled: Boolean,
) { ) {
constructor(preferences: SharedPreferences) : this( constructor(preferences: SharedPreferences) : this(
edit = { action: SharedPreferences.Editor.() -> Unit -> edit = { action: SharedPreferences.Editor.() -> Unit ->
@@ -39,7 +40,8 @@ class Preferences(
initialExoPlayerDiskCacheMaxSizeBytes = preferences.getLong(Keys.exoPlayerDiskCacheMaxSizeBytes, 512L * 1024 * 1024), initialExoPlayerDiskCacheMaxSizeBytes = preferences.getLong(Keys.exoPlayerDiskCacheMaxSizeBytes, 512L * 1024 * 1024),
initialSkipSilence = preferences.getBoolean(Keys.skipSilence, false), initialSkipSilence = preferences.getBoolean(Keys.skipSilence, false),
initialVolumeNormalization = preferences.getBoolean(Keys.volumeNormalization, 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 var songSortBy = initialSongSortBy
@@ -75,6 +77,9 @@ class Preferences(
var persistentQueue = initialPersistentQueue var persistentQueue = initialPersistentQueue
set(value) = edit { putBoolean(Keys.persistentQueue, value) } set(value) = edit { putBoolean(Keys.persistentQueue, value) }
var isInvincibilityEnabled = initialIsInvincibilityEnabled
set(value) = edit { putBoolean(Keys.isInvincibilityEnabled, value) }
object Keys { object Keys {
const val songSortOrder = "songSortOrder" const val songSortOrder = "songSortOrder"
const val songSortBy = "songSortBy" const val songSortBy = "songSortBy"
@@ -87,6 +92,7 @@ class Preferences(
const val skipSilence = "skipSilence" const val skipSilence = "skipSilence"
const val volumeNormalization = "volumeNormalization" const val volumeNormalization = "volumeNormalization"
const val persistentQueue = "persistentQueue" const val persistentQueue = "persistentQueue"
const val isInvincibilityEnabled = "isInvincibilityEnabled"
} }
companion object { 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>