Cache shikimori user

This commit is contained in:
Koitharu
2022-05-09 16:24:24 +03:00
parent c2ba716916
commit 82efa8298d
16 changed files with 225 additions and 32 deletions

View File

@@ -12,7 +12,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val boundsF = RectF()
private val selection = HashSet<Long>()
protected val selection = HashSet<Long>()
protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull
@@ -236,15 +237,7 @@ class AppSettings(context: Context) {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
fun observe() = callbackFlow<String> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}
prefs.registerOnSharedPreferenceChangeListener(listener)
awaitClose {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
}
fun observe() = prefs.observe()
companion object {

View File

@@ -18,17 +18,17 @@ import org.koitharu.kotatsu.utils.ext.getItem
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
protected val paint = Paint(Paint.ANTI_ALIAS_FLAG)
protected val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
protected val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74
)
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init {
hasBackground = false

View File

@@ -5,6 +5,7 @@ import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders
class ShikimoriAuthenticator(
@@ -38,9 +39,13 @@ class ShikimoriAuthenticator(
.build()
}
private fun refreshAccessToken(): String? {
private fun refreshAccessToken(): String? = runCatching {
val repository = repositoryProvider()
runBlocking { repository.authorize(null) }
return storage.accessToken
}
}.onFailure {
if (BuildConfig.DEBUG) {
it.printStackTrace()
}
}.getOrNull()
}

View File

@@ -4,6 +4,7 @@ import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.mapJSON
@@ -13,11 +14,12 @@ import org.koitharu.kotatsu.parsers.util.urlEncoded
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser
import org.koitharu.kotatsu.utils.ext.toRequestBody
private const val CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU"
private const val CLIENT_SECRET = "euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8"
private const val REDIRECT_URI = "kotatsu://shikimori-auth"
private const val BASE_URL = "https://shikimori.one/api/"
private const val BASE_URL = "https://shikimori.one/"
private const val MANGA_PAGE_SIZE = 10
class ShikimoriRepository(
@@ -26,7 +28,7 @@ class ShikimoriRepository(
) {
val oauthUrl: String
get() = "https://shikimori.one/oauth/authorize?client_id=$CLIENT_ID&" +
get() = "${BASE_URL}oauth/authorize?client_id=$CLIENT_ID&" +
"redirect_uri=$REDIRECT_URI&response_type=code&scope="
val isAuthorized: Boolean
@@ -45,7 +47,7 @@ class ShikimoriRepository(
}
val request = Request.Builder()
.post(body.build())
.url("https://shikimori.one/oauth/token")
.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")
@@ -54,15 +56,24 @@ class ShikimoriRepository(
suspend fun getUser(): ShikimoriUser {
val request = Request.Builder()
.get()
.url("https://shikimori.one/api/users/whoami")
.url("${BASE_URL}api/users/whoami")
val response = okHttp.newCall(request.build()).await().parseJson()
return ShikimoriUser(response)
return ShikimoriUser(response).also { storage.user = it }
}
fun getCachedUser(): ShikimoriUser? {
return storage.user
}
fun logout() {
storage.clear()
}
suspend fun findManga(query: String, offset: Int): List<ShikimoriManga> {
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())
@@ -75,11 +86,31 @@ class ShikimoriRepository(
return if (pageOffset != 0) list.drop(pageOffset) else list
}
suspend fun trackManga(manga: Manga, shikiMangaId: Long) {
val user = getCachedUser() ?: getUser()
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("target_id", shikiMangaId)
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()
}
suspend fun findMangaInfo(manga: Manga): ShikimoriMangaInfo? {
val q = manga.title.urlEncoded()
val request = Request.Builder()
.get()
.url("https://shikimori.one/api/mangas?limit=5&search=$q&censored=false")
.url("${BASE_URL}api/mangas?limit=5&search=$q&censored=false")
val response = okHttp.newCall(request.build()).await().parseJsonArray()
val candidates = response.mapJSON { ShikimoriManga(it) }
val bestCandidate = candidates.filter {
@@ -91,7 +122,7 @@ class ShikimoriRepository(
suspend fun getRelatedManga(id: Long): List<ShikimoriManga> {
val request = Request.Builder()
.get()
.url("https://shikimori.one/api/mangas/$id/related")
.url("${BASE_URL}api/mangas/$id/related")
val response = okHttp.newCall(request.build()).await().parseJsonArray()
return response.mapJSON { jo -> ShikimoriManga(jo) }
}
@@ -99,7 +130,7 @@ class ShikimoriRepository(
suspend fun getSimilarManga(id: Long): List<ShikimoriManga> {
val request = Request.Builder()
.get()
.url("https://shikimori.one/api/mangas/$id/similar")
.url("${BASE_URL}api/mangas/$id/similar")
val response = okHttp.newCall(request.build()).await().parseJsonArray()
return response.mapJSON { jo -> ShikimoriManga(jo) }
}
@@ -107,7 +138,7 @@ class ShikimoriRepository(
suspend fun getMangaInfo(id: Long): ShikimoriMangaInfo {
val request = Request.Builder()
.get()
.url("https://shikimori.one/api/mangas/$id")
.url("${BASE_URL}api/mangas/$id")
val response = okHttp.newCall(request.build()).await().parseJson()
return ShikimoriMangaInfo(response)
}

View File

@@ -2,10 +2,13 @@ package org.koitharu.kotatsu.shikimori.data
import android.content.Context
import androidx.core.content.edit
import org.json.JSONObject
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser
private const val PREF_NAME = "shikimori"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_USER = "user"
class ShikimoriStorage(context: Context) {
@@ -18,4 +21,16 @@ class ShikimoriStorage(context: Context) {
var refreshToken: String?
get() = prefs.getString(KEY_REFRESH_TOKEN, null)
set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) }
var user: ShikimoriUser?
get() = prefs.getString(KEY_USER, null)?.let {
ShikimoriUser(JSONObject(it))
}
set(value) = prefs.edit {
putString(KEY_USER, value?.toJson()?.toString())
}
fun clear() = prefs.edit {
clear()
}
}

View File

@@ -13,4 +13,30 @@ class ShikimoriUser(
nickname = json.getString("nickname"),
avatar = json.getString("avatar"),
)
fun toJson() = JSONObject().apply {
put("id", id)
put("nickname", nickname)
put("avatar", avatar)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ShikimoriUser
if (id != other.id) return false
if (nickname != other.nickname) return false
if (avatar != other.avatar) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + nickname.hashCode()
result = 31 * result + avatar.hashCode()
return result
}
}

View File

@@ -37,6 +37,10 @@ class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) {
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
KEY_USER -> openAuthorization()
KEY_LOGOUT -> {
viewModel.logout()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
@@ -50,6 +54,7 @@ class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) {
.transformations(CircleCropTransformation())
.target(PreferenceIconTarget(pref))
.enqueueWith(coil)
findPreference<Preference>(KEY_LOGOUT)?.isVisible = user != null
}
private fun openAuthorization(): Boolean {
@@ -63,6 +68,7 @@ class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) {
companion object {
private const val KEY_USER = "shiki_user"
private const val KEY_LOGOUT = "shiki_logout"
private const val ARG_AUTH_CODE = "auth_code"

View File

@@ -24,8 +24,16 @@ class ShikimoriSettingsViewModel(
}
}
fun logout() {
launchJob(Dispatchers.Default) {
repository.logout()
user.postValue(null)
}
}
private fun loadUser() = launchJob(Dispatchers.Default) {
val userModel = if (repository.isAuthorized) {
repository.getCachedUser()?.let(user::postValue)
repository.getUser()
} else {
null

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.databinding.SheetShikiSelectorBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga
import org.koitharu.kotatsu.shikimori.ui.selector.adapter.ShikiMangaSelectionDecoration
import org.koitharu.kotatsu.shikimori.ui.selector.adapter.ShikimoriSelectorAdapter
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
import org.koitharu.kotatsu.utils.ext.enqueueWith
@@ -30,7 +31,8 @@ import org.koitharu.kotatsu.utils.ext.withArgs
class ShikimoriSelectorBottomSheet :
BaseBottomSheet<SheetShikiSelectorBinding>(),
OnListItemClickListener<ShikimoriManga>,
PaginationScrollListener.Callback, View.OnClickListener {
PaginationScrollListener.Callback,
View.OnClickListener {
private val viewModel by viewModel<ShikimoriSelectorViewModel> {
parametersOf(requireNotNull(requireArguments().getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga)
@@ -46,13 +48,19 @@ class ShikimoriSelectorBottomSheet :
binding.toolbar.setNavigationOnClickListener { dismiss() }
addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar))
val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, get(), this)
val decoration = ShikiMangaSelectionDecoration(view.context)
with(binding.recyclerView) {
adapter = listAdapter
addItemDecoration(decoration)
addOnScrollListener(PaginationScrollListener(4, this@ShikimoriSelectorBottomSheet))
}
binding.imageViewUser.setOnClickListener(this)
viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it }
viewModel.selectedItemId.observe(viewLifecycleOwner) {
decoration.checkedItemId = it
binding.recyclerView.invalidateItemDecorations()
}
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.avatar.observe(viewLifecycleOwner, ::setUserAvatar)
}
@@ -64,6 +72,7 @@ class ShikimoriSelectorBottomSheet :
}
override fun onItemClick(item: ShikimoriManga, view: View) {
viewModel.selectedItemId.value = item.id
}
override fun onScrolledToEnd() {

View File

@@ -1,8 +1,10 @@
package org.koitharu.kotatsu.shikimori.ui.selector
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -37,7 +39,10 @@ class ShikimoriSelectorViewModel(
}
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val avatar = liveData {
val selectedItemId = MutableLiveData(RecyclerView.NO_ID)
val avatar = liveData(viewModelScope.coroutineContext + Dispatchers.Default) {
emit(repository.getCachedUser()?.avatar)
emit(runCatching { repository.getUser().avatar }.getOrNull())
}

View File

@@ -0,0 +1,52 @@
package org.koitharu.kotatsu.shikimori.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.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriManga
import org.koitharu.kotatsu.utils.ext.getItem
class ShikiMangaSelectionDecoration(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(ShikimoriManga::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

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ResolveInfo
import android.net.ConnectivityManager
import android.net.Network
@@ -10,8 +11,14 @@ import android.os.Build
import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.ActivityOptionsCompat
import androidx.work.CoroutineWorker
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.suspendCancellableCoroutine
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -55,4 +62,23 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(input: I, options: ActivityOptionsCo
return runCatching {
launch(input, options)
}.isSuccess
}
}
fun SharedPreferences.observe() = callbackFlow<String> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}
registerOnSharedPreferenceChangeListener(listener)
awaitClose {
unregisterOnSharedPreferenceChangeListener(listener)
}
}
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
emit(valueProducer())
observe().collect { upstreamKey ->
if (upstreamKey == key) {
emit(valueProducer())
}
}
}.distinctUntilChanged()

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.utils.ext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
private val TYPE_JSON = "application/json".toMediaType()
fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)

View File

@@ -291,4 +291,5 @@
<string name="edit_category">Edit category</string>
<string name="tracking">Tracking</string>
<string name="empty_favourite_categories">No favourite categories</string>
<string name="logout">Logout</string>
</resources>

View File

@@ -10,4 +10,10 @@
android:title="@string/loading_"
app:iconSpaceReserved="true" />
<Preference
android:key="shiki_logout"
android:persistent="false"
android:title="@string/logout"
app:allowDividerAbove="true" />
</PreferenceScreen>