diff --git a/src/pt/tsukimangas/build.gradle b/src/pt/tsukimangas/build.gradle index a95e09fd0..5a5c46c86 100644 --- a/src/pt/tsukimangas/build.gradle +++ b/src/pt/tsukimangas/build.gradle @@ -6,7 +6,7 @@ ext { extName = 'Tsuki Mangás' pkgNameSuffix = 'pt.tsukimangas' extClass = '.TsukiMangas' - extVersionCode = 19 + extVersionCode = 20 libVersion = '1.2' containsNsfw = true } diff --git a/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangas.kt b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangas.kt index 7e7fcdf1c..5edea69da 100644 --- a/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangas.kt +++ b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangas.kt @@ -1,9 +1,16 @@ package eu.kanade.tachiyomi.extension.pt.tsukimangas +import android.app.Application +import android.content.SharedPreferences +import android.text.InputType +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.annotations.Nsfw import eu.kanade.tachiyomi.lib.ratelimit.SpecificHostRateLimitInterceptor import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage @@ -11,16 +18,22 @@ 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 kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.io.IOException import java.text.ParseException @@ -28,7 +41,7 @@ import java.text.SimpleDateFormat import java.util.Locale @Nsfw -class TsukiMangas : HttpSource() { +class TsukiMangas : HttpSource(), ConfigurableSource { override val name = "Tsuki Mangás" @@ -42,17 +55,29 @@ class TsukiMangas : HttpSource() { .addInterceptor(SpecificHostRateLimitInterceptor(baseUrl.toHttpUrl(), 1)) .addInterceptor(SpecificHostRateLimitInterceptor(CDN_1_URL, 1, period = 2)) .addInterceptor(SpecificHostRateLimitInterceptor(CDN_2_URL, 1, period = 2)) - .addInterceptor(::tsukiPermissionIntercept) + .addInterceptor(::tsukiAuthIntercept) .build() + private val json: Json by injectLazy() + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private val usernameOrEmail: String + get() = preferences.getString(USERNAME_OR_EMAIL_PREF_KEY, "")!! + + private val password: String + get() = preferences.getString(PASSWORD_PREF_KEY, "")!! + + private var apiToken: String? = null + override fun headersBuilder(): Headers.Builder = Headers.Builder() .add("Accept", ACCEPT) .add("Accept-Language", ACCEPT_LANGUAGE) .add("User-Agent", USER_AGENT) .add("Referer", baseUrl) - private val json: Json by injectLazy() - override fun popularMangaRequest(page: Int): Request { return GET("$baseUrl/api/v2/mangas?page=$page&title=&adult_content=false&filter=0", headers) } @@ -295,17 +320,98 @@ class TsukiMangas : HttpSource() { return GET(page.imageUrl!!, newHeaders) } - private fun tsukiPermissionIntercept(chain: Interceptor.Chain): Response { - val response = chain.proceed(chain.request()) + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val usernameOrEmailPref = EditTextPreference(screen.context).apply { + key = USERNAME_OR_EMAIL_PREF_KEY + title = USERNAME_OR_EMAIL_PREF_TITLE + setDefaultValue("") + summary = USERNAME_OR_EMAIL_PREF_SUMMARY + dialogTitle = USERNAME_OR_EMAIL_PREF_TITLE - if (response.code == 403) { + setOnPreferenceChangeListener { _, newValue -> + apiToken = null + + preferences.edit() + .putString(USERNAME_OR_EMAIL_PREF_KEY, newValue as String) + .commit() + } + } + + val passwordPref = EditTextPreference(screen.context).apply { + key = PASSWORD_PREF_KEY + title = PASSWORD_PREF_TITLE + setDefaultValue("") + summary = PASSWORD_PREF_SUMMARY + dialogTitle = PASSWORD_PREF_TITLE + + setOnBindEditTextListener { + it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + + setOnPreferenceChangeListener { _, newValue -> + apiToken = null + + preferences.edit() + .putString(PASSWORD_PREF_KEY, newValue as String) + .commit() + } + } + + screen.addPreference(usernameOrEmailPref) + screen.addPreference(passwordPref) + } + + private fun tsukiAuthIntercept(chain: Interceptor.Chain): Response { + if (apiToken.isNullOrEmpty()) { + if (usernameOrEmail.isEmpty() || password.isEmpty()) { + throw IOException(ERROR_CREDENTIALS_MISSING) + } + + val loginRequest = loginRequest(usernameOrEmail, password) + val loginResponse = chain.proceed(loginRequest) + + // API returns 422 when the credentials are invalid. + if (loginResponse.code == 422) { + loginResponse.close() + throw IOException(ERROR_CANNOT_LOGIN) + } + + try { + val loginResponseBody = loginResponse.body?.string().orEmpty() + val authResult = json.decodeFromString(loginResponseBody) + + apiToken = authResult.token + + loginResponse.close() + } catch (e: SerializationException) { + loginResponse.close() + throw IOException(ERROR_LOGIN_FAILED) + } + } + + val authorizedRequest = chain.request().newBuilder() + .addHeader("Authorization", "Bearer $apiToken") + .build() + + val response = chain.proceed(authorizedRequest) + + // API returns 403 when User-Agent permission is disabled + // and returns 401 when the token is invalid. + if (response.code == 403 || response.code == 401) { response.close() - throw IOException(UA_DISABLED_MESSAGE) + throw IOException(if (response.code == 403) UA_DISABLED_MESSAGE else ERROR_INVALID_TOKEN) } return response } + private fun loginRequest(usernameOrEmail: String, password: String): Request { + val authInfo = TsukiAuthRequestDto(usernameOrEmail, password) + val payload = json.encodeToString(authInfo).toRequestBody(JSON_MEDIA_TYPE) + + return POST("$baseUrl/api/v2/login", headers, payload) + } + private class Genre(name: String) : Filter.CheckBox(name) private class DemographyFilter(demographies: List) : Filter.Select("Demografia", demographies.toTypedArray()) @@ -470,5 +576,24 @@ class TsukiMangas : HttpSource() { const val PREFIX_ID_SEARCH = "id:" private val ID_SEARCH_PATTERN = "^id:(\\d+)$".toRegex() + + private val JSON_MEDIA_TYPE = "application/json;charset=UTF-8".toMediaType() + + private const val USERNAME_OR_EMAIL_PREF_KEY = "username_or_email" + private const val USERNAME_OR_EMAIL_PREF_TITLE = "Usuário ou E-mail" + private const val USERNAME_OR_EMAIL_PREF_SUMMARY = "Defina aqui o seu usuário ou e-mail para o login." + + private const val PASSWORD_PREF_KEY = "password" + private const val PASSWORD_PREF_TITLE = "Senha" + private const val PASSWORD_PREF_SUMMARY = "Defina aqui a sua senha para o login." + + private const val ERROR_CANNOT_LOGIN = "Não foi possível realizar o login. " + + "Revise suas informações nas configurações." + private const val ERROR_CREDENTIALS_MISSING = "Login necessário. " + + "Defina suas credenciais de acesso nas configurações." + private const val ERROR_LOGIN_FAILED = "Não foi possível realizar o login devido " + + "a um erro inesperado. Tente novamente mais tarde." + private const val ERROR_INVALID_TOKEN = "Token inválido. " + + "Revise suas informações nas configurações." } } diff --git a/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangasDto.kt b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangasDto.kt index 7039f3a02..070a32a67 100644 --- a/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangasDto.kt +++ b/src/pt/tsukimangas/src/eu/kanade/tachiyomi/extension/pt/tsukimangas/TsukiMangasDto.kt @@ -3,6 +3,17 @@ package eu.kanade.tachiyomi.extension.pt.tsukimangas import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +data class TsukiAuthRequestDto( + val username: String, + val password: String +) + +@Serializable +data class TsukiAuthResultDto( + val token: String? = null +) + @Serializable data class TsukiPaginatedDto( val data: List = emptyList(),