Merge branch 'main' into 0.21.0

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
AntsyLich
2026-10-12 14:04:00 +06:00
16 changed files with 159 additions and 38 deletions
+13
View File
@@ -12,6 +12,19 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased]
### Fixed
- Fixed refreshing chapters failing with the app language set to Arabic or another language with its own digits ([@AntsyLich](https://github.com/AntsyLich))
- Fixed a crash on launch on some Samsung devices running Android 9 to 11 ([@AntsyLich](https://github.com/AntsyLich))
- Fixed the start and resume button going under the taskbar on tablets ([@AntsyLich](https://github.com/AntsyLich))
- Fixed a crash when updating an extension while others were installing ([@AntsyLich](https://github.com/AntsyLich))
- Fixed library entries without a date added getting their last update time as date added when updating from v0.20.x ([@AntsyLich](https://github.com/AntsyLich))
- Fixed the app closing when WebView's page process crashed ([@AntsyLich](https://github.com/AntsyLich))
- Fixed installing or updating an extension sometimes cancelling the install of another one ([@AntsyLich](https://github.com/AntsyLich))
- Fixed updating from v0.20.x taking up to hours on libraries with many duplicate entries ([@AntsyLich](https://github.com/AntsyLich))
- Fixed the app staying on the updating screen for good when updating its data failed, it now shows what went wrong, and updating failing on leftover data of deleted entries ([@AntsyLich](https://github.com/AntsyLich))
- Fixed library updates ignoring the smart update restrictions for entries whose details were never fetched ([@AntsyLich](https://github.com/AntsyLich))
- Fixed resuming a chapter sometimes opening the next chapter partway through, and saving progress to it, in the right to left reader ([@AntsyLich](https://github.com/AntsyLich))
## [v0.21.0] - 2026-10-12
### Added
- Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya), [@BrutuZ](https://github.com/BrutuZ)) ([#3776](https://github.com/mihonapp/mihon/pull/3776))
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
@@ -535,20 +536,20 @@ fun MangaScreenLargeImpl(
)
},
bottomBar = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
SharedMangaBottomActionMenu(
selected = state.selectedChapters,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
onDownloadChapter = onDownloadChapter,
onMultiDeleteClicked = onMultiDeleteClicked,
fillFraction = 0.5f,
)
}
// Aligned by its own modifier: a wrapper would always be laid out, and the scaffold then drops the
// navigation bar inset under the FAB
SharedMangaBottomActionMenu(
selected = state.selectedChapters,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
onDownloadChapter = onDownloadChapter,
onMultiDeleteClicked = onMultiDeleteClicked,
fillFraction = 0.5f,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
@@ -5,8 +5,10 @@ import android.graphics.Bitmap
import android.os.Message
import android.webkit.JsPromptResult
import android.webkit.JsResult
import android.webkit.RenderProcessGoneDetail
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -45,12 +47,15 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.util.system.getHtml
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.setUserAgent
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.ArrowBack
import mihon.icons.materialsymbols.automirroredrounded.ArrowForward
import mihon.icons.materialsymbols.rounded.Close
import mihon.icons.materialsymbols.rounded.TabClose
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
@@ -152,6 +157,17 @@ fun WebViewScreenContent(
return false
}
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
// Not handling it kills the app along with the page
logcat(LogPriority.ERROR) {
"WebView renderer gone: crashed=${detail.didCrash()}, " +
"priority=${detail.rendererPriorityAtExit()}, url=$currentUrl"
}
view.context.toast(MR.strings.information_webview_crashed, Toast.LENGTH_LONG)
onNavigateUp()
return true
}
}
}
@@ -192,9 +192,6 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
mangaToUpdate = listToUpdate
.filter {
when {
// Entries added without opening them have no details yet
!it.manga.initialized -> true
it.manga.updateStrategy == UpdateStrategy.ONLY_FETCH_ONCE && it.totalChapters > 0L -> {
skippedUpdates.add(
it.manga to context.stringResource(MR.strings.skipped_reason_not_always_update),
@@ -357,7 +354,7 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
val update = updateMangaFromRemote(
source = source,
manga = manga,
fetchDetails = !manga.initialized || libraryPreferences.autoUpdateMetadata.get(),
fetchDetails = libraryPreferences.autoUpdateMetadata.get(),
fetchChapters = true,
fetchWindow = fetchWindow,
)
@@ -22,6 +22,8 @@ abstract class Installer(private val service: Service) {
private val extensionManager: ExtensionManager by lazy { service.appGraph.extensionManager }
private var waitingInstall = AtomicReference<Entry?>(null)
// Guarded by itself: cancellation comes from any thread, not only main
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
private val cancelListener: (Long) -> Unit = { downloadId -> cancelQueue(downloadId) }
@@ -91,15 +93,15 @@ abstract class Installer(private val service: Service) {
if (!ready) {
return
}
if (queue.isEmpty()) {
service.stopSelf()
return
}
val nextEntry = queue.first()
if (waitingInstall.compareAndSet(null, nextEntry)) {
val nextEntry = synchronized(queue) {
if (queue.isEmpty()) {
service.stopSelf()
return
}
if (!waitingInstall.compareAndSet(null, queue.first())) return
queue.removeAt(0)
processEntry(nextEntry)
}
processEntry(nextEntry)
}
/**
@@ -108,11 +110,11 @@ abstract class Installer(private val service: Service) {
@CallSuper
open fun onDestroy() {
cancelListeners -= cancelListener
queue.forEach {
val remaining = synchronized(queue) { queue.toList().also { queue.clear() } }
remaining.forEach {
logcat(LogPriority.INFO) { "The installer stopped before installing ${it.downloadId}" }
extensionManager.updateInstallStep(it.downloadId, InstallStep.Error)
}
queue.clear()
waitingInstall.store(null)
}
@@ -125,7 +127,10 @@ abstract class Installer(private val service: Service) {
*/
private fun cancelQueue(downloadId: Long) {
val waitingInstall = this.waitingInstall.load()
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
// Every new install cancels its own previous attempt first, which mustn't hit another extension's install
val toCancel = synchronized(queue) { queue.find { it.downloadId == downloadId } }
?: waitingInstall?.takeIf { it.downloadId == downloadId }
?: return
if (cancelEntry(toCancel)) {
queue.remove(toCancel)
if (waitingInstall == toCancel) {
@@ -179,6 +179,9 @@ class MainActivity : BaseActivity() {
Migrator.awaitAndRelease()
isAppMigrating.value = false
}
// Uncaught on purpose: a failed database migration otherwise leaves the migrating screen up for good,
// while the crash screen tells what went wrong and lets the logs be shared
lifecycleScope.launch { databaseRepository.awaitOpen() }
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (!isTaskRoot) {
@@ -337,6 +340,16 @@ class MainActivity : BaseActivity() {
}
}
override fun reportFullyDrawn() {
try {
super.reportFullyDrawn()
} catch (e: SecurityException) {
// Some Samsung Android 9 to 11 builds deny the platform's fully drawn broadcast
// and the report only feeds startup metrics, so it shouldn't crash the app
logcat(LogPriority.WARN, e)
}
}
@Composable
private fun HandleOnNewIntent(context: Context, navigator: Navigator) {
LaunchedEffect(Unit) {
@@ -96,6 +96,8 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
chapters.nextChapter?.pages?.let(newItems::addAll)
val currentItem = items.getOrNull(viewer.pager.currentItem)
// Resets double-page splits, else insert pages get misplaced
items.filterIsInstance<InsertPage>().also { items.removeAll(it) }
@@ -107,6 +109,14 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
items = newItems
notifyDataSetChanged()
// The pager only follows the pages it has created, so before its first layout it would stay at the same
// position while pages are added in front, e.g. the next chapter's in right to left, and land in another
// chapter
val currentPosition = items.indexOf(currentItem)
if (currentPosition != -1 && currentPosition != viewer.pager.currentItem) {
viewer.pager.setCurrentItem(currentPosition, false)
}
// Will skip insert page otherwise
if (insertPageLastPage != null) {
viewer.moveToPage(insertPageLastPage)
@@ -1,9 +1,11 @@
package mihon.core.migration
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
class MigrationJobFactory(
@@ -20,7 +22,18 @@ class MigrationJobFactory(
}
async {
val prev = acc.await()
migration(migrationContext) || prev
// A failure would cancel the rest of the chain and leave the app waiting on it for good
val ran = try {
migration(migrationContext)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) {
"Migration failed: { name = ${migration::class.simpleName}, version = ${migration.version} }"
}
false
}
ran || prev
}
} else {
logcat {
@@ -69,7 +69,7 @@ class UpdateMangaFromRemote(
fetchDetails = fetchDetails,
fetchChapters = fetchChapters,
)
awaitUpdateFromSource(manga, update.manga, fetchDetails, manualFetch)
awaitUpdateFromSource(manga, update.manga, manualFetch)
val newChapters = syncChaptersWithSource.await(
rawSourceChapters = update.chapters,
manga = manga,
@@ -89,7 +89,6 @@ class UpdateMangaFromRemote(
private suspend fun awaitUpdateFromSource(
localManga: Manga,
remoteManga: SManga,
fetchDetails: Boolean,
manualFetch: Boolean,
): Boolean {
val remoteTitle = try {
@@ -135,7 +134,7 @@ class UpdateMangaFromRemote(
thumbnailUrl = thumbnailUrl,
status = remoteManga.status.toLong(),
updateStrategy = remoteManga.update_strategy,
initialized = localManga.initialized || fetchDetails,
initialized = true,
memo = remoteManga.memo,
),
)
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.network.interceptor
import android.annotation.SuppressLint
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.RenderProcessGoneDetail
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
@@ -13,12 +14,14 @@ import eu.kanade.tachiyomi.network.AndroidCookieJar
import eu.kanade.tachiyomi.util.system.isOutdated
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import logcat.LogPriority
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import java.io.IOException
import java.util.concurrent.CountDownLatch
@@ -73,6 +76,7 @@ class CloudflareInterceptor(
var challengeFound = false
var cloudflareBypassed = false
var isWebViewOutdated = false
var isRenderProcessGone = false
val origRequestUrl = originalRequest.url.toString()
val headers = parseHeaders(originalRequest.headers)
@@ -140,6 +144,17 @@ class CloudflareInterceptor(
}
}
}
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
// Not handling it kills the app, failing the bypass is enough
logcat(LogPriority.ERROR) {
"WebView renderer gone during Cloudflare bypass: crashed=${detail.didCrash()}, " +
"priority=${detail.rendererPriorityAtExit()}, url=$origRequestUrl"
}
isRenderProcessGone = true
latch.countDown()
return true
}
}
webview.loadUrl(origRequestUrl, headers)
@@ -148,12 +163,13 @@ class CloudflareInterceptor(
latch.awaitFor30Seconds()
executor.execute {
if (!cloudflareBypassed) {
// Only destroy can be called on a WebView whose renderer is gone
if (!cloudflareBypassed && !isRenderProcessGone) {
isWebViewOutdated = webview?.isOutdated() == true
}
webview?.run {
stopLoading()
if (!isRenderProcessGone) stopLoading()
destroy()
}
}
@@ -1,16 +1,29 @@
package mihon.data.database
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import mihon.domain.database.repository.DatabaseRepository
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DatabaseRepositoryImpl : DatabaseRepository {
class DatabaseRepositoryImpl(
// Lazy since the driver reports its migration state here
private val driver: Lazy<SqlDriver>,
) : DatabaseRepository {
override val isMigrating = MutableStateFlow(false)
override suspend fun awaitOpen() {
withContext(Dispatchers.IO) {
driver.value.executeQuery(null, "SELECT 1", { QueryResult.Value(Unit) }, 0).await()
}
}
}
@@ -9,8 +9,9 @@ DROP INDEX library_favorite_index;
ALTER TABLE mangas ADD COLUMN favorite_at INTEGER;
-- favorite_modified_at is no fallback for a missing date_added: every manga update rewrote it
UPDATE mangas
SET favorite_at = coalesce(nullif(date_added, 0), favorite_modified_at * 1000, 0)
SET favorite_at = coalesce(date_added, 0)
WHERE favorite = 1;
ALTER TABLE mangas DROP COLUMN favorite;
@@ -4,6 +4,19 @@ import kotlin.String;
import kotlin.collections.List;
import kotlinx.serialization.json.JsonObject;
-- ---------------------------------------------------------------------------------------------------------
-- orphans
-- ---------------------------------------------------------------------------------------------------------
-- Rows pointing at an entry, chapter or category that no longer exists were written while foreign keys weren't
-- enforced. Nothing can reach them, and copied over they'd fail the foreign key check that ends the migration.
DELETE FROM chapters WHERE manga_id NOT IN (SELECT _id FROM mangas);
DELETE FROM history WHERE chapter_id NOT IN (SELECT _id FROM chapters);
DELETE FROM mangas_categories
WHERE manga_id NOT IN (SELECT _id FROM mangas) OR category_id NOT IN (SELECT _id FROM categories);
DELETE FROM manga_sync WHERE manga_id NOT IN (SELECT _id FROM mangas);
DELETE FROM excluded_scanlators WHERE manga_id NOT IN (SELECT _id FROM mangas);
-- ---------------------------------------------------------------------------------------------------------
-- table - manga
-- ---------------------------------------------------------------------------------------------------------
@@ -31,6 +44,9 @@ WHERE (source, url) IN (
HAVING count(*) > 1
);
-- Moving the chapters looks each one's entry up here, which without an index scans the whole table per chapter
CREATE INDEX duplicate_mangas_id_index ON duplicate_mangas(_id);
UPDATE mangas
SET favorite_at = merged.favorite_at,
initialized = merged.initialized,
@@ -9,4 +9,9 @@ interface DatabaseRepository {
* app update.
*/
val isMigrating: StateFlow<Boolean>
/**
* Opens the database, migrating it if needed, and throws what made the migration fail.
*/
suspend fun awaitOpen()
}
@@ -3,6 +3,7 @@ package tachiyomi.domain.chapter.service
import tachiyomi.domain.chapter.model.Chapter
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
import kotlin.math.floor
import kotlin.math.round
@@ -77,9 +78,9 @@ object ChapterRecognition {
private val prologue = Regex("""\b(?:prologue|one.?shot|pilot)\b""")
/**
* Formats numbers without trailing zeros and with a dot whatever the locale, e.g. 10.5, 3, 24.005
* Formats numbers without trailing zeros, with ASCII digits and a dot whatever the locale, e.g. 10.5, 3, 24.005
*/
private val formatter = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' })
private val formatter = DecimalFormat("#.###", DecimalFormatSymbols(Locale.ROOT))
/**
* The volume and chapter number recognized for a chapter, e.g. "10.5" or "19a", `null` when there is none.
@@ -994,6 +994,8 @@
<string name="information_webview_required">WebView is required for the app to function</string>
<!-- Do not translate "WebView" -->
<string name="information_webview_outdated">Please update the WebView app for better compatibility</string>
<!-- Do not translate "WebView" -->
<string name="information_webview_crashed">WebView stopped working and the page was closed</string>
<string name="chapter_settings_updated">Updated default chapter settings</string>
<!-- Download Notification -->