Ver Fonte

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

vfsfitvnm há 3 anos atrás
pai
commit
742e8702e5

+ 4 - 45
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<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()

+ 16 - 2
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<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

+ 13 - 0
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",

+ 168 - 0
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.")
+                    }
+                }
+            }
+        }
+    }
+}
+
+

+ 7 - 0
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 {

+ 11 - 1
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 <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
+    }

+ 116 - 0
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)
+                }
+            }
+        }
+    }
+}

+ 7 - 1
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 {

+ 12 - 0
app/src/main/res/drawable/shapes.xml

@@ -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>