diff --git a/src/all/comikey/AndroidManifest.xml b/src/all/comikey/AndroidManifest.xml new file mode 100644 index 000000000..e5689c3e4 --- /dev/null +++ b/src/all/comikey/AndroidManifest.xml @@ -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> diff --git a/src/all/comikey/assets/i18n/messages_en.properties b/src/all/comikey/assets/i18n/messages_en.properties new file mode 100644 index 000000000..a5afeaf8c --- /dev/null +++ b/src/all/comikey/assets/i18n/messages_en.properties @@ -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 diff --git a/src/all/comikey/assets/i18n/messages_pt_br.properties b/src/all/comikey/assets/i18n/messages_pt_br.properties new file mode 100644 index 000000000..e1f09cd2a --- /dev/null +++ b/src/all/comikey/assets/i18n/messages_pt_br.properties @@ -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 diff --git a/src/all/comikey/assets/webview-script.js b/src/all/comikey/assets/webview-script.js new file mode 100644 index 000000000..4f3e39756 --- /dev/null +++ b/src/all/comikey/assets/webview-script.js @@ -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 }, +); diff --git a/src/all/comikey/build.gradle b/src/all/comikey/build.gradle new file mode 100644 index 000000000..d417b8dd2 --- /dev/null +++ b/src/all/comikey/build.gradle @@ -0,0 +1,11 @@ +ext { + extName = "Comikey" + extClass = ".ComikeyFactory" + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:i18n")) +} diff --git a/src/all/comikey/res/mipmap-hdpi/ic_launcher.png b/src/all/comikey/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..f1ed4f519 Binary files /dev/null and b/src/all/comikey/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/comikey/res/mipmap-mdpi/ic_launcher.png b/src/all/comikey/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..17c2b6cdb Binary files /dev/null and b/src/all/comikey/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/comikey/res/mipmap-xhdpi/ic_launcher.png b/src/all/comikey/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..3af6cb42a Binary files /dev/null and b/src/all/comikey/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/comikey/res/mipmap-xxhdpi/ic_launcher.png b/src/all/comikey/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..ed4cd8b8e Binary files /dev/null and b/src/all/comikey/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/comikey/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/comikey/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..68413a8ca Binary files /dev/null and b/src/all/comikey/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/Comikey.kt b/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/Comikey.kt new file mode 100644 index 000000000..550fb5400 --- /dev/null +++ b/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/Comikey.kt @@ -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" + } +} diff --git a/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyFactory.kt b/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyFactory.kt new file mode 100644 index 000000000..ff3de86b2 --- /dev/null +++ b/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyFactory.kt @@ -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"), + ) +} diff --git a/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyFilters.kt b/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyFilters.kt new file mode 100644 index 000000000..108104533 --- /dev/null +++ b/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyFilters.kt @@ -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()) + } +} diff --git a/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyModels.kt b/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyModels.kt new file mode 100644 index 000000000..02c4073ad --- /dev/null +++ b/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyModels.kt @@ -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, +) diff --git a/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyUrlActivity.kt b/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyUrlActivity.kt new file mode 100644 index 000000000..68b2bfac3 --- /dev/null +++ b/src/all/comikey/src/eu/kanade/tachiyomi/extension/all/comikey/ComikeyUrlActivity.kt @@ -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) + } +}