Repository: PaulWoitaschek/Voice (language: kotlin)

Issue:
Dismissing battery optimization toast permanently
Version 7.0.6

Hello,

My phone uses the AOSP implementation of battery optimization which respects apps just fine, I don't need to disable it. Voice is kept alive as long as it's playing.
Please allow me to keep it from prompting me every I press play. 

Your app is lovely, thank you. 


Discussion hints:
nan

Pull request: Ask for battery optimizations at most three times.
Fixes #1801

Test command: ./gradlew :playbackScreen:testDebugUnitTest --tests "voice.playbackScreen.batteryOptimization.BatteryOptimizationTest"

Fix patch:
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 77e470e25a..6022cdd8bb 100755
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -134,6 +134,7 @@ android {
 
 dependencies {
   implementation(projects.strings)
+  implementation(projects.datastore)
   implementation(projects.common)
   implementation(projects.data)
   implementation(projects.playback)
diff --git a/app/src/main/kotlin/voice/app/injection/PrefsModule.kt b/app/src/main/kotlin/voice/app/injection/PrefsModule.kt
index 8817c1a590..4d648335e4 100644
--- a/app/src/main/kotlin/voice/app/injection/PrefsModule.kt
+++ b/app/src/main/kotlin/voice/app/injection/PrefsModule.kt
@@ -17,7 +17,6 @@ import kotlinx.serialization.builtins.ListSerializer
 import kotlinx.serialization.builtins.nullable
 import kotlinx.serialization.builtins.serializer
 import voice.app.BuildConfig
-import voice.app.serialization.SerializableDataStoreFactory
 import voice.app.serialization.UriSerializer
 import voice.bookOverview.BookMigrationExplanationQualifier
 import voice.bookOverview.BookMigrationExplanationShown
@@ -29,6 +28,7 @@ import voice.common.pref.PrefKeys
 import voice.common.pref.RootAudiobookFolders
 import voice.common.pref.SingleFileAudiobookFolders
 import voice.common.pref.SingleFolderAudiobookFolders
+import voice.datastore.VoiceDataStoreFactory
 import javax.inject.Named
 import javax.inject.Singleton
 
@@ -100,28 +100,28 @@ object PrefsModule {
   @Provides
   @Singleton
   @RootAudiobookFolders
-  fun audiobookFolders(factory: SerializableDataStoreFactory): DataStore<List<Uri>> {
+  fun audiobookFolders(factory: VoiceDataStoreFactory): DataStore<List<Uri>> {
     return factory.createUriList("audiobookFolders")
   }
 
   @Provides
   @Singleton
   @SingleFolderAudiobookFolders
-  fun singleFolderAudiobookFolders(factory: SerializableDataStoreFactory): DataStore<List<Uri>> {
+  fun singleFolderAudiobookFolders(factory: VoiceDataStoreFactory): DataStore<List<Uri>> {
     return factory.createUriList("SingleFolderAudiobookFolders")
   }
 
   @Provides
   @Singleton
   @SingleFileAudiobookFolders
-  fun singleFileAudiobookFolders(factory: SerializableDataStoreFactory): DataStore<List<Uri>> {
+  fun singleFileAudiobookFolders(factory: VoiceDataStoreFactory): DataStore<List<Uri>> {
     return factory.createUriList("SingleFileAudiobookFolders")
   }
 
   @Provides
   @Singleton
   @CurrentBook
-  fun currentBook(factory: SerializableDataStoreFactory): DataStore<BookId?> {
+  fun currentBook(factory: VoiceDataStoreFactory): DataStore<BookId?> {
     return factory.create(
       serializer = BookId.serializer().nullable,
       fileName = "currentBook",
@@ -132,12 +132,12 @@ object PrefsModule {
   @Provides
   @Singleton
   @BookMigrationExplanationQualifier
-  fun bookMigrationExplanationShown(factory: SerializableDataStoreFactory): BookMigrationExplanationShown {
+  fun bookMigrationExplanationShown(factory: VoiceDataStoreFactory): BookMigrationExplanationShown {
     return factory.create(Boolean.serializer(), false, "bookMigrationExplanationShown2")
   }
 }
 
-private fun SerializableDataStoreFactory.createUriList(
+private fun VoiceDataStoreFactory.createUriList(
   name: String,
 ): DataStore<List<Uri>> = create(
   serializer = ListSerializer(UriSerializer),
diff --git a/datastore/build.gradle.kts b/datastore/build.gradle.kts
new file mode 100644
index 0000000000..82bf9def88
--- /dev/null
+++ b/datastore/build.gradle.kts
@@ -0,0 +1,14 @@
+plugins {
+  id("voice.library")
+  alias(libs.plugins.anvil)
+}
+
+anvil {
+  generateDaggerFactories.set(true)
+}
+
+dependencies {
+  implementation(libs.dagger.core)
+  api(libs.serialization.json)
+  api(libs.datastore)
+}
diff --git a/app/src/main/kotlin/voice/app/serialization/KotlinxDataStoreSerializer.kt b/datastore/src/main/kotlin/voice/datastore/KotlinxDataStoreSerializer.kt
similarity index 95%
rename from app/src/main/kotlin/voice/app/serialization/KotlinxDataStoreSerializer.kt
rename to datastore/src/main/kotlin/voice/datastore/KotlinxDataStoreSerializer.kt
index 07e3649d08..bae6571116 100644
--- a/app/src/main/kotlin/voice/app/serialization/KotlinxDataStoreSerializer.kt
+++ b/datastore/src/main/kotlin/voice/datastore/KotlinxDataStoreSerializer.kt
@@ -1,4 +1,4 @@
-package voice.app.serialization
+package voice.datastore
 
 import androidx.datastore.core.Serializer
 import kotlinx.serialization.ExperimentalSerializationApi
diff --git a/app/src/main/kotlin/voice/app/serialization/SerializableDataStoreFactory.kt b/datastore/src/main/kotlin/voice/datastore/VoiceDataStoreFactory.kt
similarity index 69%
rename from app/src/main/kotlin/voice/app/serialization/SerializableDataStoreFactory.kt
rename to datastore/src/main/kotlin/voice/datastore/VoiceDataStoreFactory.kt
index 9d4ba38b59..d55e7a8526 100644
--- a/app/src/main/kotlin/voice/app/serialization/SerializableDataStoreFactory.kt
+++ b/datastore/src/main/kotlin/voice/datastore/VoiceDataStoreFactory.kt
@@ -1,14 +1,15 @@
-package voice.app.serialization
+package voice.datastore
 
 import android.content.Context
 import androidx.datastore.core.DataStore
 import androidx.datastore.core.DataStoreFactory
 import androidx.datastore.dataStoreFile
 import kotlinx.serialization.KSerializer
+import kotlinx.serialization.builtins.serializer
 import kotlinx.serialization.json.Json
 import javax.inject.Inject
 
-class SerializableDataStoreFactory
+class VoiceDataStoreFactory
 @Inject constructor(
   private val json: Json,
   private val context: Context,
@@ -29,4 +30,15 @@ class SerializableDataStoreFactory
       context.dataStoreFile(fileName)
     }
   }
+
+  fun int(
+    fileName: String,
+    defaultValue: Int,
+  ): DataStore<Int> {
+    return create(
+      serializer = Int.serializer(),
+      defaultValue = defaultValue,
+      fileName = fileName,
+    )
+  }
 }
diff --git a/playbackScreen/build.gradle.kts b/playbackScreen/build.gradle.kts
index a9711c47d1..535a76d5e1 100644
--- a/playbackScreen/build.gradle.kts
+++ b/playbackScreen/build.gradle.kts
@@ -19,6 +19,7 @@ dependencies {
   implementation(projects.strings)
   implementation(projects.playback)
   implementation(projects.data)
+  implementation(projects.datastore)
   implementation(projects.sleepTimer)
 
   implementation(libs.datastore)
diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/BatteryOptimization.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/BatteryOptimization.kt
deleted file mode 100644
index 468f6777ae..0000000000
--- a/playbackScreen/src/main/kotlin/voice/playbackScreen/BatteryOptimization.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package voice.playbackScreen
-
-import android.app.Application
-import android.os.PowerManager
-import androidx.core.content.getSystemService
-import javax.inject.Inject
-
-class BatteryOptimization
-@Inject constructor(
-  private val context: Application,
-) {
-
-  fun isIgnoringBatteryOptimizations(): Boolean {
-    val powerManager = context.getSystemService<PowerManager>()
-      ?: return true
-    return powerManager.isIgnoringBatteryOptimizations(context.packageName)
-  }
-}
diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt
index b50d7ec082..ef03e1e9d7 100644
--- a/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt
+++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt
@@ -22,6 +22,7 @@ import voice.playback.PlayerController
 import voice.playback.misc.Decibel
 import voice.playback.misc.VolumeGain
 import voice.playback.playstate.PlayStateManager
+import voice.playbackScreen.batteryOptimization.BatteryOptimization
 import voice.sleepTimer.SleepTimer
 import javax.inject.Inject
 import kotlin.time.Duration
@@ -41,8 +42,6 @@ class BookPlayViewModel
   private val batteryOptimization: BatteryOptimization,
 ) {
 
-  private var askedToIgnoreBatteryOptimization = false
-
   private val scope = MainScope()
 
   private val _viewEffects = MutableSharedFlow<BookPlayViewEffect>(extraBufferCapacity = 1)
@@ -102,11 +101,13 @@ class BookPlayViewModel
   }
 
   fun playPause() {
-    if (playStateManager.playState != PlayStateManager.PlayState.Playing && !askedToIgnoreBatteryOptimization) {
-      if (!batteryOptimization.isIgnoringBatteryOptimizations()) {
-        _viewEffects.tryEmit(BookPlayViewEffect.RequestIgnoreBatteryOptimization)
+    if (playStateManager.playState != PlayStateManager.PlayState.Playing) {
+      scope.launch {
+        if (batteryOptimization.shouldRequest()) {
+          _viewEffects.tryEmit(BookPlayViewEffect.RequestIgnoreBatteryOptimization)
+          batteryOptimization.onBatteryOptimizationsRequested()
+        }
       }
-      askedToIgnoreBatteryOptimization = true
     }
     player.playPause()
   }
diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/batteryOptimization/BatteryOptimization.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/batteryOptimization/BatteryOptimization.kt
new file mode 100644
index 0000000000..e9629de45d
--- /dev/null
+++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/batteryOptimization/BatteryOptimization.kt
@@ -0,0 +1,23 @@
+package voice.playbackScreen.batteryOptimization
+
+import android.os.Build
+import androidx.datastore.core.DataStore
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+
+class BatteryOptimization
+@Inject constructor(
+  private val isIgnoringBatteryOptimizations: IsIgnoringBatteryOptimizations,
+  private val amountOfBatteryOptimizationsRequested: DataStore<Int>,
+) {
+
+  suspend fun shouldRequest(): Boolean = when {
+    isIgnoringBatteryOptimizations() -> false
+    Build.MANUFACTURER.equals("Google", ignoreCase = true) -> false
+    else -> amountOfBatteryOptimizationsRequested.data.first() <= 3
+  }
+
+  suspend fun onBatteryOptimizationsRequested() {
+    amountOfBatteryOptimizationsRequested.updateData { it + 1 }
+  }
+}
diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/batteryOptimization/BatteryOptimizationModule.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/batteryOptimization/BatteryOptimizationModule.kt
new file mode 100644
index 0000000000..91328d97b5
--- /dev/null
+++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/batteryOptimization/BatteryOptimizationModule.kt
@@ -0,0 +1,20 @@
+package voice.playbackScreen.batteryOptimization
+
+import androidx.datastore.core.DataStore
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Module
+import dagger.Provides
+import voice.common.AppScope
+import voice.datastore.VoiceDataStoreFactory
+import javax.inject.Singleton
+
+@Module
+@ContributesTo(AppScope::class)
+object BatteryOptimizationModule {
+
+  @Provides
+  @Singleton
+  fun amountOfBatteryOptimizationsRequestedStore(factory: VoiceDataStoreFactory): DataStore<Int> {
+    return factory.int("amountOfBatteryOptimizationsRequestedStore", 0)
+  }
+}
diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/batteryOptimization/IsIgnoringBatteryOptimizations.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/batteryOptimization/IsIgnoringBatteryOptimizations.kt
new file mode 100644
index 0000000000..1d7fa5ba47
--- /dev/null
+++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/batteryOptimization/IsIgnoringBatteryOptimizations.kt
@@ -0,0 +1,24 @@
+package voice.playbackScreen.batteryOptimization
+
+import android.app.Application
+import android.os.PowerManager
+import androidx.core.content.getSystemService
+import com.squareup.anvil.annotations.ContributesBinding
+import voice.common.AppScope
+import javax.inject.Inject
+
+fun interface IsIgnoringBatteryOptimizations {
+  operator fun invoke(): Boolean
+}
+
+@ContributesBinding(AppScope::class)
+class IsIgnoringBatteryOptimizationsImpl
+@Inject constructor(
+  private val context: Application,
+) : IsIgnoringBatteryOptimizations {
+  override fun invoke(): Boolean {
+    val powerManager = context.getSystemService<PowerManager>()
+      ?: return true
+    return powerManager.isIgnoringBatteryOptimizations(context.packageName)
+  }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 30111f0bc0..91459e2594 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -54,6 +54,7 @@ include(":sleepTimer")
 include(":settings")
 include(":search")
 include(":cover")
+include(":datastore")
 include(":folderPicker")
 include(":bookOverview")
 include(":migration")


Test patch:
diff --git a/playbackScreen/src/test/kotlin/voice/playbackScreen/batteryOptimization/BatteryOptimizationTest.kt b/playbackScreen/src/test/kotlin/voice/playbackScreen/batteryOptimization/BatteryOptimizationTest.kt
new file mode 100644
index 0000000000..b9024d911a
--- /dev/null
+++ b/playbackScreen/src/test/kotlin/voice/playbackScreen/batteryOptimization/BatteryOptimizationTest.kt
@@ -0,0 +1,56 @@
+package voice.playbackScreen.batteryOptimization
+
+import androidx.datastore.core.DataStore
+import io.kotest.matchers.booleans.shouldBeFalse
+import io.kotest.matchers.booleans.shouldBeTrue
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.updateAndGet
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class BatteryOptimizationTest {
+
+  @Test
+  fun `requests at most three times`() = runTest {
+    val batteryOptimization = BatteryOptimization(
+      isIgnoringBatteryOptimizations = { false },
+      amountOfBatteryOptimizationsRequested = MemoryDataStore(0),
+    )
+
+    batteryOptimization.shouldRequest().shouldBeTrue()
+
+    batteryOptimization.onBatteryOptimizationsRequested()
+    batteryOptimization.shouldRequest().shouldBeTrue()
+
+    batteryOptimization.onBatteryOptimizationsRequested()
+    batteryOptimization.shouldRequest().shouldBeTrue()
+
+    batteryOptimization.onBatteryOptimizationsRequested()
+    batteryOptimization.shouldRequest().shouldBeTrue()
+
+    batteryOptimization.onBatteryOptimizationsRequested()
+    batteryOptimization.shouldRequest().shouldBeFalse()
+  }
+
+  @Test
+  fun `does not request when optimizations are ignored`() = runTest {
+    val batteryOptimization = BatteryOptimization(
+      isIgnoringBatteryOptimizations = { true },
+      amountOfBatteryOptimizationsRequested = MemoryDataStore(0),
+    )
+
+    batteryOptimization.shouldRequest().shouldBeFalse()
+  }
+}
+
+private class MemoryDataStore<T>(initial: T) : DataStore<T> {
+
+  private val value = MutableStateFlow(initial)
+
+  override val data: Flow<T> get() = value
+
+  override suspend fun updateData(transform: suspend (t: T) -> T): T {
+    return value.updateAndGet { transform(it) }
+  }
+}