From a8daf1f8ec11f3b82cf8e7a198ade0d5f6643e71 Mon Sep 17 00:00:00 2001 From: Trevor Paley <10186337+TheUnlocked@users.noreply.github.com> Date: Fri, 10 Oct 2025 19:52:58 -0700 Subject: [PATCH] Add BookWalker Global (#10846) * Add BookWalker Global extension * Add option to configure opening login page in webview * BookWalker: clean up code, remove PREF_TRY_OPEN_LOGIN_WEBVIEW --- src/en/bookwalker/assets/webview-script.js | 189 ++++ src/en/bookwalker/build.gradle | 8 + .../res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3063 bytes .../res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1709 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4168 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7244 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9677 bytes .../extension/en/bookwalker/BookWalker.kt | 821 ++++++++++++++++++ .../en/bookwalker/BookWalkerChapterReader.kt | 366 ++++++++ .../en/bookwalker/BookWalkerConstants.kt | 52 ++ .../en/bookwalker/BookWalkerFilters.kt | 160 ++++ .../BookWalkerImageRequestInterceptor.kt | 96 ++ .../en/bookwalker/BookWalkerPreferences.kt | 11 + .../extension/en/bookwalker/Expiring.kt | 53 ++ .../en/bookwalker/dto/HoldBooksInfoDto.kt | 18 + .../extension/en/bookwalker/dto/SeriesDto.kt | 12 + .../extension/en/bookwalker/dto/SingleDto.kt | 18 + 17 files changed, 1804 insertions(+) create mode 100644 src/en/bookwalker/assets/webview-script.js create mode 100644 src/en/bookwalker/build.gradle create mode 100644 src/en/bookwalker/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/en/bookwalker/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/en/bookwalker/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/en/bookwalker/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/en/bookwalker/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalker.kt create mode 100644 src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerChapterReader.kt create mode 100644 src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerConstants.kt create mode 100644 src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerFilters.kt create mode 100644 src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerImageRequestInterceptor.kt create mode 100644 src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerPreferences.kt create mode 100644 src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/Expiring.kt create mode 100644 src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/HoldBooksInfoDto.kt create mode 100644 src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SeriesDto.kt create mode 100644 src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SingleDto.kt diff --git a/src/en/bookwalker/assets/webview-script.js b/src/en/bookwalker/assets/webview-script.js new file mode 100644 index 000000000..519eef32d --- /dev/null +++ b/src/en/bookwalker/assets/webview-script.js @@ -0,0 +1,189 @@ +//__INJECT_WEBVIEW_INTERFACE = { reportImage: console.log } +const webviewInterface = __INJECT_WEBVIEW_INTERFACE; + +const checkLoadedTimer = setInterval(() => { + if (document.getElementById('pageSliderCounter')?.innerText) { + webviewInterface.reportViewerLoaded(); + clearInterval(checkLoadedTimer); + } +}, 10); + +const checkMessageTimer = setInterval(() => { + const messageElt = document.querySelector('#messageDialog .message'); + if (messageElt) { + webviewInterface.reportFailedToLoad(messageElt.textContent); + clearInterval(checkMessageTimer); + } +}, 2000); + +// In order for image extraction to work, the viewer needs to be in horizontal mode. +// That setting is stored in localStorage, so we intercept calls to localStorage.getItem() +// in order to fake the value we want. +// There is a potential timing concern here if this JavaScript runs too late, but it +// seems that the value is read later in the loading process so it should be okay. +localStorage.getItem = function(key) { + let result = Storage.prototype.getItem.apply(this, [key]); + if (key === '/NFBR_Settings/NFBR.SettingData') { + try { + const data = JSON.parse(result); + data.viewerPageTransitionAxis = 'horizontal'; + result = JSON.stringify(data); + } + catch (e) {} + } + return result; +}; + +function getCurrentPageIndex() { + return +document.getElementById('pageSliderCounter').innerText.split('/')[0] - 1; +} + +function getLastPageIndex() { + return +document.getElementById('pageSliderCounter').innerText.split('/')[1] - 1; +} + +const alreadyFetched = []; + +// The goal here is to capture the full-size processed image data just before it +// gets resized and drawn to the viewport. +const baseDrawImage = CanvasRenderingContext2D.prototype.drawImage; +CanvasRenderingContext2D.prototype.drawImage = function(image, sx, sy, sWidth, sHeight /* , ... */) { + baseDrawImage.apply(this, arguments); + + // It's important that the screen size is small enough that only one page + // appears at a time so that this page number is accurate. + // Otherwise, pages could end up out of order, skipped, or duplicated. + const pageIdx = getCurrentPageIndex(); + if (alreadyFetched[pageIdx]) { + // We already found this page, no need to do the processing again + return; + } + + const current = document.querySelector('.currentScreen'); + // It can render pages on the opposite side of the spread even in one-page mode, + // so to make sure we're not grabbing the wrong image, we want to check that + // the current page is the side that the image is being drawn to. + if (current.contains(this.canvas)) { + // imageData should be a Uint8ClampedArray containing RGBA row-major bitmap image data. + // We don't create the final JPEGs right here with Canvas.toBlob/OffscreenCanvas.convertToBlob + // because in testing, that was _extremely_ slow to run in the Webview, taking upwards of + // 15 seconds per image. Doing that conversion on the JVM side is near-instantaneous. + let imageData; + + if (image instanceof ImageBitmap) { + const ctx = new OffscreenCanvas(sWidth, sHeight) + .getContext('2d', { willReadFrequently: true }); + ctx.drawImage(image, sx, sy, sWidth, sHeight); + imageData = ctx.getImageData(sx, sy, sWidth, sHeight).data; + } + else if (image instanceof HTMLCanvasElement && image.matches('canvas.dummy[width][height]')) { + const ctx = image.getContext('2d', { willReadFrequently: true }); + imageData = ctx.getImageData(sx, sy, sWidth, sHeight).data; + } + else { + // Other misc images can sometimes be drawn. We don't care about those. + return; + } + console.log("intercepted image"); + + alreadyFetched[pageIdx] = true; + + // The WebView interface only allows communicating with strings, + // so we need to convert our ArrayBuffer to a string for transport. + const textData = new TextDecoder("windows-1252").decode(imageData); + console.log("sending encoded data"); + webviewInterface.reportImage(pageIdx, textData, sWidth, sHeight); + } +} + +// JS UTILITIES +const leftArrowKeyCode = 37; +const rightArrowKeyCode = 39; + +function fireKeyboardEvent(elt, keyCode) { + elt.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode })); +} + +window.__INJECT_JS_UTILITIES = { + fetchPageData(targetPageIndex) { + alreadyFetched[targetPageIndex] = false; + + const lastPageIndex = getLastPageIndex(); + + if (targetPageIndex > lastPageIndex) { + // This generally occurs when reading a preview chapter. + webviewInterface.reportImageDoesNotExist( + targetPageIndex, + "You have reached the end of the preview.", + ); + return; + } + + const renderer = document.getElementById('renderer'); + const slider = document.getElementById('pageSliderBar'); + const isLTR = Boolean(slider.querySelector('.ui-slider-range-min')); + + const [forwardsKeyCode, backwardsKeyCode] = isLTR + ? [rightArrowKeyCode, leftArrowKeyCode] + : [leftArrowKeyCode, rightArrowKeyCode]; + + if (getCurrentPageIndex() === targetPageIndex) { + // The image may have already loaded, but we need to shuffle around for it to get reported. + // Otherwise, we can get stuck waiting for the image to be reported forever and + // eventually time out. + console.log('already at correct page'); + if (targetPageIndex === lastPageIndex) { + fireKeyboardEvent(renderer, backwardsKeyCode); + fireKeyboardEvent(renderer, forwardsKeyCode); + } + else { + fireKeyboardEvent(renderer, forwardsKeyCode); + fireKeyboardEvent(renderer, backwardsKeyCode); + } + return; + } + + function invertIfRTL(value) { + if (isLTR) { + return value; + } + return 1 - value; + } + + const { x, width, y, height } = slider.getBoundingClientRect(); + const options = { + clientX: x + width * invertIfRTL(targetPageIndex / lastPageIndex), + clientY: y + height / 2, + bubbles: true, + }; + slider.dispatchEvent(new MouseEvent('mousedown', options)); + slider.dispatchEvent(new MouseEvent('mouseup', options)); + + // That should have gotten us most of the way there but since the clicks aren't always + // perfectly accurate, we may need to make some adjustments to get the rest of the way. + // This mostly comes up for longer chapters and volumes that have a large number of pages, + // since the small webview makes the slider pretty short. + + function adjustPage() { + const distance = targetPageIndex - getCurrentPageIndex(); + console.log("current", getCurrentPageIndex(), "target", targetPageIndex, "distance", distance) + if (distance !== 0) { + const keyCode = distance > 0 ? forwardsKeyCode : backwardsKeyCode; + for (let i = 0; i < Math.abs(distance); i++) { + renderer.dispatchEvent(new KeyboardEvent('keydown', { keyCode })); + } + } + + console.log("final location", getCurrentPageIndex()); + + // Sometimes, particularly when the page has just loaded, the adjustment doesn't work. + // If that happens, retry the adjustment after a brief delay. + if (getCurrentPageIndex() !== targetPageIndex) { + console.log("retrying page adjustment in 100ms...") + setTimeout(adjustPage, 100); + } + } + + adjustPage(); + } +} diff --git a/src/en/bookwalker/build.gradle b/src/en/bookwalker/build.gradle new file mode 100644 index 000000000..6050cc24f --- /dev/null +++ b/src/en/bookwalker/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'BookWalker Global' + extClass = '.BookWalker' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/bookwalker/res/mipmap-hdpi/ic_launcher.png b/src/en/bookwalker/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..aa40647af6a856a2273bd86752c5eeb92734672f GIT binary patch literal 3063 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 FV1k@hl_LNE literal 0 HcmV?d00001 diff --git a/src/en/bookwalker/res/mipmap-mdpi/ic_launcher.png b/src/en/bookwalker/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..b00bd253f29ee484c4adcc5e74b70d857a648e7b GIT binary patch literal 1709 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 L2IvNL$H@N!XSWcY literal 0 HcmV?d00001 diff --git a/src/en/bookwalker/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/bookwalker/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..58c98a8e4a6e9849420b4db094ff780c8c1f7958 GIT binary patch literal 9677 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 P@UMG!bks_3TfP22U)*|T literal 0 HcmV?d00001 diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalker.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalker.kt new file mode 100644 index 000000000..9e894f667 --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalker.kt @@ -0,0 +1,821 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker + +import android.app.Application +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.extension.en.bookwalker.dto.HoldBookEntityDto +import eu.kanade.tachiyomi.extension.en.bookwalker.dto.HoldBooksInfoDto +import eu.kanade.tachiyomi.extension.en.bookwalker.dto.SeriesDto +import eu.kanade.tachiyomi.extension.en.bookwalker.dto.SingleDto +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.getPreferencesLazy +import keiyoushi.utils.parseAs +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.plus +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import okhttp3.Call +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MultipartBody +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import rx.Single +import uy.kohesive.injekt.injectLazy +import java.util.regex.PatternSyntaxException + +class BookWalker : ConfigurableSource, ParsedHttpSource(), BookWalkerPreferences { + + override val name = "BookWalker Global" + + override val baseUrl = "https://global.bookwalker.jp" + + override val lang = "en" + + override val supportsLatest = true + + override val client = network.client.newBuilder() + .addInterceptor(BookWalkerImageRequestInterceptor(this)) + .build() + + // The UA should be a desktop UA because all research was done with a desktop UA, and the site + // renders differently with a mobile user agent. + // However, we're not overriding headersBuilder because we don't want to show the desktop site + // when a user opens a WebView to e.g. log in or make a purchase. + // We just need the desktop site when making requests from extension logic. + val callHeaders = headers.newBuilder() + .set("User-Agent", USER_AGENT_DESKTOP) + .build() + + private val json = Json { + ignoreUnknownKeys = true + serializersModule += SerializersModule { + polymorphic(HoldBookEntityDto::class) { + subclass(SingleDto::class) + subclass(SeriesDto::class) + } + } + } + + val app by injectLazy() + + private val preferences by getPreferencesLazy() + + override val showLibraryInPopular + get() = preferences.getBoolean(PREF_SHOW_LIBRARY_IN_POPULAR, false) + + override val shouldValidateLogin + get() = preferences.getBoolean(PREF_VALIDATE_LOGGED_IN, true) + + override val imageQuality + get() = ImageQualityPref.fromKey( + preferences.getString(ImageQualityPref.PREF_KEY, ImageQualityPref.defaultOption.key)!!, + ) + + override val filterChapters + get() = FilterChaptersPref.fromKey( + preferences.getString( + FilterChaptersPref.PREF_KEY, + FilterChaptersPref.defaultOption.key, + )!!, + ) + + override val attemptToReadPreviews + get() = preferences.getBoolean(PREF_ATTEMPT_READ_PREVIEWS, false) + + override val excludeCategoryFilters + get() = Regex( + preferences.getString( + PREF_CATEGORY_EXCLUDE_REGEX, + categoryExcludeRegexDefault, + )!!, + RegexOption.IGNORE_CASE, + ) + + override val excludeGenreFilters + get() = Regex( + preferences.getString(PREF_GENRE_EXCLUDE_REGEX, genreExcludeRegexDefault)!!, + RegexOption.IGNORE_CASE, + ) + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + fun regularExpressionPref(block: EditTextPreference.() -> Unit): EditTextPreference { + fun validateRegex(regex: String): String? { + return try { + Regex(regex) + null + } catch (e: PatternSyntaxException) { + e.message + } + } + + return EditTextPreference(screen.context).apply { + dialogMessage = "Enter a regular expression. " + + "Sub-string matches will be counted as matches. Matches are case-insensitive." + + setOnBindEditTextListener { field -> + field.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(s: Editable) { + validateRegex(s.toString())?.let { field.error = it } + } + }, + ) + } + + setOnPreferenceChangeListener { _, new -> + validateRegex(new as String) == null + } + + block() + } + } + + SwitchPreferenceCompat(screen.context).apply { + key = PREF_VALIDATE_LOGGED_IN + title = "Validate Login" + summary = "Validate that you are logged in before allowing certain actions. This is " + + "recommended to avoid confusing behavior when your login session expires.\n" + + "If you are using this extension as an anonymous user, disable this option." + + setDefaultValue(true) + }.also(screen::addPreference) + + ListPreference(screen.context).apply { + key = ImageQualityPref.PREF_KEY + title = "Image Quality" + summary = "%s" + + entries = arrayOf( + "Automatic", + "Medium", + "High", + ) + + entryValues = arrayOf( + ImageQualityPref.DEVICE.key, + ImageQualityPref.MEDIUM.key, + ImageQualityPref.HIGH.key, + ) + + setDefaultValue(ImageQualityPref.defaultOption.key) + }.also(screen::addPreference) + + SwitchPreferenceCompat(screen.context).apply { + key = PREF_SHOW_LIBRARY_IN_POPULAR + title = "Show My Library in Popular" + summary = "Show your library instead of popular manga when browsing \"Popular\"." + + setDefaultValue(false) + }.also(screen::addPreference) + + ListPreference(screen.context).apply { + key = FilterChaptersPref.PREF_KEY + title = "Filter Shown Chapters" + summary = "Choose what types of chapters to show." + + entries = arrayOf( + "Show owned and free chapters", + "Show obtainable chapters", + "Show all chapters", + ) + + entryValues = arrayOf( + FilterChaptersPref.OWNED.key, + FilterChaptersPref.OBTAINABLE.key, + FilterChaptersPref.ALL.key, + ) + + setDefaultValue(FilterChaptersPref.defaultOption.key) + }.also(screen::addPreference) + + SwitchPreferenceCompat(screen.context).apply { + key = PREF_ATTEMPT_READ_PREVIEWS + title = "Show Previews When Available" + summary = "Determines whether attempting to read an un-owned chapter should show the " + + "preview. Even when disabled, you will still be able to read free chapters you " + + "have not \"purchased\"." + + setDefaultValue(true) + }.also(screen::addPreference) + + regularExpressionPref { + key = PREF_CATEGORY_EXCLUDE_REGEX + title = "Exclude Category Filters" + summary = "Hide certain categories from being listed in the search filters. " + + "This will not hide manga with those categories from search results." + + setDefaultValue(categoryExcludeRegexDefault) + }.also(screen::addPreference) + + regularExpressionPref { + key = PREF_GENRE_EXCLUDE_REGEX + title = "Exclude Genre Filters" + summary = "Hide certain genres from being listed in the search filters. " + + "This will not hide manga with those genres from search results." + + setDefaultValue(genreExcludeRegexDefault) + }.also(screen::addPreference) + } + + private val filterInfo by lazy { BookWalkerFilters(this) } + + override fun getFilterList(): FilterList { + filterInfo.fetchIfNecessaryInBackground() + + fun Iterable.prependAll(): List { + return mutableListOf(allFilter).apply { addAll(this@prependAll) } + } + + return FilterList( + SelectOneFilter( + "Sort By", + "order", + listOf( + FilterInfo("Relevancy", "score"), + FilterInfo("Popularity", "rank"), + FilterInfo("Release Date", "release"), + FilterInfo("Title", "title"), + ), + ), + SelectOneFilter( + "Categories", + QUERY_PARAM_CATEGORY, + filterInfo.categories + ?.filterNot { excludeCategoryFilters.containsMatchIn(it.name) } + ?.prependAll() ?: fallbackFilters, + ), + SelectMultipleFilter( + "Genre", + QUERY_PARAM_GENRE, + filterInfo.genres + ?.filterNot { excludeGenreFilters.containsMatchIn(it.name) } + ?: fallbackFilters, + ), + // Author filter disabled for now, since the performance/UX in-app is pretty bad +// SelectMultipleFilter( +// "Author", +// QUERY_PARAM_AUTHOR, +// filterInfo.authors ?: fallbackFilters, +// ), + SelectOneFilter( + "Publisher", + QUERY_PARAM_PUBLISHER, + filterInfo.publishers?.prependAll() ?: fallbackFilters, + ), + OthersFilter(), + PriceFilter(), + ExcludeFilter(), + ) + } + + override fun popularMangaRequest(page: Int): Request { + filterInfo.fetchIfNecessaryInBackground() + + if (showLibraryInPopular) { + return POST( + "$baseUrl/prx/holdBooks-api/hold-book-list/", + callHeaders, + MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("holdBook-series", "1") + .build(), + ) + } + // /categories/2/ - manga + // np=0 - display by series + // order=rank - sort by popularity + return GET("$baseUrl/categories/2/?order=rank&np=0&page=$page", callHeaders) + } + + override fun popularMangaParse(response: Response): MangasPage { + if (showLibraryInPopular) { + val manga = response.parseAs().holdBookList.entities + .map { + when (it) { + is SeriesDto -> SManga.create().apply { + url = "/series/${it.seriesId}/" + title = it.seriesName.cleanTitle() + thumbnail_url = it.imageUrl + } + is SingleDto -> SManga.create().apply { + url = it.detailUrl.substring(baseUrl.length) + title = it.title.cleanTitle() + thumbnail_url = it.imageUrl + author = it.authors.joinToString { a -> a.authorName } + } + } + } + return MangasPage(manga, false) + } + return super.popularMangaParse(response) + } + + override fun popularMangaNextPageSelector(): String = + ".pager-area .next > a" + + override fun popularMangaSelector(): String = + ".book-list-area .o-tile" + + override fun popularMangaFromElement(element: Element): SManga { + val titleElt = element.select(".a-tile-ttl a") + + return SManga.create().apply { + url = titleElt.attr("href").substring(baseUrl.length) + title = titleElt.attr("title").cleanTitle() + thumbnail_url = element.select(".a-tile-thumb-img > img") + .attr("data-srcset").getHighestQualitySrcset() + } + } + + override fun latestUpdatesRequest(page: Int): Request { + filterInfo.fetchIfNecessaryInBackground() + + // qcat=2 - only show manga + // np=0 - display by series + return GET("$baseUrl/new/?order=release&qcat=2&np=0&page=$page", callHeaders) + } + + override fun latestUpdatesNextPageSelector(): String? = popularMangaNextPageSelector() + override fun latestUpdatesSelector(): String = popularMangaSelector() + override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + filterInfo.fetchIfNecessaryInBackground() + + val urlBuilder = "$baseUrl/search/".toHttpUrl().newBuilder().apply { + addQueryParameter("np", "0") // display by series + addQueryParameter("page", page.toString()) + addQueryParameter("word", query) + + filters.list + .filterIsInstance() + .flatMap { it.getQueryParams() } + .forEach { + // special case since sorting by relevance doesn't work without search terms + if (query.isEmpty() && it.first == "order" && it.second == "score") { + addQueryParameter(it.first, "rank") // sort by popularity + } else { + addQueryParameter(it.first, it.second) + } + } + } + + return GET(urlBuilder.build(), callHeaders) + } + + override fun searchMangaNextPageSelector(): String? = popularMangaNextPageSelector() + override fun searchMangaSelector(): String = popularMangaSelector() + override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) + + override fun fetchMangaDetails(manga: SManga): Observable { + // Manga with "/series/" in their URL are actual series. + // Manga without are individual chapters/volumes (referred to here as "singles"). + return if (manga.url.startsWith("/series/")) { + fetchSeriesMangaDetails(manga) + } else { + fetchSingleMangaDetails(manga) + } + } + + override fun mangaDetailsParse(document: Document): SManga = + throw UnsupportedOperationException() + + private fun fetchSeriesMangaDetails(manga: SManga): Observable { + return rxSingle { + val seriesPage = client.newCall( + GET("$baseUrl${manga.url}?order=release&np=1", callHeaders), + ).awaitSuccess() + .asJsoup() + .let { validateLogin(it) } + + val mangaDetails = SManga.create().apply { + updateDetailsFromSeriesPage(seriesPage) + } + + // It generally doesn't matter which chapter we take the description from, but for + // series that release in volumes we want the earliest one, which will _usually_ be the + // last one on the page. With that said, it's not worth it to paginate in order to find + // the earliest volume, and volume releases don't usually have 60+ volumes anyways. + val chapterUrl = seriesPage + .select(".o-tile .a-tile-ttl a").last() + ?.attr("href") + ?: return@rxSingle mangaDetails + + val chapterPage = client.newCall(GET(chapterUrl, callHeaders)).await().asJsoup() + + mangaDetails.apply { + description = getDescriptionFromChapterPage(chapterPage) + } + }.toObservable() + } + + private fun fetchSingleMangaDetails(manga: SManga): Observable { + return rxSingle { + val document = client.newCall(GET(baseUrl + manga.url, callHeaders)) + .awaitSuccess() + .asJsoup() + + SManga.create().apply { + title = getTitleFromChapterPage(document)?.cleanTitle().orEmpty() + + description = getDescriptionFromChapterPage(document) + // From the browse pages we can't distinguish between a true one-shot and a + // serial manga with only one chapter, but we can detect if there's a series + // reference in the chapter page. If there is, we should let the user know that + // they may need to take some action in the future to correct the error. + if (document.selectFirst(".product-detail th:contains(Series Title)") != null) { + description = ( + "WARNING: This entry is being treated as a one-shot but appears to " + + "have an associated series. If another chapter is released in " + + "the future, you will likely need to migrate this to itself." + + "\n\n$description" + ).trim() + } + } + }.toObservable() + } + + private fun SManga.updateDetailsFromSeriesPage(document: Document) = run { + // Take the thumbnail from the first chapter that is not on pre-order. + // Pre-order chapters often just have a gray rectangle with "NOW PRINTING" as their + // thumbnail, which doesn't look very pretty for the catalog. + thumbnail_url = document + .select(".o-tile:not(:has(.a-ribbon-pre-order)) .a-tile-thumb-img > img") + .attr("data-srcset") + .getHighestQualitySrcset() + title = document.selectFirst(".title-main-inner")!!.ownText().cleanTitle() + author = getAvailableFilterNames(document, "side-author").joinToString() + genre = getAvailableFilterNames(document, "side-genre").joinToString() + + val statusIndicators = document.select("ul.side-others > li > a").map { it.ownText() } + + status = + if (statusIndicators.any { it.startsWith("Completed") }) { + if (statusIndicators.any { it.startsWith("Pre-Order") }) { + SManga.PUBLISHING_FINISHED + } else { + SManga.COMPLETED + } + } else { + SManga.ONGOING + } + } + + private fun getTitleFromChapterPage(document: Document): String? { + return document.selectFirst(".detail-book-title-box h1[itemprop='name']")?.ownText() + } + + private fun getDescriptionFromChapterPage(document: Document): String { + return buildString { + append(document.select(".synopsis-lead").text()) + append("\n\n") + append(document.select(".synopsis-text").text()) + }.trim() + } + + override fun fetchChapterList(manga: SManga): Observable> { + return rxSingle { + if (!manga.url.startsWith("/series/")) { + val document = client.newCall(GET(baseUrl + manga.url, callHeaders)) + .awaitSuccess() + .asJsoup() + + return@rxSingle listOfNotNull( + chapterFromChapterPage(document)?.apply { + url = manga.url + }, + ) + } + + suspend fun getDocumentForPage(page: Int): Document { + return client.newCall( + GET("$baseUrl${manga.url}?order=release&page=$page", callHeaders), + ).awaitSuccess().asJsoup() + } + + val firstPage = validateLogin(getDocumentForPage(1)) + val publishers = getAvailableFilterNames(firstPage, "side-publisher") + .joinToString() + + val pageCount = firstPage.selectFirst(".pager-area li:has(+ .next) > a") + ?.ownText()?.toIntOrNull() + ?: 1 + + val laterPages = (2..pageCount).map { n -> async { getDocumentForPage(n) } } + .awaitAll() + + (listOf(firstPage) + laterPages).flatMap { document -> + document.select(chapterListSelector()).map { + chapterFromElement(it).apply { + scanlator = publishers + } + } + } + }.toObservable() + } + + override fun chapterListSelector(): String { + return when (filterChapters) { + FilterChaptersPref.OWNED -> + ".book-list-area .o-tile:has(.a-read-btn-s, .a-free-btn-s)" + FilterChaptersPref.OBTAINABLE -> + ".book-list-area .o-tile:not(:has(.a-ribbon-bundle, .a-ribbon-pre-order))" + else -> // preorders shown, still not showing bundles since those aren't chapters + ".book-list-area .o-tile:not(:has(.a-ribbon-bundle))" + } + } + + override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { + val statusSuffix = + if (element.selectFirst("[data-action-label='Read']") != null) { + "" // user is currently able to read the chapter + } else if (element.selectFirst(".a-label-free") != null) { + " $FREE_ICON" // it's free to read but the user technically doesn't "own" it + } else if (element.selectFirst(".a-cart-btn-s, .a-cart-btn-s--on") != null) { + " $PURCHASE_ICON" + } else if (element.selectFirst(".a-ribbon-pre-order") != null) { + " $PREORDER_ICON" + } else { + // Some content does not fall into any of the above bins. It seems to mostly be + // bonus content you get from purchasing other items, but there may be exceptions. + " $UNKNOWN_ICON" + } + + val titleElt = element.select(".a-tile-ttl a") + val title = titleElt.attr("title").cleanTitle() + val chapterNumber = title.parseChapterNumber() + + url = titleElt.attr("href").substring(baseUrl.length) + name = WORD_JOINER + (chapterNumber?.first ?: title) + statusSuffix + chapter_number = chapterNumber?.second ?: -1f + // scanlator set by caller + } + + private fun chapterFromChapterPage(document: Document): SChapter? = SChapter.create().apply { + // See chapterFromElement for info on these statuses + val statusSuffix = + if (document.selectFirst(".a-read-on-btn") != null) { + "" + } else if (document.selectFirst(".a-cart-btn:contains(Free)") != null) { + " $FREE_ICON" + } else if (document.selectFirst(".a-cart-btn:contains(Cart)") != null) { + if (!filterChapters.includes(FilterChaptersPref.OBTAINABLE)) { + return null + } + " $PURCHASE_ICON" + } else if (document.selectFirst(".a-order-btn") != null) { + if (!filterChapters.includes(FilterChaptersPref.ALL)) { + return null + } + " $PREORDER_ICON" + } else { + if (!filterChapters.includes(FilterChaptersPref.ALL)) { + return null + } + " $UNKNOWN_ICON" + } + + val title = getTitleFromChapterPage(document).orEmpty().cleanTitle() + + val chapterNumber = title.parseChapterNumber() + + // No need to set URL, that will be handled by the caller. + name = WORD_JOINER + (chapterNumber?.first ?: title) + statusSuffix + scanlator = document.select(".product-detail tr:has(th:contains(Publisher)) > td").text() + chapter_number = chapterNumber?.second ?: -1f + } + + override fun fetchPageList(chapter: SChapter): Observable> { + return rxSingle { + val document = client.newCall(GET(baseUrl + chapter.url, callHeaders)) + .awaitSuccess() + .asJsoup() + .let { validateLogin(it) } + + val pagesText = document + .select(".product-detail tr:has(th:contains(Page count)) > td").text() + + val pagesCount = Regex("\\d+").find(pagesText)?.value?.toIntOrNull() + ?: throw Error("Could not determine number of pages in chapter") + + if (pagesCount == 0) { + throw Error("The page count is 0. If this chapter was just released, wait a bit for the page count to update.") + } + + val isFreeChapter = document.selectFirst(".a-cart-btn:contains(Free)") != null + val readerUrl = document.selectFirst("a.a-read-on-btn")?.attr("href") + ?: if (attemptToReadPreviews || isFreeChapter) { + document.selectFirst(".free-preview > a")?.attr("href") + ?: throw Error("No preview available") + } else { + throw Error("You don't own this chapter, or you aren't logged in") + } + + // We need to use the full cooperative URL every time we try to load a chapter since + // the reader page relies on transient cookies set in the cooperative flow. + // This call is simply being used to ensure the user is logged in. + // Note that this is not fool-proof since the app may cache the page list, so sometimes + // the best we can do is detect that the user is not logged in when loading the page + // and fail to load the image at that point. + tryCooperativeRedirect(readerUrl, "You must log in again. Open in WebView and click the shopping cart.") + + IntRange(0, pagesCount - 1).map { + // The page index query parameter exists only to prevent the app from trying to + // be smart about caching by page URLs, since the URL is the same for all the pages. + // It doesn't do anything, and in fact gets stripped back out in imageRequest. + Page( + it, + imageUrl = readerUrl.toHttpUrl().newBuilder() + .setQueryParameter(PAGE_INDEX_QUERY_PARAM, it.toString()) + .build() + .toString(), + ) + } + }.toObservable() + } + + override fun pageListParse(document: Document): List = + throw UnsupportedOperationException() + + override fun imageUrlParse(document: Document): String = + throw UnsupportedOperationException() + + override fun imageRequest(page: Page): Request { + // This URL doesn't actually contain the image. It will be intercepted, and the actual image + // will be extracted from a webview of the URL being sent here. + val imageUrl = page.imageUrl!!.toHttpUrl() + return GET( + imageUrl.newBuilder() + .removeAllQueryParameters(PAGE_INDEX_QUERY_PARAM) + .build() + .toString(), + callHeaders.newBuilder() + .set(HEADER_IS_REQUEST_FROM_EXTENSION, "true") + .set(HEADER_PAGE_INDEX, imageUrl.queryParameter(PAGE_INDEX_QUERY_PARAM)!!) + .build(), + ) + } + + private suspend fun validateLogin(document: Document): Document { + if (!shouldValidateLogin) { + return document + } + val signInBtn = document.selectFirst(".logout-nav-area .btn-sign-in a") + if (signInBtn != null) { + // Sometimes just clicking on the button will sign the user in without needing to input + // credentials, so we'll try to log in automatically. + val signInUrl = signInBtn.attr("href") + val redirectedPage = tryCooperativeRedirect(signInUrl) + return client.newCall(GET(redirectedPage, callHeaders)).awaitSuccess().asJsoup() + } + return document + } + + private suspend fun tryCooperativeRedirect(url: String, message: String = "Logged out, check website in WebView"): HttpUrl { + return client.newCall(GET(url, callHeaders)).await().use { + val redirectUrl = it.request.url + + if (redirectUrl.host == "member.bookwalker.jp" && redirectUrl.pathSegments.contains("login")) { + throw Exception(message) + } + + Log.d("bookwalker", "Successfully redirected to $redirectUrl") + redirectUrl + } + } + + private suspend fun Call.awaitSuccess(): Response { + return await().also { + if (!it.isSuccessful) { + it.close() + throw Exception("HTTP Error ${it.code}") + } + } + } + + private fun rxSingle(dispatcher: CoroutineDispatcher = Dispatchers.IO, block: suspend CoroutineScope.() -> T): Single { + return Single.create { sub -> + CoroutineScope(dispatcher).launch { + try { + sub.onSuccess(block()) + } catch (e: Throwable) { + sub.onError(e) + } + } + } + } + + private fun getAvailableFilterNames(doc: Document, filterClassName: String): List { + return doc.select("ul.$filterClassName > li > a > span").map { it.ownText() } + } + + private fun String.cleanTitle(): String { + return replace(CLEAN_TITLE_PATTERN, "").trim() + } + + private fun String.getHighestQualitySrcset(): String? { + val srcsetPairs = split(',').map { + val parts = it.trim().split(' ') + Pair(parts[1].trimEnd('x').toIntOrNull(), parts[0]) + } + return srcsetPairs.maxByOrNull { it.first ?: 0 }?.second + } + + private fun String.parseChapterNumber(): Pair? { + for (pattern in CHAPTER_NUMBER_PATTERNS) { + val match = pattern.find(this) + if (match != null) { + return Pair( + match.groups[0]!!.value.replaceFirstChar(Char::titlecase), + match.groups[1]!!.value.toFloat(), + ) + } + } + // Cannot parse chapter number + return null + } + + companion object { + + private val allFilter = FilterInfo("All", "") + private val fallbackFilters = listOf(FilterInfo("Press reset to load filters", "")) + + private val categoryExcludeRegexDefault = arrayOf( + "audiobooks", + "bookshelf skin", + "int'l manga", // already present in genres + ).joinToString("|") + + private val genreExcludeRegexDefault = arrayOf( + "coupon", + "bundle set", + "bonus items", + "completed series", // already present in others + "\\d{4}", // the genre list is bloated with things like "fall anime 2019" + "kc simulpub", // this is a specific publisher; "Simulpub Release" is separate + "kodansha promotion", + "shonen jump", + "media do", + "youtuber recommendations", + ).joinToString("|") + + private val CLEAN_TITLE_PATTERN = Regex( + listOf( + "(manga)", + "(comic)", + "", + "(serial)", + "", + // Deliberately not stripping tags like "(Light Novels)" + ).joinToString("|", transform = Regex::escape), + RegexOption.IGNORE_CASE, + ) + + private val CHAPTER_NUMBER_PATTERNS = listOf( + // All must have exactly one capture group for the chapter number + Regex("""vol\.?\s*([0-9.]+)""", RegexOption.IGNORE_CASE), + Regex("""volume\s+([0-9.]+)""", RegexOption.IGNORE_CASE), + Regex("""chapter\s+([0-9.]+)""", RegexOption.IGNORE_CASE), + Regex("""#([0-9.]+)""", RegexOption.IGNORE_CASE), + ) + + // Word joiners (zero-width non-breaking spaces) are used to avoid series titles from + // getting automatically stripped out from the start of chapter names. + private const val WORD_JOINER = "\u2060" + private const val PURCHASE_ICON = "\uD83D\uDCB5" // dollar bill emoji + private const val PREORDER_ICON = "\uD83D\uDD51" // two-o-clock emoji + private const val FREE_ICON = "\uD83C\uDF81" // wrapped present emoji + private const val UNKNOWN_ICON = "\u2753" // question mark emoji + + private const val PAGE_INDEX_QUERY_PARAM = "nocache_pagenum" + } +} diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerChapterReader.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerChapterReader.kt new file mode 100644 index 000000000..b7da78fa6 --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerChapterReader.kt @@ -0,0 +1,366 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker + +import android.annotation.SuppressLint +import android.app.Application +import android.graphics.Bitmap +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import eu.kanade.tachiyomi.extension.en.bookwalker.BookWalkerChapterReader.ImageResult.NotReady.get +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import uy.kohesive.injekt.injectLazy +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.Charset +import java.util.Timer +import kotlin.concurrent.schedule +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlin.math.max +import kotlin.time.Duration.Companion.seconds + +class BookWalkerChapterReader(val readerUrl: String, private val prefs: BookWalkerPreferences) { + + /** + * For when the reader is working properly but there's still an error fetching data. + * Currently this is only used when trying to fetch past the available portion of a preview. + */ + class NonFatalReaderException(message: String) : Exception(message) + + private val app by injectLazy() + + // We need to be careful to avoid CoroutineScope.launch since that will disconnect thrown + // exceptions inside the launched code from the calling coroutine and cause the entire + // application to crash rather than just showing an error when an image fails to load. + + // WebView interaction _must_ occur on the UI thread, but everything else should happen on an + // IO thread to avoid stalling the application. + + private fun evaluateOnUiThread(block: suspend CoroutineScope.() -> T): Deferred = + CoroutineScope(Dispatchers.Main.immediate).async { block() } + + private fun evaluateOnIOThread(block: suspend CoroutineScope.() -> T): Deferred = + CoroutineScope(Dispatchers.IO).async { block() } + + private val isDestroyed = MutableStateFlow(false) + + /** + * Calls [block] with the reader's active WebView as its argument and returns the result. + * [block] is guaranteed to run on a thread that can safely interact with the WebView. + */ + private suspend fun usingWebView(block: suspend (webview: WebView) -> T): T { + if (isDestroyed.value) { + throw Exception("Reader was destroyed") + } + return webview.await().let { + evaluateOnUiThread { + block(it) + }.await() + } + } + + private val webview = evaluateOnUiThread { + suspendCoroutine { cont -> + var cancelled = false + val timer = Timer().schedule(WEBVIEW_STARTUP_TIMEOUT.inWholeMilliseconds) { + // Don't destroy the webview here, that's the responsibility of the caller. + cancelled = true + cont.resumeWithException(Exception("WebView didn't load within $WEBVIEW_STARTUP_TIMEOUT")) + } + + Log.d("bookwalker", "Creating Webview...") + WebView(app).apply { + // The aspect ratio needs to be thinner than 3:2 to avoid two pages rendering at + // once, which would break the image-fetching logic. 1:1 works fine. + // We grab the image before it gets resized to fit the viewport so the image size + // doesn't directly correlate with screen size, but the size of the screen still + // affects the size of source image that the reader tries to render. + // The available resolutions vary per series, but in general, the largest resolution + // is typically on the order of 2k pixels vertical, with reduced-resolution variants + // on each factor of two (1/2, 1/4, etc.) for smaller screens. + val size = when (prefs.imageQuality) { + ImageQualityPref.DEVICE -> max( + app.resources.displayMetrics.heightPixels, + app.resources.displayMetrics.widthPixels, + ) + // "Medium" doesn't necessarily mean we'll use the 1/2x variant, just that we'll + // use the variant that BookWalker thinks is appropriate for a 1000px screen + // (which typically is the 1/2x variant for manga with high native resolutions). + ImageQualityPref.MEDIUM -> 1000 + // A 2000x2000px WebView consistently captured the largest variant in testing, + // but just in case some series can have a higher max resolution, 3000px is used + // for the "high" image quality option. In theory we could go higher (like 10k) + // and it wouldn't affect the image size, but there start to be performance + // issues when the BookWalker viewer tries to draw onto huge canvases. + ImageQualityPref.HIGH -> 3000 + } + Log.d("bookwalker", "WebView size $size") + // Note: The BookWalker viewer is DPI-aware, so even though the innerWidth/Height + // values in JavaScript may not match the layout() call, everything works properly. + layout(0, 0, size, size) + + @SuppressLint("SetJavaScriptEnabled") + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + // Mobile vs desktop doesn't matter much, but the mobile layout has a longer page + // slider which allows for more accuracy when trying to jump to a particular page. + settings.userAgentString = USER_AGENT_MOBILE + + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + + timer.cancel() + + if (cancelled) { + Log.d("bookwalker", "WebView loaded $url after being destroyed") + return + } + + Log.d("bookwalker", "WebView loaded $url") + + if (url.contains("member.bookwalker.jp")) { + cont.resumeWithException(Exception("Logged out, check website in WebView")) + return + } + + cont.resume(view) + } + } + +// webChromeClient = object : WebChromeClient() { +// override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { +// Log.println( +// when (consoleMessage.messageLevel()!!) { +// ConsoleMessage.MessageLevel.TIP -> Log.VERBOSE +// ConsoleMessage.MessageLevel.DEBUG -> Log.DEBUG +// ConsoleMessage.MessageLevel.LOG -> Log.INFO +// ConsoleMessage.MessageLevel.WARNING -> Log.WARN +// ConsoleMessage.MessageLevel.ERROR -> Log.ERROR +// }, +// "bookwalker.console", +// "${consoleMessage.sourceId()}:${consoleMessage.lineNumber()} ${consoleMessage.message()}", +// ) +// +// return super.onConsoleMessage(consoleMessage) +// } +// } + + this.addJavascriptInterface(jsInterface, INTERFACE_NAME) + + // Adding the below line makes a console error go away, but it doesn't seem to affect functionality. + // webview.addJavascriptInterface(object {}, "Notification") + + loadUrl(readerUrl) + } + }.also { + it.evaluateJavascript( + injectionScriptReplacements.asIterable() + .fold(webviewInjectionScript) { script, replacement -> + script.replace(replacement.key, replacement.value) + }, + null, + ) + } + } + + private suspend fun evaluateJavascript(script: String): String? { + return usingWebView { webview -> + suspendCoroutine { cont -> + webview.evaluateJavascript(script) { + cont.resume(it) + } + } + } + } + + suspend fun destroy() { + Log.d("bookwalker", "Destroy called") + try { + usingWebView { + it.destroy() + isDestroyed.value = true + } + } catch (e: Exception) { + // OK, the webview was probably already destroyed +// Log.d("bookwalker", "Destroy error: $e") + } + } + + /** + * Returns a flow which transparently forwards the original flow except that if the WebView is + * destroyed while waiting for data (or was already destroyed), suspending calls to obtain the + * data will throw. + * + * If the WebView was already destroyed when the suspending call was made but data from the + * original flow is immediately received, it is not deterministic whether it will return the + * data or throw. + */ + private fun Flow.throwOnDestroyed(): Flow { + return merge( + this.map { false to it }, + isDestroyed.filter { it }.map { true to null }, + ).map { + if (it.first) { + throw Exception("Reader was destroyed") + } + // Can't use !! here because T might be nullable + @Suppress("UNCHECKED_CAST") + it.second as T + } + } + + private val isViewerReady = MutableStateFlow(false) + + private suspend fun waitForViewer() { + webview.await() + isViewerReady.filter { it }.throwOnDestroyed().first() + } + + private sealed class ImageResult { + object NotReady : ImageResult() + class Found(val data: Deferred) : ImageResult() + class NotFound(val error: Throwable) : ImageResult() + + suspend fun Flow.get(): ByteArray { + @OptIn(FlowPreview::class) + return flatMapConcat { + when (it) { + is NotReady -> emptyFlow() + is Found -> flow { emit(it.data.await()) } + is NotFound -> throw it.error + } + }.first() + } + } + + private val imagesMap = mutableMapOf>() + + private val navigationMutex = Mutex() + + /** + * Retrieves JPEG image data for the requested page (0-indexed) + */ + suspend fun getPage(index: Int): ByteArray { + val state = synchronized(imagesMap) { + imagesMap.getOrPut(index) { MutableStateFlow(ImageResult.NotReady) } + } + + waitForViewer() + + // Attempting to fetch two pages concurrently doesn't work. + val imgData = navigationMutex.withLock { + evaluateJavascript("(() => $JS_UTILS_NAME.fetchPageData($index))()") + + val result = withTimeoutOrNull(IMAGE_FETCH_TIMEOUT) { + Log.d("bookwalker", "Waiting on image index $index ($state)") + state.throwOnDestroyed().get() + } ?: throw Exception("Timed out waiting for image $index to load") + + // Stop holding onto the image in the map so it can get collected. Due to caching by the + // app, it is unlikely that the same reader will need to fetch the same image twice, and + // even if it does, the image may still be stored in memory at the webview level. + state.value = ImageResult.NotReady + + result + } + + Log.d("bookwalker", "Retrieved data for image $index (${imgData.size} bytes)") + return imgData + } + + private val jsInterface = object { + @JavascriptInterface + fun reportViewerLoaded() { + Log.d("bookwalker", "Viewer loaded") + isViewerReady.value = true + } + + @JavascriptInterface + fun reportFailedToLoad(message: String) { + Log.e("bookwalker", "Failed to load BookWalker viewer: $message") + // launch should be safe here, destroy() should not throw (except for truly critical errors) + CoroutineScope(Dispatchers.IO).launch { destroy() } + } + + @JavascriptInterface + fun reportImage(index: Int, imageDataAsString: String, width: Int, height: Int) { + // Byte arrays cannot be directly transferred efficiently, so strings are our + // best choice for transporting large images out of the Webview. + // See https://stackoverflow.com/a/45506857 + val data = imageDataAsString.toByteArray(Charset.forName("windows-1252")) + Log.d("bookwalker", "received image $index (${data.size} bytes, ${width}x$height)") + + val state = synchronized(imagesMap) { + imagesMap.getOrPut(index) { MutableStateFlow(ImageResult.NotReady) } + } + + // The raw bitmap data is very large, so we want to compress it down as soon as possible + // and store that rather than keeping uncompressed images in memory. + state.value = ImageResult.Found( + evaluateOnIOThread { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)) + + ByteArrayOutputStream().apply { + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, this) + }.toByteArray() + }, + ) + } + + @JavascriptInterface + fun reportImageDoesNotExist(index: Int, reason: String) { + val state = synchronized(imagesMap) { + imagesMap.getOrPut(index) { MutableStateFlow(ImageResult.NotReady) } + } + + state.value = ImageResult.NotFound(NonFatalReaderException(reason)) + } + } + + companion object { + private val webviewInjectionScript by lazy { + this::class.java.getResource("/assets/webview-script.js")?.readText() + ?: throw Error("Failed to retrieve webview injection script") + } + + private const val INTERFACE_NAME = "BOOKWALKER_EXT_COMMS" + private const val JS_UTILS_NAME = "BOOKWALKER_EXT_UTILS" + + private val injectionScriptReplacements = mapOf( + "__INJECT_WEBVIEW_INTERFACE" to INTERFACE_NAME, + "__INJECT_JS_UTILITIES" to JS_UTILS_NAME, + ) + + // Sometimes the webview just fails to load for some reason and we need to retry, so this + // timeout should be kept as short as possible. 15 seconds seems like a decent upper bound. + private val WEBVIEW_STARTUP_TIMEOUT = 15.seconds + + // Images can take a while to load especially if the viewer is in a poor location and needs + // to track to a completely different part of the chapter, but if it's been 15 seconds + // since an image was requested with no response, it usually means something is broken. + // Note that the image fetch timer only starts after the webview loads. + private val IMAGE_FETCH_TIMEOUT = 15.seconds + } +} diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerConstants.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerConstants.kt new file mode 100644 index 000000000..f2a3b27f7 --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerConstants.kt @@ -0,0 +1,52 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker + +const val PREF_VALIDATE_LOGGED_IN = "validateLoggedIn" +const val PREF_SHOW_LIBRARY_IN_POPULAR = "showLibraryInPopular" +const val PREF_ATTEMPT_READ_PREVIEWS = "attemptReadPreviews" +const val PREF_CATEGORY_EXCLUDE_REGEX = "categoryExcludeRegex" +const val PREF_GENRE_EXCLUDE_REGEX = "genreExcludeRegex" + +enum class ImageQualityPref(val key: String) { + DEVICE("device"), + MEDIUM("medium"), + HIGH("high"), + ; + + companion object { + const val PREF_KEY = "imageResolution" + val defaultOption = DEVICE + fun fromKey(key: String) = values().find { it.key == key } ?: defaultOption + } +} + +enum class FilterChaptersPref(val key: String) { + OWNED("owned"), + OBTAINABLE("obtainable"), + ALL("all"), + ; + + fun includes(other: FilterChaptersPref): Boolean { + return this >= other + } + + companion object { + const val PREF_KEY = "filterChapters" + val defaultOption = OBTAINABLE + fun fromKey(key: String) = values().find { it.key == key } ?: defaultOption + } +} + +const val QUERY_PARAM_CATEGORY = "qcat" +const val QUERY_PARAM_GENRE = "qtag" + +// const val QUERY_PARAM_AUTHOR = "qaut" +const val QUERY_PARAM_PUBLISHER = "qcom" + +const val HEADER_IS_REQUEST_FROM_EXTENSION = "x-is-bookwalker-extension" +const val HEADER_PAGE_INDEX = "x-page-index" + +const val USER_AGENT_DESKTOP = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36" + +const val USER_AGENT_MOBILE = "Mozilla/5.0 (Linux; Android 10; Pixel 4) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36" diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerFilters.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerFilters.kt new file mode 100644 index 000000000..61b3a8716 --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerFilters.kt @@ -0,0 +1,160 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker + +import android.util.Log +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.math.max + +class BookWalkerFilters(private val bookwalker: BookWalker) { + var categories: List? = null + private set + var genres: List? = null + private set + + // Author filter disabled for now, since the performance/UX in-app is pretty bad +// var authors: List? = null +// private set + var publishers: List? = null + private set + + private val fetchMutex = Mutex() + private var hasObtainedFilters = false + + fun fetchIfNecessaryInBackground() { + CoroutineScope(Dispatchers.IO).launch { + try { + fetchIfNecessary() + } catch (e: Exception) { + Log.e("bookwalker", e.toString()) + } + } + } + + // Lock applied so that we don't try to make additional new requests before the first set of + // requests have finished. + private suspend fun fetchIfNecessary() = fetchMutex.withLock { + // In theory the list of filters could change while the app is alive, but in practice that + // seems fairly unlikely, and we can save a lot of unnecessary calls by assuming it won't. + if (hasObtainedFilters) { + return + } + + coroutineScope { + listOf( + async { if (categories == null) categories = fetchFilters("categories") }, + async { if (genres == null) genres = fetchFilters("genre") }, +// async { if (authors == null) authors = fetchFilters("authors") }, + async { if (publishers == null) publishers = fetchFilters("publishers") }, + ).awaitAll() + + hasObtainedFilters = true + } + } + + private suspend fun fetchFilters(entityName: String): List { + val entityPath = "/$entityName/" + val response = bookwalker.client.newCall( + GET(bookwalker.baseUrl + entityPath, bookwalker.callHeaders), + ).await() + val document = response.asJsoup() + return document.select(".link-list > li > a").map { + FilterInfo( + it.text(), + it.attr("href").removePrefix(entityPath).trimEnd('/'), + ) + } + } +} + +class FilterInfo(name: String, val id: String) : Filter.CheckBox(name) { + override fun toString(): String { + return name + } +} + +interface QueryParamFilter { + fun getQueryParams(): List> +} + +class SelectOneFilter( + name: String, + private val queryParam: String, + options: List, +) : QueryParamFilter, Filter.Select( + name, + options.toTypedArray(), + max(0, options.indexOfFirst { it.id == "2" }), // Default to manga +) { + override fun getQueryParams(): List> { + return listOf(queryParam to values[state].id) + } +} + +class SelectMultipleFilter( + name: String, + private val queryParam: String, + options: List, +) : QueryParamFilter, Filter.Group(name, options) { + override fun getQueryParams(): List> { + return listOf( + queryParam to state.filter { it.state }.joinToString(",") { it.id }, + ) + } +} + +class OthersFilter : QueryParamFilter, Filter.Group( + "Others", + listOf( + FilterInfo("On Sale", "qspp"), + FilterInfo("Coin Boost", "qcon"), + FilterInfo("Pre-Order", "qcos"), + FilterInfo("Completed", "qcpl"), + FilterInfo("Bonus Item", "qspe"), + FilterInfo("Exclude Purchased", "qseq"), + ), +) { + override fun getQueryParams(): List> { + return state.filter { it.state }.map { it.id to "1" } + } +} + +class ExcludeFilter : QueryParamFilter, Filter.Text("Exclude search terms (comma-separated)") { + override fun getQueryParams(): List> { + return state.split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { "qnot[]" to it.replace(" ", "+") } + } +} + +class TextFilter( + name: String, + private val queryParam: String, + defaultValue: String = "", +) : QueryParamFilter, Filter.Text(name, defaultValue) { + override fun getQueryParams(): List> { + return listOf(queryParam to state) + } +} + +class PriceFilter : QueryParamFilter, Filter.Group( + "Price", + listOf( + TextFilter("Min Price ($)", "qpri_min"), + TextFilter("Max Price ($)", "qpri_max"), + ), +) { + override fun getQueryParams(): List> { + return state.filter { it.state.isNotEmpty() }.flatMap { it.getQueryParams() } + } +} diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerImageRequestInterceptor.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerImageRequestInterceptor.kt new file mode 100644 index 000000000..a6e74472f --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerImageRequestInterceptor.kt @@ -0,0 +1,96 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker + +import android.util.Log +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.util.LinkedHashMap +import kotlin.time.Duration.Companion.minutes + +class BookWalkerImageRequestInterceptor(private val prefs: BookWalkerPreferences) : Interceptor { + + private val readerQueue = object : LinkedHashMap>( + MAX_ACTIVE_READERS + 1, + 1f, + true, + ) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean { + if (size > MAX_ACTIVE_READERS) { + eldest.value.expire() + return true + } + return false + } + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (request.headers[HEADER_IS_REQUEST_FROM_EXTENSION] != "true") { + return chain.proceed(request) + } + + val readerUrl = request.url.toString() + val pageIndex = request.headers[HEADER_PAGE_INDEX]!!.toInt() + + Log.d("bookwalker", "Intercepting request for page $pageIndex") + + val reader = synchronized(readerQueue) { + readerQueue.getOrPut(readerUrl) { + Expiring(BookWalkerChapterReader(readerUrl, prefs), READER_EXPIRATION_TIME) { + disposeReader(this) + } + } + } + + val imageData = try { + runBlocking { + reader.contents.getPage(pageIndex) + } + } catch (e: BookWalkerChapterReader.NonFatalReaderException) { + // Just re-throw, this isn't worth killing the webview over. + Log.e("bookwalker", e.toString()) + throw e + } catch (e: Exception) { + // If there's some other exception, we can generally assume that the + // webview is broken in some way and should be re-created. + reader.expire() + Log.e("bookwalker", e.toString()) + throw e + } + + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(imageData.toResponseBody(IMAGE_MEDIA_TYPE)) + .build() + } + + private fun disposeReader(reader: BookWalkerChapterReader) { +// Log.d("bookwalker", "Disposing reader ${reader.readerUrl}") + synchronized(readerQueue) { + readerQueue.remove(reader.readerUrl) + } + runBlocking { + reader.destroy() + } + } + + companion object { + // Having at most two chapter readers at once should be enough to avoid thrashing on chapter + // transitions without keeping an excessive number of webviews running in the background. + // In theory there could also be a download happening at the same time, but 2 is probably fine. + private const val MAX_ACTIVE_READERS = 2 + + // We don't get events when a user exits the reader or a download finishes, so we want to + // make sure to clean up unused readers after a period of time. + private val READER_EXPIRATION_TIME = 1.minutes + + private val IMAGE_MEDIA_TYPE = "image/jpeg".toMediaType() + } +} diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerPreferences.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerPreferences.kt new file mode 100644 index 000000000..aca4f5d57 --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/BookWalkerPreferences.kt @@ -0,0 +1,11 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker + +interface BookWalkerPreferences { + val showLibraryInPopular: Boolean + val shouldValidateLogin: Boolean + val imageQuality: ImageQualityPref + val filterChapters: FilterChaptersPref + val attemptToReadPreviews: Boolean + val excludeCategoryFilters: Regex + val excludeGenreFilters: Regex +} diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/Expiring.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/Expiring.kt new file mode 100644 index 000000000..cc79792c0 --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/Expiring.kt @@ -0,0 +1,53 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import kotlin.time.Duration + +class Expiring( + private val obj: T, + private val expirationTime: Duration, + val onExpire: T.() -> Unit, +) { + + private var lastAccessed = System.currentTimeMillis() + + private var expired = false + + val contents + get() = run { + lastAccessed = System.currentTimeMillis() + obj + } + + private var expirationJob: Job + + fun expire() { + if (!expired) { + expired = true + expirationJob.cancel() + onExpire(obj) + } + } + + init { + expirationJob = CoroutineScope(Dispatchers.IO).launch { + while (true) { + yield() + val targetTime = lastAccessed + expirationTime.inWholeMilliseconds + val currentTime = System.currentTimeMillis() + val difference = targetTime - currentTime + if (difference > 0) { + delay(difference) + } else { + break + } + } + expire() + } + } +} diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/HoldBooksInfoDto.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/HoldBooksInfoDto.kt new file mode 100644 index 000000000..cddc6d38f --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/HoldBooksInfoDto.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator + +@Serializable +class HoldBooksInfoDto( + val holdBookList: HoldBookListDto, +) + +@Serializable +class HoldBookListDto( + val entities: List, +) + +@Serializable +@JsonClassDiscriminator("type") +sealed class HoldBookEntityDto diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SeriesDto.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SeriesDto.kt new file mode 100644 index 000000000..d53300d9b --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SeriesDto.kt @@ -0,0 +1,12 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("series") +class SeriesDto( + val seriesId: Int, + val seriesName: String, + val imageUrl: String, +) : HoldBookEntityDto() diff --git a/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SingleDto.kt b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SingleDto.kt new file mode 100644 index 000000000..97cb428a1 --- /dev/null +++ b/src/en/bookwalker/src/eu/kanade/tachiyomi/extension/en/bookwalker/dto/SingleDto.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.extension.en.bookwalker.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("normal") +class SingleDto( + val detailUrl: String, + val title: String, + val imageUrl: String, + val authors: List, +) : HoldBookEntityDto() + +@Serializable +class AuthorDto( + val authorName: String, +)