Add Comick Tracker (#4056)

* Add Comick Tracker

Comick specifically requires a native reverse-domain scheme (just
`mihon://` is not accepted), so I went with `app.mihon://` which works
fine.

I added a new `intent-filter` for this because adding the scheme to
the existing `intent-filter` would effectively create all permutations
of schemes and hosts (i.e. it'd also accept `app.mihon://anilist-auth`
which we don't use).

A real client ID for Mihon's actual client is still missing.

* Changelog

 Conflicts:
	CHANGELOG.md

* Fix library adding failing with 400

I made this change after testing because I thought it'd have no
consequences.

Don't do that.

Comick does not allow `progress: {number: null}`, only
`progress: null` or `progress: {number: 123}`.

* Adjust to fit with changes in #4068

* Cut search query to fit Comick's 100 char limit

* Mihon client ID

* Close fire-forget requests according to c46317f0
This commit is contained in:
MajorTanya
2026-10-11 23:43:59 +06:00
committed by GitHub
parent 35bd0a4b35
commit 039138ce99
14 changed files with 727 additions and 0 deletions
+1
View File
@@ -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))
+11
View File
@@ -198,6 +198,17 @@
<data android:host="shikimori-auth" />
<data android:host="hikka-auth" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Comick requires native reverse-domain schemes -->
<data android:scheme="app.mihon" />
<data android:host="comick-auth" />
</intent-filter>
</activity>
<receiver
@@ -46,6 +46,7 @@ import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
import eu.kanade.tachiyomi.data.track.comick.ComickApi
import eu.kanade.tachiyomi.data.track.hikka.HikkaApi
import eu.kanade.tachiyomi.data.track.mangabaka.MangaBakaApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
@@ -178,6 +179,11 @@ object SettingsTrackingScreen : SearchableSettings {
login = { context.openInBrowser(HikkaApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackerManager.hikka) },
),
Preference.PreferenceItem.TrackerPreference(
tracker = trackerManager.comick,
login = { context.openInBrowser(ComickApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackerManager.comick) },
),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.tracking_info)),
),
),
@@ -5,6 +5,7 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
import eu.kanade.tachiyomi.data.track.comick.Comick
import eu.kanade.tachiyomi.data.track.hikka.Hikka
import eu.kanade.tachiyomi.data.track.kavita.Kavita
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
@@ -31,6 +32,7 @@ class TrackerManager {
val suwayomi = Suwayomi(9L)
val hikka = Hikka(10L)
val mangaBaka = MangaBaka(11L)
val comick = Comick(12L)
val trackers = listOf(
mangaBaka,
@@ -44,6 +46,7 @@ class TrackerManager {
kavita,
suwayomi,
hikka,
comick,
)
fun loggedInTrackers() = trackers.filter { it.isLoggedIn }
@@ -0,0 +1,149 @@
package eu.kanade.tachiyomi.data.track.comick
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.comick.dto.ComickOAuth
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import kotlin.time.Clock
import tachiyomi.domain.track.model.Track as DomainTrack
class Comick(id: Long) : BaseTracker(id, "Comick"), DeletableTracker {
private val json: Json by injectLazy()
private val interceptor by lazy { ComickInterceptor(this) }
private val api by lazy { ComickApi(id, client, interceptor) }
override fun getLogo() = R.drawable.brand_comick
override fun getStatusList(): List<Long> {
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<String> = 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<TrackSearch> {
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:"
}
}
@@ -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<ComickLibraryEntry>()
.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<TrackSearch> {
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<ComickSearchResult>()
.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<ComickTitleLookupResult>()
.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<ComickUser>()
}
}
}
/**
* @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<ComickOAuth>(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
}
}
}
@@ -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<ComickOAuth>()
}.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)
}
}
@@ -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)
@@ -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?,
)
@@ -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
}
@@ -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<ComickSearchItem>,
)
@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"
}
}
}
}
@@ -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?,
)
@@ -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" }
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB