SakuraManhwa: Added support for SakuraManhwa source (#9195)
New: Added support for SakuraManhwa source
This commit is contained in:
parent
8e7146ec24
commit
6af9f2d853
8
src/all/sakuramanhwa/build.gradle
Normal file
8
src/all/sakuramanhwa/build.gradle
Normal file
@ -0,0 +1,8 @@
|
||||
ext {
|
||||
extName = 'SakuraManhwa'
|
||||
extClass = '.SakuraManhwa'
|
||||
extVersionCode = 1
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/all/sakuramanhwa/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/all/sakuramanhwa/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.6 KiB |
BIN
src/all/sakuramanhwa/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/all/sakuramanhwa/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
BIN
src/all/sakuramanhwa/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/all/sakuramanhwa/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.8 KiB |
BIN
src/all/sakuramanhwa/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/all/sakuramanhwa/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
src/all/sakuramanhwa/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/all/sakuramanhwa/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
@ -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<ChapterInfo>,
|
||||
)
|
||||
|
||||
@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<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal class ApiMangaList(
|
||||
val mangas: List<MangaDetails>,
|
||||
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<String, String>, // all, spanish, english, chinese, raw
|
||||
val genres: Map<String, String>, // all, mature, normal
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal class I18nLibraryDictionary(
|
||||
val title: String,
|
||||
val search: String,
|
||||
val sort: Map<String, String>, // title, type, rating, date
|
||||
val filter: Map<String, String>, // title, category, language, sortBy
|
||||
)
|
@ -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<ByteArray, ByteArray> {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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<String>(
|
||||
"",
|
||||
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<List<String>>,
|
||||
) : Filter.Select<String>(
|
||||
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<List<String>> = 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<List<String>> = 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<String, String> = 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<LanguageCheckBoxFilter>(
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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<String, I18nDictionary> = run {
|
||||
val i18nJson = preference.getString(APP_I18N_KEY, null) ?: return@run hashMapOf()
|
||||
try {
|
||||
i18nJson.parseAs<HashMap<String, I18nDictionary>>()
|
||||
} 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<I18nDictionary>()
|
||||
i18nCache[lang] = i18nDictionary
|
||||
|
||||
preference.edit().putString(APP_I18N_KEY, i18nCache.toJsonString()).apply()
|
||||
|
||||
return i18nDictionary
|
||||
}
|
||||
}
|
@ -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<SChapter> {
|
||||
val data = response.parseAs<ApiMangaInfo>()
|
||||
val tag = when (data.manga.details.type) {
|
||||
"manhwa" -> "api"
|
||||
"manga" -> "cdn"
|
||||
else -> throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
val lis = mutableListOf<SChapter>()
|
||||
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<MangasPage> {
|
||||
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<ApiMangaInfo>()
|
||||
|
||||
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<String, String> {
|
||||
return Pair(tagUrl.substring(0, 3), tagUrl.substring(3))
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
val (tag, realUrl) = getTagUrl(chapter.url)
|
||||
|
||||
val response = client.newCall(GET("$baseUrl$realUrl", headers)).execute()
|
||||
val data = response.parseAs<ApiChapterInfo>()
|
||||
|
||||
val lis = mutableListOf<Page>()
|
||||
data.chapter.images.forEachIndexed { index, it ->
|
||||
lis.add(Page(index, imageUrl = "$tag/chapter$it"))
|
||||
}
|
||||
|
||||
return Observable.just(lis)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> =
|
||||
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<MangasPage> {
|
||||
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<MangasPage> {
|
||||
return focusFetchManga(page == 1, this::popularMangaRequest, this::popularMangaParse)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val data = response.parseAs<ApiMangaList>()
|
||||
|
||||
val focus = preference.getString(APP_FOCUS_LANGUAGE_KEY, "")!!
|
||||
|
||||
val lis = mutableListOf<SManga>()
|
||||
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<MangasPage> {
|
||||
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"
|
Loading…
x
Reference in New Issue
Block a user