SakuraManhwa: Added support for SakuraManhwa source (#9195)

New: Added support for SakuraManhwa source
This commit is contained in:
peakedshout 2025-06-18 21:59:43 +08:00 committed by Draff
parent 8e7146ec24
commit 6af9f2d853
Signed by: Draff
GPG Key ID: E8A89F3211677653
11 changed files with 853 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'SakuraManhwa'
extClass = '.SakuraManhwa'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -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
)

View File

@ -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),
)
}
}
}

View File

@ -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
}
}
}
}
}
}

View File

@ -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
}
}

View File

@ -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"