diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d11579ed8..d6a848e13 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -180,6 +180,7 @@
+
diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt
index 2a7231112..4eadac04c 100644
--- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt
+++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt
@@ -48,6 +48,7 @@ import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
+import eu.kanade.tachiyomi.data.track.hikka.HikkaApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.util.system.openInBrowser
@@ -174,6 +175,12 @@ object SettingsTrackingScreen : SearchableSettings {
login = { context.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackerManager.bangumi) },
),
+ Preference.PreferenceItem.TrackerPreference(
+ title = trackerManager.hikka.name,
+ tracker = trackerManager.hikka,
+ login = { context.openInBrowser(HikkaApi.authUrl(), forceDefaultBrowser = true) },
+ logout = { dialog = LogoutDialog(trackerManager.hikka) },
+ ),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.tracking_info)),
),
),
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt
index 1071fa7ee..1935a6b1e 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
+import eu.kanade.tachiyomi.data.track.hikka.Hikka
import eu.kanade.tachiyomi.data.track.kavita.Kavita
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.komga.Komga
@@ -28,8 +29,9 @@ class TrackerManager {
val mangaUpdates = MangaUpdates(7L)
val kavita = Kavita(KAVITA)
val suwayomi = Suwayomi(9L)
+ val hikka = Hikka(10L)
- val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi)
+ val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi, hikka)
fun loggedInTrackers() = trackers.filter { it.isLoggedIn }
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/Hikka.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/Hikka.kt
new file mode 100644
index 000000000..3bd43a574
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/Hikka.kt
@@ -0,0 +1,163 @@
+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.database.models.Track
+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.i18n.MR
+import uy.kohesive.injekt.injectLazy
+import tachiyomi.domain.track.model.Track as DomainTrack
+
+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 {
+ 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 {
+ return SCORE_LIST
+ }
+
+ override fun displayScore(track: DomainTrack): String {
+ return track.score.toInt().toString()
+ }
+
+ override suspend fun update(
+ track: Track,
+ didReadChapter: Boolean,
+ ): 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: Track, hasReadChapters: Boolean): 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)
+ }
+
+ override suspend fun search(query: String): List {
+ return api.searchManga(query)
+ }
+
+ override suspend fun refresh(track: Track): 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(reference: String) {
+ try {
+ val oauth = api.accessToken(reference)
+ interceptor.setAuth(oauth)
+ val user = api.getCurrentUser()
+ saveCredentials(user.reference, oauth.accessToken)
+ } catch (e: Throwable) {
+ logout()
+ }
+ }
+
+ override suspend fun delete(track: DomainTrack) {
+ api.deleteUserManga(track)
+ }
+
+ override fun logout() {
+ super.logout()
+ trackPreferences.trackToken(this).delete()
+ interceptor.setAuth(null)
+ }
+
+ fun saveOAuth(oAuth: HKOAuth?) {
+ trackPreferences.trackToken(this).set(json.encodeToString(oAuth))
+ }
+
+ fun loadOAuth(): HKOAuth? {
+ return try {
+ json.decodeFromString(trackPreferences.trackToken(this).get())
+ } catch (e: Exception) {
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt
new file mode 100644
index 000000000..6cb9fd9d1
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt
@@ -0,0 +1,217 @@
+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.HKManga
+import eu.kanade.tachiyomi.data.track.hikka.dto.HKMangaPagination
+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()
+ }
+ }
+ }
+
+ suspend fun accessToken(reference: String): HKOAuth {
+ return withIOContext {
+ with(json) {
+ client.newCall(authTokenCreate(reference))
+ .awaitSuccess()
+ .parseAs()
+ }
+ }
+ }
+
+ suspend fun searchManga(query: String): List {
+ 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()
+ .list
+ .map { it.toTrack(trackId) }
+ }
+ }
+ }
+
+ private suspend fun getRead(track: Track): HKRead? {
+ return withIOContext {
+ val slug = track.tracking_url.split("/")[4]
+ val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon().build()
+ with(json) {
+ val response = authClient.newCall(GET(url.toString())).execute()
+ if (response.code == 404) {
+ return@withIOContext null
+ }
+ response.use {
+ it.parseAs()
+ }
+ }
+ }
+ }
+
+ 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()
+ .toTrack(trackId)
+ }
+ }
+ }
+
+ suspend fun deleteUserManga(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()
+
+ var rereads = getRead(track)?.rereads ?: 0
+ if (track.status == Hikka.REREADING && rereads == 0) {
+ rereads = 1
+ }
+
+ val payload = buildJsonObject {
+ put("note", "")
+ put("chapters", track.last_chapter_read.toInt())
+ put("volumes", 0)
+ put("rereads", rereads)
+ put("score", track.score.toInt())
+ put("status", track.toApiStatus())
+ }
+
+ with(json) {
+ authClient.newCall(PUT(url.toString(), body = payload.toString().toRequestBody(jsonMime)))
+ .awaitSuccess()
+ .parseAs()
+ .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 CLIENT_REFERENCE = "49eda83d-baa6-45f8-9936-b2a41d944da4"
+ private const val CLIENT_SECRET = "8Zxzs13Pvikx6m_4rwjF7t2BxxnEb0wWtXIRQ_68HyCvmdhGE9hdfz" +
+ "SL1Pas4h927LaV2ocjVoc--S_vmorHEWWh42Z_z70j-wSFYsraQQ98" +
+ "hiOeTH2BaDf77ZcA9W5Z"
+
+ fun authUrl(): Uri = "$BASE_URL/oauth".toUri().buildUpon()
+ .appendQueryParameter("reference", CLIENT_REFERENCE)
+ .appendQueryParameter("scope", SCOPE)
+ .build()
+
+ fun refreshTokenRequest(accessToken: String): Request {
+ val headers = Headers.Builder()
+ .add("auth", accessToken)
+ .build()
+
+ return GET("$BASE_API_URL/user/me", headers = headers) // Any request with auth
+ }
+
+ fun authTokenCreate(reference: String): Request {
+ val payload = buildJsonObject {
+ put("request_reference", reference)
+ put("client_secret", CLIENT_SECRET)
+ }
+ return POST("$BASE_API_URL/auth/token", body = payload.toString().toRequestBody(jsonMime))
+ }
+
+ fun authTokenInfo(accessToken: String): Request {
+ val headers = Headers.Builder()
+ .add("auth", accessToken)
+ .build()
+
+ return GET("$BASE_API_URL/auth/token/info", headers = headers)
+ }
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaInterceptor.kt
new file mode 100644
index 000000000..062b88861
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaInterceptor.kt
@@ -0,0 +1,48 @@
+package eu.kanade.tachiyomi.data.track.hikka
+
+import eu.kanade.tachiyomi.data.track.hikka.dto.HKAuthTokenInfo
+import eu.kanade.tachiyomi.data.track.hikka.dto.HKOAuth
+import kotlinx.serialization.json.Json
+import okhttp3.Interceptor
+import okhttp3.Response
+import uy.kohesive.injekt.injectLazy
+
+class HikkaInterceptor(private val hikka: Hikka) : Interceptor {
+ private val json: Json by injectLazy()
+ private var oauth: HKOAuth? = hikka.loadOAuth()
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+
+ val currAuth = oauth ?: throw Exception("Hikka: You are not authorized")
+
+ if (currAuth.isExpired()) {
+ val refreshTokenResponse = chain.proceed(HikkaApi.refreshTokenRequest(currAuth.accessToken))
+ if (!refreshTokenResponse.isSuccessful) {
+ refreshTokenResponse.close()
+ hikka.logout()
+ throw Exception("Hikka: The token is expired")
+ }
+
+ val authTokenInfoResponse = chain.proceed(HikkaApi.authTokenInfo(currAuth.accessToken))
+ if (!authTokenInfoResponse.isSuccessful) {
+ authTokenInfoResponse.close()
+ }
+
+ val authTokenInfo = json.decodeFromString(authTokenInfoResponse.body.string())
+ setAuth(HKOAuth(oauth!!.accessToken, authTokenInfo.expiration, authTokenInfo.created))
+ }
+
+ val authRequest = originalRequest.newBuilder()
+ .addHeader("auth", oauth!!.accessToken)
+ .addHeader("accept", "application/json")
+ .build()
+
+ return chain.proceed(authRequest)
+ }
+
+ fun setAuth(oauth: HKOAuth?) {
+ this.oauth = oauth
+ hikka.saveOAuth(oauth)
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaUtils.kt
new file mode 100644
index 000000000..a31b692c9
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaUtils.kt
@@ -0,0 +1,28 @@
+package eu.kanade.tachiyomi.data.track.hikka
+
+import eu.kanade.tachiyomi.data.database.models.Track
+import java.util.UUID
+
+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 -> "reading"
+ else -> throw NotImplementedError("Hikka: 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
+ else -> throw NotImplementedError("Hikka: Unknown status: $status")
+}
+
+fun stringToNumber(input: String): Long {
+ val uuid = UUID.nameUUIDFromBytes(input.toByteArray())
+ return uuid.mostSignificantBits and Long.MAX_VALUE
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKAuthTokenInfo.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKAuthTokenInfo.kt
new file mode 100644
index 000000000..7f0e096a6
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKAuthTokenInfo.kt
@@ -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,
+ val expiration: Long,
+ val used: Long,
+)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKClient.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKClient.kt
new file mode 100644
index 000000000..824cffbb9
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKClient.kt
@@ -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,
+)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKManga.kt
new file mode 100644
index 000000000..40928aa6e
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKManga.kt
@@ -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? = null,
+ @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 = ""
+ }
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKMangaPagination.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKMangaPagination.kt
new file mode 100644
index 000000000..a8245fe65
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKMangaPagination.kt
@@ -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,
+)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKOAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKOAuth.kt
new file mode 100644
index 000000000..fbccb8d22
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKOAuth.kt
@@ -0,0 +1,15 @@
+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,
+ val expiration: Long,
+ val created: Long,
+) {
+ fun isExpired(): Boolean {
+ return (expiration - 43200) < (System.currentTimeMillis() / 1000)
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKPagination.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKPagination.kt
new file mode 100644
index 000000000..3c7e5f2c7
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKPagination.kt
@@ -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,
+)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKRead.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKRead.kt
new file mode 100644
index 000000000..213ac2951
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKRead.kt
@@ -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}"
+ }
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKUser.kt
new file mode 100644
index 000000000..4210c6b16
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/dto/HKUser.kt
@@ -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,
+)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/TrackLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/TrackLoginActivity.kt
index 3f742cfe0..9487e57f8 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/TrackLoginActivity.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/TrackLoginActivity.kt
@@ -12,6 +12,7 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
"bangumi-auth" -> handleBangumi(data)
"myanimelist-auth" -> handleMyAnimeList(data)
"shikimori-auth" -> handleShikimori(data)
+ "hikka-auth" -> handleHikka(data)
}
}
@@ -67,4 +68,17 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
returnToSettings()
}
}
+
+ private fun handleHikka(data: Uri) {
+ val reference = data.getQueryParameter("reference")
+ if (reference != null) {
+ lifecycleScope.launchIO {
+ trackerManager.hikka.login(reference)
+ returnToSettings()
+ }
+ } else {
+ trackerManager.hikka.logout()
+ returnToSettings()
+ }
+ }
}
diff --git a/app/src/main/res/drawable/ic_tracker_hikka.webp b/app/src/main/res/drawable/ic_tracker_hikka.webp
new file mode 100644
index 000000000..6ff0e77f3
Binary files /dev/null and b/app/src/main/res/drawable/ic_tracker_hikka.webp differ