diff --git a/CHANGELOG.md b/CHANGELOG.md index 2748503f5..7de9b58f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co ### Added - [Hikka](https://hikka.io/) tracker support ([@Lorg0n](https://github.com/Lorg0n)) ([#1386](https://github.com/mihonapp/mihon/pull/1386)) - Fix Hikka not defaulting to "Plan to Read" for unread titles ([@MajorTanya](https://github.com/MajorTanya)) ([#3534](https://github.com/mihonapp/mihon/pull/3534)) +- Add support for [MangaBaka](https://mangabaka.org) tracker ([@MajorTanya](https://github.com/MajorTanya)) ([#3047](https://github.com/mihonapp/mihon/pull/3047)) - Support resumable image downloads if supported by source ([@xMohnad](https://github.com/xMohnad)) ([#3167](https://github.com/mihonapp/mihon/pull/3167)) - Display authors and description in Shikimori search results ([@MajorTanya](https://github.com/MajorTanya)) ([#3499](https://github.com/mihonapp/mihon/pull/3499)) - Invalidate download cache after backup restore ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3096](https://github.com/mihonapp/mihon/pull/3096)) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 058986cf8..a36b86e37 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -193,6 +193,7 @@ + diff --git a/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt b/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt index e31fb0e68..610f0f1a8 100644 --- a/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt +++ b/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt @@ -3,6 +3,7 @@ package eu.kanade.domain.track.service import eu.kanade.domain.track.model.AutoTrackState import eu.kanade.tachiyomi.data.track.Tracker import eu.kanade.tachiyomi.data.track.anilist.Anilist +import eu.kanade.tachiyomi.data.track.mangabaka.MangaBaka import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.getEnum @@ -41,6 +42,8 @@ class TrackPreferences( val anilistScoreType: Preference = preferenceStore.getString("anilist_score_type", Anilist.POINT_10) + val mangabakaScoreType: Preference = preferenceStore.getString("mangabaka_score_type", MangaBaka.STEP_1) + val autoUpdateTrack: Preference = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true) val autoUpdateTrackOnMarkRead: Preference = preferenceStore.getEnum( diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt index 3068e06c5..c141a74c0 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt @@ -52,6 +52,7 @@ import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.anilist.AnilistApi import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi import eu.kanade.tachiyomi.data.track.hikka.HikkaApi +import eu.kanade.tachiyomi.data.track.mangabaka.MangaBakaApi import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi import eu.kanade.tachiyomi.util.system.openInBrowser @@ -137,6 +138,11 @@ object SettingsTrackingScreen : SearchableSettings { Preference.PreferenceGroup( title = stringResource(MR.strings.services), preferenceItems = listOf( + Preference.PreferenceItem.TrackerPreference( + tracker = trackerManager.mangaBaka, + login = { context.openInBrowser(MangaBakaApi.authUrl(), forceDefaultBrowser = true) }, + logout = { dialog = LogoutDialog(trackerManager.mangaBaka) }, + ), Preference.PreferenceItem.TrackerPreference( tracker = trackerManager.myAnimeList, login = { context.openInBrowser(MyAnimeListApi.authUrl(), forceDefaultBrowser = true) }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt index 1935a6b1e..6db63d3f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt @@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.data.track.hikka.Hikka import eu.kanade.tachiyomi.data.track.kavita.Kavita import eu.kanade.tachiyomi.data.track.kitsu.Kitsu import eu.kanade.tachiyomi.data.track.komga.Komga +import eu.kanade.tachiyomi.data.track.mangabaka.MangaBaka import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList import eu.kanade.tachiyomi.data.track.shikimori.Shikimori @@ -18,6 +19,7 @@ class TrackerManager { const val ANILIST = 2L const val KITSU = 3L const val KAVITA = 8L + const val MANGABAKA = 11L } val myAnimeList = MyAnimeList(1L) @@ -30,8 +32,21 @@ class TrackerManager { val kavita = Kavita(KAVITA) val suwayomi = Suwayomi(9L) val hikka = Hikka(10L) + val mangaBaka = MangaBaka(MANGABAKA) - val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi, hikka) + val trackers = listOf( + myAnimeList, + aniList, + kitsu, + shikimori, + bangumi, + komga, + mangaUpdates, + kavita, + suwayomi, + hikka, + mangaBaka, + ) fun loggedInTrackers() = trackers.filter { it.isLoggedIn } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBaka.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBaka.kt new file mode 100644 index 000000000..5c35f76aa --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBaka.kt @@ -0,0 +1,196 @@ +package eu.kanade.tachiyomi.data.track.mangabaka + +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.mangabaka.dto.MangaBakaOAuth +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.json.Json +import tachiyomi.i18n.MR +import uy.kohesive.injekt.injectLazy +import tachiyomi.domain.track.model.Track as DomainTrack + +class MangaBaka(id: Long) : BaseTracker(id, "MangaBaka"), DeletableTracker { + + private val json: Json by injectLazy() + + private val interceptor by lazy { MangaBakaInterceptor(this) } + private val api by lazy { MangaBakaApi(id, client, interceptor) } + + override val supportsReadingDates: Boolean = true + override val supportsPrivateTracking: Boolean = true + + private val scorePreference = trackPreferences.mangabakaScoreType + + override fun getLogo(): Int = R.drawable.brand_mangabaka + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, PAUSED, DROPPED, PLAN_TO_READ, REREADING, CONSIDERING) + } + + override fun getStatus(status: Long): StringResource? = when (status) { + CONSIDERING -> MR.strings.considering + COMPLETED -> MR.strings.completed + DROPPED -> MR.strings.dropped + PAUSED -> MR.strings.paused + PLAN_TO_READ -> MR.strings.plan_to_read + READING -> MR.strings.reading + REREADING -> MR.strings.repeating + else -> null + } + + override fun getReadingStatus(): Long = READING + + override fun getRereadingStatus(): Long = REREADING + + override fun getCompletionStatus(): Long = COMPLETED + + override fun getScoreList(): ImmutableList { + return when (scorePreference.get()) { + // 1, 2, ..., 99, 100 + STEP_1 -> IntRange(0, 100).map(Int::toString).toImmutableList() + // 5, 10, ..., 95, 100 + STEP_5 -> IntRange(0, 100).step(5).map(Int::toString).toImmutableList() + // 10, 20, ..., 90, 100 + STEP_10 -> IntRange(0, 100).step(10).map(Int::toString).toImmutableList() + // 20, 40, ..., 80, 100 + STEP_20 -> IntRange(0, 100).step(20).map(Int::toString).toImmutableList() + // 25, 50, 75, 100 + STEP_25 -> IntRange(0, 100).step(25).map(Int::toString).toImmutableList() + else -> throw Exception("Unknown score type") + } + } + + override fun displayScore(track: DomainTrack): String = track.score.toInt().toString() + + override suspend fun update( + track: Track, + didReadChapter: Boolean, + ): Track { + if (track.status != COMPLETED && didReadChapter) { + if (track.total_chapters > 0 && track.last_chapter_read.toLong() == track.total_chapters) { + track.status = COMPLETED + track.finished_reading_date = System.currentTimeMillis() + } else if (track.status != REREADING) { + track.status = READING + if (track.last_chapter_read == 1.0) { + track.started_reading_date = System.currentTimeMillis() + } + } + } + + 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, copyRemotePrivate = false) + track.title = remoteTrack.title + track.remote_id = remoteTrack.remote_id + + if (track.status != COMPLETED) { + val isRereading = track.status == REREADING + track.status = if (!isRereading && hasReadChapters) READING else track.status + } + + update(track) + } else { + // Set default fields if it's not found in the list + 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).toIntOrNull()?.let { id -> + return api.getMangaDetails(id)?.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) + track.remote_id = remoteTrack.remote_id + track.title = remoteTrack.title + 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() + val scoreType = when (currentUser.ratingSteps) { + 1 -> STEP_1 + 5 -> STEP_5 + 10 -> STEP_10 + 20 -> STEP_20 + 25 -> STEP_25 + else -> throw Exception("Unknown score step size ${currentUser.ratingSteps}") + } + scorePreference.set(scoreType) + saveDisplayUsername(currentUser.nickname ?: currentUser.preferredUsername ?: currentUser.id) + saveCredentials("user", oauth.accessToken) + } catch (_: Exception) { + logout() + } + } + + fun saveToken(oauth: MangaBakaOAuth?) { + trackPreferences.trackToken(this).set(json.encodeToString(oauth)) + } + + fun restoreToken(): MangaBakaOAuth? { + 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 PAUSED = 3L + const val DROPPED = 4L + const val PLAN_TO_READ = 5L + const val REREADING = 6L + const val CONSIDERING = 7L + + const val STEP_1 = "STEP_1" + const val STEP_5 = "STEP_5" + const val STEP_10 = "STEP_10" + const val STEP_20 = "STEP_20" + const val STEP_25 = "STEP_25" + + private const val SEARCH_ID_PREFIX = "id:" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt new file mode 100644 index 000000000..e84439339 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt @@ -0,0 +1,327 @@ +package eu.kanade.tachiyomi.data.track.mangabaka + +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.TrackerManager +import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaItem +import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaItemResult +import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaListResult +import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaOAuth +import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaSearchResult +import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaUserProfile +import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaUserProfileResponse +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.parseAs +import eu.kanade.tachiyomi.util.PkceUtil +import eu.kanade.tachiyomi.util.lang.toLocalDate +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.FormBody +import okhttp3.Headers.Companion.headersOf +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.i18n.MR +import uy.kohesive.injekt.injectLazy +import java.math.RoundingMode +import java.security.SecureRandom +import java.util.Base64 +import java.util.Locale +import kotlin.time.Instant +import tachiyomi.domain.track.model.Track as DomainTrack + +class MangaBakaApi( + private val trackId: Long, + baseClient: OkHttpClient, + interceptor: MangaBakaInterceptor, +) { + + private val json: Json by injectLazy() + + private val client = baseClient.newBuilder().addInterceptor { + it.request().newBuilder() + .header( + "User-Agent", + buildString { + append("${MR.strings.app_name}/v${BuildConfig.VERSION_NAME} ") + append("(${BuildConfig.APPLICATION_ID} ${BuildConfig.COMMIT_SHA}) ") + append("(Android) (https://github.com/mihonapp/mihon)") + }, + ) + .build() + .let(it::proceed) + }.build() + + private val authClient = client.newBuilder().addInterceptor(interceptor).build() + + suspend fun addLibManga(track: Track): Track { + return withIOContext { + val url = "$LIBRARY_API_URL/${track.remote_id}" + val body = buildJsonObject { + put("is_private", track.private) + put("state", track.toApiStatus()) + if (track.last_chapter_read > 0.0) { + put("progress_chapter", track.last_chapter_read) + } + if (track.score > 0) { + put("rating", track.score.toInt().coerceIn(0, 100)) + } + if (track.started_reading_date > 0) { + put("start_date", track.started_reading_date.toLocalDate().toString()) + } + if (track.finished_reading_date > 0) { + put("finish_date", track.finished_reading_date.toLocalDate().toString()) + } + } + .toString() + .toRequestBody() + + authClient + .newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))) + .awaitSuccess() + + // only returns 201 with the body { "status": 201, "data": true }, so no library ID for us + track + } + } + + suspend fun deleteLibManga(track: DomainTrack) { + withIOContext { + val url = "$LIBRARY_API_URL/${track.remoteId}" + + authClient + .newCall(DELETE(url)) + .awaitSuccess() + } + } + + suspend fun findLibManga(track: Track): Track? { + return withIOContext { + with(json) { + try { + val url = "$LIBRARY_API_URL/${track.remote_id}" + val userData = authClient.newCall(GET(url)) + .awaitSuccess() + .parseAs() + .data + + val additionalData = authClient.newCall(GET("$API_BASE_URL/v1/series/${track.remote_id}")) + .awaitSuccess() + .parseAs() + .data + + Track.create(TrackerManager.MANGABAKA).apply { + remote_id = track.remote_id + title = additionalData.chooseBestTitle() + status = userData.getStatus() + score = userData.rating?.toDouble() ?: 0.0 + started_reading_date = userData.startDate?.let { Instant.parse(it).toEpochMilliseconds() } ?: 0 + finished_reading_date = + userData.finishDate?.let { Instant.parse(it).toEpochMilliseconds() } ?: 0 + last_chapter_read = userData.progressChapter ?: 0.0 + private = userData.isPrivate + } + } catch (e: HttpException) { + if (e.code == 404) { + null + } else { + throw e + } + } + } + } + } + + suspend fun updateLibManga(track: Track): Track { + return withIOContext { + val url = "$LIBRARY_API_URL/${track.remote_id}" + val body = buildJsonObject { + put("state", track.toApiStatus()) + put("is_private", track.private) + if (track.last_chapter_read > 0.0) { + put("progress_chapter", track.last_chapter_read) + } else { + put("progress_chapter", null) + } + if (track.score > 0) { + put("rating", track.score.toInt().coerceIn(0, 100)) + } else { + put("rating", null) + } + if (track.started_reading_date > 0) { + put("start_date", track.started_reading_date.toLocalDate().toString()) + } else { + put("start_date", null) + } + if (track.finished_reading_date > 0) { + put("finish_date", track.finished_reading_date.toLocalDate().toString()) + } else { + put("finish_date", null) + } + } + .toString() + .toRequestBody() + + authClient + .newCall(PUT(url, body = body, headers = headersOf("Content-Type", APP_JSON))) + .awaitSuccess() + + track + } + } + + suspend fun search(search: String): List { + return withIOContext { + val url = "$API_BASE_URL/v1/series/search".toUri().buildUpon() + .appendQueryParameter("q", search) + .appendQueryParameter("type_not", "novel") + .build() + with(json) { + client.newCall(GET(url.toString())) + .awaitSuccess() + .parseAs() + .data + .map { parseSearchItem(it) } + } + } + } + + private fun parseSearchItem(item: MangaBakaItem): TrackSearch { + return TrackSearch.create(trackId).apply { + remote_id = item.id + title = item.chooseBestTitle() + summary = item.description?.trim().orEmpty() + score = item.rating?.toBigDecimal()?.setScale(2, RoundingMode.HALF_UP)?.toDouble() ?: -1.0 + cover_url = item.cover.x250.x1.orEmpty() + tracking_url = "$BASE_URL/${item.id}" + start_date = item.published.startDate.orEmpty() + publishing_status = item.status + publishing_type = item.type.replaceFirstChar { c -> + if (c.isLowerCase()) c.titlecase(Locale.getDefault()) else c.toString() + } + authors = item.authors.orEmpty() + artists = item.artists.orEmpty() + } + } + + suspend fun getMangaDetails(id: Int): TrackSearch? { + return withIOContext { + val url = "$API_BASE_URL/v1/series".toUri().buildUpon() + .appendPath(id.toString()) + .build() + with(json) { + try { + authClient.newCall(GET(url.toString())) + .awaitSuccess() + .parseAs() + .data + .let { parseSearchItem(it) } + } catch (e: HttpException) { + if (e.code == 404) { + return@with null + } + throw e + } + } + } + } + + suspend fun getCurrentUser(): MangaBakaUserProfile { + return withIOContext { + with(json) { + authClient.newCall(GET("$API_BASE_URL/v1/my/profile")) + .awaitSuccess() + .parseAs() + .data + } + } + } + + suspend fun getAccessToken(code: String): MangaBakaOAuth { + return withIOContext { + val formBody = FormBody.Builder() + .add("client_id", CLIENT_ID) + .add("code", code) + .add("code_verifier", codeVerifier) + .add("code_challenge_method", "S256") + .add("grant_type", "authorization_code") + .add("redirect_uri", REDIRECT_URI) + .add("scope", SCOPES) + .build() + + with(json) { + client.newCall(POST("${OAUTH_URL}/token", body = formBody)) + .awaitSuccess().parseAs() + } + } + } + + fun verifyOAuthState(state: String): Boolean = state == oauthStateParam + + companion object { + private const val CLIENT_ID = "zEZYMHXLWsLsafgbvJHXqzGvqQNOdkpo" + + private const val BASE_URL = "https://mangabaka.org" + private const val API_BASE_URL = "https://api.mangabaka.org" + private const val LIBRARY_API_URL = "$API_BASE_URL/v1/my/library" + private const val OAUTH_URL = "$BASE_URL/auth/oauth2" + private const val SCOPES = "library.read library.write offline_access openid" + + private const val REDIRECT_URI = "mihon://mangabaka-auth" + + private const val APP_JSON = "application/json" + + private var codeVerifier: String = "" + private var oauthStateParam: String = "" + + fun authUrl(): Uri = "$OAUTH_URL/authorize".toUri().buildUpon() // + .appendQueryParameter("client_id", CLIENT_ID) + .appendQueryParameter("code_challenge", getPkceS256ChallengeCode()) + .appendQueryParameter("code_challenge_method", "S256") + .appendQueryParameter("response_type", "code") + .appendQueryParameter("scope", SCOPES) + .appendQueryParameter("redirect_uri", REDIRECT_URI) + .appendQueryParameter("state", getOAuthStateParam()) + .build() + + fun refreshTokenRequest(token: String) = POST( + "$OAUTH_URL/token", + body = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("client_id", CLIENT_ID) + .add("refresh_token", token) + .add("redirect_uri", REDIRECT_URI) + .build(), + ) + + private fun getOAuthStateParam(): String { + val bytes = ByteArray(16) + SecureRandom().nextBytes(bytes) + oauthStateParam = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(bytes) + + return oauthStateParam + } + + private fun getPkceS256ChallengeCode(): String { + // MangaBaka 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/mangabaka/MangaBakaInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaInterceptor.kt new file mode 100644 index 000000000..afb760c06 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaInterceptor.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.data.track.mangabaka + +import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaOAuth +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.Response +import uy.kohesive.injekt.injectLazy + +class MangaBakaInterceptor(private val mangaBaka: MangaBaka) : Interceptor { + + private val json: Json by injectLazy() + + private var oauth: MangaBakaOAuth? = mangaBaka.restoreToken() + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + var currentAuth = oauth ?: throw Exception("Not authenticated with MangaBaka") + + if (currentAuth.isExpired()) { + val response = chain.proceed(MangaBakaApi.refreshTokenRequest(currentAuth.refreshToken)) + if (response.isSuccessful) { + currentAuth = json.decodeFromString(response.body.string()) + setAuth(currentAuth) + } else { + response.close() + } + } + + return originalRequest.newBuilder() + .addHeader("Authorization", "Bearer ${currentAuth.accessToken}") + .build() + .let(chain::proceed) + } + + fun setAuth(oauth: MangaBakaOAuth?) { + this.oauth = oauth + + mangaBaka.saveToken(oauth) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaUtils.kt new file mode 100644 index 000000000..74b7df018 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaUtils.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.data.track.mangabaka + +import eu.kanade.tachiyomi.data.database.models.Track + +fun Track.toApiStatus() = when (status) { + MangaBaka.CONSIDERING -> "considering" + MangaBaka.COMPLETED -> "completed" + MangaBaka.DROPPED -> "dropped" + MangaBaka.PAUSED -> "paused" + MangaBaka.PLAN_TO_READ -> "plan_to_read" + MangaBaka.READING -> "reading" + MangaBaka.REREADING -> "rereading" + else -> throw NotImplementedError("Unknown status: $status") +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaItem.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaItem.kt new file mode 100644 index 000000000..81d4b061d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaItem.kt @@ -0,0 +1,76 @@ +package eu.kanade.tachiyomi.data.track.mangabaka.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +private val TITLE_PRIORITIES = listOf("en", "ja-Latn", "ja", "ko-Latn", "ko", "zh-Latn", "zh") + +@Serializable +data class MangaBakaItemResult( + val data: MangaBakaItem, +) + +@Serializable +data class MangaBakaSearchResult( + val data: List, +) + +@Serializable +data class MangaBakaItem( + val id: Long, + val cover: MangaBakaCover, + val authors: List?, + val artists: List?, + val description: String?, + val published: MangaBakaPublishData, + val status: String, + val type: String, + val rating: Double?, + val titles: List?, +) { + fun chooseBestTitle(): String { + // based on https://mangabaka.org/pages/announcements/15-titles-v2#finding-the-title-you-want + // extended with zh-Latn and zh + val bestTitlePerLanguage = TITLE_PRIORITIES.associateWith { lang -> + titles?.filter { it.language == lang } + ?.minByOrNull { + when { + it.isPrimary -> 0 + "official" in it.traits -> 1 + "native" in it.traits -> 2 + else -> 3 + } + } + } + + return TITLE_PRIORITIES + .firstNotNullOfOrNull { bestTitlePerLanguage[it]?.title } + ?: titles?.firstOrNull()?.title + ?: "ID: $id - Could not find name! (report on the MangaBaka Discord)" + } +} + +@Serializable +data class MangaBakaCover( + val x250: MangaBakaScaledCover, +) + +@Serializable +data class MangaBakaScaledCover( + val x1: String?, +) + +@Serializable +data class MangaBakaPublishData( + @SerialName("start_date") + val startDate: String?, +) + +@Serializable +data class MangaBakaItemTitle( + val language: String, + val traits: List, + val title: String, + @SerialName("is_primary") + val isPrimary: Boolean, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaListEntry.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaListEntry.kt new file mode 100644 index 000000000..416c2fb63 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaListEntry.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi.data.track.mangabaka.dto + +import eu.kanade.tachiyomi.data.track.mangabaka.MangaBaka +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MangaBakaListResult( + val data: MangaBakaListEntry, +) + +@Serializable +data class MangaBakaListEntry( + val state: String, + @SerialName("start_date") + val startDate: String?, + @SerialName("finish_date") + val finishDate: String?, + @SerialName("is_private") + val isPrivate: Boolean, + @SerialName("progress_chapter") + val progressChapter: Double?, + val rating: Long?, +) { + fun getStatus(): Long = when (state) { + "considering" -> MangaBaka.CONSIDERING + "completed" -> MangaBaka.COMPLETED + "dropped" -> MangaBaka.DROPPED + "paused" -> MangaBaka.PAUSED + "plan_to_read" -> MangaBaka.PLAN_TO_READ + "reading" -> MangaBaka.READING + "rereading" -> MangaBaka.REREADING + else -> throw NotImplementedError("Unknown status: $state") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaOAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaOAuth.kt new file mode 100644 index 000000000..6ebad3b8a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaOAuth.kt @@ -0,0 +1,23 @@ +package eu.kanade.tachiyomi.data.track.mangabaka.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +@Serializable +data class MangaBakaOAuth( + @SerialName("access_token") + val accessToken: String, + @SerialName("refresh_token") + val refreshToken: String, + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("expires_at") + val expiresAt: Long, + @SerialName("token_type") + val tokenType: String, + val scope: String, +) { + fun isExpired(): Boolean = Clock.System.now().plus(1.minutes).epochSeconds > expiresAt +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaUserProfile.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaUserProfile.kt new file mode 100644 index 000000000..0675ab9b5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaUserProfile.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.data.track.mangabaka.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MangaBakaUserProfileResponse( + val data: MangaBakaUserProfile, +) + +@Serializable +data class MangaBakaUserProfile( + // incomplete DTO since this is the only part we need + val id: String, + @SerialName("rating_steps") + val ratingSteps: Int, + val nickname: String?, + @SerialName("preferred_username") + val preferredUsername: 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 028416b23..f5f057bca 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 @@ -3,6 +3,8 @@ package eu.kanade.tachiyomi.ui.setting.track import android.net.Uri import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch +import logcat.LogPriority +import tachiyomi.core.common.util.system.logcat class TrackLoginActivity : BaseOAuthLoginActivity() { @@ -24,6 +26,7 @@ class TrackLoginActivity : BaseOAuthLoginActivity() { when (uri.host) { "anilist-auth" -> handleAniList(data["access_token"]) "bangumi-auth" -> handleBangumi(data["code"]) + "mangabaka-auth" -> handleMangaBaka(data["code"], data["state"]) "myanimelist-auth" -> handleMyAnimeList(data["code"]) "shikimori-auth" -> handleShikimori(data["code"]) "hikka-auth" -> handleHikka(data["reference"]) @@ -48,6 +51,22 @@ class TrackLoginActivity : BaseOAuthLoginActivity() { } } + private suspend fun handleMangaBaka(code: String?, state: String?) { + if (state == null) { + logcat(LogPriority.WARN) { "Did not receive state parameter from MangaBaka OAuth" } + return + } + if (code != null) { + if (!trackerManager.mangaBaka.verifyOAuthState(state)) { + logcat(LogPriority.WARN) { "Received wrong OAuth state back from MangaBaka" } + return + } + trackerManager.mangaBaka.login(code) + } else { + trackerManager.mangaBaka.logout() + } + } + private suspend fun handleMyAnimeList(code: String?) { if (code != null) { trackerManager.myAnimeList.login(code) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt index 9699a7e9f..81b9258c7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.util +import java.security.MessageDigest import java.security.SecureRandom import java.util.Base64 @@ -12,4 +13,17 @@ object PkceUtil { .withoutPadding() .encodeToString(codeVerifier) } + + fun generateS256Codes(): PkceCodes { + val codeVerifier = generateCodeVerifier() + val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray()) + val codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + + return PkceCodes(codeVerifier = codeVerifier, codeChallenge = codeChallenge) + } } + +data class PkceCodes( + val codeVerifier: String, + val codeChallenge: String, +) diff --git a/app/src/main/res/drawable-nodpi/brand_mangabaka.webp b/app/src/main/res/drawable-nodpi/brand_mangabaka.webp new file mode 100644 index 000000000..89e20cfa5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/brand_mangabaka.webp differ diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 1d06cb4e4..122781492 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -786,6 +786,7 @@ On hold Paused Plan to read + Considering Rereading Reading List Wish List