Compare commits

..
15 Commits
Author SHA1 Message Date
AntsyLich e9670d8a12 Release v0.21.1 2026-10-12 14:09:01 +06:00
AntsyLich 87e808548b Merge branch 'main' into 0.21.0
# Conflicts:
#	CHANGELOG.md
2026-10-12 14:04:00 +06:00
AntsyLich 51b61a36e8 Keep the pager on its page when chapters are added before layout
Closes #4079
2026-10-12 13:56:33 +06:00
AntsyLich 010ac71a84 Revert "Always update uninitialized library entries (#3179)"
This reverts commit 8c0419657b.

Closes #4081
2026-10-12 13:56:33 +06:00
AntsyLich 9dbb7148c5 Show failed migrations instead of waiting on them forever 2026-10-12 13:56:33 +06:00
AntsyLich a999e49f2f Index duplicate entries so moving their chapters doesn't take hours 2026-10-12 13:56:33 +06:00
AntsyLich ae749bb4f8 Only cancel the install of the extension being cancelled 2026-10-12 13:56:33 +06:00
AntsyLich 649fc83ffa Keep the app alive when a WebView renderer dies 2026-10-12 13:56:32 +06:00
AntsyLich 6dffca1593 Keep a missing date added as is when merging it into favorite_at 2026-10-12 13:56:32 +06:00
AntsyLich 07b9fb695c Fix extension installer crash when cancelling off the main thread 2026-10-12 13:56:32 +06:00
AntsyLich c279291e29 Fix manga screen FAB going under the taskbar on tablets
Closes #4077
2026-10-12 13:51:25 +06:00
AntsyLich abb126ad20 Fix startup crash on some Samsung Android 9 to 11 devices
Closes #4078
2026-10-12 13:51:25 +06:00
AntsyLich 8457fb91e0 Fix chapter refresh failing in locales with non-ASCII digits
Closes #4075
2026-10-12 13:51:25 +06:00
AntsyLich 143a060ac9 Release v0.21.0
Keeps the changelog entries that f45d0ad31d removed for the release under
Unreleased, since main still has the WebGPU reader and the new image
decoder.

(cherry picked from commit 79fc3a0a8a)
2026-10-12 02:40:27 +06:00
AntsyLich 8912ef5b5c Regenerate baseline profiles
(cherry picked from commit c8b820a08f)
2026-10-12 02:34:27 +06:00
19 changed files with 167 additions and 44 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to version **[0.21.0](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.21.1](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+2 -2
View File
@@ -52,7 +52,7 @@ body:
label: Mihon version
description: You can find your Mihon version in **More → About**.
placeholder: |
Example: "0.21.0"
Example: "0.21.1"
validations:
required: true
@@ -95,7 +95,7 @@ body:
required: true
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
required: true
- label: I have updated the app to version **[0.21.0](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.21.1](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I have filled out all of the requested information in this form, including specific version numbers.
required: true
+16 -1
View File
@@ -12,6 +12,20 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased]
## [v0.21.1] - 2026-10-12
### Fixed
- Fixed refreshing chapters failing with the app language set to Arabic or another language with its own digits ([@AntsyLich](https://github.com/AntsyLich)) ([`8457fb9`](https://github.com/mihonapp/mihon/commit/8457fb9))
- Fixed a crash on launch on some Samsung devices running Android 9 to 11 ([@AntsyLich](https://github.com/AntsyLich)) ([`abb126a`](https://github.com/mihonapp/mihon/commit/abb126a))
- Fixed the start and resume button going under the taskbar on tablets ([@AntsyLich](https://github.com/AntsyLich)) ([`c279291`](https://github.com/mihonapp/mihon/commit/c279291))
- Fixed a crash when updating an extension while others were installing ([@AntsyLich](https://github.com/AntsyLich)) ([`07b9fb6`](https://github.com/mihonapp/mihon/commit/07b9fb6))
- 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)) ([`6dffca1`](https://github.com/mihonapp/mihon/commit/6dffca1))
- Fixed the app closing when WebView's page process crashed ([@AntsyLich](https://github.com/AntsyLich)) ([`649fc83`](https://github.com/mihonapp/mihon/commit/649fc83))
- Fixed installing or updating an extension sometimes cancelling the install of another one ([@AntsyLich](https://github.com/AntsyLich)) ([`ae749bb`](https://github.com/mihonapp/mihon/commit/ae749bb))
- Fixed updating from v0.20.x taking up to hours on libraries with many duplicate entries ([@AntsyLich](https://github.com/AntsyLich)) ([`a999e49`](https://github.com/mihonapp/mihon/commit/a999e49))
- 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)) ([`9dbb714`](https://github.com/mihonapp/mihon/commit/9dbb714))
- Fixed library updates ignoring the smart update restrictions for entries whose details were never fetched ([@AntsyLich](https://github.com/AntsyLich)) ([`010ac71`](https://github.com/mihonapp/mihon/commit/010ac71))
- 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)) ([`51b61a3`](https://github.com/mihonapp/mihon/commit/51b61a3))
## [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))
@@ -758,7 +772,8 @@ Same as v0.19.6
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.0...main
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.1...main
[v0.21.1]: https://github.com/mihonapp/mihon/compare/v0.21.0...v0.21.1
[v0.21.0]: https://github.com/mihonapp/mihon/compare/v0.20.4...v0.21.0
[v0.20.4]: https://github.com/mihonapp/mihon/compare/v0.20.3...v0.20.4
[v0.20.3]: https://github.com/mihonapp/mihon/compare/v0.20.2...v0.20.3
+2 -2
View File
@@ -37,8 +37,8 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 36
versionName = "0.21.0"
versionCode = 37
versionName = "0.21.1"
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
@@ -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 -->