Add settings to disable battery optimizations and to make the player service unkillable
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -20,4 +23,11 @@ inline fun <reified T: Activity> Context.activityPendingIntent(
|
|||||||
requestCode: Int = 0,
|
requestCode: Int = 0,
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
12
app/src/main/res/drawable/shapes.xml
Normal file
12
app/src/main/res/drawable/shapes.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user