diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72b3b0dd7..f73580470 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Show a screen while the app migrates its data after an update ([@AntsyLich](https://github.com/AntsyLich)) ([`fd4f73c`](https://github.com/mihonapp/mihon/commit/fd4f73c))
- Keep the screen up for at least a second so it doesn't just flash by ([@AntsyLich](https://github.com/AntsyLich)) ([`b454c1b`](https://github.com/mihonapp/mihon/commit/b454c1b))
- Add option to skip an app update version ([@AntsyLich](https://github.com/AntsyLich)) ([`fc20410`](https://github.com/mihonapp/mihon/commit/fc20410))
+- Add support for [Comick](https://comick.dev) tracker ([@MajorTanya](https://github.com/MajorTanya)) ([#4056](https://github.com/mihonapp/mihon/pull/4056))
### Changed
- Download app updates from the update screen instead of a background job, and remove the "App updates" notification channel ([@AntsyLich](https://github.com/AntsyLich)) ([#3707](https://github.com/mihonapp/mihon/pull/3707))
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a36b86e37..8f6297611 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -198,6 +198,17 @@
+
+
+
+
+
+
+
+
+
+
+
{
+ return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
+ }
+
+ override fun getStatus(status: Long): StringResource? = when (status) {
+ READING -> MR.strings.reading
+ COMPLETED -> MR.strings.completed
+ ON_HOLD -> MR.strings.on_hold
+ DROPPED -> MR.strings.dropped
+ PLAN_TO_READ -> MR.strings.plan_to_read
+ else -> null
+ }
+
+ override fun getReadingStatus(): Long = READING
+
+ override fun getRereadingStatus(): Long = -1L
+
+ override fun getCompletionStatus(): Long = COMPLETED
+
+ override fun getScoreList(): List = IntRange(0, 10).map(Int::toString).toList()
+
+ override fun displayScore(track: DomainTrack): String = track.score.toInt().toString()
+
+ override suspend fun update(
+ track: Track,
+ didReadChapter: Boolean,
+ ): Track {
+ if (track.status != COMPLETED && track.status != READING && didReadChapter) {
+ track.status = READING
+ if (track.last_chapter_read == 1.0) {
+ track.started_reading_date = Clock.System.now().toEpochMilliseconds()
+ }
+ }
+
+ return api.updateLibManga(track)
+ }
+
+ override suspend fun bind(
+ track: Track,
+ hasReadChapters: Boolean,
+ ): Track {
+ val remoteTrack = api.findLibManga(track)
+ return if (remoteTrack != null) {
+ track.copyPersonalFrom(remoteTrack)
+
+ update(track)
+ } else {
+ track.status = if (hasReadChapters) READING else PLAN_TO_READ
+ track.score = 0.0
+
+ api.addLibManga(track)
+ }
+ }
+
+ override suspend fun search(query: String): List {
+ if (query.startsWith(SEARCH_ID_PREFIX)) {
+ query.substringAfter(SEARCH_ID_PREFIX).trim().let { hid ->
+ return api.getMangaDetails(hid)?.let { listOf(it) } ?: emptyList()
+ }
+ }
+
+ return api.search(query)
+ }
+
+ override suspend fun refresh(track: Track): Track {
+ val remoteTrack = api.findLibManga(track) ?: throw Exception("Could not find manga")
+ track.copyPersonalFrom(remoteTrack)
+ return track
+ }
+
+ override suspend fun login(username: String, password: String) = login(password)
+
+ suspend fun login(code: String) {
+ try {
+ val oauth = api.getAccessToken(code)
+ interceptor.setAuth(oauth)
+ val currentUser = api.getCurrentUser()
+ saveDisplayUsername(currentUser.username ?: currentUser.id)
+ saveCredentials("user", oauth.accessToken)
+ } catch (e: Exception) {
+ logout()
+ // gets shown by [TrackLoginActivity]
+ if (e is ComickMissingScopesException) throw e
+ }
+ }
+
+ override suspend fun updateUserConfig() {
+ val currentUser = api.getCurrentUser()
+ saveDisplayUsername(currentUser.username ?: currentUser.id)
+ }
+
+ fun saveToken(oauth: ComickOAuth?) {
+ trackPreferences.trackToken(this).set(json.encodeToString(oauth))
+ }
+
+ fun restoreToken(): ComickOAuth? {
+ return try {
+ json.decodeFromString(trackPreferences.trackToken(this).get())
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ fun verifyOAuthState(state: String): Boolean = api.verifyOAuthState(state)
+
+ override fun logout() {
+ super.logout()
+ trackPreferences.trackToken(this).delete()
+ interceptor.setAuth(null)
+ }
+
+ override suspend fun delete(track: DomainTrack) {
+ api.deleteLibManga(track)
+ }
+
+ companion object {
+ const val READING = 1L
+ const val COMPLETED = 2L
+ const val ON_HOLD = 3L
+ const val DROPPED = 4L
+ const val PLAN_TO_READ = 5L
+
+ private const val SEARCH_ID_PREFIX = "id:"
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickApi.kt
new file mode 100644
index 000000000..b3e644677
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickApi.kt
@@ -0,0 +1,315 @@
+package eu.kanade.tachiyomi.data.track.comick
+
+import android.net.Uri
+import androidx.core.net.toUri
+import eu.kanade.tachiyomi.BuildConfig
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.comick.dto.ComickLibraryEntry
+import eu.kanade.tachiyomi.data.track.comick.dto.ComickOAuth
+import eu.kanade.tachiyomi.data.track.comick.dto.ComickSearchResult
+import eu.kanade.tachiyomi.data.track.comick.dto.ComickTitleLookupResult
+import eu.kanade.tachiyomi.data.track.comick.dto.ComickUser
+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.HttpException
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.PUT
+import eu.kanade.tachiyomi.network.awaitSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimit
+import eu.kanade.tachiyomi.network.jsonMime
+import eu.kanade.tachiyomi.network.parseAs
+import eu.kanade.tachiyomi.util.PkceUtil
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonObject
+import okhttp3.FormBody
+import okhttp3.Headers.Companion.headersOf
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import uy.kohesive.injekt.injectLazy
+import java.security.SecureRandom
+import java.util.Base64
+import kotlin.time.Duration.Companion.minutes
+import tachiyomi.domain.track.model.Track as DomainTrack
+
+class ComickApi(
+ private val trackerId: Long,
+ baseClient: OkHttpClient,
+ interceptor: ComickInterceptor,
+) {
+
+ private val json: Json by injectLazy()
+
+ private val client = baseClient.newBuilder()
+ .rateLimit(permits = 55, period = 1.minutes)
+ .addInterceptor {
+ it.request().newBuilder()
+ .header(
+ "User-Agent",
+ buildString {
+ append("Mihon/v${BuildConfig.VERSION_NAME} ")
+ append("(${BuildConfig.APPLICATION_ID}) ${BuildConfig.COMMIT_SHA}) ")
+ append("(Android) (https://github.com/mihonapp/mihon)")
+ },
+ )
+ .header("Accept", jsonMime.toString())
+ .build()
+ .let(it::proceed)
+ }.build()
+
+ private val authClient = client.newBuilder()
+ .addInterceptor(interceptor)
+ .build()
+
+ suspend fun addLibManga(track: Track): Track {
+ return withContext(Dispatchers.IO) {
+ val url = "$RESOURCE_URL/me/library".toUri().buildUpon()
+ .appendPath(track.remote_id.toHid())
+ .build()
+
+ val body = buildJsonObject {
+ put("status", track.status.toApiListStatus())
+ if (track.last_chapter_read > 0) {
+ putJsonObject("progress") {
+ put("number", track.last_chapter_read)
+ }
+ } else {
+ put("progress", null)
+ }
+ }
+ .toString()
+ .toRequestBody(jsonMime)
+
+ authClient
+ .newCall(PUT(url.toString(), body = body))
+ .awaitSuccess()
+ .close()
+
+ // only returns the same data back with nothing additionally useful
+ track
+ }
+ }
+
+ suspend fun deleteLibManga(track: DomainTrack) {
+ withContext(Dispatchers.IO) {
+ val url = "$RESOURCE_URL/me/library".toUri().buildUpon()
+ .appendPath(track.remoteId.toHid())
+ .build()
+
+ authClient
+ .newCall(DELETE(url.toString(), body = RequestBody.EMPTY))
+ .awaitSuccess()
+ .close()
+ }
+ }
+
+ suspend fun findLibManga(track: Track): Track? {
+ return withContext(Dispatchers.IO) {
+ val url = "$RESOURCE_URL/me/library".toUri().buildUpon()
+ .appendPath(track.remote_id.toHid())
+ .build()
+ with(json) {
+ try {
+ authClient.newCall(GET(url.toString()))
+ .awaitSuccess()
+ .parseAs()
+ .toTrack(trackerId)
+ } catch (e: HttpException) {
+ if (e.code == 404) {
+ return@with null
+ }
+ throw e
+ }
+ }
+ }
+ }
+
+ suspend fun updateLibManga(track: Track): Track {
+ return withContext(Dispatchers.IO) {
+ val url = "$RESOURCE_URL/me/library".toUri().buildUpon()
+ .appendPath(track.remote_id.toHid())
+ .build()
+
+ val body = buildJsonObject {
+ put("rating", track.score.toInt().takeIf { it > 0 })
+ put("status", track.status.toApiListStatus())
+ putJsonObject("progress") {
+ put("number", track.last_chapter_read.toString())
+ }
+ }
+ .toString()
+ .toRequestBody(jsonMime)
+
+ val request = Request.Builder()
+ .url(url.toString())
+ .patch(body)
+ .build()
+
+ authClient.newCall(request)
+ .awaitSuccess()
+ .close()
+
+ // only returns the same data back with nothing additionally useful
+ track
+ }
+ }
+
+ suspend fun search(search: String): List {
+ return withContext(Dispatchers.IO) {
+ val url = "$RESOURCE_URL/titles".toUri().buildUpon()
+ // Comick throws a 400 when the query is over 100 characters
+ .appendQueryParameter("q", search.take(100))
+ .appendQueryParameter("media_type", "manga")
+ .appendQueryParameter("limit", "20")
+ .build()
+
+ with(json) {
+ authClient.newCall(GET(url.toString()))
+ .awaitSuccess()
+ .parseAs()
+ .data
+ .map { it.toTrackSearch(trackerId) }
+ }
+ }
+ }
+
+ suspend fun getMangaDetails(hid: String): TrackSearch? {
+ return withContext(Dispatchers.IO) {
+ val url = "$RESOURCE_URL/titles".toUri().buildUpon()
+ .appendPath(hid)
+ .build()
+
+ with(json) {
+ try {
+ authClient.newCall(GET(url.toString()))
+ .awaitSuccess()
+ .parseAs()
+ .data
+ .toTrackSearch(trackerId)
+ } catch (e: HttpException) {
+ if (e.code == 404) {
+ return@with null
+ }
+ throw e
+ }
+ }
+ }
+ }
+
+ suspend fun getCurrentUser(): ComickUser {
+ return withContext(Dispatchers.IO) {
+ val url = "$RESOURCE_URL/me"
+
+ with(json) {
+ authClient.newCall(GET(url))
+ .awaitSuccess()
+ .parseAs()
+ }
+ }
+ }
+
+ /**
+ * @throws ComickMissingScopesException Thrown when user does not grant offline_access or library:write
+ */
+ suspend fun getAccessToken(code: String): ComickOAuth {
+ return withContext(Dispatchers.IO) {
+ val formBody = FormBody.Builder()
+ .add("grant_type", "authorization_code")
+ .add("client_id", CLIENT_ID)
+ .add("code", code)
+ .add("redirect_uri", REDIRECT_URI)
+ .add("code_verifier", codeVerifier)
+ .add("resource", RESOURCE_URL)
+ .build()
+
+ with(json) {
+ val response = client.newCall(
+ POST("${OAUTH_URL}/token", body = formBody),
+ )
+ .awaitSuccess()
+
+ val body = response.body.string()
+ if (!body.contains("refresh_token")) {
+ throw ComickMissingScopesException("Permission for ongoing synchronisation is required.")
+ }
+
+ val oauth = json.decodeFromString(body)
+ if (!oauth.scope.contains(LIBRARY_WRITE_SCOPE)) {
+ // Users may forget to enable this since it's off by default
+ throw ComickMissingScopesException("Permission to add/remove manga from your library is required.")
+ }
+
+ oauth
+ }
+ }
+ }
+
+ fun verifyOAuthState(state: String): Boolean = state == oauthStateParam
+
+ companion object {
+ private const val CLIENT_ID = "SGrUojDvbgqfhSCQbwfCjUPIccYMJdBH"
+ private const val REDIRECT_URI = "app.mihon://comick-auth"
+
+ private const val LIBRARY_WRITE_SCOPE = "library:write"
+ private const val SCOPES = "library:read $LIBRARY_WRITE_SCOPE offline_access"
+
+ private const val OAUTH_URL = "https://comick.dev/api/auth/oauth2"
+ private const val API_BASE_URL = "https://api.comick.dev"
+ private const val RESOURCE_URL = "$API_BASE_URL/integrations/v1"
+
+ private var codeVerifier: String = ""
+ private var oauthStateParam: String = ""
+
+ fun authUrl(): Uri = "$OAUTH_URL/authorize".toUri().buildUpon()
+ .appendQueryParameter("response_type", "code")
+ .appendQueryParameter("client_id", CLIENT_ID)
+ .appendQueryParameter("redirect_uri", REDIRECT_URI)
+ .appendQueryParameter("scope", SCOPES)
+ .appendQueryParameter("resource", RESOURCE_URL)
+ .appendQueryParameter("state", getOAuthStateParam())
+ .appendQueryParameter("code_challenge", getPkceS256ChallengeCode())
+ .appendQueryParameter("code_challenge_method", "S256")
+ .build()
+
+ fun refreshTokenRequest(refreshToken: String) = POST(
+ "$OAUTH_URL/token",
+ body = FormBody.Builder()
+ .add("grant_type", "refresh_token")
+ .add("client_id", CLIENT_ID)
+ .add("refresh_token", refreshToken)
+ .add("resource", RESOURCE_URL)
+ .build(),
+ headers = headersOf(
+ "Accept",
+ jsonMime.toString(),
+ ),
+ )
+
+ private fun getOAuthStateParam(): String {
+ val bytes = ByteArray(16)
+ SecureRandom().nextBytes(bytes)
+ oauthStateParam = Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(bytes)
+
+ return oauthStateParam
+ }
+
+ private fun getPkceS256ChallengeCode(): String {
+ // Comick requires an actually conformant PKCE process, unlike MAL
+ // 1. create verifier
+ // 2. create challenge from verifier (S256 hash -> base64 URL encode)
+ // 3. send challenge to /authorize
+ // 4. send verifier for access tokens to /token
+ val codes = PkceUtil.generateS256Codes()
+ codeVerifier = codes.codeVerifier
+ return codes.codeChallenge
+ }
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickInterceptor.kt
new file mode 100644
index 000000000..a5d877ccc
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickInterceptor.kt
@@ -0,0 +1,50 @@
+package eu.kanade.tachiyomi.data.track.comick
+
+import eu.kanade.tachiyomi.data.track.comick.dto.ComickOAuth
+import eu.kanade.tachiyomi.network.parseAs
+import kotlinx.serialization.json.Json
+import okhttp3.Interceptor
+import okhttp3.Response
+import uy.kohesive.injekt.injectLazy
+
+class ComickInterceptor(private val comick: Comick) : Interceptor {
+
+ private val json: Json by injectLazy()
+
+ @Volatile
+ private var oauth: ComickOAuth? = comick.restoreToken()
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+
+ var currentAuth = oauth ?: throw Exception("Not authenticated with Comick")
+
+ if (currentAuth.isExpired()) {
+ currentAuth = synchronized(this) {
+ val latest = oauth ?: throw Exception("Not authenticated with Comick")
+ if (!latest.isExpired()) return@synchronized latest
+
+ val response = chain.proceed(ComickApi.refreshTokenRequest(currentAuth.refreshToken))
+ if (response.isSuccessful) {
+ with(json) {
+ response.parseAs()
+ }.also(::setAuth)
+ } else {
+ response.close()
+ latest
+ }
+ }
+ }
+
+ return originalRequest.newBuilder()
+ .addHeader("Authorization", "Bearer ${currentAuth.accessToken}")
+ .build()
+ .let(chain::proceed)
+ }
+
+ fun setAuth(oauth: ComickOAuth?) {
+ this.oauth = oauth
+
+ comick.saveToken(oauth)
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickUtil.kt
new file mode 100644
index 000000000..159707c35
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickUtil.kt
@@ -0,0 +1,43 @@
+package eu.kanade.tachiyomi.data.track.comick
+
+import java.util.Base64
+
+// Deterministic en/decoding HID <-> Long for our internal storage
+internal fun String.asHidToLong(): Long {
+ require(this.length == 8) { "HID must be 8 characters for decoding, got ${this.length}: $this" }
+ return Base64.getUrlDecoder()
+ .decode(this)
+ .fold(0) { acc, b ->
+ (acc shl 8) or (b.toLong() and 0xFF)
+ }
+}
+
+internal fun Long.toHid(): String {
+ require(this in 0 until (1L shl 48)) { "Long not in 48-bit range for HID conversion: $this" }
+ return Base64.getUrlEncoder()
+ .encodeToString(
+ ByteArray(6) {
+ (this shr (8 * (5 - it))).toByte()
+ },
+ )
+}
+
+internal fun Long.toApiListStatus() = when (this) {
+ Comick.READING -> 1
+ Comick.COMPLETED -> 2
+ Comick.ON_HOLD -> 3
+ Comick.DROPPED -> 4
+ Comick.PLAN_TO_READ -> 5
+ else -> throw NotImplementedError("Unknown status: $this")
+}
+
+internal fun Long.fromApiListStatus() = when (this) {
+ 1L -> Comick.READING
+ 2L -> Comick.COMPLETED
+ 3L -> Comick.ON_HOLD
+ 4L -> Comick.DROPPED
+ 5L -> Comick.PLAN_TO_READ
+ else -> throw NotImplementedError("Unknown status: $this")
+}
+
+class ComickMissingScopesException(message: String) : Exception(message)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickLibraryEntry.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickLibraryEntry.kt
new file mode 100644
index 000000000..d9ca49791
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickLibraryEntry.kt
@@ -0,0 +1,35 @@
+package eu.kanade.tachiyomi.data.track.comick.dto
+
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.comick.asHidToLong
+import eu.kanade.tachiyomi.data.track.comick.fromApiListStatus
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ComickLibraryEntry(
+ val data: ComickLibraryEntryItem,
+) {
+ fun toTrack(trackerId: Long): Track {
+ return Track.create(trackerId).apply {
+ remote_id = data.hid.asHidToLong()
+ title = data.title
+ status = data.status.fromApiListStatus()
+ last_chapter_read = data.progress?.number?.toDouble() ?: 0.0
+ score = data.rating?.toDouble() ?: 0.0
+ }
+ }
+}
+
+@Serializable
+data class ComickLibraryEntryItem(
+ val hid: String,
+ val title: String,
+ val status: Long,
+ val progress: ComickLibraryEntryProgress?,
+ val rating: Int?,
+)
+
+@Serializable
+data class ComickLibraryEntryProgress(
+ val number: String?,
+)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickOAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickOAuth.kt
new file mode 100644
index 000000000..8c941249f
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickOAuth.kt
@@ -0,0 +1,19 @@
+package eu.kanade.tachiyomi.data.track.comick.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.minutes
+
+@Serializable
+data class ComickOAuth(
+ @SerialName("access_token")
+ val accessToken: String,
+ @SerialName("expires_at")
+ val expiresAt: Long,
+ @SerialName("refresh_token")
+ val refreshToken: String,
+ val scope: String,
+) {
+ fun isExpired() = Clock.System.now().plus(1.minutes).epochSeconds >= expiresAt
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickSearchResult.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickSearchResult.kt
new file mode 100644
index 000000000..cc43576ed
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickSearchResult.kt
@@ -0,0 +1,57 @@
+package eu.kanade.tachiyomi.data.track.comick.dto
+
+import eu.kanade.tachiyomi.data.track.comick.asHidToLong
+import eu.kanade.tachiyomi.data.track.model.TrackSearch
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ComickSearchResult(
+ val data: List,
+)
+
+@Serializable
+data class ComickTitleLookupResult(
+ val data: ComickSearchItem,
+)
+
+@Serializable
+data class ComickSearchItem(
+ val hid: String,
+ val title: String,
+ val year: Int?,
+ val description: String?,
+ val status: Int?,
+ @SerialName("bayesian_rating")
+ val bayesianRating: Double?,
+ @SerialName("cover_url")
+ val coverUrl: String?,
+ val country: String?,
+ val url: String,
+) {
+ fun toTrackSearch(trackerId: Long): TrackSearch {
+ return TrackSearch.create(trackerId).apply {
+ remote_id = hid.asHidToLong()
+ title = this@ComickSearchItem.title
+ tracking_url = url
+ summary = description.orEmpty()
+ start_date = year?.toString().orEmpty()
+ score = bayesianRating ?: -1.0
+ cover_url = coverUrl.orEmpty()
+ publishing_status = when (this@ComickSearchItem.status) {
+ null -> ""
+ 1 -> "Ongoing"
+ 2 -> "Completed"
+ 3 -> "Cancelled"
+ 4 -> "Hiatus"
+ else -> throw NotImplementedError("Unknown publishing status: ${this@ComickSearchItem.status}")
+ }
+ publishing_type = when (country) {
+ "kr" -> "Manhwa"
+ "cn" -> "Manhua"
+ "fr" -> "Manfra"
+ else -> "Manga"
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickUser.kt
new file mode 100644
index 000000000..be92a60b8
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickUser.kt
@@ -0,0 +1,9 @@
+package eu.kanade.tachiyomi.data.track.comick.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ComickUser(
+ val id: String,
+ val username: 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 f5f057bca..086212fb1 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
@@ -1,8 +1,13 @@
package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
+import android.widget.Toast
import androidx.lifecycle.lifecycleScope
+import eu.kanade.tachiyomi.data.track.comick.ComickMissingScopesException
+import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
@@ -25,6 +30,7 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
lifecycleScope.launch {
when (uri.host) {
"anilist-auth" -> handleAniList(data["access_token"])
+ "comick-auth" -> handleComick(data["code"], data["state"])
"bangumi-auth" -> handleBangumi(data["code"])
"mangabaka-auth" -> handleMangaBaka(data["code"], data["state"])
"myanimelist-auth" -> handleMyAnimeList(data["code"])
@@ -51,6 +57,29 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
}
}
+ private suspend fun handleComick(code: String?, state: String?) {
+ if (state == null) {
+ logcat(LogPriority.WARN) { "Did not receive state parameter from Comick OAuth" }
+ return
+ }
+ if (code != null) {
+ if (!trackerManager.comick.verifyOAuthState(state)) {
+ logcat(LogPriority.WARN) { "Received wrong OAuth state back from Comick" }
+ return
+ }
+ try {
+ trackerManager.comick.login(code)
+ } catch (e: ComickMissingScopesException) {
+ withContext(Dispatchers.Main) {
+ this@TrackLoginActivity.toast(e.message, Toast.LENGTH_LONG)
+ }
+ return
+ }
+ } else {
+ trackerManager.comick.logout()
+ }
+ }
+
private suspend fun handleMangaBaka(code: String?, state: String?) {
if (state == null) {
logcat(LogPriority.WARN) { "Did not receive state parameter from MangaBaka OAuth" }
diff --git a/app/src/main/res/drawable-nodpi/brand_comick.webp b/app/src/main/res/drawable-nodpi/brand_comick.webp
new file mode 100644
index 000000000..d764ce640
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/brand_comick.webp differ