Move sources from java to kotlin dir

This commit is contained in:
Koitharu
2023-05-22 18:16:50 +03:00
parent a8f5714b35
commit c3216871ed
711 changed files with 1 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
package org.koitharu.kotatsu.scrobbling
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListAuthenticator
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListInterceptor
import org.koitharu.kotatsu.scrobbling.anilist.domain.AniListScrobbler
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
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.ScrobblerType
import org.koitharu.kotatsu.scrobbling.mal.data.MALAuthenticator
import org.koitharu.kotatsu.scrobbling.mal.data.MALInterceptor
import org.koitharu.kotatsu.scrobbling.mal.domain.MALScrobbler
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor
import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ScrobblingModule {
@Provides
@Singleton
@ScrobblerType(ScrobblerService.SHIKIMORI)
fun provideShikimoriHttpClient(
@BaseHttpClient baseHttpClient: OkHttpClient,
authenticator: ShikimoriAuthenticator,
@ScrobblerType(ScrobblerService.SHIKIMORI) storage: ScrobblerStorage,
): OkHttpClient = baseHttpClient.newBuilder().apply {
authenticator(authenticator)
addInterceptor(ShikimoriInterceptor(storage))
}.build()
@Provides
@Singleton
@ScrobblerType(ScrobblerService.MAL)
fun provideMALHttpClient(
@BaseHttpClient baseHttpClient: OkHttpClient,
authenticator: MALAuthenticator,
@ScrobblerType(ScrobblerService.MAL) storage: ScrobblerStorage,
): OkHttpClient = baseHttpClient.newBuilder().apply {
authenticator(authenticator)
addInterceptor(MALInterceptor(storage))
}.build()
@Provides
@Singleton
@ScrobblerType(ScrobblerService.ANILIST)
fun provideAniListHttpClient(
@BaseHttpClient baseHttpClient: OkHttpClient,
authenticator: AniListAuthenticator,
@ScrobblerType(ScrobblerService.ANILIST) storage: ScrobblerStorage,
): OkHttpClient = baseHttpClient.newBuilder().apply {
authenticator(authenticator)
addInterceptor(AniListInterceptor(storage))
}.build()
@Provides
@Singleton
@ScrobblerType(ScrobblerService.ANILIST)
fun provideAniListStorage(
@ApplicationContext context: Context,
): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.ANILIST)
@Provides
@Singleton
@ScrobblerType(ScrobblerService.SHIKIMORI)
fun provideShikimoriStorage(
@ApplicationContext context: Context,
): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.SHIKIMORI)
@Provides
@Singleton
@ScrobblerType(ScrobblerService.MAL)
fun provideMALStorage(
@ApplicationContext context: Context,
): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.MAL)
@Provides
@ElementsIntoSet
fun provideScrobblers(
shikimoriScrobbler: ShikimoriScrobbler,
aniListScrobbler: AniListScrobbler,
malScrobbler: MALScrobbler,
): Set<@JvmSuppressWildcards Scrobbler> = setOf(shikimoriScrobbler, aniListScrobbler, malScrobbler)
}

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.scrobbling.anilist.data
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders
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 AniListAuthenticator @Inject constructor(
@ScrobblerType(ScrobblerService.ANILIST) private val storage: ScrobblerStorage,
private val repositoryProvider: Provider<AniListRepository>,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val accessToken = storage.accessToken ?: return null
if (!isRequestWithAccessToken(response)) {
return null
}
synchronized(this) {
val newAccessToken = storage.accessToken ?: return null
if (accessToken != newAccessToken) {
return newRequestWithAccessToken(response.request, newAccessToken)
}
val updatedAccessToken = refreshAccessToken() ?: return null
return newRequestWithAccessToken(response.request, updatedAccessToken)
}
}
private fun isRequestWithAccessToken(response: Response): Boolean {
val header = response.request.header(CommonHeaders.AUTHORIZATION)
return header?.startsWith("Bearer") == true
}
private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
return request.newBuilder()
.header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
.build()
}
private fun refreshAccessToken(): String? = runCatching {
val repository = repositoryProvider.get()
runBlocking { repository.authorize(null) }
return storage.accessToken
}.onFailure {
if (BuildConfig.DEBUG) {
it.printStackTrace()
}
}.getOrNull()
}

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.scrobbling.anilist.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 AniListInterceptor(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())
}
}

View File

@@ -0,0 +1,274 @@
package org.koitharu.kotatsu.scrobbling.anilist.data
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.exception.GraphQLException
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.toIntUp
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
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.ScrobblerType
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt
private const val REDIRECT_URI = "kotatsu://anilist-auth"
private const val BASE_URL = "https://anilist.co/api/v2/"
private const val ENDPOINT = "https://graphql.anilist.co"
private const val MANGA_PAGE_SIZE = 10
private const val REQUEST_QUERY = "query"
private const val REQUEST_MUTATION = "mutation"
private const val KEY_SCORE_FORMAT = "score_format"
@Singleton
class AniListRepository @Inject constructor(
@ApplicationContext context: Context,
@ScrobblerType(ScrobblerService.ANILIST) private val okHttp: OkHttpClient,
@ScrobblerType(ScrobblerService.ANILIST) private val storage: ScrobblerStorage,
private val db: MangaDatabase,
) : ScrobblerRepository {
private val clientId = context.getString(R.string.anilist_clientId)
private val clientSecret = context.getString(R.string.anilist_clientSecret)
override val oauthUrl: String
get() = "${BASE_URL}oauth/authorize?client_id=$clientId&" +
"redirect_uri=${REDIRECT_URI}&response_type=code"
override val isAuthorized: Boolean
get() = storage.accessToken != null
private val shrinkRegex = Regex("\\t+")
override suspend fun authorize(code: String?) {
val body = FormBody.Builder()
body.add("client_id", clientId)
body.add("client_secret", clientSecret)
if (code != null) {
body.add("grant_type", "authorization_code")
body.add("redirect_uri", REDIRECT_URI)
body.add("code", code)
} else {
body.add("grant_type", "refresh_token")
body.add("refresh_token", checkNotNull(storage.refreshToken))
}
val request = Request.Builder()
.post(body.build())
.url("${BASE_URL}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 {
val response = doRequest(
REQUEST_QUERY,
"""
AniChartUser {
user {
id
name
avatar {
medium
}
mediaListOptions {
scoreFormat
}
}
}
""",
)
val jo = response.getJSONObject("data").getJSONObject("AniChartUser").getJSONObject("user")
storage[KEY_SCORE_FORMAT] = jo.getJSONObject("mediaListOptions").getString("scoreFormat")
return AniListUser(jo).also { storage.user = it }
}
override val cachedUser: ScrobblerUser?
get() {
return storage.user
}
override suspend fun unregister(mangaId: Long) {
return db.scrobblingDao.delete(ScrobblerService.ANILIST.id, mangaId)
}
override fun logout() {
storage.clear()
}
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val page = (offset / MANGA_PAGE_SIZE.toFloat()).toIntUp() + 1
val response = doRequest(
REQUEST_QUERY,
"""
Page(page: $page, perPage: ${MANGA_PAGE_SIZE}) {
media(type: MANGA, sort: SEARCH_MATCH, search: ${JSONObject.quote(query)}) {
id
title {
userPreferred
native
}
coverImage {
medium
}
siteUrl
}
}
""",
)
val data = response.getJSONObject("data").getJSONObject("Page").getJSONArray("media")
return data.mapJSON { ScrobblerManga(it) }
}
override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) {
val response = doRequest(
REQUEST_MUTATION,
"""
SaveMediaListEntry(mediaId: $scrobblerMangaId) {
id
mediaId
status
notes
score
progress
}
""",
)
saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) {
val response = doRequest(
REQUEST_MUTATION,
"""
SaveMediaListEntry(id: $rateId, progress: ${chapter.number}) {
id
mediaId
status
notes
score
progress
}
""",
)
saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
val scoreRaw = (rating * 100f).roundToInt()
val statusString = status?.let { ", status: $it" }.orEmpty()
val notesString = comment?.let { ", notes: ${JSONObject.quote(it)}" }.orEmpty()
val response = doRequest(
REQUEST_MUTATION,
"""
SaveMediaListEntry(id: $rateId, scoreRaw: $scoreRaw$statusString$notesString) {
id
mediaId
status
notes
score
progress
}
""",
)
saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId)
}
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
val response = doRequest(
REQUEST_QUERY,
"""
Media(id: $id) {
id
title {
userPreferred
}
coverImage {
large
}
description
siteUrl
}
""",
)
return ScrobblerMangaInfo(response.getJSONObject("data").getJSONObject("Media"))
}
private suspend fun saveRate(json: JSONObject, mangaId: Long) {
val scoreFormat = ScoreFormat.of(storage[KEY_SCORE_FORMAT])
val entity = ScrobblingEntity(
scrobbler = ScrobblerService.ANILIST.id,
id = json.getInt("id"),
mangaId = mangaId,
targetId = json.getLong("mediaId"),
status = json.getString("status"),
chapter = json.getInt("progress"),
comment = json.getString("notes"),
rating = scoreFormat.normalize(json.getDouble("score").toFloat()),
)
db.scrobblingDao.upsert(entity)
}
private fun ScrobblerManga(json: JSONObject): ScrobblerManga {
val title = json.getJSONObject("title")
return ScrobblerManga(
id = json.getLong("id"),
name = title.getString("userPreferred"),
altName = title.getStringOrNull("native"),
cover = json.getJSONObject("coverImage").getString("medium"),
url = json.getString("siteUrl"),
)
}
private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
id = json.getLong("id"),
name = json.getJSONObject("title").getString("userPreferred"),
cover = json.getJSONObject("coverImage").getString("large"),
url = json.getString("siteUrl"),
descriptionHtml = json.getString("description"),
)
@Suppress("FunctionName")
private fun AniListUser(json: JSONObject) = ScrobblerUser(
id = json.getLong("id"),
nickname = json.getString("name"),
avatar = json.getJSONObject("avatar").getString("medium"),
service = ScrobblerService.ANILIST,
)
private suspend fun doRequest(type: String, payload: String): JSONObject {
val body = JSONObject()
body.put("query", "$type { ${payload.shrink()} }")
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = body.toString().toRequestBody(mediaType)
val request = Request.Builder()
.post(requestBody)
.url(ENDPOINT)
val json = okHttp.newCall(request.build()).await().parseJson()
json.optJSONArray("errors")?.let {
if (it.length() != 0) {
throw GraphQLException(it)
}
}
return json
}
private fun String.shrink() = replace(shrinkRegex, " ")
}

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.scrobbling.anilist.data
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
enum class ScoreFormat {
POINT_100, POINT_10_DECIMAL, POINT_10, POINT_5, POINT_3;
fun normalize(score: Float): Float = when (this) {
POINT_100 -> score / 100f
POINT_10_DECIMAL,
POINT_10 -> score / 10f
POINT_5 -> score / 5f
POINT_3 -> score / 3f
}
companion object {
fun of(rawValue: String?): ScoreFormat {
rawValue ?: return POINT_10_DECIMAL
return runCatching { valueOf(rawValue) }
.onFailure { it.printStackTraceDebug() }
.getOrDefault(POINT_10_DECIMAL)
}
}
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.scrobbling.anilist.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
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 javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AniListScrobbler @Inject constructor(
private val repository: AniListRepository,
db: MangaDatabase,
) : Scrobbler(db, ScrobblerService.ANILIST, repository) {
init {
statuses[ScrobblingStatus.PLANNED] = "PLANNING"
statuses[ScrobblingStatus.READING] = "CURRENT"
statuses[ScrobblingStatus.RE_READING] = "REPEATING"
statuses[ScrobblingStatus.COMPLETED] = "COMPLETED"
statuses[ScrobblingStatus.ON_HOLD] = "PAUSED"
statuses[ScrobblingStatus.DROPPED] = "DROPPED"
}
override suspend fun updateScrobblingInfo(
mangaId: Long,
rating: Float,
status: ScrobblingStatus?,
comment: String?,
) {
val entity = db.scrobblingDao.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,
)
}
}

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.scrobbling.common.data
import org.koitharu.kotatsu.parsers.model.MangaChapter
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.ScrobblerUser
interface ScrobblerRepository {
val oauthUrl: String
val isAuthorized: Boolean
val cachedUser: ScrobblerUser?
suspend fun authorize(code: String?)
suspend fun loadUser(): ScrobblerUser
fun logout()
suspend fun unregister(mangaId: Long)
suspend fun findManga(query: String, offset: Int): List<ScrobblerManga>
suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo
suspend fun createRate(mangaId: Long, scrobblerMangaId: Long)
suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter)
suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?)
}

View File

@@ -0,0 +1,59 @@
package org.koitharu.kotatsu.scrobbling.common.data
import android.content.Context
import androidx.core.content.edit
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_USER = "user"
class ScrobblerStorage(context: Context, service: ScrobblerService) {
private val prefs = context.getSharedPreferences(service.name, Context.MODE_PRIVATE)
var accessToken: String?
get() = prefs.getString(KEY_ACCESS_TOKEN, null)
set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) }
var refreshToken: String?
get() = prefs.getString(KEY_REFRESH_TOKEN, null)
set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) }
var user: ScrobblerUser?
get() = prefs.getString(KEY_USER, null)?.let {
val lines = it.lines()
if (lines.size != 4) {
return@let null
}
ScrobblerUser(
id = lines[0].toLong(),
nickname = lines[1],
avatar = lines[2],
service = ScrobblerService.valueOf(lines[3]),
)
}
set(value) = prefs.edit {
if (value == null) {
remove(KEY_USER)
return@edit
}
val str = StringJoiner("\n")
.add(value.id)
.add(value.nickname)
.add(value.avatar)
.add(value.service.name)
.complete()
putString(KEY_USER, str)
}
operator fun get(key: String): String? = prefs.getString(key, null)
operator fun set(key: String, value: String?) = prefs.edit { putString(key, value) }
fun clear() = prefs.edit {
clear()
}
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.scrobbling.common.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
abstract class ScrobblingDao {
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract suspend fun find(scrobbler: Int, mangaId: Long): ScrobblingEntity?
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract fun observe(scrobbler: Int, mangaId: Long): Flow<ScrobblingEntity?>
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler")
abstract fun observe(scrobbler: Int): Flow<List<ScrobblingEntity>>
@Upsert
abstract suspend fun upsert(entity: ScrobblingEntity)
@Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract suspend fun delete(scrobbler: Int, mangaId: Long)
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.scrobbling.common.data
import androidx.room.ColumnInfo
import androidx.room.Entity
@Entity(
tableName = "scrobblings",
primaryKeys = ["scrobbler", "id", "manga_id"],
)
class ScrobblingEntity(
@ColumnInfo(name = "scrobbler") val scrobbler: Int,
@ColumnInfo(name = "id") val id: Int,
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "target_id") val targetId: Long,
@ColumnInfo(name = "status") val status: String?,
@ColumnInfo(name = "chapter") val chapter: Int,
@ColumnInfo(name = "comment") val comment: String?,
@ColumnInfo(name = "rating") val rating: Float,
) {
fun copy(
status: String?,
comment: String?,
rating: Float,
) = ScrobblingEntity(
scrobbler = scrobbler,
id = id,
mangaId = mangaId,
targetId = targetId,
status = status,
chapter = chapter,
comment = comment,
rating = rating,
)
}

View File

@@ -0,0 +1,138 @@
package org.koitharu.kotatsu.scrobbling.common.domain
import androidx.collection.LongSparseArray
import androidx.collection.getOrElse
import androidx.core.text.parseAsHtml
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.util.ext.findKeyByValue
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
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
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.util.EnumMap
abstract class Scrobbler(
protected val db: MangaDatabase,
val scrobblerService: ScrobblerService,
private val repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository,
) {
private val infoCache = LongSparseArray<ScrobblerMangaInfo>()
protected val statuses = EnumMap<ScrobblingStatus, String>(ScrobblingStatus::class.java)
val user: Flow<ScrobblerUser> = flow {
repository.cachedUser?.let {
emit(it)
}
runCatchingCancellable {
repository.loadUser()
}.onSuccess {
emit(it)
}.onFailure {
it.printStackTraceDebug()
}
}
val isAvailable: Boolean
get() = repository.isAuthorized
suspend fun authorize(authCode: String): ScrobblerUser {
repository.authorize(authCode)
return repository.loadUser()
}
fun logout() {
repository.logout()
}
suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
return repository.findManga(query, offset)
}
suspend fun linkManga(mangaId: Long, targetId: Long) {
repository.createRate(mangaId, targetId)
}
suspend fun scrobble(mangaId: Long, chapter: MangaChapter) {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return
repository.updateRate(entity.id, entity.mangaId, chapter)
}
suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return null
return entity.toScrobblingInfo()
}
abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?)
fun observeScrobblingInfo(mangaId: Long): Flow<ScrobblingInfo?> {
return db.scrobblingDao.observe(scrobblerService.id, mangaId)
.map { it?.toScrobblingInfo() }
}
fun observeAllScrobblingInfo(): Flow<List<ScrobblingInfo>> {
return db.scrobblingDao.observe(scrobblerService.id)
.map { entities ->
coroutineScope {
entities.map {
async {
it.toScrobblingInfo()
}
}.awaitAll()
}.filterNotNull()
}
}
suspend fun unregisterScrobbling(mangaId: Long) {
repository.unregister(mangaId)
}
protected suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
return repository.getMangaInfo(id)
}
private suspend fun ScrobblingEntity.toScrobblingInfo(): ScrobblingInfo? {
val mangaInfo = infoCache.getOrElse(targetId) {
runCatchingCancellable {
getMangaInfo(targetId)
}.onFailure {
it.printStackTraceDebug()
}.onSuccess {
infoCache.put(targetId, it)
}.getOrNull() ?: return null
}
return ScrobblingInfo(
scrobbler = scrobblerService,
mangaId = mangaId,
targetId = targetId,
status = statuses.findKeyByValue(status),
chapter = chapter,
comment = comment,
rating = rating,
title = mangaInfo.name,
coverUrl = mangaInfo.cover,
description = mangaInfo.descriptionHtml.parseAsHtml(),
externalUrl = mangaInfo.url,
)
}
}
suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean {
return runCatchingCancellable {
scrobble(mangaId, chapter)
}.onFailure {
it.printStackTraceDebug()
}.isSuccess
}

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.scrobbling.common.domain.model
import org.koitharu.kotatsu.list.ui.model.ListModel
class ScrobblerManga(
val id: Long,
val name: String,
val altName: String?,
val cover: String,
val url: String,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ScrobblerManga
if (id != other.id) return false
if (name != other.name) return false
if (altName != other.altName) return false
if (cover != other.cover) return false
if (url != other.url) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + altName.hashCode()
result = 31 * result + cover.hashCode()
result = 31 * result + url.hashCode()
return result
}
override fun toString(): String {
return "ScrobblerManga #$id \"$name\" $url"
}
}

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.scrobbling.common.domain.model
class ScrobblerMangaInfo(
val id: Long,
val name: String,
val cover: String,
val url: String,
val descriptionHtml: String,
)

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.scrobbling.common.domain.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
enum class ScrobblerService(
val id: Int,
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int,
) {
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)
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.scrobbling.common.domain.model
import javax.inject.Qualifier
@Qualifier
annotation class ScrobblerType(
val service: ScrobblerService
)

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.scrobbling.common.domain.model
class ScrobblerUser(
val id: Long,
val nickname: String,
val avatar: String,
val service: ScrobblerService,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ScrobblerUser
if (id != other.id) return false
if (nickname != other.nickname) return false
if (avatar != other.avatar) return false
if (service != other.service) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + nickname.hashCode()
result = 31 * result + avatar.hashCode()
result = 31 * result + service.hashCode()
return result
}
}

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.scrobbling.common.domain.model
import org.koitharu.kotatsu.list.ui.model.ListModel
class ScrobblingInfo(
val scrobbler: ScrobblerService,
val mangaId: Long,
val targetId: Long,
val status: ScrobblingStatus?,
val chapter: Int,
val comment: String?,
val rating: Float,
val title: String,
val coverUrl: String,
val description: CharSequence?,
val externalUrl: String,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ScrobblingInfo
if (scrobbler != other.scrobbler) return false
if (mangaId != other.mangaId) return false
if (targetId != other.targetId) return false
if (status != other.status) return false
if (chapter != other.chapter) return false
if (comment != other.comment) return false
if (rating != other.rating) return false
if (title != other.title) return false
if (coverUrl != other.coverUrl) return false
if (description != other.description) return false
if (externalUrl != other.externalUrl) return false
return true
}
override fun hashCode(): Int {
var result = scrobbler.hashCode()
result = 31 * result + mangaId.hashCode()
result = 31 * result + targetId.hashCode()
result = 31 * result + (status?.hashCode() ?: 0)
result = 31 * result + chapter
result = 31 * result + (comment?.hashCode() ?: 0)
result = 31 * result + rating.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + coverUrl.hashCode()
result = 31 * result + (description?.hashCode() ?: 0)
result = 31 * result + externalUrl.hashCode()
return result
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.scrobbling.common.domain.model
import org.koitharu.kotatsu.list.ui.model.ListModel
enum class ScrobblingStatus : ListModel {
PLANNED,
READING,
RE_READING,
COMPLETED,
ON_HOLD,
DROPPED,
}

View File

@@ -0,0 +1,156 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.config.adapter.ScrobblingMangaAdapter
import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter
import javax.inject.Inject
@AndroidEntryPoint
class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
OnListItemClickListener<ScrobblingInfo>, View.OnClickListener {
@Inject
lateinit var coil: ImageLoader
private val viewModel: ScrobblerConfigViewModel by viewModels()
private var paddingVertical = 0
private var paddingHorizontal = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityScrobblerConfigBinding.inflate(layoutInflater))
setTitle(viewModel.titleResId)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val listAdapter = ScrobblingMangaAdapter(this, coil, this)
with(viewBinding.recyclerView) {
adapter = listAdapter
setHasFixedSize(true)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
paddingHorizontal = spacing
paddingVertical = resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
val decoration = TypedSpacingItemDecoration(
FeedAdapter.ITEM_TYPE_FEED to 0,
fallbackSpacing = spacing,
)
addItemDecoration(decoration)
}
viewBinding.imageViewAvatar.setOnClickListener(this)
viewModel.content.observe(this, listAdapter::setItems)
viewModel.user.observe(this, this::onUserChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.onError.observe(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
viewModel.onLoggedOut.observe(this) {
finishAfterTransition()
}
processIntent(intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent != null) {
setIntent(intent)
processIntent(intent)
}
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.recyclerView.updatePadding(
left = insets.left + paddingHorizontal,
right = insets.right + paddingHorizontal,
bottom = insets.bottom + paddingVertical,
)
}
override fun onItemClick(item: ScrobblingInfo, view: View) {
startActivity(
DetailsActivity.newIntent(this, item.mangaId),
)
}
override fun onClick(v: View) {
when (v.id) {
R.id.imageView_avatar -> showUserDialog()
}
}
private fun processIntent(intent: Intent) {
if (intent.action == Intent.ACTION_VIEW) {
val uri = intent.data ?: return
val code = uri.getQueryParameter("code")
if (!code.isNullOrEmpty()) {
viewModel.onAuthCodeReceived(code)
}
}
}
private fun onUserChanged(user: ScrobblerUser?) {
if (user == null) {
viewBinding.imageViewAvatar.disposeImageRequest()
viewBinding.imageViewAvatar.isVisible = false
return
}
viewBinding.imageViewAvatar.isVisible = true
viewBinding.imageViewAvatar.newImageRequest(this, user.avatar)
?.enqueueWith(coil)
}
private fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.run {
if (isLoading) {
show()
} else {
hide()
}
}
}
private fun showUserDialog() {
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(getString(R.string.logged_in_as, viewModel.user.value?.nickname))
.setNegativeButton(R.string.close, null)
.setPositiveButton(R.string.logout) { _, _ ->
viewModel.logout()
}.show()
}
companion object {
const val EXTRA_SERVICE_ID = "service"
const val HOST_SHIKIMORI_AUTH = "shikimori-auth"
const val HOST_ANILIST_AUTH = "anilist-auth"
const val HOST_MAL_AUTH = "mal-auth"
fun newIntent(context: Context, service: ScrobblerService) =
Intent(context, ScrobblerConfigActivity::class.java)
.putExtra(EXTRA_SERVICE_ID, service.id)
}
}

View File

@@ -0,0 +1,114 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.core.util.ext.emitValue
import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
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.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import javax.inject.Inject
@HiltViewModel
class ScrobblerConfigViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) : BaseViewModel() {
private val scrobblerService = getScrobblerService(savedStateHandle)
private val scrobbler = scrobblers.first { it.scrobblerService == scrobblerService }
val titleResId = scrobbler.scrobblerService.titleResId
val user = MutableLiveData<ScrobblerUser?>(null)
val onLoggedOut = SingleLiveEvent<Unit>()
val content = scrobbler.observeAllScrobblingInfo()
.onStart { loadingCounter.increment() }
.onFirst { loadingCounter.decrement() }
.catch { errorEvent.postCall(it) }
.map { buildContentList(it) }
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
init {
scrobbler.user
.onEach { user.emitValue(it) }
.launchIn(viewModelScope + Dispatchers.Default)
}
fun onAuthCodeReceived(authCode: String) {
launchLoadingJob(Dispatchers.Default) {
val newUser = scrobbler.authorize(authCode)
user.emitValue(newUser)
}
}
fun logout() {
launchLoadingJob(Dispatchers.Default) {
scrobbler.logout()
user.emitValue(null)
onLoggedOut.emitCall(Unit)
}
}
private fun buildContentList(list: List<ScrobblingInfo>): List<ListModel> {
if (list.isEmpty()) {
return listOf(
EmptyState(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.nothing_here,
textSecondary = R.string.scrobbling_empty_hint,
actionStringRes = 0,
),
)
}
val grouped = list.groupBy { it.status }
val statuses = enumValues<ScrobblingStatus>()
val result = ArrayList<ListModel>(list.size + statuses.size)
for (st in statuses) {
val subList = grouped[st]
if (subList.isNullOrEmpty()) {
continue
}
result.add(st)
result.addAll(subList)
}
return result
}
private fun getScrobblerService(
savedStateHandle: SavedStateHandle,
): ScrobblerService {
val serviceId = savedStateHandle.get<Int>(ScrobblerConfigActivity.EXTRA_SERVICE_ID) ?: 0
if (serviceId != 0) {
return enumValues<ScrobblerService>().first { it.id == serviceId }
}
val uri = savedStateHandle.require<Uri>(BaseActivity.EXTRA_DATA)
return when (uri.host) {
ScrobblerConfigActivity.HOST_SHIKIMORI_AUTH -> ScrobblerService.SHIKIMORI
ScrobblerConfigActivity.HOST_ANILIST_AUTH -> ScrobblerService.ANILIST
ScrobblerConfigActivity.HOST_MAL_AUTH -> ScrobblerService.MAL
else -> error("Wrong scrobbler uri: $uri")
}
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
fun scrobblingHeaderAD() = adapterDelegate<ScrobblingStatus, ListModel>(R.layout.item_header) {
bind {
(itemView as TextView).text = context.resources
.getStringArray(R.array.scrobbling_statuses)
.getOrNull(item.ordinal)
}
}

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.databinding.ItemScrobblingMangaBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
fun scrobblingMangaAD(
clickListener: OnListItemClickListener<ScrobblingInfo>,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ScrobblingInfo, ListModel, ItemScrobblingMangaBinding>(
{ layoutInflater, parent -> ItemScrobblingMangaBinding.inflate(layoutInflater, parent, false) },
) {
val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(clickListenerAdapter)
bind {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
enqueueWith(coil)
}
binding.textViewTitle.text = item.title
binding.ratingBar.rating = item.rating * binding.ratingBar.numStars
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
class ScrobblingMangaAdapter(
clickListener: OnListItemClickListener<ScrobblingInfo>,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(scrobblingMangaAD(clickListener, coil, lifecycleOwner))
.addDelegate(scrobblingHeaderAD())
.addDelegate(emptyStateListAD(coil, lifecycleOwner, null))
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when {
oldItem is ScrobblingInfo && newItem is ScrobblingInfo -> {
oldItem.targetId == newItem.targetId && oldItem.mangaId == newItem.mangaId
}
oldItem is ScrobblingStatus && newItem is ScrobblingStatus -> {
oldItem.ordinal == newItem.ordinal
}
oldItem is EmptyState && newItem is EmptyState -> true
else -> false
}
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return oldItem == newItem
}
}
}

View File

@@ -0,0 +1,217 @@
package org.koitharu.kotatsu.scrobbling.common.ui.selector
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import coil.ImageLoader
import com.google.android.material.tabs.TabLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerMangaSelectionDecoration
import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerSelectorAdapter
import javax.inject.Inject
@AndroidEntryPoint
class ScrobblingSelectorBottomSheet :
BaseBottomSheet<SheetScrobblingSelectorBinding>(),
OnListItemClickListener<ScrobblerManga>,
PaginationScrollListener.Callback,
View.OnClickListener,
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
TabLayout.OnTabSelectedListener,
ListStateHolderListener {
@Inject
lateinit var coil: ImageLoader
private var collapsibleActionViewCallback: CollapseActionViewCallback? = null
private val viewModel by viewModels<ScrobblingSelectorViewModel>()
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingSelectorBinding {
return SheetScrobblingSelectorBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetScrobblingSelectorBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val listAdapter = ScrobblerSelectorAdapter(viewLifecycleOwner, coil, this, this)
val decoration = ScrobblerMangaSelectionDecoration(binding.root.context)
with(binding.recyclerView) {
adapter = listAdapter
addItemDecoration(decoration)
addOnScrollListener(PaginationScrollListener(4, this@ScrobblingSelectorBottomSheet))
}
binding.buttonDone.setOnClickListener(this)
initOptionsMenu()
initTabs()
viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it }
viewModel.selectedItemId.observe(viewLifecycleOwner) {
decoration.checkedItemId = it
binding.recyclerView.invalidateItemDecorations()
}
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onClose.observe(viewLifecycleOwner) {
dismiss()
}
viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) { index ->
val tab = binding.tabs.getTabAt(index)
if (tab != null && !tab.isSelected) {
tab.select()
}
}
viewModel.searchQuery.observe(viewLifecycleOwner) {
binding.headerBar.subtitle = it
}
}
override fun onDestroyView() {
super.onDestroyView()
collapsibleActionViewCallback = null
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_done -> viewModel.onDoneClick()
}
}
override fun onItemClick(item: ScrobblerManga, view: View) {
viewModel.selectedItemId.value = item.id
}
override fun onRetryClick(error: Throwable) {
viewModel.retry()
}
override fun onEmptyActionClick() {
openSearch()
}
override fun onScrolledToEnd() {
viewModel.loadNextPage()
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = true)
collapsibleActionViewCallback?.onMenuItemActionExpand(item)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
val searchView = (item.actionView as? SearchView) ?: return false
searchView.setQuery("", false)
searchView.post { setExpanded(isExpanded = false, isLocked = false) }
collapsibleActionViewCallback?.onMenuItemActionCollapse(item)
return true
}
override fun onQueryTextSubmit(query: String?): Boolean {
if (query == null || query.length < 3) {
return false
}
viewModel.search(query)
requireViewBinding().headerBar.menu.findItem(R.id.action_search)?.collapseActionView()
return true
}
override fun onQueryTextChange(newText: String?): Boolean = false
override fun onTabSelected(tab: TabLayout.Tab) {
viewModel.setScrobblerIndex(tab.position)
}
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
override fun onTabReselected(tab: TabLayout.Tab?) {
if (!isExpanded) {
setExpanded(isExpanded = true, isLocked = behavior?.isDraggable == false)
}
requireViewBinding().recyclerView.firstVisibleItemPosition = 0
}
private fun openSearch() {
val menuItem = requireViewBinding().headerBar.menu.findItem(R.id.action_search) ?: return
menuItem.expandActionView()
}
private fun onError(e: Throwable) {
Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
if (viewModel.isEmpty) {
dismissAllowingStateLoss()
}
}
private fun initOptionsMenu() {
requireViewBinding().headerBar.inflateMenu(R.menu.opt_shiki_selector)
val searchMenuItem = requireViewBinding().headerBar.menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
collapsibleActionViewCallback = CollapseActionViewCallback(searchMenuItem).also {
onBackPressedDispatcher.addCallback(it)
}
}
private fun initTabs() {
val entries = viewModel.availableScrobblers
val tabs = requireViewBinding().tabs
if (entries.size <= 1) {
tabs.isVisible = false
return
}
val selectedId = arguments?.getInt(ARG_SCROBBLER, -1) ?: -1
tabs.removeAllTabs()
tabs.clearOnTabSelectedListeners()
tabs.addOnTabSelectedListener(this)
for (entry in entries) {
val tab = tabs.newTab()
tab.tag = entry.scrobblerService
tab.setIcon(entry.scrobblerService.iconResId)
tab.setText(entry.scrobblerService.titleResId)
tabs.addTab(tab)
if (entry.scrobblerService.id == selectedId) {
tab.select()
}
}
tabs.isVisible = true
}
companion object {
private const val TAG = "ScrobblingSelectorBottomSheet"
private const val ARG_SCROBBLER = "scrobbler"
fun show(fm: FragmentManager, manga: Manga, scrobblerService: ScrobblerService?) =
ScrobblingSelectorBottomSheet().withArgs(2) {
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false))
if (scrobblerService != null) {
putInt(ARG_SCROBBLER, scrobblerService.id)
}
}.show(fm, TAG)
}
}

View File

@@ -0,0 +1,181 @@
package org.koitharu.kotatsu.scrobbling.common.ui.selector
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.RecyclerView.NO_ID
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.core.util.ext.emitValue
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
@HiltViewModel
class ScrobblingSelectorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
val availableScrobblers = scrobblers.filter { it.isAvailable }
val selectedScrobblerIndex = MutableLiveData(0)
private val scrobblerMangaList = MutableStateFlow<List<ScrobblerManga>>(emptyList())
private val hasNextPage = MutableStateFlow(true)
private val listError = MutableStateFlow<Throwable?>(null)
private var loadingJob: Job? = null
private var doneJob: Job? = null
private var initJob: Job? = null
private val currentScrobbler: Scrobbler
get() = availableScrobblers[selectedScrobblerIndex.requireValue()]
val content: LiveData<List<ListModel>> = combine(
scrobblerMangaList,
listError,
hasNextPage,
) { list, error, isHasNextPage ->
if (list.isNotEmpty()) {
if (isHasNextPage) {
list + LoadingFooter()
} else {
list
}
} else {
listOf(
when {
error != null -> errorHint(error)
isHasNextPage -> LoadingFooter()
else -> emptyResultsHint()
},
)
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val selectedItemId = MutableLiveData(NO_ID)
val searchQuery = MutableLiveData(manga.title)
val onClose = SingleLiveEvent<Unit>()
val isEmpty: Boolean
get() = scrobblerMangaList.value.isEmpty()
init {
initialize()
}
fun search(query: String) {
loadingJob?.cancel()
searchQuery.value = query
loadList(append = false)
}
fun loadNextPage() {
if (scrobblerMangaList.value.isNotEmpty() && hasNextPage.value) {
loadList(append = true)
}
}
fun retry() {
loadingJob?.cancel()
hasNextPage.value = true
scrobblerMangaList.value = emptyList()
loadList(append = false)
}
private fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) {
return
}
loadingJob = launchLoadingJob(Dispatchers.Default) {
listError.value = null
val offset = if (append) scrobblerMangaList.value.size else 0
runCatchingCancellable {
currentScrobbler.findManga(checkNotNull(searchQuery.value), offset)
}.onSuccess { list ->
if (!append) {
scrobblerMangaList.value = list
} else if (list.isNotEmpty()) {
scrobblerMangaList.value = scrobblerMangaList.value + list
}
hasNextPage.value = list.isNotEmpty()
}.onFailure { error ->
error.printStackTraceDebug()
listError.value = error
}
}
}
fun onDoneClick() {
if (doneJob?.isActive == true) {
return
}
val targetId = selectedItemId.value ?: NO_ID
if (targetId == NO_ID) {
onClose.call(Unit)
}
doneJob = launchJob(Dispatchers.Default) {
currentScrobbler.linkManga(manga.id, targetId)
onClose.emitCall(Unit)
}
}
fun setScrobblerIndex(index: Int) {
if (index == selectedScrobblerIndex.value || index !in availableScrobblers.indices) return
selectedScrobblerIndex.value = index
initialize()
}
private fun initialize() {
initJob?.cancel()
loadingJob?.cancel()
hasNextPage.value = true
scrobblerMangaList.value = emptyList()
initJob = launchJob(Dispatchers.Default) {
try {
val info = currentScrobbler.getScrobblingInfoOrNull(manga.id)
if (info != null) {
selectedItemId.emitValue(info.targetId)
}
} finally {
loadList(append = false)
}
}
}
private fun emptyResultsHint() = ScrobblerHint(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
error = null,
actionStringRes = R.string.search,
)
private fun errorHint(e: Throwable) = ScrobblerHint(
icon = R.drawable.ic_error_large,
textPrimary = R.string.error_occurred,
error = e,
textSecondary = 0,
actionStringRes = R.string.try_again,
)
}

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
fun scrobblerHintAD(
listener: ListStateHolderListener,
) = adapterDelegateViewBinding<ScrobblerHint, ListModel, ItemEmptyHintBinding>(
{ inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) },
) {
binding.buttonRetry.setOnClickListener {
val e = item.error
if (e != null) {
listener.onRetryClick(e)
} else {
listener.onEmptyActionClick()
}
}
bind {
binding.icon.setImageResource(item.icon)
binding.textPrimary.setText(item.textPrimary)
if (item.error != null) {
binding.textSecondary.textAndVisible = item.error?.getDisplayMessage(context.resources)
} else {
binding.textSecondary.setTextAndVisible(item.textSecondary)
}
binding.buttonRetry.setTextAndVisible(item.actionStringRes)
}
}

View File

@@ -0,0 +1,52 @@
package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
class ScrobblerMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
var checkedItemId: Long
get() = selection.singleOrNull() ?: NO_ID
set(value) {
clearSelection()
if (value != NO_ID) {
selection.add(value)
}
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(ScrobblerManga::class.java) ?: return NO_ID
return item.id
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint)
checkIcon?.run {
val offset = (bounds.height() - intrinsicHeight) / 2
setBounds(
(bounds.right - offset - intrinsicWidth).toInt(),
(bounds.top + offset).toInt(),
(bounds.right - offset).toInt(),
(bounds.top + offset + intrinsicHeight).toInt(),
)
draw(canvas)
}
}
}

View File

@@ -0,0 +1,47 @@
package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
import kotlin.jvm.internal.Intrinsics
class ScrobblerSelectorAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
clickListener: OnListItemClickListener<ScrobblerManga>,
stateHolderListener: ListStateHolderListener,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(loadingStateAD())
.addDelegate(scrobblingMangaAD(lifecycleOwner, coil, clickListener))
.addDelegate(loadingFooterAD())
.addDelegate(scrobblerHintAD(stateHolderListener))
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when {
oldItem === newItem -> true
oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id
oldItem is ScrobblerHint && newItem is ScrobblerHint -> oldItem.textPrimary == newItem.textPrimary
oldItem is LoadingFooter && newItem is LoadingFooter -> oldItem.key == newItem.key
else -> false
}
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
}
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
fun scrobblingMangaAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
clickListener: OnListItemClickListener<ScrobblerManga>,
) = adapterDelegateViewBinding<ScrobblerManga, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) },
) {
itemView.setOnClickListener {
clickListener.onItemClick(item, it)
}
bind {
binding.textViewTitle.text = item.name
binding.textViewSubtitle.textAndVisible = item.altName
binding.imageViewCover.newImageRequest(lifecycleOwner, item.cover)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
enqueueWith(coil)
}
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.scrobbling.common.ui.selector.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.list.ui.model.ListModel
class ScrobblerHint(
@DrawableRes val icon: Int,
@StringRes val textPrimary: Int,
@StringRes val textSecondary: Int,
val error: Throwable?,
@StringRes val actionStringRes: Int,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ScrobblerHint
if (icon != other.icon) return false
if (textPrimary != other.textPrimary) return false
if (textSecondary != other.textSecondary) return false
if (error != other.error) return false
if (actionStringRes != other.actionStringRes) return false
return true
}
override fun hashCode(): Int {
var result = icon
result = 31 * result + textPrimary
result = 31 * result + textSecondary
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + actionStringRes
return result
}
}

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.scrobbling.mal.data
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders
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 MALAuthenticator @Inject constructor(
@ScrobblerType(ScrobblerService.MAL) private val storage: ScrobblerStorage,
private val repositoryProvider: Provider<MALRepository>,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val accessToken = storage.accessToken ?: return null
if (!isRequestWithAccessToken(response)) {
return null
}
synchronized(this) {
val newAccessToken = storage.accessToken ?: return null
if (accessToken != newAccessToken) {
return newRequestWithAccessToken(response.request, newAccessToken)
}
val updatedAccessToken = refreshAccessToken() ?: return null
return newRequestWithAccessToken(response.request, updatedAccessToken)
}
}
private fun isRequestWithAccessToken(response: Response): Boolean {
val header = response.request.header(CommonHeaders.AUTHORIZATION)
return header?.startsWith("Bearer") == true
}
private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
return request.newBuilder()
.header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
.build()
}
private fun refreshAccessToken(): String? = runCatching {
val repository = repositoryProvider.get()
runBlocking { repository.authorize(null) }
return storage.accessToken
}.onFailure {
if (BuildConfig.DEBUG) {
it.printStackTrace()
}
}.getOrNull()
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.scrobbling.mal.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 MALInterceptor(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())
}
}

View File

@@ -0,0 +1,220 @@
package org.koitharu.kotatsu.scrobbling.mal.data
import android.content.Context
import android.util.Base64
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
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.json.mapJSONNotNull
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.data.ScrobblingEntity
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.ScrobblerType
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import java.security.SecureRandom
import javax.inject.Inject
import javax.inject.Singleton
private const val REDIRECT_URI = "kotatsu://mal-auth"
private const val BASE_WEB_URL = "https://myanimelist.net"
private const val BASE_API_URL = "https://api.myanimelist.net/v2"
private const val AVATAR_STUB = "https://cdn.myanimelist.net/images/questionmark_50.gif"
@Singleton
class MALRepository @Inject constructor(
@ApplicationContext context: Context,
@ScrobblerType(ScrobblerService.MAL) private val okHttp: OkHttpClient,
@ScrobblerType(ScrobblerService.MAL) private val storage: ScrobblerStorage,
private val db: MangaDatabase,
) : ScrobblerRepository {
private val clientId = context.getString(R.string.mal_clientId)
private val codeVerifier: String by lazy(::generateCodeVerifier)
override val oauthUrl: String
get() = "$BASE_WEB_URL/v1/oauth2/authorize?" +
"response_type=code" +
"&client_id=$clientId" +
"&redirect_uri=$REDIRECT_URI" +
"&code_challenge=$codeVerifier" +
"&code_challenge_method=plain"
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("client_id", clientId)
body.add("grant_type", "authorization_code")
body.add("code", code)
body.add("redirect_uri", REDIRECT_URI)
body.add("code_verifier", codeVerifier)
}
val request = Request.Builder()
.post(body.build())
.url("${BASE_WEB_URL}/v1/oauth2/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 {
val request = Request.Builder()
.get()
.url("${BASE_API_URL}/users/@me")
val response = okHttp.newCall(request.build()).await().parseJson()
return MALUser(response).also { storage.user = it }
}
override suspend fun unregister(mangaId: Long) {
return db.scrobblingDao.delete(ScrobblerService.MAL.id, mangaId)
}
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val url = BASE_API_URL.toHttpUrl().newBuilder()
.addPathSegment("manga")
.addQueryParameter("offset", offset.toString())
.addQueryParameter("nsfw", "true")
// WARNING! MAL API throws a 400 when the query is over 64 characters
.addQueryParameter("q", query.take(64))
.build()
val request = Request.Builder().url(url).get().build()
val response = okHttp.newCall(request).await().parseJson()
val data = response.getJSONArray("data")
return data.mapJSONNotNull { jsonToManga(it) }
}
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
val url = BASE_API_URL.toHttpUrl().newBuilder()
.addPathSegment("manga")
.addPathSegment(id.toString())
.addQueryParameter("fields", "synopsis")
.build()
val request = Request.Builder().url(url)
val response = okHttp.newCall(request.build()).await().parseJson()
return ScrobblerMangaInfo(response)
}
override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) {
val body = FormBody.Builder()
.add("status", "reading")
.add("score", "0")
val url = BASE_API_URL.toHttpUrl().newBuilder()
.addPathSegment("manga")
.addPathSegment(scrobblerMangaId.toString())
.addPathSegment("my_list_status")
.addQueryParameter("fields", "synopsis")
.build()
val request = Request.Builder()
.url(url)
.put(body.build())
.build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId, scrobblerMangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) {
val body = FormBody.Builder()
.add("num_chapters_read", chapter.number.toString())
val url = BASE_API_URL.toHttpUrl().newBuilder()
.addPathSegment("manga")
.addPathSegment(rateId.toString())
.addPathSegment("my_list_status")
.build()
val request = Request.Builder()
.url(url)
.put(body.build())
.build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId, rateId.toLong())
}
override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
val body = FormBody.Builder()
.add("status", status.toString())
.add("score", rating.toString())
val url = BASE_API_URL.toHttpUrl().newBuilder()
.addPathSegment("manga")
.addPathSegment(rateId.toString())
.addPathSegment("my_list_status")
.build()
val request = Request.Builder()
.url(url)
.put(body.build())
.build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId, rateId.toLong())
}
private suspend fun saveRate(json: JSONObject, mangaId: Long, scrobblerMangaId: Long) {
val entity = ScrobblingEntity(
scrobbler = ScrobblerService.MAL.id,
id = scrobblerMangaId.toInt(),
mangaId = mangaId,
targetId = scrobblerMangaId,
status = json.getString("status"),
chapter = json.getInt("num_chapters_read"),
comment = json.getString("comments"),
rating = json.getDouble("score").toFloat() / 10f,
)
db.scrobblingDao.upsert(entity)
}
override fun logout() {
storage.clear()
}
private fun jsonToManga(json: JSONObject): ScrobblerManga? {
for (i in 0 until json.length()) {
val node = json.getJSONObject("node")
return ScrobblerManga(
id = node.getLong("id"),
name = node.getString("title"),
altName = null,
cover = node.getJSONObject("main_picture").getString("large"),
url = "$BASE_WEB_URL/manga/${node.getLong("id")}",
)
}
return null
}
private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
id = json.getLong("id"),
name = json.getString("title"),
cover = json.getJSONObject("main_picture").getString("large"),
url = "$BASE_WEB_URL/manga/${json.getLong("id")}",
descriptionHtml = json.getString("synopsis"),
)
@Suppress("FunctionName")
private fun MALUser(json: JSONObject) = ScrobblerUser(
id = json.getLong("id"),
nickname = json.getString("name"),
avatar = json.getString("picture") ?: AVATAR_STUB,
service = ScrobblerService.MAL,
)
private fun generateCodeVerifier(): String {
val codeVerifier = ByteArray(50)
SecureRandom().nextBytes(codeVerifier)
return Base64.encodeToString(codeVerifier, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE)
}
}

View File

@@ -0,0 +1,44 @@
package org.koitharu.kotatsu.scrobbling.mal.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.mal.data.MALRepository
import javax.inject.Inject
import javax.inject.Singleton
private const val RATING_MAX = 10f
@Singleton
class MALScrobbler @Inject constructor(
private val repository: MALRepository,
db: MangaDatabase,
) : Scrobbler(db, ScrobblerService.MAL, repository) {
init {
statuses[ScrobblingStatus.PLANNED] = "plan_to_read"
statuses[ScrobblingStatus.READING] = "reading"
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.scrobblingDao.find(scrobblerService.id, mangaId)
requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate(
rateId = entity.id,
mangaId = entity.mangaId,
rating = rating * RATING_MAX,
status = statuses[status],
comment = comment,
)
}
}

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders
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 ShikimoriAuthenticator @Inject constructor(
@ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage,
private val repositoryProvider: Provider<ShikimoriRepository>,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val accessToken = storage.accessToken ?: return null
if (!isRequestWithAccessToken(response)) {
return null
}
synchronized(this) {
val newAccessToken = storage.accessToken ?: return null
if (accessToken != newAccessToken) {
return newRequestWithAccessToken(response.request, newAccessToken)
}
val updatedAccessToken = refreshAccessToken() ?: return null
return newRequestWithAccessToken(response.request, updatedAccessToken)
}
}
private fun isRequestWithAccessToken(response: Response): Boolean {
val header = response.request.header(CommonHeaders.AUTHORIZATION)
return header?.startsWith("Bearer") == true
}
private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
return request.newBuilder()
.header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
.build()
}
private fun refreshAccessToken(): String? = runCatching {
val repository = repositoryProvider.get()
runBlocking { repository.authorize(null) }
return storage.accessToken
}.onFailure {
if (BuildConfig.DEBUG) {
it.printStackTrace()
}
}.getOrNull()
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import okhttp3.Interceptor
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
private const val USER_AGENT_SHIKIMORI = "Kotatsu"
class ShikimoriInterceptor(private val storage: ScrobblerStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val sourceRequest = chain.request()
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
if (!sourceRequest.url.pathSegments.contains("oauth")) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) {
throw IOException("${response.code} ${response.message}")
}
return response
}
}

View File

@@ -0,0 +1,221 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.util.ext.toRequestBody
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
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.ScrobblerType
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import javax.inject.Inject
import javax.inject.Singleton
private const val REDIRECT_URI = "kotatsu://shikimori-auth"
private const val BASE_URL = "https://shikimori.me/"
private const val MANGA_PAGE_SIZE = 10
@Singleton
class ShikimoriRepository @Inject constructor(
@ApplicationContext context: Context,
@ScrobblerType(ScrobblerService.SHIKIMORI) private val okHttp: OkHttpClient,
@ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage,
private val db: MangaDatabase,
) : ScrobblerRepository {
private val clientId = context.getString(R.string.shikimori_clientId)
private val clientSecret = context.getString(R.string.shikimori_clientSecret)
override val oauthUrl: String
get() = "${BASE_URL}oauth/authorize?client_id=$clientId&" +
"redirect_uri=$REDIRECT_URI&response_type=code&scope="
override val isAuthorized: Boolean
get() = storage.accessToken != null
override suspend fun authorize(code: String?) {
val body = FormBody.Builder()
body.add("client_id", clientId)
body.add("client_secret", clientSecret)
if (code != null) {
body.add("grant_type", "authorization_code")
body.add("redirect_uri", REDIRECT_URI)
body.add("code", code)
} else {
body.add("grant_type", "refresh_token")
body.add("refresh_token", checkNotNull(storage.refreshToken))
}
val request = Request.Builder()
.post(body.build())
.url("${BASE_URL}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 {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/users/whoami")
val response = okHttp.newCall(request.build()).await().parseJson()
return ShikimoriUser(response).also { storage.user = it }
}
override val cachedUser: ScrobblerUser?
get() {
return storage.user
}
override suspend fun unregister(mangaId: Long) {
return db.scrobblingDao.delete(ScrobblerService.SHIKIMORI.id, mangaId)
}
override fun logout() {
storage.clear()
}
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val page = offset / MANGA_PAGE_SIZE
val pageOffset = offset % MANGA_PAGE_SIZE
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("mangas")
.addEncodedQueryParameter("page", (page + 1).toString())
.addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString())
.addEncodedQueryParameter("censored", false.toString())
.addQueryParameter("search", query)
.build()
val request = Request.Builder().url(url).get().build()
val response = okHttp.newCall(request).await().parseJsonArray()
val list = response.mapJSON { ScrobblerManga(it) }
return if (pageOffset != 0) list.drop(pageOffset) else list
}
override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) {
val user = cachedUser ?: loadUser()
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("target_id", scrobblerMangaId)
put("target_type", "Manga")
put("user_id", user.id)
},
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.build()
val request = Request.Builder().url(url).post(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) {
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("chapters", chapter.number)
},
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.addPathSegment(rateId.toString())
.build()
val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("score", rating.toString())
if (comment != null) {
put("text", comment)
}
if (status != null) {
put("status", status)
}
},
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.addPathSegment(rateId.toString())
.build()
val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/mangas/$id")
val response = okHttp.newCall(request.build()).await().parseJson()
return ScrobblerMangaInfo(response)
}
private suspend fun saveRate(json: JSONObject, mangaId: Long) {
val entity = ScrobblingEntity(
scrobbler = ScrobblerService.SHIKIMORI.id,
id = json.getInt("id"),
mangaId = mangaId,
targetId = json.getLong("target_id"),
status = json.getString("status"),
chapter = json.getInt("chapters"),
comment = json.getString("text"),
rating = json.getDouble("score").toFloat() / 10f,
)
db.scrobblingDao.upsert(entity)
}
private fun ScrobblerManga(json: JSONObject) = ScrobblerManga(
id = json.getLong("id"),
name = json.getString("name"),
altName = json.getStringOrNull("russian"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.me"),
url = json.getString("url").toAbsoluteUrl("shikimori.me"),
)
private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
id = json.getLong("id"),
name = json.getString("name"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.me"),
url = json.getString("url").toAbsoluteUrl("shikimori.me"),
descriptionHtml = json.getString("description_html"),
)
@Suppress("FunctionName")
private fun ShikimoriUser(json: JSONObject) = ScrobblerUser(
id = json.getLong("id"),
nickname = json.getString("nickname"),
avatar = json.getString("avatar"),
service = ScrobblerService.SHIKIMORI,
)
}

View File

@@ -0,0 +1,44 @@
package org.koitharu.kotatsu.scrobbling.shikimori.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.shikimori.data.ShikimoriRepository
import javax.inject.Inject
import javax.inject.Singleton
private const val RATING_MAX = 10f
@Singleton
class ShikimoriScrobbler @Inject constructor(
private val repository: ShikimoriRepository,
db: MangaDatabase,
) : Scrobbler(db, ScrobblerService.SHIKIMORI, repository) {
init {
statuses[ScrobblingStatus.PLANNED] = "planned"
statuses[ScrobblingStatus.READING] = "watching"
statuses[ScrobblingStatus.RE_READING] = "rewatching"
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.scrobblingDao.find(scrobblerService.id, mangaId)
requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate(
rateId = entity.id,
mangaId = entity.mangaId,
rating = rating * RATING_MAX,
status = statuses[status],
comment = comment,
)
}
}