From 039138ce99fd18a56ae662aa22a7bcdb3e937a47 Mon Sep 17 00:00:00 2001 From: MajorTanya <39014446+MajorTanya@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:43:59 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + app/src/main/AndroidManifest.xml | 11 + .../settings/screen/SettingsTrackingScreen.kt | 6 + .../tachiyomi/data/track/TrackerManager.kt | 3 + .../tachiyomi/data/track/comick/Comick.kt | 149 +++++++++ .../tachiyomi/data/track/comick/ComickApi.kt | 315 ++++++++++++++++++ .../data/track/comick/ComickInterceptor.kt | 50 +++ .../tachiyomi/data/track/comick/ComickUtil.kt | 43 +++ .../track/comick/dto/ComickLibraryEntry.kt | 35 ++ .../data/track/comick/dto/ComickOAuth.kt | 19 ++ .../track/comick/dto/ComickSearchResult.kt | 57 ++++ .../data/track/comick/dto/ComickUser.kt | 9 + .../ui/setting/track/TrackLoginActivity.kt | 29 ++ .../main/res/drawable-nodpi/brand_comick.webp | Bin 0 -> 15352 bytes 14 files changed, 727 insertions(+) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/comick/Comick.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickApi.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickInterceptor.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/comick/ComickUtil.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickLibraryEntry.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickOAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickSearchResult.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/comick/dto/ComickUser.kt create mode 100644 app/src/main/res/drawable-nodpi/brand_comick.webp 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 0000000000000000000000000000000000000000..d764ce6407379a22be8dfe04f0d173132daef53a GIT binary patch literal 15352 zcmY+r19T?A7O4CGv2EK%S1`GfIK>C-yf&V!KWfUZMPyqnoZMbcgRoUk^-^M;! zTW6=LgZpY9t?K4h4qBze?(%*vCIj@seod_cFA^I(M*l&}k;gWOrg!!D6z{#hhzwxP zDzJvNWlK&%Lc+socl1kDpxGOOpuu>Cv10R~KQ5{}x zysrShLW%yUYpAFTtN4rxw4w!r$2zNRpvWhd*H}{3Ph)n7ZKzBL6vMBnL?|*ug{`Q# zw%Co8nxGw#sDYrdBEfm93%ZNpNNp_k!U=}RE6V28!=V@BiWL}CSC&~ZA9>eeh8H_v zRFqD&fS4&Sxk>0Zmm2?w2=ypI3!&>#l`N&ANK#cIV#z2?x~@a6P4FVgQiM5!AWd6R z#58$G;O>}^DrY$lsA5nTOH0qFjnX1*JW!d}bn8(RnNHYZh&m+`j$Kv|HfhVm;T}*@ zB!A#)xU2|4Xh=q&X=7->`yDcn-tjxgsw2H4Kgf-E5fWWNl~OwkeNN4UC*+nFCiS;! zx6|vwFzm_mb3K#FeE*t`(S6l|&}->*=d9nqX^WENk6QLnGCjgXl50+ySR z9msIK7}o;iyzj@0Raf2^<&~NBRW!A|3-r}13zyN)tzQ=zaPnHN`Iz)6j$vR$#W%qwpD}PUo@a()C#Gkl!~@7wNCw<8y&PAX8l7eqwI>gIjqC( zsO!u@FaBf;hR(Fdd^|DhM{^ZMf_+&6689b#jO~1wMD9M_LSAbF8J;+_|h=a1yE^AAXTlW#* z@<9Non&o<)4aApSot;RN4qj13wqDT32vuY6!JiIx;T^3s*CRO|as@49aR#u#?1*CL zN@IbOD8_xuVtCpY($!MHeE+7J6(rm9#wO=(ClU0>6Rzm7BZZv(MfJycj2O=JJ%y2Z zO5inD7}hoiB{ew@MXFa6n@}DqcU5ts7G;1U`60@L3D4A*0DWmTspVb7pgr;Z*vXc5ANnmU7wR&;ii7D9(o;XOvv+%m7 zl$Ln$;6v|-Nz=$W(oukC;`=!8$j6J?7l!ITgL=i7OYKE3)0Vue_xe2!N%{eHL-)Ds(=SbwU;H728Gyzsa(bRP=Tl%U zDr6l}LkUff)&V3_kv|<#o`Xet^F&MjXr7}lAsXtHg4qMl}h+ zR-QI;OO8XTO-5^Ak{aN+60xDBE^w}q>{?ks*`NQ&tMiCZg8H%7$Oy<^I=9aF!?CK4 zUPw3&-6+;yXYyfjtkMXWE~FfinA!t2zR2X4XV+rp87_swhEOJSITW8R6)>9H_y2WF z#57~a#Ptx=e<_Gr%O+#$vO>*FK?|DNyN58O~G{us~#TW$(g&(3Ne9F@=8e9ByOhq z72p%zL_kH5q7*@|#Mxy}()^4OyCrem3Py?r-4m_wARfg}g_38UklkI*i54%MnMVQF zVlDB27`jJE_D&IWTM~TD9vQd_TKulUPplD^Oh^SXty|nG2=FmZcrXSx0HXNIUZcyF z^iXeV$1eFtxu;wmrX3BoGZlZ&_*h54IQ-n1DGzX{Sr$Xb8f&B79juY5 z1RKAYMyY|}zAR&-@2UEQ{tnWx^DM0hvaxd&Gfj_Iw_Xxd)f=u1#>lRWKW9o%?uRji zE32Uj0rz1kqYcr2Q9)PT=}q3TpSWqe^R9k_DLDUKf#!Ft1Y5$CBJG;z%PwJaweP7&@1tjK3VXm8VR<)X%l+0_w^XtKPgUNUyi70KpizI-SzbZTF}M z!Povn2i@ew(K?Ri1<}E~oyKCO_9|)@`S!hvBz>Y@3gX!FLx%hihE%&H5?BTl<%%MwyY@xTC-l1!WwAg=jweRowtO76&)Xy8tyZ}T8 z>cj2}pt@_d>(&Q>S=qNmVdokxOztrTkv-E3zidzr&Z&EeUD=Pc>zcb!4q*(>rpf36AUvnw-ezZZ-QI z^xk)cQW0eU>@^4@DW759ASJIO^w<`JUOK{=QUvVvy<#7x>7=?dWxPQEz9EYf^f)67 zUZX=WGt{B_*!ZH)3!{UmR(gb;>rDmmst@>)Hb3u(l3&$1%dboT5oWOmO)V=RAnUIn zIb4*%`F{0_a>f;W0y@(4awK(YUPMiPs7B=%Vvb0<&O5Zlu6>XqZS#7Nn)J+pL}|Kr zNyV4Kg2lKDal~aq>_qF2em(+H^QwYF)>ECrVwkW2mce3lIUf z-t4(3sRA5{s)8t!zRmk#!A2`yfiiDR*^ehYT9W68C|aI<9b_!+Y6OQ(CKM@jje*5A zqBy>*_$JNSIbsYcAeLJ}Xy{6QvbWkxv>^H11fZTj0lDq*ipHZ2Pz>Q$#EZ9$cAKqQ-*1`_p` zcDf*MU>CnwlTFeCiH27!DkCmHK=j=Vs}{fZo~1^x;zY1BF~R(rVENDA2BC86*^oET zMf^~X9om5uq@wF-Z-HZwhH>Jj$Iq!G49g$|jDLyE6+5Pdy?j`az`n_>Z1R=A$ebLi zry+y*5t`$cEs>QDrXh&lR9V?#SIRWFnaN^VgHN?I#H6nCE_=K{NZN!jsLNFk9LGmX@U?#-o8%YQt#`52`6kK)^Rk7?>&l%;;s{ zOGMHSK*NYwJ+=aRd5I_wm~yR!ucrmnEGDA3OZK~$f7XOEh$=}K*25*sEu^YKx@?6M zbIO6pm@B@!%ssMV(mx~xjF*2FO6EyRMCV28Ps`X+9|^1$NW_SS%(}rgBZBBglcNms zAbsg8yaVSr;3EHq-2QO2^468e)02qpjmq8OFYo3##X%BB_!PAVwb}u^)d)LPk$G$> zzVrLNH8~^-vqW@LYh^V;pPoNrjsNZ1brrwr4d}s5AV`DWhPG|BdGm<{du8nRE~ajT zIMw0`CCUZ|yNN87%>R92OEw0D^zp7Wllh<-i^iq@;J1*Vg5z=(PWDIOfyU|u!KFGu z2E|^s5c0yg3EXAc8&lGW2ddZ89U(QKCKoe-q8FXNjzRW2!kG8eYCE3Il93Du2&J&) z{%c*vn}35tHt|U`d7ntbGr7Q2JdtK5d45F}O(+l-&Y3`yV|r)-TsUG*FuX7$o9baX zbjO3f&rl7D+7g<;L%7qv!B#qU&U(-YzLLl!6>z)Q6k7K&{DpVXWkWWTNYvVFN8;M^ zkbtu|WkWW*N7NV^N9rq#eRf{;51f}2d%QgEw8A7X9mbc$TQfyd?nzHewHG9JPBOk z4Df?4i6J1B!-804pzpz#jvOzI8Ib3BbGF-(4Sb35k^zTqfG{|B%!SCNumla5bz6f5 z_?jT(9~O$dvX0{i?7UcvOGAR#G@R#5br%2t+O1pI3mC$nfCTeC>ay%Da!>%(&)3u# zQ3Viy%J<(mmmCTR02MPEuV^L0JSbpIi!ly`UR?-;l}B>_I}O{DN_GPmIPT@-rF`4B zg3d?(oUSQ{0oa}Q`}3ZCRp-KEaI*euth`fwmS7vV!|QAy;~?B`t5Hz#$0^_`o;Jp`Ql{GuHA+&h?VDRw*uJ8j3#m~KdfO#hkZw?94Y zsxKtx+ga<|Uu5VNBjRQc@@FN}ufHGLoESgTl(i|*4nSnzi;j#Cmks`$<`oMUPn`2j zIG)ISegA^1X(#kk{dJG#x~F-pVKUxl=JtT4&6kMf6Kl*Jo1dbiS`R`S_pB^A2e)L) z4f4;m0DoN~rhjnfJIRJUJ=mdV0P5nFTUvdyK!5788-t3AIuXluW(WXAzU^fpcVi2} zeaHARB_raix58mdF8HL1+u^Z<&pq>?=56c+hR9NRuOlb8hmghUV@b20pI)!W%J;NI zm0uQnvvb+9w6fir!g|_b;?p+ah|1wvbHqrt_!xC?3B>)?jI{+$;?v!vBdoK7Xd`aI z#egQ6pJ>&3&*&Vl&70d;I}m1)-X}0(6~g1#id>&~v3v6gS37P(WsV$`|Emg)M-^kZ zgq$Cip)~m9hY9z{Wy_%39!lb?gmg?wLaj$f4iL25o+F)8!t!x(i44O=XSXEohmFZ1!=?l~X!Jz>I!E2PM9#vw_2fJAwP!un8 zwMw_%L3}ao`koTH3T|P5VZ{$GR-0Wz-wk7dXm~|-sz)~LF*73&^w`i$p30JWL}Nt= zYu7cLe!==_Q2KWS6xvIy;K{!4^j`5D+M%d6x?a~nV75$58=S2TuK5O7wAy0*t}uX4 zo@nbWN-E$VjNdvZV4c?T@)7oqXoU#ku4ZmSj&+}2Q1}rD2CA3;6ld^2qPgxCsEu2f zqBTVH_Yj z)-YeMQn29-0FU8aHa!oB;a6LPad4I{^$G~wD8t(KbtW1xF82j8t%*(w+quDDV-bo3kQK_B=jS}B$KRLsohiL zeH$>DRE?ag)TyhZKqT|RV?^={;A9_V#LM37SH3m4njb(77I7`BnAp=ipD|&-x7in5 z2Gd?|5zI@x&hgI_96&>RM*n;Dv8}4j8d81sY9F}NCbVcb_BhItr~z^6m6+P*)2?p_ z3HupX$G^G&1GKQa8>W9me=4?*h#&~Yr9dCRyg?2$w|KgDwVVJyD4JxX1XrC ziua#H+GpJ;IoT-g`lB*K(*O-1;NPqoQ5+QjFt-HA0nz|K03bgTCGw?8ii--#<}=Fc zP@qliKFnWP?aknBcW9fYqI|Ax?FmxwNW<07dUq-F^fMT_ccl6Ed=d*@+r}_uoY`x#_NuRUd-`6YLq(|Ef z*Z<>``}xAo?EeP{PrK(hK}GRv#{U<7+|~yg^Ci#!d(YEYU)w;%Hp7M}^eOc6l8oPQ zXzKrA{LHszP_GivkCyb+zymrv8dt6**f|WuGn2l9RHlfh^M7=70O)x>$B8!A_NWRD zA;P}9f9ia)prZ|By|DA~Zo8<7lFckvtNs@iKGcTqwvrdlJ6xsDZl3b(ArWej`w|ag zzUu(UoBL)v@TQ91xYj?zO9QjxZI_6G1X^ibZd?B&koymP3@$b#4VO)p{;7#86v_SW zl-&T>b25wpev5;4K_OH*oKfpkJ2vpg10yD)`+srE%0PL(_@)1QnyDef+s1BKS&DuP zIc4vRE;~mB6L!1IjXzE)4%a zFJ83vl8@e=up%?F!O6|U)wv;05kC=<&_(8Fk%8TTxy}L=;l7fGz%12w`_tMIGL^;Q zqGKcCAGi$q&<8w{hGpE|#3G2AmvpnfF8-q#AbHw&LIZz|Jm%F#rlGvni>Es z6UF|oj0lo$OaF4XDOoYDfotwoRar|KC_u4k|L6a<{%pVIhQbh&nVRsx{OTc7WhbPi zE85h=Pl^9m)zv+#fY?)NDs{*GrU^poPJ6R-;r;40s%DUtl_Cu@V2asJV#1Hz)Yd|V zxALx0Z4EPCij|d>B?|ju??uMSm03l-I;FUp^KquKvSJ(WGlO6r)IzWQR|JT{p0c9C zY_XfRSODA5GUUH!nab%IpB*$Ug?xX1-@m9ypS&%j#@g{J6xuWL_e;fnxHtHK+rb*? ziqTKzWTQ5mgPLF{S6lu~7a<=!tmR(lRT5Xn5L&vtH4v)`VLwe2hgPestZalIPEoW| zT6@L=2VW=rJxLV>B6D#VWUYbDc8ZWFww*HDlG2~{KAoV@(OmE-?VFnTW;1geIN#x= z0|w9a@T8IK_+PV&WI`^BHTXjkO@CUJFz9B(jrXcTKm_L4DUgO7`W`eG+*bH0XiymD zT<9Sm`k*#&&CLO@Z3Fq$pp1!Mxfa^bHS`4(|Y)o5}JX@N3z{!dj_ zmM@gBddV(P7v;ctMx`M(1+-?{0CkXD_>E|imxR7LJ5%frt9I3-|1(U zs@A}x@2XOQxa(|qT)ht;^Ns)LO=m3*?bB>E<+iZ1Q4tBfJ=$x2An|^xQIKm=W+z+O zE*9G(zF`bZJ2_3yidYfui~k0Q;mY7V{~mq|+r9h^?SI?oOlyBfFkLTc>pI88;`A=^ zm+n5h*1Atsp8M^Py8Tv=vp|#JHNl}(EW%fGutEnsl|Um z#_Tlfya>k@#8wl5{^8n*LAB#;`KO?S^LMof-+!$9AK&!s(JG8| z;KgY{I^I~BO45J5q(8yLlUS%m|F6SMO&6$QfX^N*d-xG(Sr7N7^9XC#A8)e!KmYij zT#VZ+Jl?tsX#g}YAtGb+zXtlhjL$eY?f>=MRDiED3jjcRMIBa!(Ck;ZeE(Ab0Dx;H z#C1;Q;!WI_M+X+QX$P$m!_v7kl5p}O)sT=I?vTSJ?92Zmhbeio82$3~m!GB*fr4{2 zQVr?xW;cb}t^~&c007OQ5}4(h^lKf?gK7s$j~ zPvbz|rH#T7i2A(3E-5=#ZKwA)?q4A4YH}qluvAFYVFPBt#x#~)=6&@8d}=Rtpy9yj zV#54_4?Oc1Zl^+Nt7L`qSCa@6L^N`4LjRIzra)m??e_=p}H6Z{hC zr2X}!>gM-=)t;gX-T=O&ssI2~7i#WUsDlip(9uwH`ymIF!x|KfYzD-W8Eop+Aahz&;au#DjTaUPdj~Xl4BInN;_kp24jwrK5im`Vd=D z-x?L@6ltUy#Vk%OyJ+HN>n-U+=y+qf1jaN-w~%u!6D6bW!O6;qP4?yL5LLP~LoE34 zN7DS~3G?H)uOIbUfeI%=SbBFTBZMwG8wT)$XD#>&3=)N?=TDcud+HQmp8@E6kKY&2K?FWs8%N4v&RLJoX+(cTxSTyY41=_JZ!ABLShi%9)3zin`PE# z6b$ZcBnc&V@}8Qrc_&w9fPLgO0S?KGHM(h!za-!$wzZts02e?arTvO{IB0 z&cK_=dg)|L_Id&e57gS{p|-bY2^ht1P!ch~kOD>9l<}nUN3nk3^qf7$aKwv^VPNof z;C#xLrg0eWoKRtAHOTWxQXk%3_TF!v@FesiNONk zXfSMvp{&%avCuqJ1@I+mp!wX?PT+)dn+?S!O5Hp%JH6Vz;da+mnmxBR5Yb;@#lS+C z{oTr!K6Q5ztF;-9W+L;0mkhh=PVN@r3%$AUYy&Z4!zS}_h(OYKiy?UAv=A&GYZDt= z5^=hUl-d8ya{XX><^#&V7tkB#DkI@dVG`l&G(`3+!z^x9m6T`2i|NesfOa=;M+D|S z9_fIu*+tsogXRW4_r-`dKKS@y5C~^p?~QlRE57eG`_qmi%CDnM2o&UiD)wJnRS)eU z-QjvN#qHe6L_zxycW=2E+fOoq7s~u->VY_O1od@(bGg-%P+drvCXN4g|8_y-xRir6 z?Xo77L-0HRD_jc!Rm}sUKu1?m=u7dkvTR#?K2NELJJP=}U?8cFVy|us$tK%QkOIzJ zkAg_Xk$L>*d@?3YF)Z(gd`k**;dbh==-2x|*%ix#`ypY1IOW#ayk7?I;OZF>P~@F) zIh8idg366Z_kV&VJ-tQe4|=~&shXIumwAZR7kG^ZhDdQToVE)iiv|EVq~?^ULygaJ z+Ye1ie|64=x`zKPWnl(r+gcwe2wq+E5hsvYjC{x%7M((j4BGtVn^K*gyH?dxS8&EC zk?@1Si;!r?Wp@5!+G7Q9&{kil`kb|Zuu9$1YoXhDT={x_?i)7P4vIJX#ezRxw6*-8 zVuAAJEZL7JEw9yI%IDRJ7hlpO%SFJ1=&OGXu3m$fKGo#Q5eP#ClSqVim`1{^1)7`T>FD_Q}qw2K0&s#4LV{C=aP^=WGW zAaheDDJ9YvT`xAF+-1~$kTW%fbviXOmXwe_qtRM~VdkUY zHu}l{EI`g&cIX_W(2D@vj_*Y!)wl)kKkya_j3f)AcW%`1?t$_Mm@AP-A8oS8B-AZiKL@q7O+G%}cIG(8 zfIk!g7&%e=4r8p0hqJf+kkz_@2$1#k;tZpYRG_=fWGhp%QfrB zq1v3i+w*%-3PGH{hrG`EzjijfvEv6Cm1OBXM;nsUI~&ZwH{H|8c}=%?h@Z3q{@V51 zhB>I-kya+{<#55HN%f+y8u#ft*_5zjmlv$Q(`}eWKlF8O_XxfCN;TtdF|RYO>o}Wi zq?;gvT-QZx)i{$3*;Xf7c%;*J#Nh~VoUL_6dbkAH{F3RxX+$MoP&{hjoF&Uo8*mQ= z4*pvHVENm2O$xuMX~0UEfoiXV5AH(I9{-c%&$5Zw>Xa*L|Y)4g};MjBaZ?V`QZOjKOmTonGYS`QHRq_8N23K~Y_$&LgUQ z1>eu<@e7XC(eaHH%B^G}d2|GNw@7VD@08c+2$*C{#k2bH?}p&&mx=)IrI=*93|okF zBYJj-*15|&hE|I>oQ-+WqQ?GY?Y)Xd?P3evf z=-A_Y_~B2>4w3sGlDOdL!h*-qrx&zAiz#|Mo;M2RU^kR?gN?I~QGKcg!py_3+)lEp zg|H_r*A{ca874x=ohiJe7gJ{majbkGg=$xQUKPn(p(>Z-QJc53G?J^_U)5~NGZx2taO8rEDcn)B{!bOCDcIC6e@3X2fdw(XUi@jbIqI$WK;LqWeQ zk0bc)>dO?)YKH&(_rQI4Ro}XH59h|9AM?{mSA^tAd5gBj7SmY$;o!nm7;E!#%p*E| z9?9{WdOb64GV7UV!a&a_Xjm&=9dW8dcjV0bLqa|PuS!W~z@asQ5{?%`kNQuT&u;M` z-l|2%Q)Pv=b_2Y8-}{+|N!OKEuNK@(d|VX)VxFFf%TJA|+g-V<`_s(D#;{|@7`*aA zoAkTKy>Z82b;FN@Xt95T4urf%rXX5pQC*4J3#280Lgeuo<@91O(Wx)jmD=xQE)!GzN@K+Sgd-X;Yh|6l@p!=YsYEWFC|K{k>I zFRMxch$SjS~crHaDAUdcV2J6i+CIQQGS87bpsf5tyo$5_O3$60jrO`t%Niy0&`=T$$w}|FO$ebg(glS@ZFAqU}r*-I70=oD!dQS{zWp zDc7{4`&u1aN>tzZOhem6!?fidkQ4?*KtVWX1W8Z-yEJBazPrgdgOQ zx3*?<$_lCGJ|HOjh-*%}7&snnQ6q2e?d@s^GgKO4XTNjlt4S?k6>LZqx_y6RJr&Uz zEA~W(KaQ_CAqjEzX{+&BMiZCGQG(Qvx}BR{xHw1J1sqG2l}Cck3Lqtf#xYnf1s6%S zFyZpD{`6ng`i*rTRSn4+*-fK=%1krkjSoG*lC-@HS;J6-8R>DgG5o9kbWrOn$YgaOB{zLb~kQRg^_CUb+LI^K)U#5wOQJpTRk}|P;RicS%D#slKp$d8kKb` z%psiCdXUh^+M3z&vx5Ru8Ws0#=I9yK%QPBimGZ*i29?Bps2Pgr=Bc0LxD$x$DlD8a z8h@H4d`*`(+SF+k&^UwJ%T2=hV=X-M3@4Sz6>@}EV8Dj!#91_tfB4BZ>`6XmzY8=c z)(VS3`f9q>_*lBI@XE|3mFL-NR(-k+mGH=`9yUP68*xX#N{ zG%bVyvW3p(z>PJcD(vn!P7#kMl3u6%g1&knsr?O#s(Xtd{v?J#$j&Jyo^WM zpq4WcO$%xFscy_wPvkQ3h>|4GoW^$iO-T2;grWrX%CvIA!Q%vV%3AUe?rV0u8f;w2 z{wEC$G!z?D|HT%?dxzx%l{`fh2rX?Bem;36L@-*qHkA4={B-aYs?HD5N*Li-oNiTH zPA!o(UeS%7oR~03MI^I4#a(^Yn)KLQLJto`*rnaX?0h3D?TdXt zq9fgW1g#xo5+2;ipkAOK_1pBlmZ^MC>P|5W2tN`TAGvYM6zxGZ_u@4UnbnvxOL1Ty zB>yGht$6}?#qgzeXO?0#%rYABD>+zLrZ_MYu)?P$)E^6=diGy@&2l_g0)FECvoYkPT4 zBWA*YQrJ~vw#O|u8SCB_jV>YL0wtTa`F3?ca+>i%hwwHTBk z2hjrz^NrW?*I;j_WY_luxEd$>a$h}-EDhH3Cz(U}ykqPwZkV@J?K-*vm9{ADciZs^ zX+~ZHM24XQLMUqm*@$qaH}AHw*2?>_I$Q3SA1 zZ)PFVwSx1~(b1l`NiVLcv65T{sdGTkET6rt^3rr8u%Mr)pjFx6qX}Ixc>MR5et|QJt&SeGV*vmU067zX`kMK>y+; zAUthGEhZ@}Kz$xYf7CN{ANCmRYiNpfK5Xzm9!k=`tMfEqOyt`Bw$DC*`6=63Z%v&* zoM2;2QCe@f?nj3YtOWzR+dgCRrev#(J(9k7jzRrlaPEgc@dWRHc7A!7ZeIYByR73x zzx#c_#O;Q*YCFgBCBZ9PYmNNN@=0TTI94`iJFm&v3Bf+VI6K<>)V#U0axB7g*vNgy zmTAGg>F#T}=P;skK`|j&Wqx1R&wwbK@YcM8ukh|u(ak_!%LmHh_hBQiFZM%4k75J- z*03kvI1yCd-2p0SJatJFR9Fdqhj6+@2roh9bhtC8KINe^X=l~Kt7ck(hnv7WptxyDc>rPM$d*R zMqaE43q$KD?wGA}%3tjbn~#H$3Ux&ucXw#2v0l-w6o4~I{zrGBm<9eaaVHv1M*CA? zjLwu0eM&5PUo9Jq#4L?56=I75Dz^@JVVS_~&DvtI95I5i+Y7vQ3F43Do>CAedVQJM zE#!V@QE&nG){kii145EL{+kRWfzlU#>OFO>j+Jm3h8-K>CLr<%p*b4nO*6+w`>Cw6 zR)j1uP}PyE*QRMO)7aZ=T)Wq@EM=N>rmbVq$srv5aJkhPvh|b}Pk7v$PjRG7l1Umb zu9lpSL_OlYs$!k~m*a$|8r3ttCzaRjp9RY)Ekly7Y^v7TR@?%{O?XcP|wLzWvdSuVQ+wF?HZ9 z_GqKvqJ`5f%3(!5a_(X%hEdMPI*Ok9!O3Fg4C=cf#$g*?|=&vcw&JM6bq&Ar?;^j6s7*_;cDZ*`DPWNNU zjH2>1?nlq`2hKSy;wt1zzEb<9tJaxG=srb@D6^^2*I9`lPC2x`T~yK~f~Q7IA$XJX zqNbrrs45<1nlhmI9|g5npaC+yFtTKsgCbvxb{4z1i?UNr(2H;lXdwy?>gxPVE0@PV#=OLvcg*&a(?2 z*_)RQs1WA&F=BJv8c!rnSBtuwZ09@#dTXb=4i z<@CoL{x}s|XpTD4v$#6Zrsi;I(6~iqdDT@83=OigFUwBLj$}YE&?FB^kPXH}gEtX^ z-665>Y8gOY^7o?Lub*Wu0>3^0i(FxPEG=M*6p44ucPi(qK1QxSSPKdoo6Z@83WVhr z;1wqK-Lwo-4V=>Gyg3YA&7+PDYTIUt%Dk%=EeH!~kv^Xo>ucOx;*kK;_>C-5{lHBq z%eFeC>4V;v@K9Vcnh56Al+lRT;E`@(>|45okN<1&)}vl^#oA)5C5Mn*L42rUT9zRT zl;)E5n5@X3u#rcOwNWN%5A*vkb1x%^4`E%Q0!ur`EX;C0)d+KQ$@BGlL}+q+@@8WQ z?)@E=y0Dh2ENYzR>`({tVI?b4aEuC3gvNFL0@%z8BG)vV5C!()wl-hj_wFV+XDKykJvw}`|zXgH66eQ zn#(>Mx78>&+ziXP>$8sG<=-D|dP7``?FQP^;y5=6G9P~q+?HtOLkx%`y#F~>nnslM zXXi9c+dlmL6iV?pggU7%MH-uexdlHvBzEIcd|@cb$IhnT8!q2yw>}-AEeNg5Pw9WY zh~vxoeh3D^NZcAD*b_AgLbb$Dy@FHSO`=pb45GB@3 zfv4HRa+>)78cHj$A+VAz_!;wjVGXPF1Q7u3Y+`X_kqZ7vDc*bC^lUm7w?&#pVZ%VQ z%9;zA;jl*@t9&GvA8`Ad>D_E3&|a2Go!})1qhh#|qp?aEt`kfd&K~&L1h%B$Ty-pb z@cu6_XKjxwOP@fSU$UyKB~n?)wR?`(&0!b-}NS9XS@g$2g#zT z!QQ%(H!qoDygM zG`CHrNA%%E>ap`YeY^|5&T7i%n-0iv+pxH9+)o0L1L*V7hQ!LWt$o-|JUM)gY$X&# zcpvE!)^MHLP&WEoQUvu)D!X^RpH3*Py1?9Rz`cokc2Vp|KWM3ZN{aQ`eWZtc=gX)% z27YB6NOgA&6v0^_Ql*e3LV=yxV$8v}MavpmGO*?SKx5~k8zY9D@fXv=SlhZ9i{ELJ ze#%BQ`n^G1qV;yb=Qzh~l(JE~GwP!0_M4g9W#U$3!}yT~STph=^0RJY?A1q+HsmwE z+>yu^HodF$&a&RP8PRy?q(fl57m6rSI)kc)tH^5qSddq)V$OvjuMVi?@cp9hBS_oc%IZJc4X@NSVzUiDXR!PCNS{fRVq zmDcrE>|if?`YHJsj3TAhzvxvOIT69|6eY*^*ZeB95fuo|?H)5lgoS)rTgjr)qNYi0 zOwgK7KNnw&F|9(LkG5OBOB40H5sTZX!;Z!2J)SSa71n_xyaCL=4O1lm0HkRoiu3{D z%QCzVbSQrq^1~pY=B&>BC1nb5nzGWiaR_|-oGYjU<)&xa?LQ?OMcKxB4x4BEeFyRj z3krUaN){G>3O703*{Jbi0Adk%nWKY9DuOX{iNcIzb+~K&U3_C}GF9CB4&l;Ik}v-3 z&I&=6;pKZ2;6q}1n;V!4`fj9nxh!_FJy-pXbY(LBg^GOK8k%v=_&fQo;z!ZB>PGb$ z8%KYuiVr2~9`~A#rA(i3e1PXO_!6gualvao#@_wA>0SHQ%M++bbs_$wJT{D!7t#0y zj!9H&GjY6FjT(kM91sNk37u0peg17~j*HY(AJ;W#8N&zM68tR!nOtoj!@aQ-{EnM} z$HFLle)8qh8VACp7@YsdzD+k)RgDbih9X3|(h^<=GB-MYL0T!?Ab$ zM|1`;(xmSsNB#NoJBdo#<~ceBdw3s1b|;2}0huAqeMK+Uns3lH`WcmelYffm{g~IB zmh|de;LtKKf*JroehQg^#ixxoPihp)8sAZG&C|inEjN4qwS!Rf@WEo%Tu95!K0W)| zBPUTh7eF9K$#96x!HPvoO!#`#tI0O8W$kH+#o=+DQ(=X)Fz{_Sqj{U5AgMZNcUmjH zoQ@!^WB!1+