diff --git a/src/en/kmanga/AndroidManifest.xml b/src/en/kmanga/AndroidManifest.xml
new file mode 100644
index 000000000..add22611a
--- /dev/null
+++ b/src/en/kmanga/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/en/kmanga/build.gradle b/src/en/kmanga/build.gradle
new file mode 100644
index 000000000..db9216258
--- /dev/null
+++ b/src/en/kmanga/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'K Manga'
+ extClass = '.KManga'
+ extVersionCode = 1
+ isNsfw = false
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/kmanga/res/mipmap-hdpi/ic_launcher.png b/src/en/kmanga/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..4e7de12f2
Binary files /dev/null and b/src/en/kmanga/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/kmanga/res/mipmap-mdpi/ic_launcher.png b/src/en/kmanga/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..5e92c8d90
Binary files /dev/null and b/src/en/kmanga/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/kmanga/res/mipmap-xhdpi/ic_launcher.png b/src/en/kmanga/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..a7fdeca4c
Binary files /dev/null and b/src/en/kmanga/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/kmanga/res/mipmap-xxhdpi/ic_launcher.png b/src/en/kmanga/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..4f1c1d421
Binary files /dev/null and b/src/en/kmanga/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/kmanga/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/kmanga/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..b19f584d0
Binary files /dev/null and b/src/en/kmanga/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/Dto.kt b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/Dto.kt
new file mode 100644
index 000000000..79355e8c3
--- /dev/null
+++ b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/Dto.kt
@@ -0,0 +1,64 @@
+package eu.kanade.tachiyomi.extension.en.kmanga
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+class RankingApiResponse(
+ @SerialName("ranking_title_list") val rankingTitleList: List,
+)
+
+@Serializable
+class RankingTitleId(
+ val id: Int,
+)
+
+@Serializable
+class TitleListResponse(
+ @SerialName("title_list") val titleList: List,
+)
+
+@Serializable
+class TitleDetail(
+ @SerialName("title_id") val titleId: Int,
+ @SerialName("title_name") val titleName: String,
+ @SerialName("thumbnail_image_url") val thumbnailImageUrl: String? = null,
+ @SerialName("banner_image_url") val bannerImageUrl: String? = null,
+)
+
+@Serializable
+class BirthdayCookie(val value: String, val expires: Long)
+
+@Serializable
+class EpisodeListResponse(
+ @SerialName("episode_list") val episodeList: List,
+)
+
+@Serializable
+class Episode(
+ @SerialName("episode_id") val episodeId: Int,
+ @SerialName("episode_name") val episodeName: String,
+ @SerialName("start_time") val startTime: String,
+ val point: Int,
+ @SerialName("title_id") val titleId: Int,
+ val badge: Int,
+ @SerialName("rental_finish_time") val rentalFinishTime: String? = null,
+)
+
+@Serializable
+class ViewerApiResponse(
+ @SerialName("page_list") val pageList: List,
+ @SerialName("scramble_seed") val scrambleSeed: Long,
+)
+
+@Serializable
+class SearchApiResponse(
+ @SerialName("title_list") val titleList: List,
+)
+
+@Serializable
+class SearchTitleDetail(
+ @SerialName("title_id") val titleId: Int,
+ @SerialName("title_name") val titleName: String,
+ @SerialName("thumbnail_image_url") val thumbnailImageUrl: String? = null,
+)
diff --git a/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/ImageInterceptor.kt b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/ImageInterceptor.kt
new file mode 100644
index 000000000..ab4257950
--- /dev/null
+++ b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/ImageInterceptor.kt
@@ -0,0 +1,95 @@
+package eu.kanade.tachiyomi.extension.en.kmanga
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Rect
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Response
+import okhttp3.ResponseBody
+import okhttp3.ResponseBody.Companion.toResponseBody
+import java.io.ByteArrayOutputStream
+
+// https://greasyfork.org/en/scripts/467901-k-manga-ripper
+class ImageInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val fragment = request.url.fragment
+ if (fragment.isNullOrEmpty() || !fragment.startsWith("scramble_seed=")) {
+ return chain.proceed(request)
+ }
+
+ val seed = fragment.substringAfter("scramble_seed=").toLong()
+ val response = chain.proceed(request)
+ val descrambledBody = descrambleImage(response.body, seed)
+
+ return response.newBuilder().body(descrambledBody).build()
+ }
+
+ private class Coord(val x: Int, val y: Int)
+ private class CoordPair(val source: Coord, val dest: Coord)
+
+ private fun xorshift32(seed: UInt): UInt {
+ var n = seed
+ n = n xor (n shl 13)
+ n = n xor (n shr 17)
+ n = n xor (n shl 5)
+ return n
+ }
+
+ private fun getUnscrambledCoords(seed: Long): List {
+ var seed32 = seed.toUInt()
+ val pairs = mutableListOf>()
+
+ for (i in 0 until 16) {
+ seed32 = xorshift32(seed32)
+ pairs.add(seed32 to i)
+ }
+
+ pairs.sortBy { it.first }
+ val sortedVal = pairs.map { it.second }
+
+ return sortedVal.mapIndexed { i, e ->
+ CoordPair(
+ source = Coord(x = e % 4, y = e / 4),
+ dest = Coord(x = i % 4, y = i / 4),
+ )
+ }
+ }
+
+ private fun descrambleImage(responseBody: ResponseBody, seed: Long): ResponseBody {
+ val unscrambledCoords = getUnscrambledCoords(seed)
+ val originalBitmap = BitmapFactory.decodeStream(responseBody.byteStream())
+ ?: throw Exception("Failed to decode image stream")
+
+ val originalWidth = originalBitmap.width
+ val originalHeight = originalBitmap.height
+
+ val descrambledBitmap = Bitmap.createBitmap(originalWidth, originalHeight, originalBitmap.config)
+ val canvas = Canvas(descrambledBitmap)
+
+ val getTileDimension = { size: Int -> (size / 8 * 8) / 4 }
+ val tileWidth = getTileDimension(originalWidth)
+ val tileHeight = getTileDimension(originalHeight)
+
+ unscrambledCoords.forEach { coord ->
+ val sx = coord.source.x * tileWidth
+ val sy = coord.source.y * tileHeight
+ val dx = coord.dest.x * tileWidth
+ val dy = coord.dest.y * tileHeight
+
+ val srcRect = Rect(sx, sy, sx + tileWidth, sy + tileHeight)
+ val destRect = Rect(dx, dy, dx + tileWidth, dy + tileHeight)
+
+ canvas.drawBitmap(originalBitmap, srcRect, destRect, null)
+ }
+ originalBitmap.recycle()
+
+ val outputStream = ByteArrayOutputStream()
+ descrambledBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
+ descrambledBitmap.recycle()
+
+ return outputStream.toByteArray().toResponseBody("image/jpeg".toMediaType())
+ }
+}
diff --git a/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KManga.kt b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KManga.kt
new file mode 100644
index 000000000..5446fb077
--- /dev/null
+++ b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KManga.kt
@@ -0,0 +1,394 @@
+package eu.kanade.tachiyomi.extension.en.kmanga
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.asObservable
+import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
+import eu.kanade.tachiyomi.source.model.Filter
+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.HttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import keiyoushi.utils.firstInstance
+import keiyoushi.utils.parseAs
+import keiyoushi.utils.tryParse
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.FormBody
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okio.ByteString.Companion.encodeUtf8
+import rx.Observable
+import java.net.URLDecoder
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class KManga : HttpSource() {
+
+ override val name = "K Manga"
+ override val baseUrl = "https://kmanga.kodansha.com"
+ override val lang = "en"
+ override val supportsLatest = false
+
+ private val apiUrl = "https://api.kmanga.kodansha.com"
+ private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
+ private val pageLimit = 25
+ private val searchLimit = 25
+
+ override fun headersBuilder() = super.headersBuilder()
+ .add("Referer", "$baseUrl/")
+ .add("X-Requested-With", "XMLHttpRequest")
+ .add("x-kmanga-platform", "3")
+
+ override val client: OkHttpClient = network.cloudflareClient.newBuilder()
+ .addInterceptor(ImageInterceptor())
+ .rateLimitHost(apiUrl.toHttpUrl(), 1)
+ .build()
+
+ // Popular
+ override fun popularMangaRequest(page: Int): Request {
+ val offset = (page - 1) * pageLimit
+ val url = apiUrl.toHttpUrl().newBuilder()
+ .addPathSegments("ranking/all")
+ .addQueryParameter("ranking_id", "12")
+ .addQueryParameter("offset", offset.toString())
+ .addQueryParameter("limit", (pageLimit + 1).toString())
+ .build()
+
+ return hashedGet(url)
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val rankingResult = response.parseAs()
+ val titleIds = rankingResult.rankingTitleList.map { it.id.toString() }
+
+ if (titleIds.isEmpty()) {
+ return MangasPage(emptyList(), false)
+ }
+
+ val hasNextPage = titleIds.size > pageLimit
+ val mangaIdsToFetch = if (hasNextPage) titleIds.dropLast(1) else titleIds
+
+ val detailsUrl = apiUrl.toHttpUrl().newBuilder()
+ .addPathSegments("title/list")
+ .addQueryParameter("title_id_list", mangaIdsToFetch.joinToString(","))
+ .build()
+
+ val detailsRequest = hashedGet(detailsUrl)
+ val detailsResponse = client.newCall(detailsRequest).execute()
+
+ if (!detailsResponse.isSuccessful) {
+ throw Exception("Failed to fetch title details: ${detailsResponse.code} - ${detailsResponse.body.string()}")
+ }
+
+ val detailsResult = detailsResponse.parseAs()
+ val mangas = detailsResult.titleList.map { manga ->
+ SManga.create().apply {
+ url = "/title/${manga.titleId}"
+ title = manga.titleName
+ thumbnail_url = manga.thumbnailImageUrl ?: manga.bannerImageUrl
+ }
+ }
+ return MangasPage(mangas, hasNextPage)
+ }
+
+ // Search
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return if (query.startsWith(PREFIX_SEARCH)) {
+ val titleId = query.removePrefix(PREFIX_SEARCH)
+ fetchMangaDetails(
+ SManga.create().apply { url = "/title/$titleId" },
+ ).map { manga ->
+ MangasPage(listOf(manga.apply { url = "/title/$titleId" }), false)
+ }
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val genreFilter = filters.firstInstance()
+
+ if (query.isNotBlank()) {
+ val offset = (page - 1) * searchLimit
+ val url = apiUrl.toHttpUrl().newBuilder()
+ .addPathSegments("search/title")
+ .addQueryParameter("keyword", query)
+ .addQueryParameter("offset", offset.toString())
+ .addQueryParameter("limit", searchLimit.toString())
+ .build()
+ return hashedGet(url)
+ }
+
+ if (genreFilter.state != 0) {
+ val url = baseUrl.toHttpUrl().newBuilder()
+ .addPathSegments(genreFilter.toUriPart().removePrefix("/"))
+ .build()
+ return GET(url, headers)
+ }
+ return popularMangaRequest(page)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ if (response.request.url.host.contains("api.")) {
+ val result = response.parseAs()
+ val mangas = result.titleList.map { manga ->
+ SManga.create().apply {
+ url = "/title/${manga.titleId}"
+ title = manga.titleName
+ thumbnail_url = manga.thumbnailImageUrl
+ }
+ }
+ return MangasPage(mangas, mangas.size >= searchLimit)
+ }
+
+ if (response.request.url.toString().contains("/search/genre/")) {
+ val document = response.asJsoup()
+ val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data()
+ ?: return MangasPage(emptyList(), false)
+
+ val rootArray = nuxtData.parseAs()
+ fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()]
+
+ val genreResultObject = rootArray.firstOrNull { it is JsonObject && "title_list" in it.jsonObject }
+ ?: return MangasPage(emptyList(), false)
+
+ val mangaRefs = resolve(genreResultObject.jsonObject["title_list"]!!).jsonArray
+
+ val mangas = mangaRefs.map { ref ->
+ val mangaObject = resolve(ref).jsonObject
+ SManga.create().apply {
+ url = "/title/${resolve(mangaObject["title_id"]!!).jsonPrimitive.content}"
+ title = resolve(mangaObject["title_name"]!!).jsonPrimitive.content
+ thumbnail_url = mangaObject["thumbnail_image_url"]?.let { resolve(it).jsonPrimitive.content }
+ }
+ }
+ return MangasPage(mangas, false)
+ }
+ return popularMangaParse(response)
+ }
+
+ // Details
+ override fun mangaDetailsParse(response: Response): SManga {
+ val document = response.asJsoup()
+ val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data()
+ ?: throw Exception("Could not find Nuxt data")
+
+ val rootArray = nuxtData.parseAs()
+ fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()]
+
+ val titleDetailsObject = rootArray.first { it is JsonObject && it.jsonObject.containsKey("title_in_japanese") }.jsonObject
+
+ val genreMap = buildMap {
+ rootArray.forEach { element ->
+ if (element is JsonObject && element.jsonObject.containsKey("genre_id")) {
+ val genreObject = element.jsonObject
+ val id = resolve(genreObject["genre_id"]!!).jsonPrimitive.content.toInt()
+ val name = resolve(genreObject["genre_name"]!!).jsonPrimitive.content
+ put(id, name)
+ }
+ }
+ }
+
+ return SManga.create().apply {
+ title = resolve(titleDetailsObject["title_name"]!!).jsonPrimitive.content
+ author = resolve(titleDetailsObject["author_text"]!!).jsonPrimitive.content
+ description = resolve(titleDetailsObject["introduction_text"]!!).jsonPrimitive.content
+ thumbnail_url = titleDetailsObject["thumbnail_image_url"]?.let { resolve(it).jsonPrimitive.content }
+ val genreIdRefs = resolve(titleDetailsObject["genre_id_list"]!!).jsonArray
+ val genreIds = genreIdRefs.map { resolve(it).jsonPrimitive.content.toInt() }
+ genre = genreIds.mapNotNull { genreMap[it] }.joinToString()
+ }
+ }
+
+ // Chapters
+ override fun chapterListRequest(manga: SManga): Request {
+ return GET(baseUrl + manga.url, headers)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val document = response.asJsoup()
+ val nuxtData = document.selectFirst("script#__NUXT_DATA__")?.data()
+ ?: throw Exception("Could not find Nuxt data")
+
+ val rootArray = nuxtData.parseAs()
+ fun resolve(ref: JsonElement): JsonElement = rootArray[ref.jsonPrimitive.content.toInt()]
+
+ val titleDetailsObject = rootArray.first { it is JsonObject && it.jsonObject.containsKey("episode_id_list") }.jsonObject
+ val episodeIdRefs = resolve(titleDetailsObject["episode_id_list"]!!).jsonArray
+ val episodeIds = episodeIdRefs.map { resolve(it).jsonPrimitive.content }
+
+ val (birthday, expires) = getBirthdayCookie(response.request.url)
+
+ val formBody = FormBody.Builder()
+ .add("episode_id_list", episodeIds.joinToString(","))
+ .build()
+
+ val params = (0 until formBody.size).associate { formBody.name(it) to formBody.value(it) }
+ val hash = generateHash(params, birthday, expires)
+
+ val postHeaders = headersBuilder()
+ .add("Accept", "application/json")
+ .add("Content-Type", "application/x-www-form-urlencoded")
+ .add("Origin", baseUrl)
+ .add("x-kmanga-is-crawler", "false")
+ .add("x-kmanga-hash", hash)
+ .build()
+
+ val apiRequest = POST("$apiUrl/episode/list", postHeaders, formBody)
+ val apiResponse = client.newCall(apiRequest).execute()
+
+ if (!apiResponse.isSuccessful) {
+ throw Exception("API request failed with code ${apiResponse.code}: ${apiResponse.body.string()}")
+ }
+
+ val result = apiResponse.parseAs()
+
+ return result.episodeList.map { chapter ->
+ SChapter.create().apply {
+ url = "/title/${chapter.titleId}/episode/${chapter.episodeId}"
+ name = if (chapter.point > 0 && chapter.badge != 3 && chapter.rentalFinishTime == null) {
+ "🔒 ${chapter.episodeName}"
+ } else {
+ chapter.episodeName
+ }
+ date_upload = dateFormat.tryParse(chapter.startTime)
+ }
+ }.reversed()
+ }
+
+ private fun getBirthdayCookie(url: HttpUrl): Pair {
+ val cookies = client.cookieJar.loadForRequest(url)
+ val birthdayCookie = cookies.firstOrNull { it.name == "birthday" }?.value
+
+ return if (birthdayCookie != null) {
+ try {
+ val decoded = URLDecoder.decode(birthdayCookie, "UTF-8")
+ val cookieData = decoded.parseAs()
+ cookieData.value to cookieData.expires.toString()
+ } catch (e: Exception) {
+ // Fallback to default if cookie is malformed
+ "2000-01" to (System.currentTimeMillis() / 1000 + 315360000).toString()
+ }
+ } else {
+ // Default for logged out users or users without the cookie to bypass age restrictions
+ "2000-01" to (System.currentTimeMillis() / 1000 + 315360000).toString()
+ }
+ }
+
+ // https://kmanga.kodansha.com/_nuxt/vl9so/entry-CSwIbMdW.js
+ private fun generateHash(params: Map, birthday: String, expires: String): String {
+ val paramStrings = params.toSortedMap().map { (key, value) ->
+ getHashedParam(key, value)
+ }
+
+ val joinedParams = paramStrings.joinToString(",")
+ val hash1 = joinedParams.encodeUtf8().sha256().hex()
+
+ val cookieHash = getHashedParam(birthday, expires)
+
+ val finalString = "$hash1$cookieHash"
+ return finalString.encodeUtf8().sha512().hex()
+ }
+
+ private fun getHashedParam(key: String, value: String): String {
+ val keyHash = key.encodeUtf8().sha256().hex()
+ val valueHash = value.encodeUtf8().sha512().hex()
+ return "${keyHash}_$valueHash"
+ }
+
+ // Pages
+ override fun fetchPageList(chapter: SChapter): Observable> {
+ return client.newCall(pageListRequest(chapter))
+ .asObservable()
+ .map { response ->
+ if (!response.isSuccessful) {
+ if (response.code == 400) {
+ throw Exception("This chapter is locked. Log in via WebView and rent or purchase the chapter to read.")
+ }
+ throw Exception("HTTP error ${response.code}")
+ }
+ pageListParse(response)
+ }
+ }
+
+ override fun pageListParse(response: Response): List {
+ val apiResponse = response.parseAs()
+ val seed = apiResponse.scrambleSeed
+ return apiResponse.pageList.mapIndexed { index, imageUrl ->
+ Page(index = index, imageUrl = "$imageUrl#scramble_seed=$seed")
+ }
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val episodeId = chapter.url.substringAfter("episode/")
+ val url = "$apiUrl/web/episode/viewer".toHttpUrl().newBuilder()
+ .addQueryParameter("episode_id", episodeId)
+ .build()
+
+ return hashedGet(url)
+ }
+
+ private fun hashedGet(url: HttpUrl): Request {
+ val (birthday, expires) = getBirthdayCookie(url)
+ val queryParams = url.queryParameterNames.associateWith { url.queryParameter(it)!! }
+ val hash = generateHash(queryParams, birthday, expires)
+ val newHeaders = headersBuilder()
+ .add("x-kmanga-hash", hash)
+ .build()
+ return GET(url, newHeaders)
+ }
+
+ // Filters
+ override fun getFilterList() = FilterList(
+ Filter.Header("NOTE: Search query will ignore genre filter"),
+ GenreFilter(),
+ )
+
+ private class GenreFilter : UriPartFilter(
+ "Genre",
+ arrayOf(
+ Pair("All", "/ranking"),
+ Pair("Romance・Romcom", "/search/genre/1"),
+ Pair("Horror・Mystery・Suspense", "/search/genre/2"),
+ Pair("Gag・Comedy・Slice-of-Life", "/search/genre/3"),
+ Pair("SF・Fantasy", "/search/genre/4"),
+ Pair("Sports", "/search/genre/5"),
+ Pair("Drama", "/search/genre/6"),
+ Pair("Outlaws・Underworld・Punks", "/search/genre/7"),
+ Pair("Action・Battle", "/search/genre/8"),
+ Pair("Isekai・Super Powers", "/search/genre/9"),
+ Pair("One-off Books", "/search/genre/10"),
+ Pair("Shojo/josei", "/search/genre/11"),
+ Pair("Yaoi/BL", "/search/genre/12"),
+ Pair("LGBTQ", "/search/genre/13"),
+ Pair("Yuri/GL", "/search/genre/14"),
+ Pair("Anime", "/search/genre/15"),
+ Pair("Award Winner", "/search/genre/16"),
+ ),
+ )
+
+ private open class UriPartFilter(displayName: String, val vals: Array>) :
+ Filter.Select(displayName, vals.map { it.first }.toTypedArray()) {
+ fun toUriPart() = vals[state].second
+ }
+
+ companion object {
+ const val PREFIX_SEARCH = "id:"
+ }
+
+ // Unsupported
+ override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
+ override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
+ override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
+}
diff --git a/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KMangaUrlActivity.kt b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KMangaUrlActivity.kt
new file mode 100644
index 000000000..755021a6a
--- /dev/null
+++ b/src/en/kmanga/src/eu/kanade/tachiyomi/extension/en/kmanga/KMangaUrlActivity.kt
@@ -0,0 +1,34 @@
+package eu.kanade.tachiyomi.extension.en.kmanga
+
+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 KMangaUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 1) {
+ val titleId = pathSegments[1]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${KManga.PREFIX_SEARCH}$titleId")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("KMangaUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("KMangaUrlActivity", "Could not parse URI from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}