Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co

### Added

- **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)
- **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
- **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
- **Persist dialog/form state across rotation** — PIN setup, PIN change (step/value/error), and enrichment chooser source selection now survive configuration changes via `rememberSaveable`
- **PIN error feedback** — shows an error message when saving a new PIN fails (e.g., storage write error), instead of silently closing the dialog
- **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
- **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

### Fixed

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

## [0.18.1.75] - 2026-03-13
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package eu.kanade.presentation.components

import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource

/**
* Dialog shown when the user queues 10+ items for download and the app is subject
* to battery optimization. Prompts user to exempt the app from battery optimization.
*/
@Composable
fun BatteryOptimizationDialog(
onDismiss: () -> Unit,
) {
val context = LocalContext.current

AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(text = stringResource(MR.strings.battery_optimization_title))
},
text = {
Text(text = stringResource(MR.strings.battery_optimization_description))
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
confirmButton = {
TextButton(onClick = {
openBatteryOptimizationSettings(context)
onDismiss()
}) {
Text(text = stringResource(MR.strings.battery_optimization_settings))
}
},
)
}

/**
* Opens the battery optimization settings for the app.
*/
fun openBatteryOptimizationSettings(context: Context) {
val packageName = context.packageName
val intent = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = android.net.Uri.parse("package:$packageName")
}
}

else -> {
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
}
}

try {
context.startActivity(intent)
} catch (e: Exception) {
// Fallback to general battery settings if specific intent fails
context.startActivity(Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.annotation.VisibleForTesting
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.data.download.anime.model.AnimeDownload
import eu.kanade.tachiyomi.data.download.core.BatteryOptimizationChecker
import eu.kanade.tachiyomi.data.download.core.BatteryOptimizationPromptRequest
import eu.kanade.tachiyomi.data.download.core.DownloadQueueMutations
import eu.kanade.tachiyomi.data.download.model.DownloadDisplayStatus
import eu.kanade.tachiyomi.util.size
Expand All @@ -15,6 +17,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emitAll
Expand Down Expand Up @@ -53,9 +57,18 @@ class AnimeDownloadManager(
private val getCategories: GetAnimeCategories = Injekt.get(),
private val sourceManager: AnimeSourceManager = Injekt.get(),
private val downloadPreferences: DownloadPreferences = Injekt.get(),
private val batteryOptimizationChecker: BatteryOptimizationChecker = BatteryOptimizationChecker(
context,
context.getSystemService(Context.POWER_SERVICE) as? PowerManager,
),
private val downloaderForTesting: AnimeDownloader? = null,
private val scopeForTesting: CoroutineScope? = null,
) {

private val downloader: AnimeDownloader by lazy { AnimeDownloader(context, provider, cache, sourceManager) }
private val downloader: AnimeDownloader by lazy {
downloaderForTesting
?: AnimeDownloader(context, provider, cache, sourceManager)
}
private val pendingDeleter: AnimeDownloadPendingDeleter by lazy { AnimeDownloadPendingDeleter(context) }

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

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
private val scope = scopeForTesting ?: CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)

/**
* SharedFlow that emits battery optimization prompt requests when 10+ items
* are queued and battery optimization is enabled.
*/
private val _batteryOptimizationPromptFlow =
MutableSharedFlow<BatteryOptimizationPromptRequest>(extraBufferCapacity = 1)

/**
* Public flow for battery optimization prompt requests.
*/
val batteryOptimizationPromptFlow: SharedFlow<BatteryOptimizationPromptRequest> =
_batteryOptimizationPromptFlow

/**
* Cancels the manager-owned coroutine scope, stopping all background operations.
Expand Down Expand Up @@ -517,16 +543,26 @@ class AnimeDownloadManager(
/**
* Checks if battery optimization is disabled and logs a warning if not.
* Called when user queues 10+ items for download.
* Emits a BatteryOptimizationPromptRequest if optimization is enabled.
*/
private fun checkBatteryOptimization() {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
val isIgnoringBatteryOptimizations = powerManager?.isIgnoringBatteryOptimizations(context.packageName) ?: true

if (!isIgnoringBatteryOptimizations) {
if (batteryOptimizationChecker.isOptimizationEnabled()) {
logcat(LogPriority.WARN) {
"Battery optimization is enabled - bulk downloads may be interrupted. " +
"Consider exempting app from battery optimization."
}

// Emit the battery optimization prompt signal
// Launch on the scope's context (Dispatchers.IO in production, test dispatcher in tests)
scope.launch {
try {
_batteryOptimizationPromptFlow.emit(BatteryOptimizationPromptRequest())
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) {
"Failed to emit battery optimization prompt"
}
}
}
}

// Mark as shown so we don't prompt again
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.download.core

import android.content.Context
import android.os.PowerManager

class BatteryOptimizationChecker(
private val context: Context,
private val powerManager: PowerManager?,
) {
fun isOptimizationEnabled(): Boolean {
if (powerManager == null) return true
return !powerManager.isIgnoringBatteryOptimizations(context.packageName)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.data.download.core

class BatteryOptimizationPromptRequest {
override fun equals(other: Any?) = other is BatteryOptimizationPromptRequest

override fun hashCode() = this::class.hashCode()

override fun toString() = "BatteryOptimizationPromptRequest()"
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ package eu.kanade.tachiyomi.data.download.manga
import android.content.Context
import android.os.PowerManager
import androidx.annotation.VisibleForTesting
import eu.kanade.tachiyomi.data.download.core.BatteryOptimizationChecker
import eu.kanade.tachiyomi.data.download.core.BatteryOptimizationPromptRequest
import eu.kanade.tachiyomi.data.download.core.DownloadQueueMutations
import eu.kanade.tachiyomi.data.download.manga.model.MangaDownload
import eu.kanade.tachiyomi.data.download.model.DownloadDisplayStatus
import eu.kanade.tachiyomi.source.MangaSource
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.size
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emitAll
Expand Down Expand Up @@ -53,9 +59,17 @@ class MangaDownloadManager(
private val getCategories: GetMangaCategories = Injekt.get(),
private val sourceManager: MangaSourceManager = Injekt.get(),
private val downloadPreferences: DownloadPreferences = Injekt.get(),
private val batteryOptimizationChecker: BatteryOptimizationChecker = BatteryOptimizationChecker(
context,
context.getSystemService(Context.POWER_SERVICE) as? PowerManager,
),
private val downloaderForTesting: MangaDownloader? = null,
private val scopeForTesting: CoroutineScope? = null,
) {

private val downloader: MangaDownloader by lazy { MangaDownloader(context, provider, cache) }
private val downloader: MangaDownloader by lazy {
downloaderForTesting ?: MangaDownloader(context, provider, cache)
}
private val pendingDeleter: MangaDownloadPendingDeleter by lazy { MangaDownloadPendingDeleter(context) }

@VisibleForTesting
Expand All @@ -65,7 +79,32 @@ class MangaDownloadManager(
* Manager-owned coroutine scope for background operations.
* Uses SupervisorJob to prevent child failures from cancelling other operations.
*/
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
logcat(LogPriority.ERROR, throwable) { "Unhandled exception in MangaDownloadManager scope" }
}

private val scope = scopeForTesting ?: CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)

/**
* SharedFlow that emits battery optimization prompt requests when 10+ items
* are queued and battery optimization is enabled.
*/
private val _batteryOptimizationPromptFlow =
MutableSharedFlow<BatteryOptimizationPromptRequest>(extraBufferCapacity = 1)

/**
* Public flow for battery optimization prompt requests.
*/
val batteryOptimizationPromptFlow: SharedFlow<BatteryOptimizationPromptRequest> =
_batteryOptimizationPromptFlow

/**
* Cancels the manager-owned coroutine scope, stopping all background operations.
* Should be called when the manager is no longer needed.
*/
fun close() {
scope.cancel()
}

/**
* Mutex to synchronize download queue manipulation operations.
Expand Down Expand Up @@ -506,17 +545,23 @@ class MangaDownloadManager(
* Called when user queues 10+ items for download.
*/
private fun checkBatteryOptimization() {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
val isIgnoringBatteryOptimizations = powerManager?.isIgnoringBatteryOptimizations(context.packageName) ?: true

if (!isIgnoringBatteryOptimizations) {
if (batteryOptimizationChecker.isOptimizationEnabled()) {
logcat(LogPriority.WARN) {
"Battery optimization is enabled - bulk downloads may be interrupted. " +
"Consider exempting app from battery optimization."
}

scope.launch {
try {
_batteryOptimizationPromptFlow.emit(BatteryOptimizationPromptRequest())
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) {
"Failed to emit battery optimization prompt"
}
}
}
}

// Mark as shown so we don't prompt again
downloadPreferences.batteryOptimizationPromptShown().set(true)
}
}
34 changes: 34 additions & 0 deletions app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.source.anime.interactor.GetAnimeIncognitoState
import eu.kanade.domain.source.manga.interactor.GetMangaIncognitoState
import eu.kanade.presentation.components.AppStateBanners
import eu.kanade.presentation.components.BatteryOptimizationDialog
import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor
import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor
import eu.kanade.presentation.components.IndexingBannerBackgroundColor
Expand All @@ -75,7 +76,9 @@ import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.core.common.Constants
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadCache
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadCache
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.updater.AppUpdateChecker
import eu.kanade.tachiyomi.data.updater.RELEASE_URL
Expand Down Expand Up @@ -130,6 +133,9 @@ class MainActivity : BaseActivity() {
private val downloadCache: MangaDownloadCache by injectLazy()
private val chapterCache: ChapterCache by injectLazy()

private val animeDownloadManager: AnimeDownloadManager by injectLazy()
private val mangaDownloadManager: MangaDownloadManager by injectLazy()

private val getAnimeIncognitoState: GetAnimeIncognitoState by injectLazy()
private val getMangaIncognitoState: GetMangaIncognitoState by injectLazy()

Expand Down Expand Up @@ -338,6 +344,34 @@ class MainActivity : BaseActivity() {
},
)
}

// Battery optimization prompt from anime downloads
var showBatteryOptimizationAnimeDlg by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
animeDownloadManager.batteryOptimizationPromptFlow
.collectLatest {
showBatteryOptimizationAnimeDlg = true
}
}
if (showBatteryOptimizationAnimeDlg) {
BatteryOptimizationDialog(
onDismiss = { showBatteryOptimizationAnimeDlg = false },
)
}

// Battery optimization prompt from manga downloads
var showBatteryOptimizationMangaDlg by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
mangaDownloadManager.batteryOptimizationPromptFlow
.collectLatest {
showBatteryOptimizationMangaDlg = true
}
}
if (showBatteryOptimizationMangaDlg) {
BatteryOptimizationDialog(
onDismiss = { showBatteryOptimizationMangaDlg = false },
)
}
}

val startTime = System.currentTimeMillis()
Expand Down
Loading
Loading