Shikimori authorization
parent
a8a65e953f
commit
786914b1a6
@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.shikimori
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.network.CurlLoggingInterceptor
|
||||
import org.koitharu.kotatsu.shikimori.data.ShikimoriAuthenticator
|
||||
import org.koitharu.kotatsu.shikimori.data.ShikimoriInterceptor
|
||||
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository
|
||||
import org.koitharu.kotatsu.shikimori.data.ShikimoriStorage
|
||||
import org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsViewModel
|
||||
|
||||
val shikimoriModule
|
||||
get() = module {
|
||||
single { ShikimoriStorage(androidContext()) }
|
||||
factory {
|
||||
val okHttp = OkHttpClient.Builder().apply {
|
||||
authenticator(ShikimoriAuthenticator(get(), ::get))
|
||||
addInterceptor(ShikimoriInterceptor(get()))
|
||||
if (BuildConfig.DEBUG) {
|
||||
addNetworkInterceptor(CurlLoggingInterceptor())
|
||||
}
|
||||
}.build()
|
||||
ShikimoriRepository(okHttp, get())
|
||||
}
|
||||
viewModel { params ->
|
||||
ShikimoriSettingsViewModel(get(), params.getOrNull())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package org.koitharu.kotatsu.shikimori.data
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
|
||||
class ShikimoriAuthenticator(
|
||||
private val storage: ShikimoriStorage,
|
||||
private val repositoryProvider: () -> 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? {
|
||||
val repository = repositoryProvider()
|
||||
runBlocking { repository.authorize(null) }
|
||||
return storage.accessToken
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package org.koitharu.kotatsu.shikimori.data
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
|
||||
private const val USER_AGENT_SHIKIMORI = "Kotatsu"
|
||||
|
||||
class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request().newBuilder()
|
||||
request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
|
||||
storage.accessToken?.let {
|
||||
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
|
||||
}
|
||||
return chain.proceed(request.build())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package org.koitharu.kotatsu.shikimori.data
|
||||
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser
|
||||
import org.koitharu.kotatsu.utils.ext.await
|
||||
import org.koitharu.kotatsu.utils.ext.parseJson
|
||||
|
||||
private const val CLIENT_ID = "Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU"
|
||||
private const val CLIENT_SECRET = "euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8"
|
||||
private const val REDIRECT_URI = "kotatsu://shikimori-auth"
|
||||
|
||||
class ShikimoriRepository(
|
||||
private val okHttp: OkHttpClient,
|
||||
private val storage: ShikimoriStorage,
|
||||
) {
|
||||
|
||||
val oauthUrl: String
|
||||
get() = "https://shikimori.one/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&response_type=code&scope="
|
||||
|
||||
val isAuthorized: Boolean
|
||||
get() = storage.accessToken != null
|
||||
|
||||
suspend fun authorize(code: String?) {
|
||||
val body = FormBody.Builder()
|
||||
body.add("grant_type", "authorization_code")
|
||||
body.add("client_id", CLIENT_ID)
|
||||
body.add("client_secret", CLIENT_SECRET)
|
||||
if (code != null) {
|
||||
body.add("redirect_uri", REDIRECT_URI)
|
||||
body.add("code", code)
|
||||
} else {
|
||||
body.add("refresh_token", checkNotNull(storage.refreshToken))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.post(body.build())
|
||||
.url("https://shikimori.one/oauth/token")
|
||||
val response = okHttp.newCall(request.build()).await().parseJson()
|
||||
storage.accessToken = response.getString("access_token")
|
||||
storage.refreshToken = response.getString("refresh_token")
|
||||
}
|
||||
|
||||
suspend fun getUser(): ShikimoriUser {
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url("https://shikimori.one/api/users/whoami")
|
||||
val response = okHttp.newCall(request.build()).await().parseJson()
|
||||
return ShikimoriUser(response)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.shikimori.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
|
||||
private const val PREF_NAME = "shikimori"
|
||||
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
|
||||
class ShikimoriStorage(context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREF_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) }
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package org.koitharu.kotatsu.shikimori.data.model
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
class ShikimoriUser(
|
||||
val id: Long,
|
||||
val nickname: String,
|
||||
val avatar: String,
|
||||
) {
|
||||
|
||||
constructor(json: JSONObject) : this(
|
||||
id = json.getLong("id"),
|
||||
nickname = json.getString("nickname"),
|
||||
avatar = json.getString("avatar"),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package org.koitharu.kotatsu.shikimori.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.preference.Preference
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.transform.CircleCropTransformation
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser
|
||||
import org.koitharu.kotatsu.utils.PreferenceIconTarget
|
||||
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
|
||||
private const val KEY_USER = "shiki_user"
|
||||
|
||||
class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) {
|
||||
|
||||
private val viewModel by viewModel<ShikimoriSettingsViewModel> {
|
||||
parametersOf(arguments?.getString(ARG_AUTH_CODE))
|
||||
}
|
||||
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_shikimori)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.user.observe(viewLifecycleOwner, this::onUserChanged)
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
KEY_USER -> openAuthorization()
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onUserChanged(user: ShikimoriUser?) {
|
||||
val pref = findPreference<Preference>(KEY_USER) ?: return
|
||||
pref.isSelectable = user == null
|
||||
pref.title = user?.nickname ?: getString(R.string.sign_in)
|
||||
ImageRequest.Builder(requireContext())
|
||||
.data(user?.avatar)
|
||||
.transformations(CircleCropTransformation())
|
||||
.target(PreferenceIconTarget(pref))
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
|
||||
private fun openAuthorization(): Boolean {
|
||||
return runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(viewModel.authorizationUrl)
|
||||
startActivity(intent)
|
||||
}.isSuccess
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ARG_AUTH_CODE = "auth_code"
|
||||
|
||||
fun newInstance(authCode: String?) = ShikimoriSettingsFragment().withArgs(1) {
|
||||
putString(ARG_AUTH_CODE, authCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.shikimori.ui
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository
|
||||
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriUser
|
||||
|
||||
class ShikimoriSettingsViewModel(
|
||||
private val repository: ShikimoriRepository,
|
||||
authCode: String?,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val authorizationUrl: String
|
||||
get() = repository.oauthUrl
|
||||
|
||||
val user = MutableLiveData<ShikimoriUser?>()
|
||||
|
||||
init {
|
||||
if (authCode != null) {
|
||||
authorize(authCode)
|
||||
} else {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadUser() = launchJob(Dispatchers.Default) {
|
||||
val userModel = if (repository.isAuthorized) {
|
||||
repository.getUser()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
user.postValue(userModel)
|
||||
}
|
||||
|
||||
private fun authorize(code: String) = launchJob(Dispatchers.Default) {
|
||||
repository.authorize(code)
|
||||
user.postValue(repository.getUser())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.preference.Preference
|
||||
import coil.target.Target
|
||||
|
||||
class PreferenceIconTarget(
|
||||
private val preference: Preference,
|
||||
) : Target {
|
||||
|
||||
override fun onError(error: Drawable?) {
|
||||
preference.icon = error
|
||||
}
|
||||
|
||||
override fun onStart(placeholder: Drawable?) {
|
||||
preference.icon = placeholder
|
||||
}
|
||||
|
||||
override fun onSuccess(result: Drawable) {
|
||||
preference.icon = result
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
app:initialExpandedChildrenCount="5">
|
||||
|
||||
<Preference
|
||||
android:key="shiki_user"
|
||||
android:persistent="false"
|
||||
android:title="@string/loading_"
|
||||
app:iconSpaceReserved="true" />
|
||||
|
||||
</PreferenceScreen>
|
||||
Loading…
Reference in New Issue