Add Comikey (#1110)
* Add Comikey * Remove logging * i18n * Comikey Brasil, paid chapters toggle, use other chapter endpoint * Don't parse author/artist in searchMangaFromElement * makeEpisodeSlug private * Move gundamUrl outside of class constructor * paginate latest * paginate search * Properly distinguish i18n keys from normal messages in WebView script * Parse statuses better * Add genre for entry format * remove unnecessary getChapterUrl * Fix status on BR * ACTUALLY fix status on BR * Fix more Comikey Brasil stupidity * Validate that manifestUrl is valid * Revert "Validate that manifestUrl is valid" This reverts commit d744fd42b45ae46baf48308ec3f354546d1452af. * Proper i18n in WebView script * Add explanation for weird binding * Move helper functions to bottom * Support signing in through WebView * Fix chapter list when signed in * Properly filter locked chapters * Remove WebView logging
This commit is contained in:
parent
879eb629b1
commit
4682cc8752
22
src/all/comikey/AndroidManifest.xml
Normal file
22
src/all/comikey/AndroidManifest.xml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<activity
|
||||||
|
android:name=".all.comikey.ComikeyUrlActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
|
||||||
|
<data android:host="comikey.com" />
|
||||||
|
<data android:host="br.comikey.com" />
|
||||||
|
<data
|
||||||
|
android:pathPattern="/comics/..*/..*/"
|
||||||
|
android:scheme="https"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
23
src/all/comikey/assets/i18n/messages_en.properties
Normal file
23
src/all/comikey/assets/i18n/messages_en.properties
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
sort_by=Sort by
|
||||||
|
sort_last_updated=Last updated
|
||||||
|
sort_name=Name
|
||||||
|
sort_popularity=Popularity
|
||||||
|
sort_chapter_count=Chapter count
|
||||||
|
filter_by=Filter by
|
||||||
|
all=All
|
||||||
|
manga=Manga
|
||||||
|
webtoon=Webtoon
|
||||||
|
new=New
|
||||||
|
complete=Complete
|
||||||
|
exclusive=Exclusive
|
||||||
|
simulpub=Simulpub
|
||||||
|
search_use_two_characters=Please use at least 2 characters when searching by title.
|
||||||
|
pref_hide_locked_chapters=Hide locked chapters
|
||||||
|
pref_hide_locked_chapters_summary=App restart required
|
||||||
|
error_timed_out_decrypting_image_links=Timed out decrypting image links
|
||||||
|
error_locked_chapter_unlock_in_webview=Locked chapter, unlock in WebView.
|
||||||
|
error_open_in_webview_then_try_again=Open chapter in WebView, then try again
|
||||||
|
error_token_expired=token expired
|
||||||
|
error_token_not_found=token not found
|
||||||
|
error_webview_script_not_found=WebView script not found.
|
||||||
|
error_unknown_error=Unknown error
|
16
src/all/comikey/assets/i18n/messages_pt_br.properties
Normal file
16
src/all/comikey/assets/i18n/messages_pt_br.properties
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
sort_by=Ordenar por
|
||||||
|
sort_last_updated=Última atualização
|
||||||
|
sort_name=Nome
|
||||||
|
sort_popularity=Popularidade
|
||||||
|
sort_chapter_count=Capítulos
|
||||||
|
filter_by=Filtrar por
|
||||||
|
all=Todos
|
||||||
|
manga=Manga
|
||||||
|
webtoon=Webtoon
|
||||||
|
new=Novo
|
||||||
|
complete=Completo
|
||||||
|
exclusive=Exclusivo
|
||||||
|
simulpub=Simulpub
|
||||||
|
search_use_two_characters=Use pelo menos 2 caracteres ao pesquisar por título.
|
||||||
|
pref_hide_locked_chapters=Ocultar capítulos bloqueados
|
||||||
|
pref_hide_locked_chapters_summary=Se requiere reiniciar la app
|
54
src/all/comikey/assets/webview-script.js
Normal file
54
src/all/comikey/assets/webview-script.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", (e) => {
|
||||||
|
// This is intentional. Simply binding `_` to `window.__interface__.gettext` will
|
||||||
|
// throw an error: "Java bridge method can't be invoked on a non-injected object".
|
||||||
|
const _ = (key) => window.__interface__.gettext(key);
|
||||||
|
|
||||||
|
if (document.querySelector("#unlock-full")) {
|
||||||
|
window.__interface__.passError(_("error_locked_chapter_unlock_in_webview"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"you-right-now:reeeeeee",
|
||||||
|
async (e) => {
|
||||||
|
const _ = (key) => window.__interface__.gettext(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open("firebase-app-check-database");
|
||||||
|
|
||||||
|
request.onsuccess = (event) => resolve(event.target.result);
|
||||||
|
request.onerror = (event) => reject(event.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
const act = await new Promise((resolve, reject) => {
|
||||||
|
db.onerror = (event) => reject(event.target);
|
||||||
|
|
||||||
|
const request = db.transaction("firebase-app-check-store").objectStore("firebase-app-check-store").getAll();
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const entries = event.target.result;
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
if (entries.length < 1) {
|
||||||
|
window.__interface__.passError(`${_("error_open_in_webview_then_try_again")} (${_("error_token_not_found")}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = entries[0].value;
|
||||||
|
|
||||||
|
if (value.expireTimeMillis < Date.now()) {
|
||||||
|
window.__interface__.passError(`${_("error_open_in_webview_then_try_again")} (${_("error_token_expired")}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(value.token)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifest = JSON.parse(document.querySelector("#lmao-init").textContent).manifest;
|
||||||
|
window.__interface__.passPayload(manifest, act, await e.detail);
|
||||||
|
} catch (e) {
|
||||||
|
window.__interface__.passError(`${_("error_unknown_error")}: ${e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
11
src/all/comikey/build.gradle
Normal file
11
src/all/comikey/build.gradle
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
ext {
|
||||||
|
extName = "Comikey"
|
||||||
|
extClass = ".ComikeyFactory"
|
||||||
|
extVersionCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":lib:i18n"))
|
||||||
|
}
|
BIN
src/all/comikey/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/all/comikey/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
BIN
src/all/comikey/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/all/comikey/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
BIN
src/all/comikey/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/all/comikey/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
BIN
src/all/comikey/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/all/comikey/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.8 KiB |
BIN
src/all/comikey/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/all/comikey/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.2 KiB |
@ -0,0 +1,405 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.comikey
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.View
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
|
import eu.kanade.tachiyomi.lib.i18n.Intl
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
|
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 kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
open class Comikey(
|
||||||
|
final override val lang: String,
|
||||||
|
override val name: String = "Comikey",
|
||||||
|
override val baseUrl: String = "https://comikey.com",
|
||||||
|
private val defaultLanguage: String = "en",
|
||||||
|
) : ParsedHttpSource(), ConfigurableSource {
|
||||||
|
|
||||||
|
private val gundamUrl: String = "https://gundam.comikey.net"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override val client = network.cloudflareClient.newBuilder()
|
||||||
|
.rateLimit(3)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
.add("Referer", "$baseUrl/")
|
||||||
|
|
||||||
|
private val dateFormat by lazy {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val intl = Intl(
|
||||||
|
language = lang,
|
||||||
|
baseLanguage = "en",
|
||||||
|
availableLanguages = setOf("en", "pt-BR"),
|
||||||
|
classLoader = this::class.java.classLoader!!,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val preferences by lazy {
|
||||||
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = GET("$baseUrl/comics/?order=-views&page=$page", headers)
|
||||||
|
|
||||||
|
override fun popularMangaSelector() = searchMangaSelector()
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element)
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector() = searchMangaNextPageSelector()
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/comics/?page=$page", headers)
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = searchMangaSelector()
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element)
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector()
|
||||||
|
|
||||||
|
override fun fetchSearchManga(
|
||||||
|
page: Int,
|
||||||
|
query: String,
|
||||||
|
filters: FilterList,
|
||||||
|
): Observable<MangasPage> {
|
||||||
|
return if (query.startsWith(PREFIX_SLUG_SEARCH)) {
|
||||||
|
val slug = query.removePrefix(PREFIX_SLUG_SEARCH)
|
||||||
|
val url = "/comics/$slug/"
|
||||||
|
|
||||||
|
fetchMangaDetails(SManga.create().apply { this.url = url })
|
||||||
|
.map { MangasPage(listOf(it), false) }
|
||||||
|
} else {
|
||||||
|
super.fetchSearchManga(page, query, filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val url = "$baseUrl/comics/".toHttpUrl().newBuilder().apply {
|
||||||
|
if (page > 1) {
|
||||||
|
addQueryParameter("page", page.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.length >= 2) {
|
||||||
|
addQueryParameter("q", query)
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.ifEmpty { getFilterList() }
|
||||||
|
.filterIsInstance<UriFilter>()
|
||||||
|
.forEach { it.addToUri(this) }
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaSelector() = "div.series-listing[data-view=list] > ul > li"
|
||||||
|
|
||||||
|
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
|
||||||
|
element.selectFirst("div.series-data span.title a")!!.let {
|
||||||
|
setUrlWithoutDomain(it.attr("href"))
|
||||||
|
title = it.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
description = element.select("div.excerpt p").text() +
|
||||||
|
"\n\n" +
|
||||||
|
element.select("div.desc p").text()
|
||||||
|
genre = element.select("ul.category-listing li a").joinToString { it.text() }
|
||||||
|
thumbnail_url = element.selectFirst("div.image picture img")?.absUrl("src")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaNextPageSelector() = "ul.pagination li.next-page:not(.disabled)"
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(document: Document): SManga {
|
||||||
|
val data = json.decodeFromString<ComikeyComic>(
|
||||||
|
document.selectFirst("script#comic")!!.data(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return SManga.create().apply {
|
||||||
|
url = data.link
|
||||||
|
title = data.name
|
||||||
|
author = data.author.joinToString { it.name }
|
||||||
|
artist = data.artist.joinToString { it.name }
|
||||||
|
description = "\"${data.excerpt}\"\n\n${data.description}"
|
||||||
|
thumbnail_url = "$baseUrl${data.fullCover}"
|
||||||
|
status = when (data.updateStatus) {
|
||||||
|
// HACK: Comikey Brasil
|
||||||
|
0 -> when {
|
||||||
|
data.updateText.startsWith("toda", true) -> SManga.ONGOING
|
||||||
|
listOf("em pausa", "hiato").any { data.updateText.startsWith(it, true) } -> SManga.ON_HIATUS
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
1 -> SManga.COMPLETED
|
||||||
|
3 -> SManga.ON_HIATUS
|
||||||
|
in (4..14) -> SManga.ONGOING // daily, weekly, bi-weekly, monthly, every day of the week
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
genre = buildList(data.tags.size + 1) {
|
||||||
|
addAll(data.tags.map { it.name })
|
||||||
|
|
||||||
|
when (data.format) {
|
||||||
|
0 -> add("Comic")
|
||||||
|
1 -> add("Manga")
|
||||||
|
2 -> add("Webtoon")
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}.joinToString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
val mangaSlug = response.request.url.pathSegments[1]
|
||||||
|
val mangaData = json.decodeFromString<ComikeyComic>(
|
||||||
|
document.selectFirst("script#comic")!!.data(),
|
||||||
|
)
|
||||||
|
val defaultChapterPrefix = if (mangaData.format == 2) "episode" else "chapter"
|
||||||
|
|
||||||
|
val chapterUrl = gundamUrl.toHttpUrl().newBuilder().apply {
|
||||||
|
val mangaId = response.request.url.pathSegments[2]
|
||||||
|
val gundamToken = document.selectFirst("script:containsData(GUNDAM.token)")
|
||||||
|
?.data()
|
||||||
|
?.substringAfter("= \"")
|
||||||
|
?.substringBefore("\";")
|
||||||
|
|
||||||
|
if (gundamToken != null) {
|
||||||
|
addPathSegment("comic")
|
||||||
|
} else {
|
||||||
|
addPathSegment("comic.public")
|
||||||
|
}
|
||||||
|
|
||||||
|
addPathSegment(mangaId)
|
||||||
|
addPathSegment("episodes")
|
||||||
|
addQueryParameter("language", lang.lowercase())
|
||||||
|
gundamToken?.let { addQueryParameter("token", gundamToken) }
|
||||||
|
}.build()
|
||||||
|
val data = json.decodeFromString<ComikeyEpisodeListResponse>(
|
||||||
|
client.newCall(GET(chapterUrl, headers))
|
||||||
|
.execute()
|
||||||
|
.body
|
||||||
|
.string(),
|
||||||
|
)
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
return data.episodes
|
||||||
|
.filter { it.readable || !hideLockedChapters }
|
||||||
|
.map {
|
||||||
|
SChapter.create().apply {
|
||||||
|
url = "/read/$mangaSlug/${makeEpisodeSlug(it, defaultChapterPrefix)}/"
|
||||||
|
name = buildString {
|
||||||
|
append(it.title)
|
||||||
|
|
||||||
|
if (it.subtitle != null) {
|
||||||
|
append(": ")
|
||||||
|
append(it.subtitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chapter_number = it.number
|
||||||
|
date_upload = try {
|
||||||
|
dateFormat.parse(it.releasedAt)!!.time
|
||||||
|
} catch (e: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter { it.date_upload <= currentTime }
|
||||||
|
.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListSelector() = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
return Observable.fromCallable {
|
||||||
|
pageListParse(chapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(document: Document) = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun pageListParse(chapter: SChapter): List<Page> {
|
||||||
|
val interfaceName = randomString()
|
||||||
|
|
||||||
|
val handler = Handler(Looper.getMainLooper())
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
val jsInterface = JsInterface(latch, json, intl)
|
||||||
|
var webView: WebView? = null
|
||||||
|
|
||||||
|
handler.post {
|
||||||
|
val innerWv = WebView(Injekt.get<Application>())
|
||||||
|
|
||||||
|
webView = innerWv
|
||||||
|
innerWv.settings.domStorageEnabled = true
|
||||||
|
innerWv.settings.javaScriptEnabled = true
|
||||||
|
innerWv.settings.blockNetworkImage = true
|
||||||
|
innerWv.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
||||||
|
innerWv.addJavascriptInterface(jsInterface, interfaceName)
|
||||||
|
|
||||||
|
innerWv.webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
super.onPageStarted(view, url, favicon)
|
||||||
|
view?.evaluateJavascript(webviewScript.replace("__interface__", interfaceName)) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
innerWv.loadUrl("$baseUrl${chapter.url}")
|
||||||
|
}
|
||||||
|
|
||||||
|
latch.await(30, TimeUnit.SECONDS)
|
||||||
|
handler.post { webView?.destroy() }
|
||||||
|
|
||||||
|
if (latch.count == 1L) {
|
||||||
|
throw Exception(intl["error_timed_out_decrypting_image_links"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsInterface.error.isNotEmpty()) {
|
||||||
|
throw Exception(jsInterface.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
val manifestUrl = jsInterface.manifestUrl.toHttpUrl()
|
||||||
|
|
||||||
|
return jsInterface.images.mapIndexed { i, it ->
|
||||||
|
val href = it.alternate.firstOrNull { it.type == "image/webp" }?.href
|
||||||
|
?: it.href
|
||||||
|
val url = manifestUrl.newBuilder().apply {
|
||||||
|
removePathSegment(manifestUrl.pathSegments.size - 1)
|
||||||
|
addPathSegments(href)
|
||||||
|
addQueryParameter("act", jsInterface.act)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
Page(i, imageUrl = url.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
override fun getFilterList() = getComikeyFilters(intl)
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
SwitchPreferenceCompat(screen.context).apply {
|
||||||
|
key = PREF_HIDE_LOCKED_CHAPTERS
|
||||||
|
title = intl["pref_hide_locked_chapters"]
|
||||||
|
summary = intl["pref_hide_locked_chapters_summary"]
|
||||||
|
setDefaultValue(false)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
preferences.edit().putBoolean(PREF_HIDE_LOCKED_CHAPTERS, newValue as Boolean).commit()
|
||||||
|
}
|
||||||
|
}.also(screen::addPreference)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val hideLockedChapters by lazy {
|
||||||
|
preferences.getBoolean(PREF_HIDE_LOCKED_CHAPTERS, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val webviewScript by lazy {
|
||||||
|
javaClass.getResource("/assets/webview-script.js")?.readText()
|
||||||
|
?: throw Exception(intl["error_webview_script_not_found"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomString() = buildString(15) {
|
||||||
|
val charPool = ('a'..'z') + ('A'..'Z')
|
||||||
|
|
||||||
|
for (i in 0 until 15) {
|
||||||
|
append(charPool.random())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeEpisodeSlug(episode: ComikeyEpisode, defaultChapterPrefix: String): String {
|
||||||
|
val e4pid = episode.id.split("-", limit = 2).last()
|
||||||
|
val chapterPrefix = if (defaultChapterPrefix == "chapter" && lang != defaultLanguage) {
|
||||||
|
when (lang) {
|
||||||
|
"es" -> "capitulo-espanol"
|
||||||
|
"pt-br" -> "capitulo-portugues"
|
||||||
|
"fr" -> "chapitre-francais"
|
||||||
|
"id" -> "bab-bahasa"
|
||||||
|
else -> "chapter"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
defaultChapterPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
return "$e4pid/$chapterPrefix-${episode.number.toString().replace(".", "-")}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private class JsInterface(
|
||||||
|
private val latch: CountDownLatch,
|
||||||
|
private val json: Json,
|
||||||
|
private val intl: Intl,
|
||||||
|
) {
|
||||||
|
var images: List<ComikeyPage> = emptyList()
|
||||||
|
private set
|
||||||
|
|
||||||
|
var manifestUrl: String = ""
|
||||||
|
private set
|
||||||
|
|
||||||
|
var act: String = ""
|
||||||
|
private set
|
||||||
|
|
||||||
|
var error: String = ""
|
||||||
|
private set
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
@Suppress("UNUSED")
|
||||||
|
fun gettext(key: String): String {
|
||||||
|
return intl[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
@Suppress("UNUSED")
|
||||||
|
fun passError(msg: String) {
|
||||||
|
error = msg
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
@Suppress("UNUSED")
|
||||||
|
fun passPayload(manifestUrl: String, act: String, rawData: String) {
|
||||||
|
this.manifestUrl = manifestUrl
|
||||||
|
this.act = act
|
||||||
|
images = json.decodeFromString<ComikeyEpisodeManifest>(rawData).readingOrder
|
||||||
|
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
internal const val PREFIX_SLUG_SEARCH = "slug:"
|
||||||
|
internal const val PREF_HIDE_LOCKED_CHAPTERS = "hide_locked_chapters"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.comikey
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
|
|
||||||
|
class ComikeyFactory : SourceFactory {
|
||||||
|
override fun createSources() = listOf(
|
||||||
|
Comikey("en"),
|
||||||
|
Comikey("es"),
|
||||||
|
Comikey("id"),
|
||||||
|
Comikey("pt-BR"),
|
||||||
|
Comikey("pt-BR", "Comikey Brasil", "https://br.comikey.com", defaultLanguage = "pt-BR"),
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,68 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.comikey
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.lib.i18n.Intl
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
|
||||||
|
fun getComikeyFilters(intl: Intl) = FilterList(
|
||||||
|
Filter.Header(intl["search_use_two_characters"]),
|
||||||
|
Filter.Separator(),
|
||||||
|
SortFilter(intl["sort_by"], getSortOptions(intl)),
|
||||||
|
TypeFilter(intl["filter_by"], getTypeOptions(intl)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getSortOptions(intl: Intl) = arrayOf(
|
||||||
|
intl["sort_last_updated"],
|
||||||
|
intl["sort_name"],
|
||||||
|
intl["sort_popularity"],
|
||||||
|
intl["sort_chapter_count"],
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getTypeOptions(intl: Intl) = arrayOf(
|
||||||
|
intl["all"],
|
||||||
|
intl["manga"],
|
||||||
|
intl["webtoon"],
|
||||||
|
intl["new"],
|
||||||
|
intl["complete"],
|
||||||
|
intl["exclusive"],
|
||||||
|
intl["simulpub"],
|
||||||
|
)
|
||||||
|
|
||||||
|
interface UriFilter {
|
||||||
|
fun addToUri(builder: HttpUrl.Builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
class SortFilter(name: String, values: Array<String>) :
|
||||||
|
Filter.Sort(name, values, Selection(2, false)),
|
||||||
|
UriFilter {
|
||||||
|
override fun addToUri(builder: HttpUrl.Builder) {
|
||||||
|
val state = this.state ?: return
|
||||||
|
val value = buildString {
|
||||||
|
if (!state.ascending) {
|
||||||
|
append("-")
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state.index) {
|
||||||
|
0 -> append("updated")
|
||||||
|
1 -> append("name")
|
||||||
|
2 -> append("views")
|
||||||
|
3 -> append("chapters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.addQueryParameter("order", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TypeFilter(name: String, values: Array<String>) :
|
||||||
|
Filter.Select<String>(name, values),
|
||||||
|
UriFilter {
|
||||||
|
override fun addToUri(builder: HttpUrl.Builder) {
|
||||||
|
if (state == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.addQueryParameter("filter", values[state].lowercase())
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.comikey
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ComikeyComic(
|
||||||
|
val id: Int,
|
||||||
|
val link: String,
|
||||||
|
val name: String,
|
||||||
|
val author: List<ComikeyAuthor>,
|
||||||
|
val artist: List<ComikeyAuthor>,
|
||||||
|
val tags: List<ComikeyNameWrapper>,
|
||||||
|
val description: String,
|
||||||
|
val excerpt: String,
|
||||||
|
val e4pid: String,
|
||||||
|
val format: Int,
|
||||||
|
val uslug: String,
|
||||||
|
@SerialName("full_cover") val fullCover: String,
|
||||||
|
@SerialName("update_status") val updateStatus: Int,
|
||||||
|
@SerialName("update_text") val updateText: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ComikeyEpisodeListResponse(
|
||||||
|
val episodes: List<ComikeyEpisode> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ComikeyEpisode(
|
||||||
|
val id: String,
|
||||||
|
val number: Float = 0F,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String? = null,
|
||||||
|
val releasedAt: String,
|
||||||
|
val availability: ComikeyEpisodeAvailability,
|
||||||
|
val finalPrice: Int = 0,
|
||||||
|
val owned: Boolean = false,
|
||||||
|
) {
|
||||||
|
val readable
|
||||||
|
get() = finalPrice == 0 || owned
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ComikeyEpisodeManifest(
|
||||||
|
val readingOrder: List<ComikeyPage>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ComikeyPage(
|
||||||
|
val href: String,
|
||||||
|
val type: String,
|
||||||
|
val height: Int,
|
||||||
|
val width: Int,
|
||||||
|
val alternate: List<ComikeyAlternatePage>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ComikeyAlternatePage(
|
||||||
|
val href: String,
|
||||||
|
val type: String,
|
||||||
|
val height: Int,
|
||||||
|
val width: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ComikeyEpisodeAvailability(
|
||||||
|
val purchaseEnabled: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ComikeyLmaoInitData(
|
||||||
|
val manifest: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ComikeyNameWrapper(
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ComikeyAuthor(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
)
|
@ -0,0 +1,35 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.comikey
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class ComikeyUrlActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val pathSegments = intent?.data?.pathSegments
|
||||||
|
|
||||||
|
if (pathSegments != null && pathSegments.size > 2) {
|
||||||
|
val intent = Intent().apply {
|
||||||
|
action = "eu.kanade.tachiyomi.SEARCH"
|
||||||
|
putExtra("query", "${Comikey.PREFIX_SLUG_SEARCH}${pathSegments[1]}/${pathSegments[2]}")
|
||||||
|
putExtra("filter", packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e("ComikeyUrlActivity", "Could not start activity", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e("ComikeyUrlActivity", "Could not parse URI from intent $intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
finish()
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user