diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt index cc6f6a0a7..3a550ec35 100644 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt +++ b/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt @@ -10,6 +10,8 @@ class CurlLoggingInterceptor( private val curlOptions: String? = null ) : Interceptor { + private val escapeRegex = Regex("([\\[\\]\"])") + override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() var isCompressed = false @@ -40,7 +42,7 @@ class CurlLoggingInterceptor( if (isCompressed) { curlCmd.append(" --compressed") } - curlCmd.append(" \"").append(request.url).append('"') + curlCmd.append(" \"").append(request.url.toString().escape()).append('"') log("---cURL (" + request.url + ")") log(curlCmd.toString()) @@ -48,7 +50,12 @@ class CurlLoggingInterceptor( return chain.proceed(request) } - private fun String.escape() = replace("\"", "\\\"") + private fun String.escape() = replace(escapeRegex) { match -> + "\\" + match.value + } + // .replace("\"", "\\\"") + // .replace("[", "\\[") + // .replace("]", "\\]") private fun log(msg: String) { Log.d("CURL", msg) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt index d3448ccfe..f3c729700 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt @@ -25,7 +25,7 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image } // disposeImageRequest() return ImageRequest.Builder(context) - .data(data) + .data(data?.takeUnless { it == "" }) .lifecycle(lifecycleOwner) .crossfade(context) .target(this) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt index cf49379f4..a050061b5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt @@ -111,7 +111,9 @@ class ScrobblerConfigActivity : BaseActivity(), return } viewBinding.imageViewAvatar.newImageRequest(this, user.avatar) - ?.placeholder(R.drawable.ic_shortcut_default) + ?.placeholder(R.drawable.bg_badge_empty) + ?.fallback(R.drawable.ic_shortcut_default) + ?.error(R.drawable.ic_shortcut_default) ?.enqueueWith(coil) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt index 582575958..2c28d382d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt @@ -9,6 +9,8 @@ import android.widget.Toast import androidx.appcompat.widget.SearchView import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.RecyclerView.NO_ID import coil.ImageLoader import com.google.android.material.tabs.TabLayout import dagger.hilt.android.AndroidEntryPoint @@ -19,6 +21,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback +import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.observe @@ -29,6 +32,8 @@ 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.list.ui.adapter.TypedListSpacingDecoration +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter 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 @@ -45,7 +50,7 @@ class ScrobblingSelectorSheet : MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener, TabLayout.OnTabSelectedListener, - ListStateHolderListener { + ListStateHolderListener, AsyncListDiffer.ListListener { @Inject lateinit var coil: ImageLoader @@ -62,6 +67,7 @@ class ScrobblingSelectorSheet : super.onViewBindingCreated(binding, savedInstanceState) disableFitToContents() val listAdapter = ScrobblerSelectorAdapter(viewLifecycleOwner, coil, this, this) + listAdapter.addListListener(this) val decoration = ScrobblerMangaSelectionDecoration(binding.root.context) with(binding.recyclerView) { adapter = listAdapter @@ -73,7 +79,7 @@ class ScrobblingSelectorSheet : initOptionsMenu() initTabs() - viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it } + viewModel.content.observe(viewLifecycleOwner, listAdapter) viewModel.selectedItemId.observe(viewLifecycleOwner) { decoration.checkedItemId = it binding.recyclerView.invalidateItemDecorations() @@ -104,6 +110,19 @@ class ScrobblingSelectorSheet : collapsibleActionViewCallback = null } + override fun onCurrentListChanged(previousList: MutableList, currentList: MutableList) { + if (previousList.singleOrNull() is LoadingFooter) { + val rv = viewBinding?.recyclerView ?: return + val selectedId = viewModel.selectedItemId.value + val target = if (selectedId == NO_ID) { + 0 + } else { + currentList.indexOfFirst { it is ScrobblerManga && it.id == selectedId }.coerceAtLeast(0) + } + rv.post(RecyclerViewScrollCallback(rv, target, if (target == 0) 0 else rv.height / 3)) + } + } + override fun onClick(v: View) { when (v.id) { R.id.button_done -> viewModel.onDoneClick() 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 index 1c4300d2b..c1a087fd8 100644 --- 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 @@ -5,15 +5,13 @@ import okhttp3.Response import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage -private const val JSON = "application/vnd.api+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) + request.header(CommonHeaders.CONTENT_TYPE, VND_JSON) + request.header(CommonHeaders.ACCEPT, VND_JSON) if (!sourceRequest.url.pathSegments.contains("oauth")) { storage.accessToken?.let { request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") @@ -22,4 +20,8 @@ class KitsuInterceptor(private val storage: ScrobblerStorage) : Interceptor { return chain.proceed(request.build()) } + companion object { + + const val VND_JSON = "application/vnd.api+json" + } } 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 index e633f40cd..c6b0b7386 100644 --- 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 @@ -3,22 +3,31 @@ package org.koitharu.kotatsu.scrobbling.kitsu.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 okio.IOException +import org.json.JSONObject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault +import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault +import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault 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.urlEncoded 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.ScrobblerUser +import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuInterceptor.Companion.VND_JSON private const val BASE_WEB_URL = "https://kitsu.io" @@ -29,6 +38,7 @@ class KitsuRepository( private val db: MangaDatabase, ) : ScrobblerRepository { + // not in use yet private val clientId = context.getString(R.string.kitsu_clientId) private val clientSecret = context.getString(R.string.kitsu_clientSecret) @@ -54,7 +64,7 @@ class KitsuRepository( } val request = Request.Builder() .post(body.build()) - .url("${BASE_WEB_URL}/api/oauth/token") + .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") @@ -63,16 +73,16 @@ class KitsuRepository( override suspend fun loadUser(): ScrobblerUser { val request = Request.Builder() .get() - .url("${BASE_WEB_URL}/api/edge/users?filter[self]=true") + .url("$BASE_WEB_URL/api/edge/users?filter[self]=true") val response = okHttp.newCall(request.build()).await().parseJson() .getJSONArray("data") .getJSONObject(0) return ScrobblerUser( - id = response.getLongOrDefault("id", 0L), + id = response.getAsLong("id"), nickname = response.getJSONObject("attributes").getString("name"), avatar = response.getJSONObject("attributes").optJSONObject("avatar")?.getStringOrNull("small"), service = ScrobblerService.KITSU, - ) + ).also { storage.user = it } } override fun logout() { @@ -86,25 +96,155 @@ class KitsuRepository( override suspend fun findManga(query: String, offset: Int): List { val request = Request.Builder() .get() - .url("${BASE_WEB_URL}/api/edge/manga?page[limit]=20&page[offset]=$offset&filter[text]=${query.urlEncoded()}") - val response = okHttp.newCall(request.build()).await().parseJson() - return emptyList() + .url("$BASE_WEB_URL/api/edge/manga?page[limit]=20&page[offset]=$offset&filter[text]=${query.urlEncoded()}") + val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess() + return response.getJSONArray("data").mapJSON { jo -> + val attrs = jo.getJSONObject("attributes") + val titles = attrs.getJSONObject("titles").valuesToStringList() + ScrobblerManga( + id = jo.getAsLong("id"), + name = titles.first(), + altName = titles.drop(1).joinToString(), + cover = attrs.getJSONObject("posterImage").getStringOrNull("small").orEmpty(), + url = "$BASE_WEB_URL/manga/${attrs.getString("slug")}", + ) + } } override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { - TODO("Not yet implemented") + val request = Request.Builder() + .get() + .url("$BASE_WEB_URL/api/edge/manga/$id") + val data = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data") + val attrs = data.getJSONObject("attributes") + return ScrobblerMangaInfo( + id = data.getAsLong("id"), + name = attrs.getString("canonicalTitle"), + cover = attrs.getJSONObject("posterImage").getString("medium"), + url = "$BASE_WEB_URL/manga/${attrs.getString("slug")}", + descriptionHtml = attrs.getString("description").replace("\\n", "
"), + ) } override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { - TODO("Not yet implemented") + findExistingRate(scrobblerMangaId)?.let { + saveRate(it, mangaId) + return + } + val user = cachedUser ?: loadUser() + val payload = JSONObject() + payload.putJO("data") { + put("type", "libraryEntries") + putJO("attributes") { + put("status", "planned") // will be updated by next call + put("progress", 0) + } + putJO("relationships") { + putJO("manga") { + putJO("data") { + put("type", "manga") + put("id", scrobblerMangaId) + } + } + putJO("user") { + putJO("data") { + put("type", "users") + put("id", user.id) + } + } + } + } + val request = Request.Builder() + .url("$BASE_WEB_URL/api/edge/library-entries?include=manga") + .post(payload.toKitsuRequestBody()) + val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data") + saveRate(response, mangaId) } override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { - TODO("Not yet implemented") + val payload = JSONObject() + payload.putJO("data") { + put("type", "libraryEntries") + put("id", rateId) + putJO("attributes") { + put("progress", chapter.number.toInt()) // TODO + } + } + val request = Request.Builder() + .url("$BASE_WEB_URL/api/edge/library-entries/$rateId?include=manga") + .patch(payload.toKitsuRequestBody()) + val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data") + saveRate(response, mangaId) } override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { - TODO("Not yet implemented") + val payload = JSONObject() + payload.putJO("data") { + put("type", "libraryEntries") + put("id", rateId) + putJO("attributes") { + put("status", status) + put("ratingTwenty", (rating * 20).toInt().coerceIn(2, 20)) + put("notes", comment) + } + } + val request = Request.Builder() + .url("$BASE_WEB_URL/api/edge/library-entries/$rateId?include=manga") + .patch(payload.toKitsuRequestBody()) + val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data") + saveRate(response, mangaId) } + private fun JSONObject.valuesToStringList(): List { + val result = ArrayList(length()) + for (key in keys()) { + result.add(getStringOrNull(key) ?: continue) + } + return result + } + + private inline fun JSONObject.putJO(name: String, init: JSONObject.() -> Unit) { + put(name, JSONObject().apply(init)) + } + + private fun JSONObject.toKitsuRequestBody() = toString().toRequestBody(VND_JSON.toMediaType()) + + private suspend fun findExistingRate(scrobblerMangaId: Long): JSONObject? { + val userId = (cachedUser ?: loadUser()).id + val request = Request.Builder() + .get() + .url("$BASE_WEB_URL/api/edge/library-entries?filter[manga_id]=$scrobblerMangaId&filter[userId]=$userId&include=manga") + val data = okHttp.newCall(request.build()).await().parseJsonOrNull()?.optJSONArray("data") ?: return null + return data.optJSONObject(0) + } + + private suspend fun saveRate(json: JSONObject, mangaId: Long) { + val attrs = json.getJSONObject("attributes") + val manga = json.getJSONObject("relationships").getJSONObject("manga").getJSONObject("data") + val entity = ScrobblingEntity( + scrobbler = ScrobblerService.KITSU.id, + id = json.getInt("id"), + mangaId = mangaId, + targetId = manga.getAsLong("id"), + status = attrs.getString("status"), + chapter = attrs.getIntOrDefault("progress", 0), + comment = attrs.getStringOrNull("notes"), + rating = (attrs.getFloatOrDefault("ratingTwenty", 0f) / 20f).coerceIn(0f, 1f), + ) + db.getScrobblingDao().upsert(entity) + } + + private fun JSONObject.ensureSuccess(): JSONObject { + val error = optJSONArray("errors")?.optJSONObject(0) ?: return this + val title = error.getString("title") + val detail = error.getStringOrNull("detail") + throw IOException("$title: $detail") + } + + private fun JSONObject.getAsLong(name: String): Long = when (val rawValue = opt(name)) { + is Long -> rawValue + is Number -> rawValue.toLong() + is String -> rawValue.toLong() + else -> throw IllegalArgumentException("Value $rawValue at \"$name\" is not of type long") + } }