diff --git a/CHANGELOG.md b/CHANGELOG.md index 80da7b360..89a16a773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 7e7d8175d..5e0d4224b 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -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 = { diff --git a/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt b/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt index fbc26ff3f..7ff8c7ee2 100644 --- a/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt +++ b/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt @@ -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 + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateWorker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateWorker.kt index 8e2d9f4d6..c96e06639 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateWorker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateWorker.kt @@ -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, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt index 3900806b2..18f9e7fa7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt @@ -22,6 +22,8 @@ abstract class Installer(private val service: Service) { private val extensionManager: ExtensionManager by lazy { service.appGraph.extensionManager } private var waitingInstall = AtomicReference(null) + + // Guarded by itself: cancellation comes from any thread, not only main private val queue = Collections.synchronizedList(mutableListOf()) 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) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 5794a4d30..3984efa4f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -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) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt index 0fef0b8bc..b240f8997 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt @@ -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().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) diff --git a/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt b/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt index fb2c3787c..d9d6a45f6 100644 --- a/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt +++ b/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt @@ -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 { diff --git a/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt b/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt index d7e6d1660..75f35b17d 100644 --- a/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt +++ b/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt @@ -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, ), ) diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt index 7cc97fb01..3df2f876b 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt @@ -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() } } diff --git a/data/src/main/java/mihon/data/database/DatabaseRepositoryImpl.kt b/data/src/main/java/mihon/data/database/DatabaseRepositoryImpl.kt index 2dbd4e1e7..200b31722 100644 --- a/data/src/main/java/mihon/data/database/DatabaseRepositoryImpl.kt +++ b/data/src/main/java/mihon/data/database/DatabaseRepositoryImpl.kt @@ -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, +) : DatabaseRepository { override val isMigrating = MutableStateFlow(false) + + override suspend fun awaitOpen() { + withContext(Dispatchers.IO) { + driver.value.executeQuery(null, "SELECT 1", { QueryResult.Value(Unit) }, 0).await() + } + } } diff --git a/data/src/main/sqldelight/tachiyomi/migrations/16.sqm b/data/src/main/sqldelight/tachiyomi/migrations/16.sqm index 4b839aa6c..b488220d5 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/16.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/16.sqm @@ -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; diff --git a/data/src/main/sqldelight/tachiyomi/migrations/17.sqm b/data/src/main/sqldelight/tachiyomi/migrations/17.sqm index b0d945b5d..282d0726a 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/17.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/17.sqm @@ -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, diff --git a/domain/src/main/java/mihon/domain/database/repository/DatabaseRepository.kt b/domain/src/main/java/mihon/domain/database/repository/DatabaseRepository.kt index dfc52725c..fd64f03e2 100644 --- a/domain/src/main/java/mihon/domain/database/repository/DatabaseRepository.kt +++ b/domain/src/main/java/mihon/domain/database/repository/DatabaseRepository.kt @@ -9,4 +9,9 @@ interface DatabaseRepository { * app update. */ val isMigrating: StateFlow + + /** + * Opens the database, migrating it if needed, and throws what made the migration fail. + */ + suspend fun awaitOpen() } diff --git a/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt b/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt index 5d294f0dc..cca8e62f1 100644 --- a/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt +++ b/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt @@ -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. diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index dbbbca520..6ff5c040a 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -994,6 +994,8 @@ WebView is required for the app to function Please update the WebView app for better compatibility + + WebView stopped working and the page was closed Updated default chapter settings