mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-17 14:12:25 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9612d955db | ||
|
|
5ca2a39328 | ||
|
|
fb2b224030 | ||
|
|
ee454f3a82 | ||
|
|
4fd0081817 | ||
|
|
68dbd7a185 | ||
|
|
7475f4554c | ||
|
|
8e688ed51e | ||
|
|
cca6b5e2e7 | ||
|
|
4c316ccc3a | ||
|
|
7c7392a533 | ||
|
|
d4fe03cd18 |
@@ -30,7 +30,7 @@ body:
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: I have updated the app to version **[0.21.1](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
|
||||
|
||||
@@ -52,7 +52,7 @@ body:
|
||||
label: Mihon version
|
||||
description: You can find your Mihon version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "0.21.1"
|
||||
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.21.1](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
|
||||
|
||||
+16
-1
@@ -12,6 +12,20 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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))
|
||||
@@ -772,7 +786,8 @@ Same as v0.19.6
|
||||
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
|
||||
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
|
||||
|
||||
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.1...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
|
||||
|
||||
@@ -37,8 +37,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "app.mihon"
|
||||
|
||||
versionCode = 37
|
||||
versionName = "0.21.1"
|
||||
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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+11
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-10
@@ -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)
|
||||
@@ -105,7 +107,8 @@ 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()
|
||||
|
||||
@@ -161,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
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+131
-44
@@ -10,13 +10,15 @@ 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 logcat.LogPriority
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -24,7 +26,11 @@ 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,
|
||||
@@ -35,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:
|
||||
@@ -42,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
|
||||
@@ -66,65 +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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +225,7 @@ class CloudflareInterceptor(
|
||||
}
|
||||
}
|
||||
|
||||
webview.loadUrl(origRequestUrl, headers)
|
||||
webview.loadUrl(challengeUrl, headers)
|
||||
}
|
||||
|
||||
latch.awaitFor30Seconds()
|
||||
@@ -169,13 +237,27 @@ class CloudflareInterceptor(
|
||||
}
|
||||
|
||||
webview?.run {
|
||||
if (!isRenderProcessGone) 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)
|
||||
@@ -186,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")
|
||||
|
||||
|
||||
+11
-3
@@ -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> {
|
||||
|
||||
@@ -4,6 +4,64 @@ import kotlin.String;
|
||||
import kotlin.collections.List;
|
||||
import kotlinx.serialization.json.JsonObject;
|
||||
|
||||
-- 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
|
||||
-- ---------------------------------------------------------------------------------------------------------
|
||||
@@ -21,11 +79,6 @@ DELETE FROM excluded_scanlators WHERE manga_id NOT IN (SELECT _id FROM mangas);
|
||||
-- table - manga
|
||||
-- ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- Every view reads `mangas`, so they are dropped here and recreated against the new table below
|
||||
DROP VIEW historyView;
|
||||
DROP VIEW libraryView;
|
||||
DROP VIEW updatesView;
|
||||
|
||||
-- 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
|
||||
@@ -326,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
|
||||
@@ -349,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;
|
||||
|
||||
@@ -388,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
|
||||
|
||||
@@ -15,6 +15,7 @@ androidx-coreSplashScreen = "1.2.0"
|
||||
androidx-glance = "1.2.0"
|
||||
androidx-interpolator = "1.0.0"
|
||||
androidx-lifecycle = "2.11.0"
|
||||
androidx-navigationEvent = "1.2.0"
|
||||
androidx-paging = "3.5.2"
|
||||
androidx-preference = "1.2.1"
|
||||
androidx-profileInstaller = "1.4.1"
|
||||
@@ -109,6 +110,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" }
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user