From 742e8702e5d0734b2e307cc4d218762210aba480 Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Mon, 11 Jul 2022 20:05:24 +0200 Subject: [PATCH] Add settings to disable battery optimizations and to make the player service unkillable --- .../it/vfsfitvnm/vimusic/MainActivity.kt | 49 +---- .../vimusic/service/PlayerService.kt | 18 +- .../vimusic/ui/screens/SettingsScreen.kt | 13 ++ .../screens/settings/OtherSettingsScreen.kt | 168 ++++++++++++++++++ .../vimusic/ui/screens/settings/routes.kt | 7 + .../it/vfsfitvnm/vimusic/utils/Context.kt | 12 +- .../vimusic/utils/InvincibleService.kt | 116 ++++++++++++ .../it/vfsfitvnm/vimusic/utils/Preferences.kt | 8 +- app/src/main/res/drawable/shapes.xml | 12 ++ 9 files changed, 354 insertions(+), 49 deletions(-) create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsScreen.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/InvincibleService.kt create mode 100644 app/src/main/res/drawable/shapes.xml diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt index 5038f5c..f00ba72 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt @@ -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()?.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() diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt index 9b453ea..005da7f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt @@ -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()) 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 diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt index e2936ff..c547116 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt @@ -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", diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsScreen.kt new file mode 100644 index 0000000..ae43337 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsScreen.kt @@ -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.") + } + } + } + } + } +} + + diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/routes.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/routes.kt index 3b236fe..b3f313b 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/routes.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/routes.kt @@ -32,6 +32,13 @@ fun rememberCacheSettingsRoute(): Route0 { } } +@Composable +fun rememberOtherSettingsRoute(): Route0 { + return remember { + Route0("OtherSettingsRoute") + } +} + @Composable fun rememberAboutRoute(): Route0 { return remember { diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Context.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Context.kt index a67b148..e99b64f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Context.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Context.kt @@ -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 Context.intent(): Intent = Intent(this@Context, T::class.java) @@ -20,4 +23,11 @@ inline fun Context.activityPendingIntent( requestCode: Int = 0, flags: Int = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0, ): PendingIntent = - PendingIntent.getActivity(this, requestCode, intent(), flags) \ No newline at end of file + PendingIntent.getActivity(this, requestCode, intent(), flags) + +val Context.isIgnoringBatteryOptimizations: Boolean + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + getSystemService()?.isIgnoringBatteryOptimizations(packageName) ?: true + } else { + true + } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/InvincibleService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/InvincibleService.kt new file mode 100644 index 0000000..917cd4e --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/InvincibleService.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt index e9a0638..0f21569 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt @@ -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 { diff --git a/app/src/main/res/drawable/shapes.xml b/app/src/main/res/drawable/shapes.xml new file mode 100644 index 0000000..6346d36 --- /dev/null +++ b/app/src/main/res/drawable/shapes.xml @@ -0,0 +1,12 @@ + + + +