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