Skip to content

Commit 1f0df04

Browse files
authored
feat: battery optimization prompt for bulk downloads (R539) (#566)
* feat(539): BatteryOptimizationPromptRequest signal class — all tests passing (autoloop iter 1) * feat(539): BatteryOptimizationChecker utility — all tests passing (autoloop iter 2) * R539: Fix battery optimization flow emissions in tests Resolved timing issue where test emissions weren't reaching collectors due to dispatcher mismatch. The manager's scope was using Dispatchers.IO which the test's runTest couldn't control. Changes: - Added scopeForTesting parameter to AnimeDownloadManager allowing test environment to inject test's CoroutineScope - Manager now uses test scope when provided, otherwise uses production scope with SupervisorJob + Dispatchers.IO - Updated checkBatteryOptimization() to use scope.launch() for emissions, which now runs on the test dispatcher in tests - Updated all 5 battery prompt tests to pass test scope via constructor - Added advanceUntilIdle() calls to ensure flow emissions complete before assertions run All 5 tests now pass: ✓ flow emits when 10+ items queued with optimization enabled ✓ flow does not emit when optimization disabled ✓ flow does not emit when prompt already shown ✓ flow does not emit when fewer than 10 items ✓ flow emits at most once even with multiple rapid calls This fixes the root cause by making the manager testable via dependency injection of the coroutine scope, rather than attempting workarounds with tryEmit() or arbitrary delays. * R539: Implement battery optimization prompts for MangaDownloadManager - Add BatteryOptimizationChecker dependency injection to MangaDownloadManager - Add batteryOptimizationPromptFlow for UI signal emissions - Implement checkBatteryOptimization() to emit signals when 10+ chapters queued - Add scope testability via downloaderForTesting and scopeForTesting parameters - Create MangaDownloadManagerBatteryPromptTest with 5 test scenarios - All 10 battery prompt tests passing (5 anime + 5 manga) * R539: Integrate battery optimization dialog into MainActivity Completes R539 battery optimization prompt feature: ## Changes - Created BatteryOptimizationDialog Composable: Shows when user queues 10+ downloads with battery optimization enabled - Displays title, description, and action buttons (Cancel, Open Settings) - Opens device battery optimization settings via Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) - Handles API level differences: Android S+ uses app-specific action, earlier versions use general battery settings - Includes fallback to battery saver settings if device doesn't support the primary intent - Integrated battery optimization observers into MainActivity - Injected AnimeDownloadManager and MangaDownloadManager - Added LaunchedEffect blocks collecting from batteryOptimizationPromptFlow - Displays dialog separately for anime/manga downloads using state management - Added localized string resources: - battery_optimization_title: "Battery Optimization Enabled" - battery_optimization_description: Explains impact on bulk downloads - battery_optimization_settings: "Open Settings" button text ## Testing - All 10 battery optimization prompt tests pass (5 anime + 5 manga): - Emission with 10+ items and optimization enabled - No emission with <10 items - No emission when optimization disabled - No emission when prompt already shown - Emission at most once with multiple rapid calls - No regressions in existing tests ## Architecture Notes - Dialog follows existing pattern in MainActivity (similar to changelog dialog) - String keys use existing keys (action_cancel) where available - Respects owned coroutine scope pattern (viewModelScope in DownloadManager) * chore(539): fix ktlint max-line-length in battery prompt test files * docs(539): update changelog (autoloop) * style(r539): spotless import reorder in MangaDownloadManager --------- Co-authored-by: ryacub <ryacub@users.noreply.github.com>
1 parent 17ac22a commit 1f0df04

12 files changed

Lines changed: 1020 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,17 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
1414

1515
### Added
1616

17+
- **Custom accent color theme** — Material 3 app-wide theming from a user-selected accent seed; generates light/dark color schemes with Android 14 contrast-awareness and readability guardrails (contrast clamp + fallback)
1718
- **Download crash notification** — notifies the user when the anime or manga download job crashes repeatedly (threshold: 3 consecutive crashes), with a tap-to-open link to the download manager
1819
- **LightNovelPluginManager unit tests** — 37 tests covering install flow, manifest validation, update policy, APK download/checksum verification, install launch, in-flight mutex deduplication, error recovery, and orphaned APK cleanup
1920
- **Persist dialog/form state across rotation** — PIN setup, PIN change (step/value/error), and enrichment chooser source selection now survive configuration changes via `rememberSaveable`
2021
- **PIN error feedback** — shows an error message when saving a new PIN fails (e.g., storage write error), instead of silently closing the dialog
2122
- **Download queue** — migrated anime and manga download queue screens from RecyclerView/FlexibleAdapter to Jetpack Compose; supports drag-to-reorder sources, per-item progress display, and move-to-top/bottom actions; removes flexibleadapter dependency
23+
- **Battery optimization prompt** — shows a one-time dialog when queuing 10 or more downloads while the app is subject to battery optimization; offers direct navigation to system settings to exempt the app
2224

2325
### Fixed
2426

27+
- Browse tab reselect now opens anime or manga global search based on the current Browse context instead of always defaulting to anime search
2528
- Coroutine cancellation no longer surfaces as a user-visible error in Discover and entry enrichment screens
2629
- One-off UI events in Migrate and PlayerSettingsCustomButton screens no longer drop on delivery when the UI collector is temporarily inactive during lifecycle transitions; channels switched to buffered
2730
- Migrate rayniyomi-specific screen state collection to `collectAsStateWithLifecycle()` — stops background Flow collection when UI is STOPPED
@@ -49,6 +52,9 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
4952
- Firebase BOM → 34.10.0; migrate analytics and crashlytics from deprecated `-ktx` modules to base modules
5053
- Test/tooling upgrade: JUnit Jupiter → 6.0.3, Kotest → 6.1.7, MockK → 1.14.9, unifile snapshot update
5154
- Align Firebase config comments in `build.gradle.kts` with actual runtime configuration
55+
- Custom theme mode foundation: added `ThemeMode.CUSTOM` and `AppTheme.CUSTOM` enum values wired with safe fallback to system behavior; no UI exposure yet
56+
- Pin real SHA-256 certificate fingerprint (`f3565300…`) for LightNovel plugin trust verification; removes placeholder fingerprints
57+
- Remove unused `CoroutineScope` parameter from `PlayerMpvInitializer` constructor
5258
- Remove dead `rollbackToLastGood()` stub and `ROLLBACK_NOT_AVAILABLE` error code from `LightNovelPluginManager`; converted 4 deferred TODO comments to tracked GitHub issues (#536#539)
5359

5460
## [0.18.1.75] - 2026-03-13
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package eu.kanade.presentation.components
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.os.Build
6+
import android.provider.Settings
7+
import androidx.compose.material3.AlertDialog
8+
import androidx.compose.material3.Text
9+
import androidx.compose.material3.TextButton
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.ui.platform.LocalContext
12+
import tachiyomi.i18n.MR
13+
import tachiyomi.presentation.core.i18n.stringResource
14+
15+
/**
16+
* Dialog shown when the user queues 10+ items for download and the app is subject
17+
* to battery optimization. Prompts user to exempt the app from battery optimization.
18+
*/
19+
@Composable
20+
fun BatteryOptimizationDialog(
21+
onDismiss: () -> Unit,
22+
) {
23+
val context = LocalContext.current
24+
25+
AlertDialog(
26+
onDismissRequest = onDismiss,
27+
title = {
28+
Text(text = stringResource(MR.strings.battery_optimization_title))
29+
},
30+
text = {
31+
Text(text = stringResource(MR.strings.battery_optimization_description))
32+
},
33+
dismissButton = {
34+
TextButton(onClick = onDismiss) {
35+
Text(text = stringResource(MR.strings.action_cancel))
36+
}
37+
},
38+
confirmButton = {
39+
TextButton(onClick = {
40+
openBatteryOptimizationSettings(context)
41+
onDismiss()
42+
}) {
43+
Text(text = stringResource(MR.strings.battery_optimization_settings))
44+
}
45+
},
46+
)
47+
}
48+
49+
/**
50+
* Opens the battery optimization settings for the app.
51+
*/
52+
fun openBatteryOptimizationSettings(context: Context) {
53+
val packageName = context.packageName
54+
val intent = when {
55+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
56+
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
57+
data = android.net.Uri.parse("package:$packageName")
58+
}
59+
}
60+
61+
else -> {
62+
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
63+
}
64+
}
65+
66+
try {
67+
context.startActivity(intent)
68+
} catch (e: Exception) {
69+
// Fallback to general battery settings if specific intent fails
70+
context.startActivity(Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS))
71+
}
72+
}

app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloadManager.kt

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import androidx.annotation.VisibleForTesting
66
import eu.kanade.tachiyomi.animesource.AnimeSource
77
import eu.kanade.tachiyomi.animesource.model.Video
88
import eu.kanade.tachiyomi.data.download.anime.model.AnimeDownload
9+
import eu.kanade.tachiyomi.data.download.core.BatteryOptimizationChecker
10+
import eu.kanade.tachiyomi.data.download.core.BatteryOptimizationPromptRequest
911
import eu.kanade.tachiyomi.data.download.core.DownloadQueueMutations
1012
import eu.kanade.tachiyomi.data.download.model.DownloadDisplayStatus
1113
import eu.kanade.tachiyomi.util.size
@@ -15,6 +17,8 @@ import kotlinx.coroutines.Dispatchers
1517
import kotlinx.coroutines.SupervisorJob
1618
import kotlinx.coroutines.cancel
1719
import kotlinx.coroutines.flow.Flow
20+
import kotlinx.coroutines.flow.MutableSharedFlow
21+
import kotlinx.coroutines.flow.SharedFlow
1822
import kotlinx.coroutines.flow.asFlow
1923
import kotlinx.coroutines.flow.drop
2024
import kotlinx.coroutines.flow.emitAll
@@ -53,9 +57,18 @@ class AnimeDownloadManager(
5357
private val getCategories: GetAnimeCategories = Injekt.get(),
5458
private val sourceManager: AnimeSourceManager = Injekt.get(),
5559
private val downloadPreferences: DownloadPreferences = Injekt.get(),
60+
private val batteryOptimizationChecker: BatteryOptimizationChecker = BatteryOptimizationChecker(
61+
context,
62+
context.getSystemService(Context.POWER_SERVICE) as? PowerManager,
63+
),
64+
private val downloaderForTesting: AnimeDownloader? = null,
65+
private val scopeForTesting: CoroutineScope? = null,
5666
) {
5767

58-
private val downloader: AnimeDownloader by lazy { AnimeDownloader(context, provider, cache, sourceManager) }
68+
private val downloader: AnimeDownloader by lazy {
69+
downloaderForTesting
70+
?: AnimeDownloader(context, provider, cache, sourceManager)
71+
}
5972
private val pendingDeleter: AnimeDownloadPendingDeleter by lazy { AnimeDownloadPendingDeleter(context) }
6073

6174
@VisibleForTesting
@@ -69,7 +82,20 @@ class AnimeDownloadManager(
6982
logcat(LogPriority.ERROR, throwable) { "Unhandled exception in AnimeDownloadManager scope" }
7083
}
7184

72-
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
85+
private val scope = scopeForTesting ?: CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
86+
87+
/**
88+
* SharedFlow that emits battery optimization prompt requests when 10+ items
89+
* are queued and battery optimization is enabled.
90+
*/
91+
private val _batteryOptimizationPromptFlow =
92+
MutableSharedFlow<BatteryOptimizationPromptRequest>(extraBufferCapacity = 1)
93+
94+
/**
95+
* Public flow for battery optimization prompt requests.
96+
*/
97+
val batteryOptimizationPromptFlow: SharedFlow<BatteryOptimizationPromptRequest> =
98+
_batteryOptimizationPromptFlow
7399

74100
/**
75101
* Cancels the manager-owned coroutine scope, stopping all background operations.
@@ -517,16 +543,26 @@ class AnimeDownloadManager(
517543
/**
518544
* Checks if battery optimization is disabled and logs a warning if not.
519545
* Called when user queues 10+ items for download.
546+
* Emits a BatteryOptimizationPromptRequest if optimization is enabled.
520547
*/
521548
private fun checkBatteryOptimization() {
522-
val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
523-
val isIgnoringBatteryOptimizations = powerManager?.isIgnoringBatteryOptimizations(context.packageName) ?: true
524-
525-
if (!isIgnoringBatteryOptimizations) {
549+
if (batteryOptimizationChecker.isOptimizationEnabled()) {
526550
logcat(LogPriority.WARN) {
527551
"Battery optimization is enabled - bulk downloads may be interrupted. " +
528552
"Consider exempting app from battery optimization."
529553
}
554+
555+
// Emit the battery optimization prompt signal
556+
// Launch on the scope's context (Dispatchers.IO in production, test dispatcher in tests)
557+
scope.launch {
558+
try {
559+
_batteryOptimizationPromptFlow.emit(BatteryOptimizationPromptRequest())
560+
} catch (e: Exception) {
561+
logcat(LogPriority.ERROR, e) {
562+
"Failed to emit battery optimization prompt"
563+
}
564+
}
565+
}
530566
}
531567

532568
// Mark as shown so we don't prompt again
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package eu.kanade.tachiyomi.data.download.core
2+
3+
import android.content.Context
4+
import android.os.PowerManager
5+
6+
class BatteryOptimizationChecker(
7+
private val context: Context,
8+
private val powerManager: PowerManager?,
9+
) {
10+
fun isOptimizationEnabled(): Boolean {
11+
if (powerManager == null) return true
12+
return !powerManager.isIgnoringBatteryOptimizations(context.packageName)
13+
}
14+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package eu.kanade.tachiyomi.data.download.core
2+
3+
class BatteryOptimizationPromptRequest {
4+
override fun equals(other: Any?) = other is BatteryOptimizationPromptRequest
5+
6+
override fun hashCode() = this::class.hashCode()
7+
8+
override fun toString() = "BatteryOptimizationPromptRequest()"
9+
}

app/src/main/java/eu/kanade/tachiyomi/data/download/manga/MangaDownloadManager.kt

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,22 @@ package eu.kanade.tachiyomi.data.download.manga
33
import android.content.Context
44
import android.os.PowerManager
55
import androidx.annotation.VisibleForTesting
6+
import eu.kanade.tachiyomi.data.download.core.BatteryOptimizationChecker
7+
import eu.kanade.tachiyomi.data.download.core.BatteryOptimizationPromptRequest
68
import eu.kanade.tachiyomi.data.download.core.DownloadQueueMutations
79
import eu.kanade.tachiyomi.data.download.manga.model.MangaDownload
810
import eu.kanade.tachiyomi.data.download.model.DownloadDisplayStatus
911
import eu.kanade.tachiyomi.source.MangaSource
1012
import eu.kanade.tachiyomi.source.model.Page
1113
import eu.kanade.tachiyomi.util.size
14+
import kotlinx.coroutines.CoroutineExceptionHandler
1215
import kotlinx.coroutines.CoroutineScope
1316
import kotlinx.coroutines.Dispatchers
1417
import kotlinx.coroutines.SupervisorJob
18+
import kotlinx.coroutines.cancel
1519
import kotlinx.coroutines.flow.Flow
20+
import kotlinx.coroutines.flow.MutableSharedFlow
21+
import kotlinx.coroutines.flow.SharedFlow
1622
import kotlinx.coroutines.flow.asFlow
1723
import kotlinx.coroutines.flow.drop
1824
import kotlinx.coroutines.flow.emitAll
@@ -53,9 +59,17 @@ class MangaDownloadManager(
5359
private val getCategories: GetMangaCategories = Injekt.get(),
5460
private val sourceManager: MangaSourceManager = Injekt.get(),
5561
private val downloadPreferences: DownloadPreferences = Injekt.get(),
62+
private val batteryOptimizationChecker: BatteryOptimizationChecker = BatteryOptimizationChecker(
63+
context,
64+
context.getSystemService(Context.POWER_SERVICE) as? PowerManager,
65+
),
66+
private val downloaderForTesting: MangaDownloader? = null,
67+
private val scopeForTesting: CoroutineScope? = null,
5668
) {
5769

58-
private val downloader: MangaDownloader by lazy { MangaDownloader(context, provider, cache) }
70+
private val downloader: MangaDownloader by lazy {
71+
downloaderForTesting ?: MangaDownloader(context, provider, cache)
72+
}
5973
private val pendingDeleter: MangaDownloadPendingDeleter by lazy { MangaDownloadPendingDeleter(context) }
6074

6175
@VisibleForTesting
@@ -65,7 +79,32 @@ class MangaDownloadManager(
6579
* Manager-owned coroutine scope for background operations.
6680
* Uses SupervisorJob to prevent child failures from cancelling other operations.
6781
*/
68-
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
82+
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
83+
logcat(LogPriority.ERROR, throwable) { "Unhandled exception in MangaDownloadManager scope" }
84+
}
85+
86+
private val scope = scopeForTesting ?: CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
87+
88+
/**
89+
* SharedFlow that emits battery optimization prompt requests when 10+ items
90+
* are queued and battery optimization is enabled.
91+
*/
92+
private val _batteryOptimizationPromptFlow =
93+
MutableSharedFlow<BatteryOptimizationPromptRequest>(extraBufferCapacity = 1)
94+
95+
/**
96+
* Public flow for battery optimization prompt requests.
97+
*/
98+
val batteryOptimizationPromptFlow: SharedFlow<BatteryOptimizationPromptRequest> =
99+
_batteryOptimizationPromptFlow
100+
101+
/**
102+
* Cancels the manager-owned coroutine scope, stopping all background operations.
103+
* Should be called when the manager is no longer needed.
104+
*/
105+
fun close() {
106+
scope.cancel()
107+
}
69108

70109
/**
71110
* Mutex to synchronize download queue manipulation operations.
@@ -506,17 +545,23 @@ class MangaDownloadManager(
506545
* Called when user queues 10+ items for download.
507546
*/
508547
private fun checkBatteryOptimization() {
509-
val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
510-
val isIgnoringBatteryOptimizations = powerManager?.isIgnoringBatteryOptimizations(context.packageName) ?: true
511-
512-
if (!isIgnoringBatteryOptimizations) {
548+
if (batteryOptimizationChecker.isOptimizationEnabled()) {
513549
logcat(LogPriority.WARN) {
514550
"Battery optimization is enabled - bulk downloads may be interrupted. " +
515551
"Consider exempting app from battery optimization."
516552
}
553+
554+
scope.launch {
555+
try {
556+
_batteryOptimizationPromptFlow.emit(BatteryOptimizationPromptRequest())
557+
} catch (e: Exception) {
558+
logcat(LogPriority.ERROR, e) {
559+
"Failed to emit battery optimization prompt"
560+
}
561+
}
562+
}
517563
}
518564

519-
// Mark as shown so we don't prompt again
520565
downloadPreferences.batteryOptimizationPromptShown().set(true)
521566
}
522567
}

app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import eu.kanade.domain.base.BasePreferences
6161
import eu.kanade.domain.source.anime.interactor.GetAnimeIncognitoState
6262
import eu.kanade.domain.source.manga.interactor.GetMangaIncognitoState
6363
import eu.kanade.presentation.components.AppStateBanners
64+
import eu.kanade.presentation.components.BatteryOptimizationDialog
6465
import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor
6566
import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor
6667
import eu.kanade.presentation.components.IndexingBannerBackgroundColor
@@ -75,7 +76,9 @@ import eu.kanade.tachiyomi.animesource.model.Video
7576
import eu.kanade.tachiyomi.core.common.Constants
7677
import eu.kanade.tachiyomi.data.cache.ChapterCache
7778
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadCache
79+
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager
7880
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadCache
81+
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
7982
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
8083
import eu.kanade.tachiyomi.data.updater.AppUpdateChecker
8184
import eu.kanade.tachiyomi.data.updater.RELEASE_URL
@@ -130,6 +133,9 @@ class MainActivity : BaseActivity() {
130133
private val downloadCache: MangaDownloadCache by injectLazy()
131134
private val chapterCache: ChapterCache by injectLazy()
132135

136+
private val animeDownloadManager: AnimeDownloadManager by injectLazy()
137+
private val mangaDownloadManager: MangaDownloadManager by injectLazy()
138+
133139
private val getAnimeIncognitoState: GetAnimeIncognitoState by injectLazy()
134140
private val getMangaIncognitoState: GetMangaIncognitoState by injectLazy()
135141

@@ -338,6 +344,34 @@ class MainActivity : BaseActivity() {
338344
},
339345
)
340346
}
347+
348+
// Battery optimization prompt from anime downloads
349+
var showBatteryOptimizationAnimeDlg by remember { mutableStateOf(false) }
350+
LaunchedEffect(Unit) {
351+
animeDownloadManager.batteryOptimizationPromptFlow
352+
.collectLatest {
353+
showBatteryOptimizationAnimeDlg = true
354+
}
355+
}
356+
if (showBatteryOptimizationAnimeDlg) {
357+
BatteryOptimizationDialog(
358+
onDismiss = { showBatteryOptimizationAnimeDlg = false },
359+
)
360+
}
361+
362+
// Battery optimization prompt from manga downloads
363+
var showBatteryOptimizationMangaDlg by remember { mutableStateOf(false) }
364+
LaunchedEffect(Unit) {
365+
mangaDownloadManager.batteryOptimizationPromptFlow
366+
.collectLatest {
367+
showBatteryOptimizationMangaDlg = true
368+
}
369+
}
370+
if (showBatteryOptimizationMangaDlg) {
371+
BatteryOptimizationDialog(
372+
onDismiss = { showBatteryOptimizationMangaDlg = false },
373+
)
374+
}
341375
}
342376

343377
val startTime = System.currentTimeMillis()

0 commit comments

Comments
 (0)