diff --git a/README.md b/README.md index ab5cbe31e..acdd1ab05 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Kotatsu is a free and open source manga reader for Android. * Tablet-optimized Material You UI * Standard and Webtoon-optimized reader * Notifications about new chapters with updates feed -* Integration with manga tracking services: Shikimori, AniList, MyAnimeList +* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu * Password/fingerprint protect access to the app * History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c22072304..64de70c55 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -221,12 +221,24 @@ + + + + + + + + = setOf(shikimoriScrobbler, aniListScrobbler, malScrobbler) + kitsuScrobbler: KitsuScrobbler + ): Set<@JvmSuppressWildcards Scrobbler> = setOf(shikimoriScrobbler, aniListScrobbler, malScrobbler, kitsuScrobbler) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerService.kt index 5b47a3a25..28f860d4d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerService.kt @@ -12,5 +12,6 @@ enum class ScrobblerService( SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori), ANILIST(2, R.string.anilist, R.drawable.ic_anilist), - MAL(3, R.string.mal, R.drawable.ic_mal) + MAL(3, R.string.mal, R.drawable.ic_mal), + KITSU(4, R.string.kitsu, R.drawable.ic_kitsu) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuAuthenticator.kt new file mode 100644 index 000000000..106651267 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuAuthenticator.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.scrobbling.kitsu.data + +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType +import javax.inject.Inject +import javax.inject.Provider + +class KitsuAuthenticator @Inject constructor( + @ScrobblerType(ScrobblerService.KITSU) private val storage: ScrobblerStorage, + private val repositoryProvider: Provider, +) : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + TODO("Not yet implemented") + } + +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuInterceptor.kt new file mode 100644 index 000000000..13b0069e3 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuInterceptor.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.scrobbling.kitsu.data + +import okhttp3.Interceptor +import okhttp3.Response +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage + +private const val JSON = "application/json" + +class KitsuInterceptor(private val storage: ScrobblerStorage) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val sourceRequest = chain.request() + val request = sourceRequest.newBuilder() + request.header(CommonHeaders.CONTENT_TYPE, JSON) + request.header(CommonHeaders.ACCEPT, JSON) + if (!sourceRequest.url.pathSegments.contains("oauth")) { + storage.accessToken?.let { + request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") + } + } + return chain.proceed(request.build()) + } + +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuRepository.kt new file mode 100644 index 000000000..1bac6bd17 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/data/KitsuRepository.kt @@ -0,0 +1,92 @@ +package org.koitharu.kotatsu.scrobbling.kitsu.data + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.parseJson +import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository +import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser + +private const val BASE_WEB_URL = "https://kitsu.io" + +class KitsuRepository( + @ApplicationContext context: Context, + private val okHttp: OkHttpClient, + private val storage: ScrobblerStorage, + private val db: MangaDatabase, +) : ScrobblerRepository { + + private val clientId = context.getString(R.string.kitsu_clientId) + private val clientSecret = context.getString(R.string.kitsu_clientSecret) + + override val oauthUrl: String = "kotatsu+kitsu://auth" + + override val isAuthorized: Boolean + get() = storage.accessToken != null + + override val cachedUser: ScrobblerUser? + get() { + return storage.user + } + + override suspend fun authorize(code: String?) { + val body = FormBody.Builder() + if (code != null) { + body.add("grant_type", "password") + body.add("username", "test@test") + body.add("password", "test") + } else { + body.add("grant_type", "refresh_token") + body.add("refresh_token", checkNotNull(storage.refreshToken)) + } + val request = Request.Builder() + .post(body.build()) + .url("${BASE_WEB_URL}/api/oauth/token") + val response = okHttp.newCall(request.build()).await().parseJson() + storage.accessToken = response.getString("access_token") + storage.refreshToken = response.getString("refresh_token") + } + + override suspend fun loadUser(): ScrobblerUser { + TODO("Not yet implemented") + } + + override fun logout() { + TODO("Not yet implemented") + } + + override suspend fun unregister(mangaId: Long) { + return db.getScrobblingDao().delete(ScrobblerService.KITSU.id, mangaId) + } + + override suspend fun findManga(query: String, offset: Int): List { + TODO("Not yet implemented") + } + + override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + TODO("Not yet implemented") + } + + override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { + TODO("Not yet implemented") + } + + override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { + TODO("Not yet implemented") + } + + override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { + TODO("Not yet implemented") + } + +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/domain/KitsuScrobbler.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/domain/KitsuScrobbler.kt new file mode 100644 index 000000000..f19cf899e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/domain/KitsuScrobbler.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.scrobbling.kitsu.domain + +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus +import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuRepository +import javax.inject.Inject + +class KitsuScrobbler @Inject constructor( + private val repository: KitsuRepository, + db: MangaDatabase, +) : Scrobbler(db, ScrobblerService.KITSU, repository) { + + init { + statuses[ScrobblingStatus.PLANNED] = "planned" + statuses[ScrobblingStatus.READING] = "current" + statuses[ScrobblingStatus.COMPLETED] = "completed" + statuses[ScrobblingStatus.ON_HOLD] = "on_hold" + statuses[ScrobblingStatus.DROPPED] = "dropped" + } + + override suspend fun updateScrobblingInfo( + mangaId: Long, + rating: Float, + status: ScrobblingStatus?, + comment: String? + ) { + val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) + requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } + repository.updateRate( + rateId = entity.id, + mangaId = entity.mangaId, + rating = rating, + status = statuses[status], + comment = comment, + ) + } + +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt new file mode 100644 index 000000000..b01f14adb --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.scrobbling.kitsu.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.graphics.Insets +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.databinding.ActivityKitsuAuthBinding + +class KitsuAuthActivity : BaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityKitsuAuthBinding.inflate(layoutInflater)) + } + + override fun onWindowInsetsChanged(insets: Insets) { + val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) + viewBinding.root.setPadding( + basePadding + insets.left, + basePadding + insets.top, + basePadding + insets.right, + basePadding + insets.bottom, + ) + } + + companion object { + fun newIntent(context: Context) = Intent(context, KitsuAuthActivity::class.java) + } + +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt index fe549eca7..2eccb5ce2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt @@ -19,8 +19,10 @@ import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository +import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity +import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuRepository import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.sync.domain.SyncController @@ -41,6 +43,9 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), @Inject lateinit var malRepository: MALRepository + @Inject + lateinit var kitsuRepository: KitsuRepository + @Inject lateinit var syncController: SyncController @@ -64,6 +69,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), bindScrobblerSummary(AppSettings.KEY_SHIKIMORI, shikimoriRepository) bindScrobblerSummary(AppSettings.KEY_ANILIST, aniListRepository) bindScrobblerSummary(AppSettings.KEY_MAL, malRepository) + bindScrobblerSummary(AppSettings.KEY_KITSU, kitsuRepository) bindSyncSummary() } @@ -103,6 +109,15 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), true } + AppSettings.KEY_KITSU -> { + if (!kitsuRepository.isAuthorized) { + launchScrobblerAuth(kitsuRepository) + } else { + startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.KITSU)) + } + true + } + AppSettings.KEY_SYNC -> { val am = AccountManager.get(requireContext()) val accountType = getString(R.string.account_type_sync) @@ -121,7 +136,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), private fun bindScrobblerSummary( key: String, - repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository + repository: ScrobblerRepository ) { val pref = findPreference(key) ?: return if (!repository.isAuthorized) { @@ -147,7 +162,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), } } - private fun launchScrobblerAuth(repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository) { + private fun launchScrobblerAuth(repository: ScrobblerRepository) { runCatching { val intent = Intent(Intent.ACTION_VIEW) intent.data = Uri.parse(repository.oauthUrl) diff --git a/app/src/main/res/drawable/ic_kitsu.xml b/app/src/main/res/drawable/ic_kitsu.xml new file mode 100644 index 000000000..b124346cb --- /dev/null +++ b/app/src/main/res/drawable/ic_kitsu.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_kitsu_auth.xml b/app/src/main/res/layout/activity_kitsu_auth.xml new file mode 100644 index 000000000..f6166696e --- /dev/null +++ b/app/src/main/res/layout/activity_kitsu_auth.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + +