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)
+    }
+}