feat: add core Hikka tracker classes and DTOs for API requests

This commit is contained in:
Lorg0n 2024-10-17 17:21:53 +03:00
parent 68d24dff27
commit f6d8d415cc
12 changed files with 628 additions and 0 deletions

View file

@ -0,0 +1,177 @@
package eu.kanade.tachiyomi.data.track.hikka
import android.graphics.Color
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.BaseTracker
import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.hikka.dto.HKOAuth
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import tachiyomi.domain.track.model.Track
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
class Hikka(id: Long) : BaseTracker(id, "Hikka"), DeletableTracker {
companion object {
const val READING = 0L
const val COMPLETED = 1L
const val ON_HOLD = 2L
const val DROPPED = 3L
const val PLAN_TO_READ = 4L
const val REREADING = 5L
private val SCORE_LIST = IntRange(0, 10)
.map(Int::toString)
.toImmutableList()
}
private val json: Json by injectLazy()
private val interceptor by lazy { HikkaInterceptor(this) }
private val api by lazy { HikkaApi(id, client, interceptor) }
override fun getLogoColor(): Int {
return Color.rgb(0, 0, 0)
}
override fun getLogo(): Int {
return R.drawable.ic_tracker_hikka
}
override fun getStatusList(): List<Long> {
return listOf(
READING,
COMPLETED,
ON_HOLD,
DROPPED,
PLAN_TO_READ,
REREADING
)
}
override fun getStatus(status: Long): StringResource? = when (status) {
READING -> MR.strings.reading
PLAN_TO_READ -> MR.strings.plan_to_read
COMPLETED -> MR.strings.completed
ON_HOLD -> MR.strings.on_hold
DROPPED -> MR.strings.dropped
REREADING -> MR.strings.repeating
else -> null
}
override fun getReadingStatus(): Long {
return READING
}
override fun getRereadingStatus(): Long {
return REREADING
}
override fun getCompletionStatus(): Long {
return COMPLETED
}
override fun getScoreList(): ImmutableList<String> {
return SCORE_LIST
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
override suspend fun update(
track: eu.kanade.tachiyomi.data.database.models.Track,
didReadChapter: Boolean,
): eu.kanade.tachiyomi.data.database.models.Track {
if (track.status != COMPLETED) {
if (didReadChapter) {
if (track.last_chapter_read.toLong() == track.total_chapters && track.total_chapters > 0) {
track.status = COMPLETED
} else if (track.status != REREADING) {
track.status = READING
}
}
}
return api.updateUserManga(track)
}
override suspend fun bind(
track: eu.kanade.tachiyomi.data.database.models.Track,
hasReadChapters: Boolean,
): eu.kanade.tachiyomi.data.database.models.Track {
val remoteTrack = api.getManga(track)
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
if (track.status != COMPLETED) {
val isRereading = track.status == REREADING
track.status = if (!isRereading && hasReadChapters) READING else track.status
}
return update(track)
}
private suspend fun add(track: eu.kanade.tachiyomi.data.database.models.Track): eu.kanade.tachiyomi.data.database.models.Track {
return api.addUserManga(track)
}
override suspend fun search(query: String): List<TrackSearch> {
return api.searchManga(query)
}
override suspend fun refresh(track: eu.kanade.tachiyomi.data.database.models.Track): eu.kanade.tachiyomi.data.database.models.Track {
val remoteTrack = api.updateUserManga(track)
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
}
override suspend fun login(username: String, password: String) = login(password)
suspend fun login(code: String) {
try {
val oauth = HKOAuth(code, System.currentTimeMillis() / 1000 + 30 * 60)
interceptor.setAuth(oauth)
val reference = api.getCurrentUser().reference
saveCredentials(reference, oauth.accessToken)
} catch (e: Throwable) {
logout()
}
}
override suspend fun delete(track: Track) {
api.deleteManga(track)
}
override fun logout() {
super.logout()
trackPreferences.trackToken(this).delete()
interceptor.setAuth(null)
}
fun getIfAuthExpired(): Boolean {
return trackPreferences.trackAuthExpired(this).get()
}
fun setAuthExpired() {
trackPreferences.trackAuthExpired(this).set(true)
}
fun saveOAuth(oAuth: HKOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oAuth))
}
fun loadOAuth(): HKOAuth? {
return try {
json.decodeFromString<HKOAuth>(trackPreferences.trackToken(this).get())
} catch (e: Exception) {
null
}
}
}

View file

@ -0,0 +1,177 @@
package eu.kanade.tachiyomi.data.track.hikka
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.hikka.dto.HKAuthTokenInfo
import eu.kanade.tachiyomi.data.track.hikka.dto.HKMangaPagination
import eu.kanade.tachiyomi.data.track.hikka.dto.HKManga
import eu.kanade.tachiyomi.data.track.hikka.dto.HKOAuth
import eu.kanade.tachiyomi.data.track.hikka.dto.HKRead
import eu.kanade.tachiyomi.data.track.hikka.dto.HKUser
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
class HikkaApi(
private val trackId: Long,
private val client: OkHttpClient,
interceptor: HikkaInterceptor,
) {
suspend fun getCurrentUser(): HKUser {
return withIOContext {
val request = Request.Builder()
.url("${BASE_API_URL}/user/me")
.get()
.build()
with(json) {
authClient.newCall(request)
.awaitSuccess()
.parseAs<HKUser>()
}
}
}
suspend fun getTokenInfo(): HKAuthTokenInfo {
return withIOContext {
val request = Request.Builder()
.url("${BASE_API_URL}/auth/token/info")
.get()
.build()
with(json) {
authClient.newCall(request)
.awaitSuccess()
.parseAs<HKAuthTokenInfo>()
}
}
}
suspend fun searchManga(query: String): List<TrackSearch> {
return withIOContext {
val url = "$BASE_API_URL/manga".toUri().buildUpon()
.appendQueryParameter("page", "1")
.appendQueryParameter("size", "50")
.build()
val payload = buildJsonObject {
put("media_type", buildJsonArray { })
put("status", buildJsonArray { })
put("only_translated", false)
put("magazines", buildJsonArray { })
put("genres", buildJsonArray { })
put("score", buildJsonArray {
add(0)
add(10)
})
put("query", query)
put("sort", buildJsonArray {
add("score:asc")
add("scored_by:asc")
})
}
with(json) {
authClient.newCall(POST(url.toString(), body=payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
.parseAs<HKMangaPagination>()
.list
.map { it.toTrack(trackId) }
}
}
}
suspend fun getManga(track: Track): TrackSearch {
return withIOContext {
val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/manga/${slug}".toUri().buildUpon()
.build()
with(json) {
authClient.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<HKManga>()
.toTrack(trackId)
}
}
}
suspend fun deleteManga(track: tachiyomi.domain.track.model.Track) {
return withIOContext {
val slug = track.remoteUrl.split("/")[4]
val url = "$BASE_API_URL/read/manga/${slug}".toUri().buildUpon()
.build()
authClient.newCall(DELETE(url.toString()))
.awaitSuccess()
}
}
suspend fun addUserManga(track: Track): Track {
return withIOContext {
val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/read/manga/${slug}".toUri().buildUpon()
.build()
val payload = buildJsonObject {
put("note", "")
put("chapters", track.last_chapter_read.toInt())
put("volumes", 0)
put("rereads", 0)
put("score", track.score.toInt())
put("status", track.toApiStatus())
}
with(json) {
authClient.newCall(PUT(url.toString(), body=payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
.parseAs<HKRead>()
.toTrack(trackId)
}
}
}
suspend fun updateUserManga(track: Track): Track = addUserManga(track)
private val json: Json by injectLazy()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
companion object {
const val BASE_API_URL = "https://hikka.io/api"
const val BASE_URL = "https://hikka.io"
private const val SCOPE = "readlist,read:user-details"
private const val REFERENCE = "49eda83d-baa6-45f8-9936-b2a41d944da4"
fun authUrl(): Uri = "$BASE_URL/oauth".toUri().buildUpon()
.appendQueryParameter("reference", REFERENCE)
.appendQueryParameter("scope", SCOPE)
.build()
fun refreshTokenRequest(oauth: HKOAuth): Request {
val headers = Headers.Builder()
.add("auth", oauth.accessToken)
.add("Cookie", "auth=${oauth.accessToken}")
.build()
return GET("$BASE_API_URL/auth/token/info", headers = headers)
}
}
}

View file

@ -0,0 +1,88 @@
package eu.kanade.tachiyomi.data.track.hikka
import android.util.Log
import eu.kanade.tachiyomi.data.track.hikka.dto.HKOAuth
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import org.json.JSONObject
import uy.kohesive.injekt.injectLazy
import java.io.IOException
class HikkaInterceptor(private val hikka: Hikka) : Interceptor {
private val json: Json by injectLazy()
private var oauth: HKOAuth? = hikka.loadOAuth()
private val tokenExpired get() = hikka.getIfAuthExpired()
override fun intercept(chain: Interceptor.Chain): Response {
if (tokenExpired) {
throw HKTokenExpired()
}
val originalRequest = chain.request()
if (oauth?.isExpired() == true) {
refreshToken(chain)
}
if (oauth == null) {
throw IOException("Hikka.io: User is not authenticated")
}
val authRequest = originalRequest.newBuilder()
.addHeader("auth", oauth!!.accessToken)
.addHeader("Cookie", "auth=${oauth!!.accessToken}")
.addHeader("accept", "application/json")
.build()
Log.println(Log.WARN, "interceptor", "Set Auth Request Headers: " + authRequest.headers)
return chain.proceed(authRequest)
}
/**
* Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token
* and the oauth object.
*/
fun setAuth(oauth: HKOAuth?) {
this.oauth = oauth
hikka.saveOAuth(oauth)
}
private fun refreshToken(chain: Interceptor.Chain): HKOAuth = synchronized(this) {
if (tokenExpired) throw HKTokenExpired()
oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it }
val response = try {
chain.proceed(HikkaApi.refreshTokenRequest(oauth!!))
} catch (_: Throwable) {
throw HKTokenRefreshFailed()
}
if (response.code == 401) {
hikka.setAuthExpired()
throw HKTokenExpired()
}
return runCatching {
if (response.isSuccessful && oauth != null) {
val responseBody = response.body?.string() ?: return@runCatching null
val jsonObject = JSONObject(responseBody)
val secret = oauth!!.accessToken
val expiration = jsonObject.getLong("expiration")
HKOAuth(secret, expiration)
} else {
response.close()
null
}
}.getOrNull()?.also {
this.oauth = it
hikka.saveOAuth(it)
} ?: throw HKTokenRefreshFailed()
}
}
class HKTokenRefreshFailed : IOException("Hikka.io: Failed to refresh account token")
class HKTokenExpired : IOException("Hikka.io: Login has expired")

View file

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.data.track.hikka
import eu.kanade.tachiyomi.data.database.models.Track
import java.security.MessageDigest
fun Track.toApiStatus() = when (status) {
Hikka.READING -> "reading"
Hikka.COMPLETED -> "completed"
Hikka.ON_HOLD -> "on_hold"
Hikka.DROPPED -> "dropped"
Hikka.PLAN_TO_READ -> "planned"
Hikka.REREADING -> "completed"
else -> throw NotImplementedError("To Api: Unknown status: $status")
}
fun toTrackStatus(status: String) = when (status) {
"reading" -> Hikka.READING
"completed" -> Hikka.COMPLETED
"on_hold" -> Hikka.ON_HOLD
"dropped" -> Hikka.DROPPED
"planned" -> Hikka.PLAN_TO_READ
"rewatching" -> Hikka.REREADING
else -> throw NotImplementedError("To Track: Unknown status: $status")
}
fun stringToNumber(input: String): Long {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(input.toByteArray())
return hash.copyOfRange(0, 8).fold(0L) { acc, byte ->
acc shl 8 or (byte.toLong() and 0xff)
}
}

View file

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKAuthTokenInfo(
val reference: String,
val created: Long,
val client: HKClient,
val scope: List<String>,
val expiration: Long,
val used: Long
)

View file

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKClient(
val reference: String,
val name: String,
val description: String,
val verified: Boolean,
val user: HKUser,
val created: Long,
val updated: Long
)

View file

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import eu.kanade.tachiyomi.data.track.hikka.HikkaApi
import eu.kanade.tachiyomi.data.track.hikka.stringToNumber
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class HKManga(
@SerialName("data_type") val dataType: String,
@SerialName("title_original") val titleOriginal: String,
@SerialName("media_type") val mediaType: String,
@SerialName("title_ua") val titleUa: String? = null,
@SerialName("title_en") val titleEn: String? = null,
val chapters: Int? = null,
val volumes: Int? = null,
@SerialName("translated_ua") val translatedUa: Boolean,
val status: String,
val image: String,
val year: Int,
@SerialName("scored_by") val scoredBy: Int,
val score: Double,
val slug: String
) {
fun toTrack(trackId: Long): TrackSearch {
return TrackSearch.create(trackId).apply {
remote_id = stringToNumber(this@HKManga.slug)
title = this@HKManga.titleUa ?: this@HKManga.titleEn ?: this@HKManga.titleOriginal
total_chapters = this@HKManga.chapters?.toLong() ?: 0
cover_url = this@HKManga.image
summary = ""
score = this@HKManga.score
tracking_url = HikkaApi.BASE_URL + "/manga/${this@HKManga.slug}"
publishing_status = this@HKManga.status
publishing_type = "manga"
start_date = ""
}
}
}

View file

@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKMangaPagination(
val pagination: HKPagination,
val list: List<HKManga>
)

View file

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class HKOAuth(
@SerialName("secret")
val accessToken: String,
@SerialName("expiration")
val expiration: Long,
) {
fun isExpired(): Boolean {
return (expiration - 1000) < System.currentTimeMillis() / 1000
}
}

View file

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKPagination(
val total: Int,
val pages: Int,
val page: Int
)

View file

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import eu.kanade.tachiyomi.data.track.hikka.HikkaApi
import eu.kanade.tachiyomi.data.track.hikka.stringToNumber
import eu.kanade.tachiyomi.data.track.hikka.toTrackStatus
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.Serializable
@Serializable
data class HKRead(
val reference: String,
val note: String,
val updated: Long,
val created: Long,
val status: String,
val chapters: Int,
val volumes: Int,
val rereads: Int,
val score: Int,
val content: HKManga
) {
fun toTrack(trackId: Long): TrackSearch {
return TrackSearch.create(trackId).apply {
title = this@HKRead.content.titleUa ?: this@HKRead.content.titleEn ?: this@HKRead.content.titleOriginal
remote_id = stringToNumber(this@HKRead.content.slug)
total_chapters = this@HKRead.content.chapters?.toLong() ?: 0
library_id = stringToNumber(this@HKRead.content.slug)
last_chapter_read = this@HKRead.chapters.toDouble()
score = this@HKRead.score.toDouble()
status = toTrackStatus(this@HKRead.status)
tracking_url = HikkaApi.BASE_URL + "/manga/${this@HKRead.content.slug}"
}
}
}

View file

@ -0,0 +1,16 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKUser(
val reference: String,
val updated: Long,
val created: Long,
val description: String,
val username: String,
val cover: String,
val active: Boolean,
val avatar: String,
val role: String
)