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