diff --git a/src/all/sakuramanhwa/build.gradle b/src/all/sakuramanhwa/build.gradle new file mode 100644 index 000000000..b345abece --- /dev/null +++ b/src/all/sakuramanhwa/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'SakuraManhwa' + extClass = '.SakuraManhwa' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/sakuramanhwa/res/mipmap-hdpi/ic_launcher.png b/src/all/sakuramanhwa/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..e92d7965a Binary files /dev/null and b/src/all/sakuramanhwa/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/sakuramanhwa/res/mipmap-mdpi/ic_launcher.png b/src/all/sakuramanhwa/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..25859aff7 Binary files /dev/null and b/src/all/sakuramanhwa/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/sakuramanhwa/res/mipmap-xhdpi/ic_launcher.png b/src/all/sakuramanhwa/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..e3505ab61 Binary files /dev/null and b/src/all/sakuramanhwa/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/sakuramanhwa/res/mipmap-xxhdpi/ic_launcher.png b/src/all/sakuramanhwa/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..1ceeaf311 Binary files /dev/null and b/src/all/sakuramanhwa/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/sakuramanhwa/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/sakuramanhwa/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..0087a87cb Binary files /dev/null and b/src/all/sakuramanhwa/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/ApiModel.kt b/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/ApiModel.kt new file mode 100644 index 000000000..8a64f8605 --- /dev/null +++ b/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/ApiModel.kt @@ -0,0 +1,93 @@ +package eu.kanade.tachiyomi.extension.all.sakuramanhwa + +import kotlinx.serialization.Serializable + +@Serializable +internal class ApiMangaInfo( + val manga: MangaInfo, + val chapters: List, +) + +@Serializable +internal class MangaInfo( + val details: MangaDetails, + val follows: Int, + val views: Int, +) + +@Serializable +internal class MangaDetails( + val id: String, + val title: String, + val img: String, + val description: String?, + val language: String, + val slug: String, + val type: String, + val status: String?, + val author: String, + val rating: Float?, + val create_at: String?, +) + +@Serializable +internal class ChapterInfo( + val id: String, + val title: String?, + val create_at: String, + val number: Float, +) + +@Serializable +internal class ApiChapterInfo( + val chapter: PageInfo, + val prev_chapter: String?, + val next_chapter: String?, +) + +@Serializable +internal class PageInfo( + val id: String, + val number: Float, + val title: String?, + val images: List, +) + +@Serializable +internal class ApiMangaList( + val mangas: List, + val next_page: Int?, + val prev_page: Int?, + val max_pages: Int?, +) + +@Serializable +internal class I18nDictionary( + val home: I18nHomeDictionary, + val library: I18nLibraryDictionary, +) + +@Serializable +internal class I18nHomeDictionary( + val updates: I18nHomeUpdatesDictionary, + val lastUpdatesNormal: String, +) + +@Serializable +internal class I18nHomeUpdatesDictionary( + val buttons: I18nHomeButtonsDictionary, +) + +@Serializable +internal class I18nHomeButtonsDictionary( + val language: Map, // all, spanish, english, chinese, raw + val genres: Map, // all, mature, normal +) + +@Serializable +internal class I18nLibraryDictionary( + val title: String, + val search: String, + val sort: Map, // title, type, rating, date + val filter: Map, // title, category, language, sortBy +) diff --git a/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/CryptoHelper.kt b/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/CryptoHelper.kt new file mode 100644 index 000000000..bf2045e74 --- /dev/null +++ b/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/CryptoHelper.kt @@ -0,0 +1,191 @@ +package eu.kanade.tachiyomi.extension.all.sakuramanhwa + +import android.util.Base64 +import eu.kanade.tachiyomi.network.GET +import keiyoushi.utils.toJsonString +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import okio.IOException +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +class CryptoHelper( + private val baseUrl: String, + private val secretKey: String, + private val encryptKey: String, +) : Interceptor { + private var client: OkHttpClient? = null + fun setClient(c: OkHttpClient) { + client = c + } + + @Volatile + private var serverTimeAtGeneration: Long? = null + private var localTimeAtGeneration: Long? = null + + private val cacheValidityMillis = 4 * 60 * 1000 // 5min expiration + + private var cachedSignedData: CacheState? = null + + private class CacheState(val cachedSignedData: String, val cachedSigneTime: Long) + + @Synchronized + fun initServerTime() { + if (serverTimeAtGeneration != null) { + return + } + + val response = + client!!.newCall(GET("$baseUrl/v1", Headers.Builder().set("NX", "").build())).execute() + val serverTime = response.headers.getDate("Date")?.time + ?: throw IOException("Uninitialized server date") + + this.localTimeAtGeneration = System.currentTimeMillis() + this.serverTimeAtGeneration = serverTime + this.cachedSignedData = null + } + + @Synchronized + fun generateSigned(): String { + if (serverTimeAtGeneration == null) { + throw Exception("Uninitialized time") + } + + val currentTime = System.currentTimeMillis() + if (cachedSignedData != null && currentTime - cachedSignedData!!.cachedSigneTime <= cacheValidityMillis) { + return cachedSignedData!!.cachedSignedData + } + + val timestamp = getCurrentServerTime(currentTime).toString() + + val dataToHash = mapOf(Pair("timesTamp", timestamp)).toJsonString() + val hash = hmacSha256(dataToHash, secretKey) + val dataToEncrypt = mapOf(Pair("hash", hash), Pair("timesTamp", timestamp)).toJsonString() + + val encrypted = AESCrypt.encrypt(dataToEncrypt, encryptKey) + + cachedSignedData = CacheState( + encrypted, + currentTime, + ) + return cachedSignedData!!.cachedSignedData + } + + fun hmacSha256(data: String, key: String): String { + val secretKeySpec = SecretKeySpec(key.toByteArray(), "HmacSHA256") + val mac = Mac.getInstance("HmacSHA256") + mac.init(secretKeySpec) + val hashBytes = mac.doFinal(data.toByteArray()) + return hashBytes.joinToString("") { "%02x".format(it) } + } + + private fun getCurrentServerTime(currentLocalTime: Long): Long { + val elapsedLocalMillis = currentLocalTime - localTimeAtGeneration!! + return serverTimeAtGeneration!! + elapsedLocalMillis + } + + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + if (request.headers["NX"] == null && serverTimeAtGeneration == null) { + initServerTime() + } + if (request.headers["NX"] == null && request.url.toString().startsWith(baseUrl)) { + request = request.newBuilder().apply { + header("Referer", "$baseUrl/") + header("St-soon", generateSigned()) + }.build() + } + return chain.proceed(request) + } + + private object AESCrypt { + private const val SALTED_PREFIX = "Salted__" + private const val KEY_SIZE = 32 + private const val IV_SIZE = 16 + private const val SALT_SIZE = 8 + private const val MD5_ALGORITHM = "MD5" + private const val AES_ALGORITHM = "AES" + private const val CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding" + + fun encrypt(value: String, key: String): String { + val salt = ByteArray(SALT_SIZE).apply { + SecureRandom().nextBytes(this) + } + + val (keyBytes, iv) = deriveKeyAndIV(key, salt) + + val secretKeySpec = SecretKeySpec(keyBytes, AES_ALGORITHM) + val ivParameterSpec = IvParameterSpec(iv) + + val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec) + + val encrypted = cipher.doFinal(value.toByteArray(StandardCharsets.UTF_8)) + + val resultBytes = ByteArray(SALTED_PREFIX.length + SALT_SIZE + encrypted.size) + + SALTED_PREFIX.toByteArray().copyInto(resultBytes) + salt.copyInto(resultBytes, SALTED_PREFIX.length) + encrypted.copyInto(resultBytes, SALTED_PREFIX.length + SALT_SIZE) + + return Base64.encodeToString(resultBytes, Base64.NO_WRAP) + } + + fun decrypt(encrypted: String, key: String): String { + val encryptedBytes = Base64.decode(encrypted, Base64.NO_WRAP) + + val salt = ByteArray(SALT_SIZE).also { + encryptedBytes.copyInto( + it, + 0, + SALTED_PREFIX.length, + SALTED_PREFIX.length + SALT_SIZE, + ) + } + + val (keyBytes, iv) = deriveKeyAndIV(key, salt) + + val secretKeySpec = SecretKeySpec(keyBytes, AES_ALGORITHM) + val ivParameterSpec = IvParameterSpec(iv) + + val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) + + val ciphertext = ByteArray(encryptedBytes.size - (SALTED_PREFIX.length + SALT_SIZE)) + encryptedBytes.copyInto(ciphertext, 0, SALTED_PREFIX.length + SALT_SIZE) + + val decrypted = cipher.doFinal(ciphertext) + + return String(decrypted, StandardCharsets.UTF_8) + } + + private fun deriveKeyAndIV(key: String, salt: ByteArray): Pair { + val keyBytes = key.toByteArray(StandardCharsets.UTF_8) + val result = ByteArray(KEY_SIZE + IV_SIZE) + val md5 = MessageDigest.getInstance(MD5_ALGORITHM) + + var currentResult = md5.digest(keyBytes + salt) + currentResult.copyInto(result, 0, 0, currentResult.size) + + var keyMaterial = currentResult.size + while (keyMaterial < result.size) { + currentResult = md5.digest(currentResult + keyBytes + salt) + val copyLength = minOf(currentResult.size, result.size - keyMaterial) + currentResult.copyInto(result, keyMaterial, 0, copyLength) + keyMaterial += copyLength + } + + return Pair( + result.copyOfRange(0, KEY_SIZE), + result.copyOfRange(KEY_SIZE, KEY_SIZE + IV_SIZE), + ) + } + } +} diff --git a/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/Filter.kt b/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/Filter.kt new file mode 100644 index 000000000..974d4b816 --- /dev/null +++ b/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/Filter.kt @@ -0,0 +1,143 @@ +package eu.kanade.tachiyomi.extension.all.sakuramanhwa + +import eu.kanade.tachiyomi.source.model.Filter +import okhttp3.HttpUrl.Builder + +internal const val GroupTypeNone = -1 +internal const val GroupTypeSearch = 0 +internal const val GroupTypeLatesUpdates = 1 + +internal class GroupFilter( + i18n: I18nDictionary, +) : Filter.Select( + "", + arrayOf( + i18n.library.title, + i18n.home.lastUpdatesNormal, + ), +) { + fun setUrlPath(builder: Builder): Int { + val path = when (state) { + GroupTypeSearch -> "/v1/manga" + GroupTypeLatesUpdates -> "/v1/manga/search/latesUpdates" + else -> { + throw UnsupportedOperationException() + } + } + builder.encodedPath(path) + return state + } +} + +internal open class UrlFilter( + title: String, + private val requireState: Int, + val lis: List>, +) : Filter.Select( + title, + lis.map { it[0] }.toTypedArray(), +) { + fun checkGroupState(groupState: Int): Boolean { + if (state != 0 && (requireState == GroupTypeNone || requireState == groupState)) { + return true + } + state = 0 + return false + } +} + +internal class CategoryFilter( + i18n: I18nDictionary, + lis: List> = listOf( + listOf(i18n.home.updates.buttons.genres["all"]!!, "author", ""), + listOf(i18n.home.updates.buttons.genres["mature"]!!, "author", "mature"), + listOf(i18n.home.updates.buttons.genres["normal"]!!, "author", "normal"), + ), +) : UrlFilter( + i18n.library.filter["category"]!!, + GroupTypeNone, + lis, +) { + fun setUrlParam(builder: Builder, groupState: Int) { + if (!checkGroupState(groupState)) { + return + } + if (groupState == GroupTypeSearch) { + builder.setQueryParameter("${lis[state][1]}[]", lis[state][2]) + } else { + builder.setQueryParameter(lis[state][1], lis[state][2]) + } + } +} + +internal class SortFilter( + i18n: I18nDictionary, + lis: List> = listOf( + listOf("_", "sort", ""), + listOf("${i18n.library.sort["title"]!!}⬇️", "sort", "title"), + listOf("${i18n.library.sort["title"]!!}⬆️", "sort", "title"), + listOf("${i18n.library.sort["type"]!!}⬇️", "sort", "type"), + listOf("${i18n.library.sort["type"]!!}⬆️", "sort", "type"), + listOf("${i18n.library.sort["rating"]!!}⬇️", "sort", "rating"), + listOf("${i18n.library.sort["rating"]!!}⬆️", "sort", "rating"), + listOf("${i18n.library.sort["date"]!!}⬇️", "sort", "create_at"), + listOf("${i18n.library.sort["date"]!!}⬆️", "sort", "create_at"), + ), +) : UrlFilter( + i18n.library.filter["sortBy"]!!, + GroupTypeSearch, + lis, +) { + fun setUrlParam(builder: Builder, groupState: Int) { + if (!checkGroupState(groupState)) { + return + } + builder.setQueryParameter(lis[state][1], lis[state][2]) + builder.setQueryParameter("order", if (state % 2 == 1) "desc" else "asc") + } +} + +internal class LanguageCheckBoxFilter(name: String, val key: String) : Filter.CheckBox(name) { + override fun toString(): String { + return key + } +} + +internal class LanguageCheckBoxFilterGroup( + i18n: I18nDictionary, + data: LinkedHashMap = linkedMapOf( + i18n.home.updates.buttons.language["all"]!! to "", + i18n.home.updates.buttons.language["spanish"]!! to "esp", + i18n.home.updates.buttons.language["english"]!! to "eng", + i18n.home.updates.buttons.language["chinese"]!! to "ch", + i18n.home.updates.buttons.language["raw"]!! to "raw", + ), +) : Filter.Group( + i18n.library.filter["language"]!!, + data.map { (k, v) -> + LanguageCheckBoxFilter(k, v) + }, +) { + fun setUrlParam(builder: Builder, groupState: Int) { + if (state[0].state) { + // clear + state.forEach { it.state = false } + return + } + var langParam = false + state.forEach { + if (it.state) { + if (groupState == GroupTypeSearch) { + builder.addQueryParameter("language[]", it.toString()) + } else { + if (langParam) { + it.state = false + } else { + builder.addQueryParameter("language", it.toString()) + langParam = true + } + } + } + } + } +} diff --git a/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/I18nHelper.kt b/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/I18nHelper.kt new file mode 100644 index 000000000..c89fbfaa4 --- /dev/null +++ b/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/I18nHelper.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.extension.all.sakuramanhwa + +import android.content.SharedPreferences +import eu.kanade.tachiyomi.network.GET +import keiyoushi.utils.parseAs +import keiyoushi.utils.toJsonString +import okhttp3.OkHttpClient +import okio.IOException + +internal class I18nHelper( + val baseUrl: String, + val client: OkHttpClient, + val preference: SharedPreferences, +) { + private val i18nCache: HashMap = run { + val i18nJson = preference.getString(APP_I18N_KEY, null) ?: return@run hashMapOf() + try { + i18nJson.parseAs>() + } catch (_: Exception) { + hashMapOf() + } + } + + @Synchronized + fun getI18nByLanguage(lang: String): I18nDictionary { + var i18nDictionary = i18nCache[lang] + if (i18nDictionary != null) { + return i18nDictionary + } + + val request = GET("$baseUrl/assets/i18n/$lang.json?v=2") + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + response.close() + throw IOException("Unexpected get i18n(${request.url}) error") + } + i18nDictionary = response.parseAs() + i18nCache[lang] = i18nDictionary + + preference.edit().putString(APP_I18N_KEY, i18nCache.toJsonString()).apply() + + return i18nDictionary + } +} diff --git a/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/SakuraManhwa.kt b/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/SakuraManhwa.kt new file mode 100644 index 000000000..378537868 --- /dev/null +++ b/src/all/sakuramanhwa/src/eu/kanade/tachiyomi/extension/all/sakuramanhwa/SakuraManhwa.kt @@ -0,0 +1,374 @@ +package eu.kanade.tachiyomi.extension.all.sakuramanhwa + +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +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.HttpSource +import keiyoushi.utils.getPreferences +import keiyoushi.utils.parseAs +import keiyoushi.utils.tryParse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okio.IOException +import rx.Observable +import java.text.SimpleDateFormat +import java.util.Locale + +class SakuraManhwa( + override val lang: String = "all", +) : HttpSource(), ConfigurableSource { + override val name = "SakuraManhwa" + + override val supportsLatest = true + + override val baseUrl = "https://api.sakuramanhwa.com" + + private val apiImageUrl = "https://api.sakuramanhwa.com/v1/images" + private val cdnImageUrl = "https://cdn.sakuramanhwa.com/v1/images" + + private val secretKey = "EA^UfBOF9lNdQDS3i2qAnsqxIrTpH%" + private val encryptKey = "6dFGd4Laa3vE%kLpr5eCtSEaAL%wJm" + + private val apiCryptoHelper = CryptoHelper(baseUrl, secretKey, encryptKey) + + override val client: OkHttpClient = + network.cloudflareClient.newBuilder().addInterceptor(apiCryptoHelper) + .addInterceptor { chain -> + val request = chain.request() + val response = chain.proceed(request) + if (!response.isSuccessful && request.url.toString().startsWith(baseUrl)) { + throw IOException(response.body.string()) + } + response + }.build().also { apiCryptoHelper.setClient(it) } + + private val preference = getPreferences() + + private val i18nHelper: I18nHelper = I18nHelper("https://sakuramanhwa.com", client, preference) + + // Chapter + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS", Locale.ENGLISH) + + override fun chapterListParse(response: Response): List { + val data = response.parseAs() + val tag = when (data.manga.details.type) { + "manhwa" -> "api" + "manga" -> "cdn" + else -> throw UnsupportedOperationException() + } + + val lis = mutableListOf() + data.chapters.forEach { + val chapterName = getChapterName(it.number) + lis.add( + SChapter.create().apply { + url = "$tag/v1/manga/${data.manga.details.slug}/chapter/$chapterName" + name = "${chapterName}${if (it.title != null) " ${it.title}" else ""}" + date_upload = dateFormat.tryParse(it.create_at) + chapter_number = it.number + }, + ) + } + + return lis + } + + private fun getChapterName(number: Float): String { + return if (number % 1 == 0f) "${number.toInt()}" else "$number" + } + + // Image + + override fun imageRequest(page: Page): Request { + val (tag, realUrl) = getTagUrl(page.imageUrl!!) + val prefixUrl = when (tag) { + "api" -> apiImageUrl + "cdn" -> cdnImageUrl + else -> throw UnsupportedOperationException() + } + return GET("$prefixUrl$realUrl", headers) + } + + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + + // LatestUpdates + + override fun fetchLatestUpdates(page: Int): Observable { + return focusFetchManga(page == 1, this::latestUpdatesRequest, this::latestUpdatesParse) + } + + override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response) + + override fun latestUpdatesRequest(page: Int): Request = GET( + baseUrl.toHttpUrl().newBuilder().encodedPath("/v1/manga/search/latesUpdates") + .addQueryParameter("limit", "72").addQueryParameter("page", "$page").build().toString(), + headers, + ) + + // Details + + override fun mangaDetailsParse(response: Response): SManga { + val data = response.parseAs() + + return mangaDetailsToSManga(data.manga.details).apply { + genre = listOf( + genre!!, + "follows: ${data.manga.follows}", + "views: ${data.manga.views}", + ).joinToString() + } + } + + private fun mangaDetailsToSManga(details: MangaDetails): SManga { + return SManga.create().apply { + url = "/v1/manga/findBySlug/${details.slug}" + title = getTitle(details.title, details.language) + genre = listOf( + "lang: ${details.language}", + "type: ${details.type}", + "author: ${details.author}", + "rating: ${details.rating}", + ).joinToString() + status = if (details.status == "ongoing") SManga.ONGOING else SManga.COMPLETED + thumbnail_url = "$baseUrl/v1/images/manga${details.img}" + } + } + + // Pages + + private fun getTagUrl(tagUrl: String): Pair { + return Pair(tagUrl.substring(0, 3), tagUrl.substring(3)) + } + + override fun fetchPageList(chapter: SChapter): Observable> { + val (tag, realUrl) = getTagUrl(chapter.url) + + val response = client.newCall(GET("$baseUrl$realUrl", headers)).execute() + val data = response.parseAs() + + val lis = mutableListOf() + data.chapter.images.forEachIndexed { index, it -> + lis.add(Page(index, imageUrl = "$tag/chapter$it")) + } + + return Observable.just(lis) + } + + override fun pageListParse(response: Response): List = + throw UnsupportedOperationException() + + // Popular + + // Independent page-turn count, used to force content filtering, single request if there is no content and there is the next page to continue the request + private var focusPage: Int = 1 + + private fun focusFetchManga( + reset: Boolean, + reqFunc: (page: Int) -> Request, + respFunc: (response: Response) -> MangasPage, + ): Observable { + if (reset) { + focusPage = 1 + } + var hasNextPage = true + var mangasPage: MangasPage? = null + while (hasNextPage) { + val request = reqFunc(focusPage) + val response = client.newCall(request).execute() + mangasPage = respFunc(response) + hasNextPage = mangasPage.hasNextPage + focusPage++ + if (mangasPage.mangas.isNotEmpty()) { + break + } + } + return Observable.just(mangasPage!!) + } + + override fun fetchPopularManga(page: Int): Observable { + return focusFetchManga(page == 1, this::popularMangaRequest, this::popularMangaParse) + } + + override fun popularMangaParse(response: Response): MangasPage { + val data = response.parseAs() + + val focus = preference.getString(APP_FOCUS_LANGUAGE_KEY, "")!! + + val lis = mutableListOf() + data.mangas.forEach { + if (focus == "" || focus == it.language) { + lis.add(mangaDetailsToSManga(it)) + } + } + + return MangasPage(lis, data.next_page != null) + } + + override fun popularMangaRequest(page: Int): Request { + return GET( + baseUrl.toHttpUrl().newBuilder().encodedPath("/v1/manga/views/top") + .addQueryParameter("limit", "72").addQueryParameter("page", "$page").build() + .toString(), + headers, + ) + } + + // Search + + override fun fetchSearchManga( + page: Int, + query: String, + filters: FilterList, + ): Observable { + return focusFetchManga( + page == 1, + { currentPage -> this.searchMangaRequest(currentPage, query, filters) }, + this::searchMangaParse, + ) + } + + override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + var groupState = 0 + (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> + when (filter) { + is GroupFilter -> { + groupState = filter.setUrlPath(this) + } + + is CategoryFilter -> { + filter.setUrlParam(this, groupState) + } + + is SortFilter -> { + filter.setUrlParam(this, groupState) + } + + is LanguageCheckBoxFilterGroup -> { + filter.setUrlParam(this, groupState) + } + + else -> {} + } + } + + if (groupState == GroupTypeSearch && query.isNotBlank()) { + addQueryParameter("search", query) + } + addQueryParameter("limit", "72") + addQueryParameter("page", page.toString()) + }.build().toString() + + return GET(url, headers) + } + + private fun getTitle(title: String, lang: String): String { + return capitalizeWords(title.removeSuffix(lang)) + } + + private fun capitalizeWords(str: String): String { + return str.split(" ").joinToString(" ") { + it.replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase() else char.toString() + } + } + } + + // Filter + + override fun getFilterList(): FilterList { + val i18nDictionary = getI18nDictionary() + return FilterList( + GroupFilter(i18nDictionary), + CategoryFilter(i18nDictionary), + SortFilter(i18nDictionary), + LanguageCheckBoxFilterGroup(i18nDictionary), + ) + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + ListPreference(screen.context).apply { + title = getI18nDictionary().library.filter["language"] + key = APP_LANGUAGE_KEY + entries = arrayOf( + "🇬🇧English", + "🇪🇸Español", + "🇨🇳中文", + "🇷🇺Русский", + "🇹🇷Türkçe", + "🇮🇩Bahasa Indonesia", + "🇹🇭ไทย", + "🇻🇳Tiếng Việt", + ) + entryValues = arrayOf( + "en", + "es", + "zh", + "ru", + "tr", + "id", + "th", + "vi", + ) + setDefaultValue(entryValues[0]) + setOnPreferenceChangeListener { _, click -> + try { + getI18nDictionary(click as String) + true + } catch (_: Exception) { + false + } + } + }.let { screen.addPreference(it) } + + // Lock the filter language type from the result. + // Non-locked content is simply ignored, which makes the experience more comfortable. + ListPreference(screen.context).apply { + val i18nDictionary = getI18nDictionary() + title = "👀➡️🔒" + key = APP_FOCUS_LANGUAGE_KEY + entries = arrayOf( + "🔓", + "🇬🇧${i18nDictionary.home.updates.buttons.language["english"]!!}🔒", + "🇪🇸${i18nDictionary.home.updates.buttons.language["spanish"]!!}🔒", + "🇨🇳${i18nDictionary.home.updates.buttons.language["chinese"]!!}🔒", + "${i18nDictionary.home.updates.buttons.language["raw"]!!}🔒", + ) + entryValues = arrayOf( + "", + "eng", + "esp", + "ch", + "raw", + ) + setDefaultValue(entryValues[0]) + }.let { screen.addPreference(it) } + } + + private fun getI18nDictionary(language: String? = null): I18nDictionary { + val currentLang = language ?: preference.getString(APP_LANGUAGE_KEY, "en")!! + return runBlocking { + withContext(Dispatchers.IO) { + i18nHelper.getI18nByLanguage(currentLang) + } + } + } +} + +internal const val APP_LANGUAGE_KEY = "APP_LANGUAGE_KEY" +internal const val APP_I18N_KEY = "APP_I18N_KEY" +internal const val APP_FOCUS_LANGUAGE_KEY = "APP_FOCUS_LANGUAGE_KEY"