Shikimori authorization

This commit is contained in:
Koitharu
2022-03-10 19:49:40 +02:00
parent a8a65e953f
commit 786914b1a6
18 changed files with 394 additions and 0 deletions

View File

@@ -61,6 +61,12 @@
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule
import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.shikimori.shikimoriModule
import org.koitharu.kotatsu.suggestions.suggestionsModule
import org.koitharu.kotatsu.tracker.trackerModule
import org.koitharu.kotatsu.widget.WidgetUpdater
@@ -69,6 +70,7 @@ class KotatsuApp : Application() {
readerModule,
appWidgetModule,
suggestionsModule,
shikimoriModule,
)
}
}

View File

@@ -9,6 +9,7 @@ object CommonHeaders {
const val ACCEPT = "Accept"
const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie"
const val AUTHORIZATION = "Authorization"
val CACHE_CONTROL_DISABLED: CacheControl
get() = CacheControl.Builder().noStore().build()

View File

@@ -249,6 +249,7 @@ class AppSettings(context: Context) {
const val KEY_PAGES_PRELOAD = "pages_preload"
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SHIKIMORI = "shikimori"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -2,12 +2,14 @@ package org.koitharu.kotatsu.reader.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
@@ -15,6 +17,9 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding
import org.koitharu.kotatsu.settings.*
import org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsFragment
private const val HOST_SHIKIMORI_AUTH = "shikimori-auth"
class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
@@ -27,6 +32,7 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
R.id.container,
when (intent?.action) {
Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment()
Intent.ACTION_VIEW -> handleUri(intent.data) ?: return
ACTION_READER -> ReaderSettingsFragment()
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
@@ -50,6 +56,15 @@ class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
}
}
private fun handleUri(uri: Uri?): Fragment? {
when (uri?.host) {
HOST_SHIKIMORI_AUTH -> return ShikimoriSettingsFragment
.newInstance(authCode = uri.getQueryParameter("code"))
}
finishAfterTransition()
return null
}
companion object {
private const val ACTION_READER =

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
@@ -12,6 +13,8 @@ import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceScreen
import androidx.preference.TwoStatePreference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import leakcanary.LeakCanary
import org.koin.android.ext.android.inject
@@ -24,6 +27,7 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
@@ -37,6 +41,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
StorageSelectDialog.OnStorageSelectListener {
private val storageManager by inject<LocalStorageManager>()
private val shikimoriRepository by inject<ShikimoriRepository>(mode = LazyThreadSafetyMode.NONE)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -165,6 +170,14 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
}
true
}
AppSettings.KEY_SHIKIMORI -> {
if (!shikimoriRepository.isAuthorized) {
showShikimoriDialog()
true
} else {
super.onPreferenceTreeClick(preference)
}
}
else -> super.onPreferenceTreeClick(preference)
}
}
@@ -179,4 +192,20 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
}
}
private fun showShikimoriDialog() {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.shikimori)
.setMessage(R.string.shikimori_info)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.sign_in) { _, _ ->
runCatching {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(shikimoriRepository.oauthUrl)
startActivity(intent)
}.onFailure {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_LONG).show()
}
}.show()
}
}

View File

@@ -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())
}
}

View File

@@ -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
}
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}

View File

@@ -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) }
}

View File

@@ -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"),
)
}

View File

@@ -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)
}
}
}

View File

@@ -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())
}
}

View File

@@ -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
}
}

View File

@@ -266,4 +266,6 @@
<string name="always">Always</string>
<string name="preload_pages">Preload pages</string>
<string name="logged_in_as">Logged in as %s</string>
<string name="shikimori" translatable="false">Shikimori</string>
<string name="shikimori_info">Sign in into your Shikimori account to get more features</string>
</resources>

View File

@@ -95,6 +95,12 @@
android:title="@string/check_for_new_chapters"
app:iconSpaceReserved="false" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.shikimori.ui.ShikimoriSettingsFragment"
android:title="@string/shikimori"
android:key="shikimori"
app:iconSpaceReserved="false" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.backup.BackupSettingsFragment"
android:title="@string/backup_restore"

View File

@@ -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>