Compare commits

...
28 Commits
Author SHA1 Message Date
Mend Renovate 446f6df82c Update androidx.paging to v3.5.3 (#4099) 2026-10-13 10:30:48 +06:00
AntsyLich 3100dceddd Release v0.21.2
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 9612d955db)
2026-10-13 10:27:57 +06:00
AntsyLichandNGB-Was-Taken 771ba33a33 Stream entries when creating and restoring backups
Closes #3850

Co-authored-by: NGB-Was-Taken <76197326+NGB-Was-Taken@users.noreply.github.com>
2026-10-13 01:51:35 +06:00
AntsyLichandtigeryu8900 5a5ff22c37 Fix concurrent Cloudflare challenges each opening a WebView
Closes #3858

Co-authored-by: tigeryu8900 <tigeryu8900@gmail.com>
2026-10-13 01:51:35 +06:00
AntsyLich f0f1e4de5a Fix some downloads not being recognized 2026-10-12 22:33:31 +06:00
AntsyLich c371d6331d Ignore long strip taps confirmed after the reader is gone 2026-10-12 22:33:31 +06:00
AntsyLich 2df48e19a1 Fail the install when nothing can confirm it instead of crashing 2026-10-12 22:33:30 +06:00
AntsyLich c9e1b6e96e Show invalid filters from extensions instead of crashing 2026-10-12 22:33:30 +06:00
AntsyLich 3156b4a1c4 Insert split pages only into the chapter they belong to 2026-10-12 22:33:30 +06:00
hazptr ee59394542 Fix tracker start date being shifted by the UTC offset (#4049)
Closes #1340
Closes #4049
2026-10-12 22:33:30 +06:00
AntsyLich 7ae858e602 Solve Cloudflare challenges of non-GET requests on the site's root
Closes #3909
2026-10-12 22:33:30 +06:00
AntsyLich 43de88c718 Update navigationevent to stop back gestures crashing closed screens 2026-10-12 22:33:30 +06:00
AntsyLich ea2b73ad78 Guard the database update against duplicates and damaged indexes 2026-10-12 22:33:30 +06:00
MajorTanya 1ca9b0efb8 Don't send full Doubles to Comick for progress (#4090)
Previously, user could end up with progress displays like

35.019996643066406/100

because Mihon was sending raw Doubles/simply stringified Doubles for
progress data.

We now round to 2 decimal places & trim off any trailing zeroes such
that integers are sent as "1" instead of "1.0" while retaining "25.5"
and "41.02" as such.
2026-10-12 12:27:25 +00:00
AntsyLich 66e736222d Release v0.21.1
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 e9670d8a12)
2026-10-12 14:22:38 +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
55 changed files with 19232 additions and 21475 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.20.4](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.21.2](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.20.4"
Example: "0.21.2"
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.20.4](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.21.2](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
+45 -3
View File
@@ -28,6 +28,46 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Show pages progressively while they download ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4023](https://github.com/mihonapp/mihon/pull/4023))
- Lower memory use for animated images and support cropping borders in long strip ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4029](https://github.com/mihonapp/mihon/pull/4029))
- Pair more wide pages in dual page view by comparing them with neighbouring pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4051](https://github.com/mihonapp/mihon/pull/4051))
### Changed
- Use a new decoder for images Android can't decode itself, like AVIF, JPEG XL and HEIF, and support JPEG 2000 images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
### Improved
- Create and restore backups faster and with less memory ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken), [@AntsyLich](https://github.com/AntsyLich))
### Removed
- Remove "Custom hardware bitmap threshold" and "Custom display profile" advanced settings ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
## [v0.21.2] - 2026-10-13
### Fixed
- Fix long floating point numbers showing up in Comick progress ([@MajorTanya](https://github.com/MajorTanya)) ([#4090](https://github.com/mihonapp/mihon/pull/4090))
- Fixed updating from v0.20.x failing when the database had duplicate history or tracking entries, or damaged indexes ([@AntsyLich](https://github.com/AntsyLich)) ([`ea2b73a`](https://github.com/mihonapp/mihon/commit/ea2b73a))
- Fixed a crash when a back gesture was still in progress as the reader or a dialog closed ([@AntsyLich](https://github.com/AntsyLich)) ([`43de88c`](https://github.com/mihonapp/mihon/commit/43de88c))
- Fixed Kitsu login and other requests that send data failing with "Failed to bypass Cloudflare" ([@AntsyLich](https://github.com/AntsyLich)) ([`7ae858e`](https://github.com/mihonapp/mihon/commit/7ae858e))
- Fixed tracker start date being off by a day when adding tracking to an entry with read history in some timezones ([@hazptr](https://github.com/hazptr)) ([#4049](https://github.com/mihonapp/mihon/pull/4049))
- Fixed the paged reader crashing when moving to another chapter after a wide page of a neighbouring chapter was split ([@AntsyLich](https://github.com/AntsyLich)) ([`3156b4a`](https://github.com/mihonapp/mihon/commit/3156b4a))
- Fixed a crash when opening the filters of a source with an invalid filter ([@AntsyLich](https://github.com/AntsyLich)) ([`c9e1b6e`](https://github.com/mihonapp/mihon/commit/c9e1b6e))
- Fixed a crash when installing an extension on a device where the system installer can't ask for confirmation ([@AntsyLich](https://github.com/AntsyLich)) ([`2df48e1`](https://github.com/mihonapp/mihon/commit/2df48e1))
- Fixed a crash when tapping in the long strip reader as it closed ([@AntsyLich](https://github.com/AntsyLich)) ([`c371d63`](https://github.com/mihonapp/mihon/commit/c371d63))
- Fixed chapters downloaded before v0.21.0 showing as not downloaded when their name started with a space or dot, was long, or had a non-ASCII scanlator ([@AntsyLich](https://github.com/AntsyLich)) ([`f0f1e4d`](https://github.com/mihonapp/mihon/commit/f0f1e4d))
- Fixed slowdowns and running out of memory when many requests to a site hit a Cloudflare challenge at once ([@tigeryu8900](https://github.com/tigeryu8900), [@AntsyLich](https://github.com/AntsyLich)) ([`5a5ff22`](https://github.com/mihonapp/mihon/commit/5a5ff22))
## [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))
- Allow `id:` to search for slugs on Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3792](https://github.com/mihonapp/mihon/pull/3792))
- Add support for using the user's chosen rating system for Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3818](https://github.com/mihonapp/mihon/pull/3818))
@@ -45,7 +85,6 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
### Changed
- Download app updates from the update screen instead of a background job, and remove the "App updates" notification channel ([@AntsyLich](https://github.com/AntsyLich)) ([#3707](https://github.com/mihonapp/mihon/pull/3707))
- Use a new decoder for images Android can't decode itself, like AVIF, JPEG XL and HEIF, and support JPEG 2000 images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
- Switch app icons to Material Symbols ([@AntsyLich](https://github.com/AntsyLich)) ([#3873](https://github.com/mihonapp/mihon/pull/3873))
- Use the filled icon for the library continue reading button ([@AntsyLich](https://github.com/AntsyLich)) ([`34a33ed`](https://github.com/mihonapp/mihon/commit/34a33ed))
- Move notification, launcher shortcut, bottom navigation and reading mode icons to Material Symbols, bring back filled icons where the switch dropped them, and show a compact grid icon for the compact display modes ([@AntsyLich](https://github.com/AntsyLich)) ([#4057](https://github.com/mihonapp/mihon/pull/4057))
@@ -102,7 +141,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Speed up queueing many chapters for download at once ([@AntsyLich](https://github.com/AntsyLich)) ([`f02bf5b`](https://github.com/mihonapp/mihon/commit/f02bf5b))
### Removed
- Remove "Custom hardware bitmap threshold", "Use legacy decoder for long strip reader" and "Custom display profile" advanced settings ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
- Remove "Use legacy decoder for long strip reader" advanced setting, since long strip pages no longer load as one big image ([@AntsyLich](https://github.com/AntsyLich))
### Fixed
- Fixed app and extension update check running again on configuration change ([@AntsyLich](https://github.com/AntsyLich)) ([#3708](https://github.com/mihonapp/mihon/pull/3708))
@@ -773,7 +812,10 @@ 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.20.4...main
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.2...main
[v0.21.2]: https://github.com/mihonapp/mihon/compare/v0.21.1...v0.21.2
[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
[v0.20.2]: https://github.com/mihonapp/mihon/compare/v0.20.1...v0.20.2
+3 -2
View File
@@ -37,8 +37,8 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 35
versionName = "0.20.4"
versionCode = 38
versionName = "0.21.2"
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
@@ -229,6 +229,7 @@ dependencies {
debugImplementation(libs.androidx.compose.uiTooling)
implementation(libs.androidx.compose.uiToolingPreview)
implementation(libs.androidx.compose.uiUtil)
implementation(libs.androidx.navigationEvent)
implementation(libs.androidx.interpolator)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8,10 +8,8 @@ import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@@ -61,10 +59,7 @@ class AddTracks(
.minOrNull()
firstReadChapterDate?.let {
val startDate = firstReadChapterDate.toEpochMilliseconds().convertEpochMillisZone(
TimeZone.currentSystemDefault(),
TimeZone.UTC,
)
val startDate = firstReadChapterDate.toEpochMilliseconds()
track = track.copy(
startDate = Instant.fromEpochMilliseconds(startDate),
)
@@ -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
}
}
}
@@ -4,8 +4,11 @@ import android.content.Context
import android.net.Uri
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.Backup
import kotlinx.serialization.SerializationException
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.protobuf.ProtoBuf
import okio.BufferedSource
import okio.buffer
import okio.gzip
import okio.source
@@ -19,9 +22,38 @@ class BackupDecoder(
private val parser: ProtoBuf,
) {
/**
* Decode a potentially-gzipped backup.
* Decode everything but the entries of a potentially-gzipped backup, counting the entries without decoding them.
*/
fun decode(uri: Uri): Backup {
fun decodeMetadata(uri: Uri): Metadata {
return read(uri) { reader ->
orInvalid {
var entryCount = 0
while (reader.nextEntry()) {
reader.skipEntry()
entryCount++
}
Metadata(entryCount, reader.readMetadata())
}
}
}
/**
* Decode the entries of a potentially-gzipped backup one at a time, skipping those whose index [include] rejects.
*/
fun decodeEntries(uri: Uri, include: (index: Int) -> Boolean = { true }): Flow<BackupManga> = flow {
read(uri) { reader ->
var index = 0
while (orInvalid { reader.nextEntry() }) {
if (include(index++)) {
emit(orInvalid { reader.readEntry() })
} else {
orInvalid { reader.skipEntry() }
}
}
}
}
private inline fun <T> read(uri: Uri, block: (BackupReader) -> T): T {
return context.contentResolver.openInputStream(uri)!!.use { inputStream ->
val source = inputStream.source().buffer()
@@ -29,22 +61,32 @@ class BackupDecoder(
require(2)
}
val id1id2 = peeked.readShort()
val backupString = when (id1id2.toInt()) {
val backupSource: BufferedSource = when (id1id2.toInt()) {
0x1f8b -> source.gzip().buffer() // 0x1f8b is gzip magic bytes
MAGIC_JSON_SIGNATURE1, MAGIC_JSON_SIGNATURE2, MAGIC_JSON_SIGNATURE3 -> {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_json))
}
else -> source
}.use { it.readByteArray() }
try {
parser.decodeFromByteArray(Backup.serializer(), backupString)
} catch (_: SerializationException) {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown))
}
backupSource.use { block(BackupReader(it, parser)) }
}
}
// Only around reading, so the JSON error above and anything thrown downstream of emit keep their own messages
private inline fun <T> orInvalid(block: () -> T): T {
return try {
block()
} catch (e: IOException) {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown), e)
}
}
data class Metadata(
val entryCount: Int,
val backup: Backup,
)
companion object {
private const val MAGIC_JSON_SIGNATURE1 = 0x7b7d // `{}`
private const val MAGIC_JSON_SIGNATURE2 = 0x7b22 // `{"`
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.backup
import android.net.Uri
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.coroutines.CancellationException
import tachiyomi.domain.source.service.SourceManager
@Inject
@@ -18,8 +19,14 @@ class BackupFileValidator(
* @return List of missing sources or missing trackers.
*/
suspend fun validate(uri: Uri): Results {
val trackerIds = mutableSetOf<Int>()
val backup = try {
backupDecoder.decode(uri)
backupDecoder.decodeEntries(uri).collect { manga ->
manga.tracking.mapTo(trackerIds) { it.syncId }
}
backupDecoder.decodeMetadata(uri).backup
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException(e)
}
@@ -38,11 +45,7 @@ class BackupFileValidator(
.distinct()
.sorted()
val trackers = backup.backupManga
.flatMap { it.tracking }
.map { it.syncId }
.distinct()
val missingTrackers = trackers
val missingTrackers = trackerIds
.mapNotNull { trackerManager.get(it.toLong()) }
.filter { !it.isLoggedIn }
.map { it.name }
@@ -0,0 +1,153 @@
package eu.kanade.tachiyomi.data.backup
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.SerializationException
import kotlinx.serialization.protobuf.ProtoBuf
import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource
import java.io.IOException
/**
* Writes the same bytes as encoding a whole [Backup], without holding all of its entries at once.
*/
class BackupWriter(
private val sink: BufferedSink,
private val parser: ProtoBuf,
) {
var isEmpty = true
private set
fun writeEntry(entry: BackupManga) {
val bytes = parser.encodeToByteArray(BackupManga.serializer(), entry)
sink.writeVarint(ENTRY_TAG)
sink.writeVarint(bytes.size.toLong())
sink.write(bytes)
isEmpty = false
}
/**
* Writes the other fields after the entries, as [metadata] without entries of its own.
*/
fun writeMetadata(metadata: Backup) {
val bytes = parser.encodeToByteArray(Backup.serializer(), metadata)
sink.write(bytes)
if (bytes.isNotEmpty()) isEmpty = false
}
}
/**
* Reads an encoded [Backup] one entry at a time. Every other field is set aside for [readMetadata], wherever it
* appears.
*
* Malformed or truncated input throws an [IOException].
*/
class BackupReader(
private val source: BufferedSource,
private val parser: ProtoBuf,
) {
private val metadata = Buffer()
private var entryLength = NO_ENTRY
/**
* Moves to the next entry, which [readEntry] or [skipEntry] then consumes.
*
* @return false once the input is exhausted.
*/
fun nextEntry(): Boolean {
check(entryLength == NO_ENTRY)
while (!source.exhausted()) {
val tag = source.readVarint()
val fieldNumber = tag ushr 3
val wireType = (tag and 0x7).toInt()
if (fieldNumber == 0L) throw IOException("Invalid field number 0")
if (tag == ENTRY_TAG) {
entryLength = source.readLength()
return true
}
metadata.writeVarint(tag)
when (wireType) {
WIRE_TYPE_VARINT -> metadata.writeVarint(source.readVarint())
WIRE_TYPE_FIXED64 -> metadata.write(source, 8)
WIRE_TYPE_LENGTH_DELIMITED -> {
val length = source.readLength()
metadata.writeVarint(length)
metadata.write(source, length)
}
WIRE_TYPE_FIXED32 -> metadata.write(source, 4)
else -> throw IOException("Unsupported wire type $wireType")
}
}
return false
}
fun readEntry(): BackupManga {
val bytes = source.readByteArray(takeEntryLength())
return decode(BackupManga.serializer(), bytes)
}
fun skipEntry() {
source.skip(takeEntryLength())
}
/**
* Skips the remaining entries and decodes everything else as a [Backup] without entries.
*/
fun readMetadata(): Backup {
if (entryLength != NO_ENTRY) skipEntry()
while (nextEntry()) skipEntry()
return decode(Backup.serializer(), metadata.readByteArray())
}
private fun takeEntryLength(): Long {
check(entryLength != NO_ENTRY)
return entryLength.also { entryLength = NO_ENTRY }
}
private fun <T> decode(deserializer: DeserializationStrategy<T>, bytes: ByteArray): T {
return try {
parser.decodeFromByteArray(deserializer, bytes)
} catch (e: SerializationException) {
throw IOException(e)
}
}
}
private fun BufferedSource.readVarint(): Long {
var result = 0L
for (shift in 0 until Long.SIZE_BITS step 7) {
val byte = readByte().toInt()
result = result or ((byte and 0x7F).toLong() shl shift)
if ((byte and 0x80) == 0) return result
}
throw IOException("Malformed varint")
}
private fun BufferedSource.readLength(): Long {
val length = readVarint()
if (length !in 0..Int.MAX_VALUE) throw IOException("Invalid length $length")
return length
}
private fun BufferedSink.writeVarint(value: Long) {
var remaining = value
while ((remaining and 0x7FL.inv()) != 0L) {
writeByte((remaining and 0x7F).toInt() or 0x80)
remaining = remaining ushr 7
}
writeByte(remaining.toInt())
}
private const val WIRE_TYPE_VARINT = 0
private const val WIRE_TYPE_FIXED64 = 1
private const val WIRE_TYPE_LENGTH_DELIMITED = 2
private const val WIRE_TYPE_FIXED32 = 5
// Backup.backupManga is field 1, and each entry is length-delimited
private const val ENTRY_TAG = 1L shl 3 or WIRE_TYPE_LENGTH_DELIMITED.toLong()
private const val NO_ENTRY = -1L
@@ -8,6 +8,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.BackupWriter
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
@@ -20,6 +21,8 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
import okio.buffer
@@ -85,27 +88,31 @@ class BackupCreator(
val nonFavoriteManga = if (options.readEntries) mangaRepository.getReadMangaNotInLibrary() else emptyList()
val backupManga = backupMangas(getFavorites.await() + nonFavoriteManga, options)
val backup = Backup(
backupManga = backupManga,
backupCategories = backupCategories(options),
backupSources = backupSources(backupManga),
backupPreferences = backupAppPreferences(options),
backupExtensionStores = backupExtensionStores(options),
backupSourcePreferences = backupSourcePreferences(options),
)
val byteArray = parser.encodeToByteArray(Backup.serializer(), backup)
if (byteArray.isEmpty()) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
}
file.openOutputStream()
.also {
// Force overwrite old file
(it as? FileOutputStream)?.channel?.truncate(0)
}
.sink().gzip().buffer().use {
it.write(byteArray)
val writer = BackupWriter(it, parser)
val sourceIds = mutableSetOf<Long>()
backupManga.collect { manga ->
writer.writeEntry(manga)
sourceIds += manga.source
}
writer.writeMetadata(
Backup(
backupManga = emptyList(),
backupCategories = backupCategories(options),
backupSources = backupSources(sourceIds),
backupPreferences = backupAppPreferences(options),
backupExtensionStores = backupExtensionStores(options),
backupSourcePreferences = backupSourcePreferences(options),
),
)
if (writer.isEmpty) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
}
}
val fileUri = file.uri
@@ -130,14 +137,14 @@ class BackupCreator(
return categoriesBackupCreator()
}
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
if (!options.libraryEntries) return emptyList()
private fun backupMangas(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> {
if (!options.libraryEntries) return emptyFlow()
return mangaBackupCreator(mangas, options)
}
private suspend fun backupSources(mangas: List<BackupManga>): List<BackupSource> {
return sourcesBackupCreator(mangas)
private suspend fun backupSources(sourceIds: Set<Long>): List<BackupSource> {
return sourcesBackupCreator(sourceIds)
}
private fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
@@ -7,11 +7,13 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.toBackupChapter
import eu.kanade.tachiyomi.data.backup.models.toBackupTracking
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import mihon.core.common.extensions.toByteArray
import mihon.core.common.extensions.toEpochMillisOrZero
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.repository.CategoryRepository
import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.repository.HistoryRepository
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.track.repository.TrackRepository
@@ -20,62 +22,71 @@ import tachiyomi.domain.track.repository.TrackRepository
class MangaBackupCreator(
private val mangaRepository: MangaRepository,
private val chapterRepository: ChapterRepository,
private val categoryRepository: CategoryRepository,
private val historyRepository: HistoryRepository,
private val trackRepository: TrackRepository,
private val getCategories: GetCategories,
private val getHistory: GetHistory,
) {
suspend operator fun invoke(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
return mangas.map {
backupManga(it, options)
/**
* Builds the entries a chunk at a time, so only one chunk's chapters are held at once.
*/
operator fun invoke(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> = flow {
mangas.chunked(CHUNK_SIZE).forEach { chunk ->
backupMangas(chunk, options).forEach { emit(it) }
}
}
private suspend fun backupManga(manga: Manga, options: BackupOptions): BackupManga {
// Entry for this manga
val mangaObject = manga.toBackupManga()
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
val mangaIds = mangas.map { it.id }
mangaObject.excludedScanlators = mangaRepository.getExcludedScanlators(manga.id).toList()
if (options.chapters) {
// Backup all the chapters
chapterRepository.getChapterByMangaId(manga.id, applyScanlatorFilter = false)
.map { it.toBackupChapter() }
.takeUnless { it.isEmpty() }
?.let { mangaObject.chapters = it }
val excludedScanlatorsByMangaId = mangaRepository.getExcludedScanlatorsByMangaIds(mangaIds)
// History is backed up by chapter URL, so it needs the chapters too
val chaptersByMangaId = if (options.chapters || options.history) {
chapterRepository.getChaptersByMangaIds(mangaIds)
} else {
emptyMap()
}
val categoriesByMangaId = if (options.categories) {
categoryRepository.getCategoriesByMangaIds(mangaIds)
} else {
emptyMap()
}
val tracksByMangaId = if (options.tracking) trackRepository.getTracksByMangaIds(mangaIds) else emptyMap()
val historyByMangaId = if (options.history) historyRepository.getHistoryByMangaIds(mangaIds) else emptyMap()
if (options.categories) {
// Backup categories for this manga
val categoriesForManga = getCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) {
mangaObject.categories = categoriesForManga.map { it.order }
return mangas.map { manga ->
val mangaObject = manga.toBackupManga()
val chapters = chaptersByMangaId[manga.id].orEmpty()
mangaObject.excludedScanlators = excludedScanlatorsByMangaId[manga.id].orEmpty()
if (options.chapters) {
mangaObject.chapters = chapters.map { it.toBackupChapter() }
}
}
if (options.tracking) {
val tracks = trackRepository.getTracksByMangaId(manga.id).map { it.toBackupTracking() }
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
if (options.categories) {
mangaObject.categories = categoriesByMangaId[manga.id].orEmpty().map { it.order }
}
}
if (options.history) {
val historyByMangaId = getHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) {
val chapterUrlsById = chapterRepository.getChapterByMangaId(manga.id).associate { it.id to it.url }
val history = historyByMangaId.mapNotNull { history ->
if (options.tracking) {
mangaObject.tracking = tracksByMangaId[manga.id].orEmpty().map { it.toBackupTracking() }
}
if (options.history) {
val chapterUrlsById = chapters.associate { it.id to it.url }
mangaObject.history = historyByMangaId[manga.id].orEmpty().mapNotNull { history ->
// A chapter removed since its history was read takes that history with it
val url = chapterUrlsById[history.chapterId] ?: return@mapNotNull null
BackupHistory(url, history.readAt.toEpochMillisOrZero(), history.readDuration)
}
if (history.isNotEmpty()) {
mangaObject.history = history
}
}
}
return mangaObject
mangaObject
}
}
private companion object {
const val CHUNK_SIZE = 100
}
}
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.source.Source
import tachiyomi.domain.source.service.SourceManager
@@ -11,11 +10,8 @@ class SourcesBackupCreator(
private val sourceManager: SourceManager,
) {
suspend operator fun invoke(mangas: List<BackupManga>): List<BackupSource> {
return mangas
.map(BackupManga::source)
.distinct()
.map { sourceManager.getOrStub(it).toBackupSource() }
suspend operator fun invoke(sourceIds: Set<Long>): List<BackupSource> {
return sourceIds.map { sourceManager.getOrStub(it).toBackupSource() }
}
}
@@ -9,7 +9,6 @@ import eu.kanade.tachiyomi.data.backup.BackupDecoder
import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
@@ -23,6 +22,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.chunked
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource
@@ -32,6 +37,7 @@ import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import java.io.File
import java.text.SimpleDateFormat
import java.util.BitSet
import java.util.Date
import java.util.Locale
import java.util.concurrent.CopyOnWriteArrayList
@@ -97,7 +103,7 @@ class BackupRestorer(
}
private suspend fun restoreFromFile(uri: Uri, options: RestoreOptions) {
val backup = backupDecoder.decode(uri)
val (entryCount, backup) = backupDecoder.decodeMetadata(uri)
// Store source mapping for error messages
val backupMaps = backup.backupSources
@@ -105,7 +111,7 @@ class BackupRestorer(
if (options.libraryEntries) {
restoreSourceNames(backupMaps)
restoreAmount += backup.backupManga.size
restoreAmount += entryCount
}
if (options.categories) {
restoreAmount += 1
@@ -138,7 +144,7 @@ class BackupRestorer(
}
if (options.libraryEntries) {
restoreManga(
backup.backupManga,
uri,
if (options.categories) backup.backupCategories else emptyList(),
restoreCategoriesJob,
)
@@ -175,14 +181,31 @@ class BackupRestorer(
}
private fun CoroutineScope.restoreManga(
backupMangas: List<BackupManga>,
uri: Uri,
backupCategories: List<BackupCategory>,
categoriesRestoreJob: Job?,
) = launch {
categoriesRestoreJob?.join()
mangaRestorer.sortByNew(backupMangas)
// New entries go first. Rather than hold the others back in memory, a second pass reads them again.
val isExisting = mangaRestorer.getExistingMatcher()
val existingIndices = BitSet()
val newMangas = backupDecoder.decodeEntries(uri)
.withIndex()
.filter { (index, manga) ->
val exists = isExisting(manga)
if (exists) existingIndices.set(index)
!exists
}
.map { it.value }
val backupMangas = flow {
emitAll(newMangas)
emitAll(backupDecoder.decodeEntries(uri, existingIndices::get))
}
backupMangas
.chunked(100)
.forEach { chunk ->
.collect { chunk ->
val restoredAsBatch = try {
ensureActive()
mangaRestorer.restore(chunk, backupCategories)
@@ -23,11 +23,13 @@ class MangaRestorer(
private val now = Clock.System.now().toLocalDateTime(timeZone)
private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone)
suspend fun sortByNew(backupMangas: List<BackupManga>): List<BackupManga> {
/**
* Returns whether an entry was already in the database at the time of this call.
*/
suspend fun getExistingMatcher(): (BackupManga) -> Boolean {
val urlsBySource = restoreRepository.getMangaUrlsBySourceId()
return backupMangas
.sortedBy { it.url in urlsBySource[it.source].orEmpty() }
return { it.url in urlsBySource[it.source].orEmpty() }
}
/**
@@ -231,7 +231,7 @@ class DownloadProvider(
enableChapterNameHash: Boolean = libraryPreferences.enableChapterNameHash.get(),
): String {
return ChapterDirNameParts(chapterName, chapterScanlator, chapterUrl)
.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
.dirName(disallowNonAsciiFilenames, enableChapterNameHash)
}
/**
@@ -254,14 +254,18 @@ class DownloadProvider(
val booleanPairPermutation = listOf(false to false, false to true, true to false, true to true)
val othersChapterDirNames = booleanPairPermutation
.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash)
}
val splitChapterDirNames = booleanPairPermutation
.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
}
// Scanlators weren't sanitized for a while. One with a "/" nested its folder, so it was never found anyway.
val scanlator = parts.chapterScanlator
val unsanitizedScanlatorChapterDirNames = if (scanlator != null && '/' !in scanlator) {
booleanPairPermutation.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
}
} else {
emptyList()
@@ -272,6 +276,7 @@ class DownloadProvider(
// chapter names)
add(chapterNameV1)
addAll(othersChapterDirNames)
addAll(splitChapterDirNames)
addAll(unsanitizedScanlatorChapterDirNames)
}
}
@@ -291,7 +296,6 @@ class DownloadProvider(
val chapterDirName = parts.dirName(
disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames.get(),
enableHash = libraryPreferences.enableChapterNameHash.get(),
sanitizeScanlator = true,
)
val legacyChapterDirNames = getLegacyChapterDirNames(parts)
@@ -330,9 +334,29 @@ private class ChapterDirNameParts(chapterName: String, val chapterScanlator: Str
chapterScanlator?.let { DiskUtil.buildValidFilename(it) }
}
private val validDirNames = arrayOfNulls<String>(2)
private val validChapterNames = arrayOfNulls<String>(2)
fun dirName(disallowNonAscii: Boolean, enableHash: Boolean, sanitizeScanlator: Boolean): String {
fun dirName(disallowNonAscii: Boolean, enableHash: Boolean): String {
val index = if (disallowNonAscii) 1 else 0
val dirName = validDirNames[index]
// The scanlator and the name are made valid together, as every version before 0.21.0 did
?: DiskUtil.buildValidFilename(
if (chapterScanlator.isNullOrBlank()) chapterName else "${chapterScanlator}_$chapterName",
// Subtract 7 bytes for hash and underscore, 4 bytes for .cbz
DiskUtil.MAX_FILE_NAME_BYTES - 11,
disallowNonAscii,
)
.also { validDirNames[index] = it }
return if (enableHash) dirName + hashSuffix else dirName
}
/**
* The name 0.21.0 and 0.21.1 gave a chapter, which made only the chapter name valid and so differs for names
* starting with a space or dot, long names, and non-ASCII scanlators.
*/
fun splitDirName(disallowNonAscii: Boolean, enableHash: Boolean, sanitizeScanlator: Boolean): String {
return buildString {
if (!chapterScanlator.isNullOrBlank()) {
// A "/" in the scanlator would otherwise nest the chapter in a folder of its own
@@ -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,
)
@@ -33,6 +33,7 @@ import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import uy.kohesive.injekt.injectLazy
import java.math.RoundingMode
import java.security.SecureRandom
import java.util.Base64
import kotlin.time.Duration.Companion.minutes
@@ -77,7 +78,14 @@ class ComickApi(
put("status", track.status.toApiListStatus())
if (track.last_chapter_read > 0) {
putJsonObject("progress") {
put("number", track.last_chapter_read)
put(
"number",
track.last_chapter_read
.toBigDecimal()
.setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString(),
)
}
} else {
put("progress", null)
@@ -140,7 +148,14 @@ class ComickApi(
put("rating", track.score.toInt().takeIf { it > 0 })
put("status", track.status.toApiListStatus())
putJsonObject("progress") {
put("number", track.last_chapter_read.toString())
put(
"number",
track.last_chapter_read
.toBigDecimal()
.setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString(),
)
}
}
.toString()
@@ -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) {
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension.installer
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -57,7 +58,16 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
return
}
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
service.startActivity(userAction)
try {
service.startActivity(userAction)
} catch (e: ActivityNotFoundException) {
// Some devices have the system installer's confirmation screen disabled or removed
logcat(LogPriority.WARN, e) { "Nothing can confirm the install" }
activeSession?.let { (_, sessionId) ->
runCatching { packageInstaller.abandonSession(sessionId) }
}
continueQueue(InstallStep.Error)
}
}
PackageInstaller.STATUS_FAILURE_ABORTED -> {
continueQueue(InstallStep.Idle)
@@ -169,7 +169,7 @@ private fun Int.toTriStateFilter(): TriState {
Filter.TriState.STATE_IGNORE -> TriState.DISABLED
Filter.TriState.STATE_INCLUDE -> TriState.ENABLED_IS
Filter.TriState.STATE_EXCLUDE -> TriState.ENABLED_NOT
else -> throw IllegalStateException("Unknown TriState state: $this")
else -> TriState.DISABLED
}
}
@@ -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) {
@@ -26,7 +26,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
/**
* Holds preprocessed items so they don't get removed when changing chapter
*/
private var preprocessed: MutableMap<Int, InsertPage> = mutableMapOf()
private var preprocessed: List<InsertPage> = emptyList()
var nextTransition: ChapterTransition.Next? = null
private set
@@ -68,13 +68,15 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
val lastPage = pages.last()
// Insert preprocessed pages into current page list
preprocessed.keys.sortedDescending()
.forEach { key ->
if (lastPage.index == key) {
insertPageLastPage = preprocessed[key]
// Insert preprocessed pages into current page list. Pages of the adjacent chapters are split too, and
// placed by their index, so only this chapter's belong here.
preprocessed.filter { it.chapter.chapter.id == chapters.currChapter.chapter.id }
.sortedByDescending { it.index }
.forEach { page ->
if (lastPage.index == page.index) {
insertPageLastPage = page
}
preprocessed[key]?.let { pages.add(key + 1, it) }
pages.add(page.index + 1, page)
}
newItems.addAll(pages)
@@ -96,6 +98,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) }
@@ -103,10 +107,19 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
newItems.reverse()
}
preprocessed = mutableMapOf()
val adjacentChapterIds = setOfNotNull(chapters.prevChapter?.chapter?.id, chapters.nextChapter?.chapter?.id)
preprocessed = preprocessed.filter { it.chapter.chapter.id in adjacentChapterIds }
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)
@@ -151,9 +164,12 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
val currentIndex = items.indexOf(currentPage)
// Put aside preprocessed pages for next chapter so they don't get removed when changing chapter
// Put aside pages of an adjacent chapter until it becomes the current one, so they are not lost on the change
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
preprocessed[newPage.index] = newPage
val chapterId = newPage.chapter.chapter.id
if (preprocessed.none { it.chapter.chapter.id == chapterId && it.index == newPage.index }) {
preprocessed = preprocessed + newPage
}
return
}
@@ -243,7 +243,9 @@ class WebtoonRecyclerView @JvmOverloads constructor(
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
if (!tapDuringManualScroll) {
// A tap is confirmed after a delay, by when the reader can be gone, and scrolling a detached list
// lays out pages that can't compose
if (!tapDuringManualScroll && isAttachedToWindow) {
tapListener?.invoke(ev)
}
return false
@@ -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,
),
)
@@ -0,0 +1,202 @@
package eu.kanade.tachiyomi.data.backup
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue
import io.kotest.matchers.shouldBe
import kotlinx.serialization.protobuf.ProtoBuf
import okio.Buffer
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode
import java.io.IOException
@Execution(ExecutionMode.CONCURRENT)
class BackupStreamTest {
private val parser = ProtoBuf
@Test
fun `writer output is the plain encoding`() {
val backup = backup()
val bytes = write(backup)
bytes shouldBe encode(backup)
encode(parser.decodeFromByteArray(Backup.serializer(), bytes)) shouldBe encode(backup)
}
@Test
fun `reader reads the plain encoding`() {
val backup = backup()
val (entries, metadata) = read(encode(backup))
entries.map(::encode) shouldBe backup.backupManga.map(::encode)
encode(metadata) shouldBe encode(backup.copy(backupManga = emptyList()))
}
@Test
fun `reader counts entries without decoding them`() {
val backup = backup()
val reader = BackupReader(Buffer().write(encode(backup)), parser)
var count = 0
while (reader.nextEntry()) {
reader.skipEntry()
count++
}
count shouldBe backup.backupManga.size
encode(reader.readMetadata()) shouldBe encode(backup.copy(backupManga = emptyList()))
}
@Test
fun `reader takes fields in any order`() {
val backup = backup()
val (first, second, third) = backup.backupManga
val input = Buffer()
.write(encode(Backup(emptyList(), backupSources = backup.backupSources)))
.write(encode(Backup(listOf(first))))
// Fields Backup doesn't know, one of each remaining wire type
.writeByte(3 shl 3 or 0).writeByte(0x96).writeByte(0x01)
.writeByte(4 shl 3 or 1).writeLongLe(42)
.write(encode(Backup(listOf(second), backupCategories = backup.backupCategories)))
.writeByte(5 shl 3 or 5).writeIntLe(42)
.write(encode(Backup(emptyList(), backupPreferences = backup.backupPreferences)))
.write(encode(Backup(listOf(third), backupExtensionStores = backup.backupExtensionStores)))
.write(encode(Backup(emptyList(), backupSourcePreferences = backup.backupSourcePreferences)))
.readByteArray()
val (entries, metadata) = read(input)
entries.map(::encode) shouldBe backup.backupManga.map(::encode)
encode(metadata) shouldBe encode(backup.copy(backupManga = emptyList()))
}
@Test
fun `empty backup`() {
val sink = Buffer()
val writer = BackupWriter(sink, parser)
writer.writeMetadata(Backup(emptyList()))
writer.isEmpty shouldBe true
sink.size shouldBe 0L
val (entries, metadata) = read(ByteArray(0))
entries shouldBe emptyList()
encode(metadata) shouldBe ByteArray(0)
}
@Test
fun `truncated input throws`() {
val bytes = encode(backup())
// Cut inside the last entry, inside the metadata, and right after a tag
val entryEnd = encode(Backup(backup().backupManga)).size
listOf(entryEnd - 1, bytes.size - 1, entryEnd + 1).forEach { size ->
assertThrows<IOException> { read(bytes.copyOf(size)) }
}
}
@Test
fun `garbage input throws`() {
listOf(
// Wire type 7 doesn't exist
byteArrayOf(0x0F),
// Field number 0
byteArrayOf(0x02, 0x00),
// A varint that never ends
ByteArray(11) { 0xFF.toByte() },
// An entry longer than the input
byteArrayOf(0x0A, 0x10, 0x01),
// An entry that isn't a BackupManga
byteArrayOf(0x0A, 0x02, 0x0F, 0x00),
).forEach { input ->
assertThrows<IOException> { read(input) }
}
}
private fun write(backup: Backup): ByteArray {
val sink = Buffer()
val writer = BackupWriter(sink, parser)
backup.backupManga.forEach(writer::writeEntry)
writer.writeMetadata(backup.copy(backupManga = emptyList()))
writer.isEmpty shouldBe false
return sink.readByteArray()
}
private fun read(bytes: ByteArray): Pair<List<BackupManga>, Backup> {
val reader = BackupReader(Buffer().write(bytes), parser)
val entries = buildList {
while (reader.nextEntry()) add(reader.readEntry())
}
return entries to reader.readMetadata()
}
private fun encode(backup: Backup) = parser.encodeToByteArray(Backup.serializer(), backup)
private fun encode(manga: BackupManga) = parser.encodeToByteArray(BackupManga.serializer(), manga)
private fun backup() = Backup(
backupManga = listOf(
BackupManga(
source = 1L,
url = "/manga/1",
title = "First",
genre = listOf("Action", "Drama"),
chapters = listOf(
BackupChapter(url = "/chapter/1", name = "Chapter 1", read = true, chapterNumber = 1f),
BackupChapter(url = "/chapter/2", name = "Chapter 2", bookmark = true, lastPageRead = 4),
),
categories = listOf(0L, 1L),
tracking = listOf(BackupTracking(syncId = 2, libraryId = 3L, title = "First", mediaId = 7L)),
history = listOf(BackupHistory(url = "/chapter/1", lastRead = 1_700_000_000_000L, readDuration = 60)),
excludedScanlators = listOf("Group"),
),
BackupManga(source = 2L, url = "/manga/2", title = "Second", favorite = false),
BackupManga(
source = 1L,
url = "/manga/3",
title = "Third",
description = "x".repeat(300),
chapters = List(200) { BackupChapter(url = "/chapter/3/$it", name = "Chapter $it") },
),
),
backupCategories = listOf(
BackupCategory(name = "Reading", order = 0),
BackupCategory(name = "Done", order = 1),
),
backupSources = listOf(BackupSource(name = "One", sourceId = 1L), BackupSource(name = "Two", sourceId = 2L)),
backupPreferences = listOf(
BackupPreference("flag", BooleanPreferenceValue(true)),
BackupPreference("tags", StringSetPreferenceValue(setOf("a", "b"))),
),
backupSourcePreferences = listOf(
BackupSourcePreferences("source_1", listOf(BackupPreference("lang", StringPreferenceValue("en")))),
),
backupExtensionStores = listOf(
BackupExtensionStore(
indexUrl = "https://example.org/index.min.json",
name = "Store",
badgeLabel = null,
signingKey = "key",
contactWebsite = "https://example.org",
contactDiscord = null,
isLegacy = false,
extensionListUrl = null,
),
),
)
}
@@ -0,0 +1,73 @@
package eu.kanade.tachiyomi.data.download
import eu.kanade.tachiyomi.util.lang.Hash.md5
import eu.kanade.tachiyomi.util.storage.DiskUtil
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import tachiyomi.core.common.preference.InMemoryPreferenceStore
import tachiyomi.domain.library.service.LibraryPreferences
class DownloadProviderTest {
private fun provider(disallowNonAscii: Boolean): DownloadProvider {
val preferences = LibraryPreferences(InMemoryPreferenceStore())
preferences.disallowNonAsciiFilenames.set(disallowNonAscii)
preferences.enableChapterNameHash.set(true)
return DownloadProvider(mockk(relaxed = true), mockk(relaxed = true), preferences)
}
private fun dirNameBefore0210(name: String, scanlator: String?, url: String, disallowNonAscii: Boolean): String {
val dirName = if (scanlator.isNullOrBlank()) name else "${scanlator}_$name"
return DiskUtil.buildValidFilename(dirName, DiskUtil.MAX_FILE_NAME_BYTES - 11, disallowNonAscii) +
"_" + md5(url).take(6)
}
private fun dirName0210(name: String, scanlator: String?, url: String, disallowNonAscii: Boolean): String {
return DiskUtil.buildValidFilename(scanlator!!) + "_" +
DiskUtil.buildValidFilename(name, DiskUtil.MAX_FILE_NAME_BYTES - 11, disallowNonAscii) +
"_" + md5(url).take(6)
}
@ParameterizedTest
@MethodSource("chapters")
fun `finds chapters downloaded before 0_21_0 and names new ones the same`(
name: String,
scanlator: String?,
disallowNonAscii: Boolean,
) {
val provider = provider(disallowNonAscii)
val expected = dirNameBefore0210(name, scanlator, URL, disallowNonAscii)
provider.getValidChapterDirNames(name, scanlator, URL) shouldContain expected
provider.getChapterDirName(name, scanlator, URL) shouldBe expected
}
@ParameterizedTest
@MethodSource("chapters")
fun `finds chapters downloaded by 0_21_0 and 0_21_1`(name: String, scanlator: String?, disallowNonAscii: Boolean) {
if (scanlator.isNullOrBlank()) return
provider(disallowNonAscii).getValidChapterDirNames(name, scanlator, URL) shouldContain
dirName0210(name, scanlator, URL, disallowNonAscii)
}
companion object {
private const val URL = "/chapter/1"
@JvmStatic
fun chapters() = listOf(
Arguments.of("Chapter 1", "Group", false),
Arguments.of(" Chapter 1", "Group", false),
Arguments.of(".Chapter 1", "Group", false),
Arguments.of("Chapter 1", "Group ", false),
Arguments.of("Chapter 1", "A/B ", false),
Arguments.of("Chapter ".repeat(40), "Group", false),
Arguments.of("Chapter 1", "翻訳グループ", true),
Arguments.of(" Chapter 1", null, false),
)
}
}
@@ -3,25 +3,34 @@ 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
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.webkit.ScriptHandler
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
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 okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import logcat.LogPriority
import okhttp3.HttpUrl
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.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
class CloudflareInterceptor(
private val context: Context,
@@ -32,6 +41,18 @@ class CloudflareInterceptor(
private val executor = ContextCompat.getMainExecutor(context)
// Requests to a host share its lock while a challenge is solved under it alone, so concurrent requests wait for
// one WebView instead of each opening their own
private val hostLocks = ConcurrentHashMap<String, ReentrantReadWriteLock>()
private val listenerScript = """
addEventListener("message", ({data}) => {
if (data?.source === "cloudflare-challenge") {
mihon?.postMessage(data.event);
}
})
""".trimIndent()
override fun shouldIntercept(response: Response): Boolean {
// Check if Cloudflare anti-bot is on
// Checking the cf-mitigated header is the official way to detect a Cloudflare challenge:
@@ -39,19 +60,50 @@ class CloudflareInterceptor(
return response.header("cf-mitigated") == "challenge" && response.header("Server") in SERVER_CHECK
}
override fun intercept(
chain: Interceptor.Chain,
request: Request,
response: Response,
): Response {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val lock = hostLocks.computeIfAbsent(request.url.host) { ReentrantReadWriteLock() }
val (response, clearance) = lock.read {
val response = chain.proceed(request)
if (!shouldIntercept(response)) return response
response to clearance(request.url)
}
// write releases the read hold of a request made from within another to the same host, like an extension
// refreshing a token, which would otherwise wait on itself
lock.write {
// Another request may have solved the challenge while this one waited
if (isCleared(request.url, clearance)) {
response.close()
} else {
if (!prepareWebView()) return response
solve(request, response, clearance)
}
}
return chain.proceed(request)
}
override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response {
solve(request, response, clearance(request.url))
return chain.proceed(request)
}
private fun clearance(url: HttpUrl): String? {
return cookieManager.get(url).firstOrNull { it.name == "cf_clearance" }?.value
}
private fun isCleared(url: HttpUrl, previousClearance: String?): Boolean {
val clearance = clearance(url)
return !clearance.isNullOrBlank() && clearance != previousClearance
}
private fun solve(request: Request, response: Response, clearance: String?) {
try {
response.close()
cookieManager.remove(request.url, COOKIE_NAMES, 0)
val oldCookie = cookieManager.get(request.url)
.firstOrNull { it.name == "cf_clearance" }
resolveWithWebView(request, oldCookie)
return chain.proceed(request)
resolveWithWebView(request, clearance)
}
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
@@ -63,64 +115,84 @@ class CloudflareInterceptor(
}
@SuppressLint("SetJavaScriptEnabled")
private fun resolveWithWebView(originalRequest: Request, oldCookie: Cookie?) {
private fun resolveWithWebView(originalRequest: Request, originalClearance: String?) {
// We need to lock this thread until the WebView finds the challenge solution url, because
// OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
var webview: WebView? = null
var listenerScriptHandler: ScriptHandler? = null
val useIsolatedWorld = WebViewFeature.isFeatureSupported(WebViewFeature.JS_INJECTION_IN_FRAME_AND_WORLD)
var challengeFound = false
var cloudflareBypassed = false
var challengeFailed = false
var isWebViewOutdated = false
var isRenderProcessGone = false
val origRequestUrl = originalRequest.url.toString()
// A WebView can only load a page with GET, and loading the url of another method usually reaches an
// endpoint that answers GET without a challenge to solve. The clearance covers the whole site, so it's
// earned on the site's root page instead.
val challengeUrl = if (originalRequest.method == "GET") {
origRequestUrl
} else {
originalRequest.url.newBuilder().encodedPath("/").query(null).fragment(null).build().toString()
}
val headers = parseHeaders(originalRequest.headers)
executor.execute {
webview = createWebView(originalRequest)
webview.addJavascriptInterface(
object {
@Suppress("unused")
@JavascriptInterface
fun interactiveDetected() {
// The challenge cannot be solved non-interactively, abort.
fun onChallengeEvent(event: String) {
when (event) {
// The challenge cannot be solved non-interactively, abort.
"interactiveBegin" -> latch.countDown()
"fail" -> {
challengeFailed = true
latch.countDown()
}
},
"mihon",
)
}
}
if (useIsolatedWorld) {
// An isolated world keeps the bridge out of the page's reach
val world = WebViewCompat.getExecutionWorld(webview, "mihon")
val origins = setOf(originalRequest.url.origin())
WebViewCompat.addWebMessageListener(webview, "mihon", origins, world) { _, message, _, isMainFrame, _ ->
if (isMainFrame) message.data?.let(::onChallengeEvent)
}
listenerScriptHandler = WebViewCompat.addJavaScriptOnEvent(
webview,
listenerScript,
WebViewCompat.INJECTION_EVENT_DOCUMENT_START,
origins,
world,
)
} else {
webview.addJavascriptInterface(
object {
@Suppress("unused")
@JavascriptInterface
fun postMessage(event: String) = onChallengeEvent(event)
},
"mihon",
)
}
webview.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
fun isCloudFlareBypassed(): Boolean {
return cookieManager.get(origRequestUrl.toHttpUrl())
.firstOrNull { it.name == "cf_clearance" }
.let { it != null && it != oldCookie }
}
if (isCloudFlareBypassed()) {
if (!challengeFailed && isCleared(originalRequest.url, originalClearance)) {
cloudflareBypassed = true
latch.countDown()
}
if (url == origRequestUrl) {
if (url == challengeUrl) {
if (!challengeFound) {
// The first request didn't return the challenge, abort.
latch.countDown()
} else {
// Listen for an interactiveBegin event
view.evaluateJavascript(
"""
addEventListener("message", ({data}) => {
if (data?.source === "cloudflare-challenge" && data?.event === "interactiveBegin") {
mihon.interactiveDetected();
}
})
""".trimIndent(),
null,
)
} else if (!useIsolatedWorld) {
view.evaluateJavascript(listenerScript, null)
}
}
}
@@ -140,26 +212,52 @@ 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)
webview.loadUrl(challengeUrl, headers)
}
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) {
if (useIsolatedWorld) {
WebViewCompat.removeWebMessageListener(
this,
WebViewCompat.getExecutionWorld(this, "mihon"),
"mihon",
)
} else {
removeJavascriptInterface("mihon")
}
listenerScriptHandler?.remove()
stopLoading()
}
destroy()
}
}
// Throw exception if we failed to bypass Cloudflare
if (!cloudflareBypassed) {
// A clearance from a failed challenge would only be rejected again
cookieManager.remove(originalRequest.url, COOKIE_NAMES, 0)
// Prompt user to update WebView if it seems too outdated
if (isWebViewOutdated) {
context.toast(MR.strings.information_webview_outdated, Toast.LENGTH_LONG)
@@ -170,6 +268,11 @@ class CloudflareInterceptor(
}
}
// An origin rule without a port only matches the scheme's default one
private fun HttpUrl.origin(): String {
return if (port == HttpUrl.defaultPort(scheme)) "$scheme://$host" else "$scheme://$host:$port"
}
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
private val COOKIE_NAMES = listOf("cf_clearance")
@@ -59,15 +59,23 @@ abstract class WebViewInterceptor(
return response
}
if (!prepareWebView()) return response
return intercept(chain, request, response)
}
/**
* Returns whether a WebView can be used, initializing it if so.
*/
protected fun prepareWebView(): Boolean {
if (!WebViewUtil.supportsWebView(context)) {
scope.launch(Dispatchers.Main) {
context.toast(MR.strings.information_webview_required, Toast.LENGTH_LONG)
}
return response
return false
}
initWebView
return intercept(chain, request, response)
return true
}
fun parseHeaders(headers: Headers): Map<String, String> {
@@ -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()
}
}
}
@@ -50,6 +50,15 @@ class CategoryRepositoryImpl(
.subscribeToList()
}
override suspend fun getCategoriesByMangaIds(mangaIds: List<Long>): Map<Long, List<Category>> {
return database.categoryQueries
.getCategoriesByMangaIds(mangaIds) { mangaId, id, name, order, flags ->
mangaId to mapCategory(id, name, order, flags)
}
.awaitAsList()
.groupBy({ it.first }, { it.second })
}
override suspend fun insert(category: NewCategory) {
database.categoryQueries.insert(name = category.name, flags = category.flags)
}
@@ -113,6 +113,13 @@ class ChapterRepositoryImpl(
.awaitAsList()
}
override suspend fun getChaptersByMangaIds(mangaIds: List<Long>): Map<Long, List<Chapter>> {
return database.chapterQueries
.getChaptersByMangaIds(mangaIds, ::mapChapter)
.awaitAsList()
.groupBy { it.mangaId }
}
override suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary {
return database.chapterQueries
.getChapterSummaryByMangaId(mangaId) { total, latestChapterNumber ->
@@ -48,6 +48,15 @@ class HistoryRepositoryImpl(
.awaitAsList()
}
override suspend fun getHistoryByMangaIds(mangaIds: List<Long>): Map<Long, List<History>> {
return database.historyQueries
.getHistoryByMangaIds(mangaIds) { mangaId, id, chapterId, readAt, readDuration ->
mangaId to HistoryMapper.mapHistory(id, chapterId, readAt, readDuration)
}
.awaitAsList()
.groupBy({ it.first }, { it.second })
}
override suspend fun resetHistory(historyId: Long) {
try {
database.historyQueries.resetHistoryById(historyId)
@@ -144,6 +144,13 @@ class MangaRepositoryImpl(
.toSet()
}
override suspend fun getExcludedScanlatorsByMangaIds(mangaIds: List<Long>): Map<Long, List<String>> {
return database.excluded_scanlatorQueries
.getExcludedScanlatorsByMangaIds(mangaIds)
.awaitAsList()
.groupBy({ it.manga_id }, { it.scanlator })
}
override fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>> {
return database.excluded_scanlatorQueries
.getExcludedScanlatorsByMangaId(mangaId)
@@ -31,6 +31,13 @@ class TrackRepositoryImpl(
.awaitAsList()
}
override suspend fun getTracksByMangaIds(mangaIds: List<Long>): Map<Long, List<Track>> {
return database.manga_trackQueries
.getTracksByMangaIds(mangaIds, TrackMapper::mapTrack)
.awaitAsList()
.groupBy { it.mangaId }
}
override suspend fun getTracks(): List<Track> {
return database.manga_trackQueries
.getTracks(TrackMapper::mapTrack)
@@ -34,6 +34,14 @@ ON C.id = MC.category_id
WHERE MC.manga_id = :mangaId
ORDER BY C.`order`;
getCategoriesByMangaIds:
SELECT MC.manga_id, C.id, C.name, C.`order`, C.flags
FROM category C
JOIN manga_category MC
ON C.id = MC.category_id
WHERE MC.manga_id IN :mangaIds
ORDER BY MC.manga_id, C.`order`;
insert:
-- The position is taken inside the insert, so two categories created at once cannot claim the same one
INSERT INTO category(name, `order`, flags)
@@ -48,6 +48,16 @@ AND (
OR ES.scanlator IS NULL
);
getChaptersByMangaIds:
-- The unique index's order, which getChaptersByMangaId returns too, so backups keep their chapter order
SELECT
id, manga_id, remote_url, remote_name, remote_scanlator, remote_chapter_number,
remote_date_upload, remote_order, remote_memo, user_read, user_bookmark,
user_last_page_read, state_date_fetch
FROM chapter
WHERE manga_id IN :mangaIds
ORDER BY manga_id, remote_url;
getChapterSummaryByMangaId:
SELECT count(*) AS total, max(remote_chapter_number) AS latestChapterNumber
FROM chapter
@@ -21,3 +21,9 @@ getExcludedScanlatorsByMangaId:
SELECT scanlator
FROM excluded_scanlator
WHERE manga_id = :mangaId;
getExcludedScanlatorsByMangaIds:
SELECT manga_id, scanlator
FROM excluded_scanlator
WHERE manga_id IN :mangaIds
ORDER BY manga_id, scanlator;
@@ -20,6 +20,12 @@ SELECT id, chapter_id, read_at, read_duration
FROM history
WHERE manga_id = :mangaId;
getHistoryByMangaIds:
SELECT manga_id, id, chapter_id, read_at, read_duration
FROM history
WHERE manga_id IN :mangaIds
ORDER BY manga_id, id;
resetHistoryById:
UPDATE history
SET read_at = NULL
@@ -48,6 +48,14 @@ SELECT
FROM manga_track
WHERE manga_id = :mangaId;
getTracksByMangaIds:
SELECT
id, manga_id, tracker_id, remote_id, remote_url, library_id, title, last_chapter_read,
total_chapters, status, score, start_date, finish_date, private
FROM manga_track
WHERE manga_id IN :mangaIds
ORDER BY manga_id, tracker_id;
upsert:
INSERT INTO manga_track(
manga_id, tracker_id, remote_id, remote_url, library_id, title, last_chapter_read,
@@ -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,15 +4,81 @@ import kotlin.String;
import kotlin.collections.List;
import kotlinx.serialization.json.JsonObject;
-- ---------------------------------------------------------------------------------------------------------
-- table - manga
-- ---------------------------------------------------------------------------------------------------------
-- Every view reads `mangas`, so they are dropped here and recreated against the new table below
-- Every view reads `mangas`, so they are dropped here and recreated against the new tables at the end
DROP VIEW historyView;
DROP VIEW libraryView;
DROP VIEW updatesView;
-- ---------------------------------------------------------------------------------------------------------
-- damaged indexes
-- ---------------------------------------------------------------------------------------------------------
-- Some databases hold rows their unique constraints should have rejected, which a damaged index explains. A
-- write to a table also writes its indexes and fails on a damaged one, and a read through one can miss rows,
-- so each table changed in place below is first copied from its rows alone, leaving its indexes behind.
CREATE TABLE mangas_copy AS
SELECT
_id, source, url, artist, author, description, genre, title, status, thumbnail_url, last_update, next_update,
initialized, viewer, chapter_flags, cover_last_modified, update_strategy, calculate_interval, notes, memo,
favorite_at
FROM mangas NOT INDEXED;
DROP TABLE mangas;
ALTER TABLE mangas_copy RENAME TO mangas;
CREATE TABLE chapters_copy AS
SELECT
_id, manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch,
date_upload, memo
FROM chapters NOT INDEXED;
DROP TABLE chapters;
ALTER TABLE chapters_copy RENAME TO chapters;
CREATE TABLE history_copy AS
SELECT _id, chapter_id, last_read, time_read
FROM history NOT INDEXED;
DROP TABLE history;
ALTER TABLE history_copy RENAME TO history;
CREATE TABLE mangas_categories_copy AS
SELECT _id, manga_id, category_id
FROM mangas_categories NOT INDEXED;
DROP TABLE mangas_categories;
ALTER TABLE mangas_categories_copy RENAME TO mangas_categories;
CREATE TABLE manga_sync_copy AS
SELECT
_id, manga_id, sync_id, remote_id, library_id, title, last_chapter_read, total_chapters, status, score,
remote_url, start_date, finish_date, private
FROM manga_sync NOT INDEXED;
DROP TABLE manga_sync;
ALTER TABLE manga_sync_copy RENAME TO manga_sync;
CREATE TABLE excluded_scanlators_copy AS
SELECT manga_id, scanlator
FROM excluded_scanlators NOT INDEXED;
DROP TABLE excluded_scanlators;
ALTER TABLE excluded_scanlators_copy RENAME TO excluded_scanlators;
-- Merging duplicate chapters looks their history up by chapter
CREATE INDEX history_chapter_id_index ON history(chapter_id);
-- ---------------------------------------------------------------------------------------------------------
-- 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
-- ---------------------------------------------------------------------------------------------------------
-- Entries sharing a source and url are merged into one, since the new table won't hold both. The copies are
-- ranked with the ones in the library first, since that is the entry the user has, then by age. The first
-- one stays, takes each detail it is missing from the first copy that has it, and takes over everything
@@ -31,6 +97,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,
@@ -310,8 +379,7 @@ CREATE INDEX manga_category_category_id_index ON manga_category(category_id);
-- ---------------------------------------------------------------------------------------------------------
-- `last_read` is when the chapter was last read and `time_read` is how long it has been read for, so
-- both are renamed to say so. The chapter it belongs to is already unique, which leaves
-- `history_history_chapter_id_index` a prefix of that constraint.
-- both are renamed to say so. The chapter it belongs to is already unique, so the constraint indexes it.
-- The table keeps its name, so the original is renamed aside and the new one built under
-- the real name; renaming a table into place makes SQLite rewrite its stored definition,
-- leaving it spelled differently from the same table on a fresh install. The views were
@@ -333,11 +401,14 @@ CREATE TABLE history(
-- Reaching the entry a row belongs to meant going through its chapter; it names the entry
-- itself now, taken from that same chapter. A row whose chapter is already gone cannot name
-- one and is dropped, which is what the foreign key would do to it once it is enforced again.
-- Some databases hold more than one row for a chapter despite the old unique constraint, and the new one
-- rejects them, so they merge the way the history of duplicate chapters does above.
INSERT INTO history(id, manga_id, chapter_id, read_at, read_duration)
SELECT H._id, C.manga_id, H.chapter_id, H.last_read, H.time_read
SELECT min(H._id), C.manga_id, H.chapter_id, max(H.last_read), sum(H.time_read)
FROM history_temp H
JOIN chapter C
ON C.id = H.chapter_id;
ON C.id = H.chapter_id
GROUP BY H.chapter_id;
DROP TABLE history_temp;
@@ -372,19 +443,20 @@ DROP TABLE excluded_scanlators;
-- ---------------------------------------------------------------------------------------------------------
-- `manga_sync` is what tracking was called when it was still called syncing; `sync_id` is the tracker.
-- An entry that merged can leave two rows for the same tracker on the survivor, which the unique constraint
-- rejects, so the one to keep is chosen first: one on a copy in the library, then the one furthest read, then
-- the one on the copy ranked first, then the oldest. A track is taken whole rather than merged field by field,
-- because two rows on the same tracker can point at different remote entries.
-- An entry that merged can leave two rows for the same tracker on the survivor, and an entry that didn't can
-- still hold two despite the old unique constraint. The new one rejects both, so the one to keep is chosen
-- first: one on a copy in the library, then the one furthest read, then the one on the copy ranked first, then
-- the oldest. A track is taken whole rather than merged field by field, because two rows on the same tracker
-- can point at different remote entries.
CREATE TEMP TABLE duplicate_tracks AS
SELECT
T._id,
first_value(T._id) OVER (
PARTITION BY D.keep_id, T.sync_id
PARTITION BY coalesce(D.keep_id, T.manga_id), T.sync_id
ORDER BY D.favorite DESC, T.last_chapter_read DESC, D.precedence ASC, T._id ASC
) AS keep_track_id
FROM manga_sync T
JOIN duplicate_mangas D
LEFT JOIN duplicate_mangas D
ON D._id = T.manga_id;
DELETE FROM manga_sync
@@ -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()
}
@@ -16,6 +16,8 @@ interface CategoryRepository {
fun getCategoriesByMangaIdAsFlow(mangaId: Long): Flow<List<Category>>
suspend fun getCategoriesByMangaIds(mangaIds: List<Long>): Map<Long, List<Category>>
suspend fun insert(category: NewCategory)
suspend fun insertAll(categories: List<NewCategory>)
@@ -20,6 +20,8 @@ interface ChapterRepository {
suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter>
suspend fun getChaptersByMangaIds(mangaIds: List<Long>): Map<Long, List<Chapter>>
suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary
suspend fun getScanlatorsByMangaId(mangaId: Long): List<String>
@@ -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.
@@ -15,6 +15,8 @@ interface HistoryRepository {
suspend fun getHistoryByMangaId(mangaId: Long): List<History>
suspend fun getHistoryByMangaIds(mangaIds: List<Long>): Map<Long, List<History>>
suspend fun resetHistory(historyId: Long)
suspend fun resetHistoryByMangaId(mangaId: Long)
@@ -43,6 +43,8 @@ interface MangaRepository {
suspend fun getExcludedScanlators(mangaId: Long): Set<String>
suspend fun getExcludedScanlatorsByMangaIds(mangaIds: List<Long>): Map<Long, List<String>>
fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>>
suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set<String>)
@@ -9,6 +9,8 @@ interface TrackRepository {
suspend fun getTracksByMangaId(mangaId: Long): List<Track>
suspend fun getTracksByMangaIds(mangaIds: List<Long>): Map<Long, List<Track>>
suspend fun getTracks(): List<Track>
fun getTracksAsFlow(): Flow<List<Track>>
+3 -1
View File
@@ -15,7 +15,8 @@ androidx-coreSplashScreen = "1.2.0"
androidx-glance = "1.2.0"
androidx-interpolator = "1.0.0"
androidx-lifecycle = "2.11.0"
androidx-paging = "3.5.2"
androidx-navigationEvent = "1.2.0"
androidx-paging = "3.5.3"
androidx-preference = "1.2.1"
androidx-profileInstaller = "1.4.1"
androidx-recyclerView = "1.4.0"
@@ -110,6 +111,7 @@ androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process",
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-navigationEvent = { module = "androidx.navigationevent:navigationevent", version.ref = "androidx-navigationEvent" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" }
androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }
@@ -1020,6 +1020,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 -->
@@ -300,7 +300,7 @@ fun SelectItem(
vertical = SettingsItemsPaddings.Vertical,
),
label = { Text(text = label) },
value = options[selectedIndex].toString(),
value = options.getOrNull(selectedIndex)?.toString().orEmpty(),
onValueChange = {},
readOnly = true,
singleLine = true,