mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-16 13:48:09 -07:00
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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
@@ -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 |
Reference in New Issue
Block a user