Shikimori authorization

pull/172/head
Koitharu 4 years ago
parent a8a65e953f
commit 786914b1a6
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -61,6 +61,12 @@
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" /> <action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </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>
<activity <activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity" android:name="org.koitharu.kotatsu.browser.BrowserActivity"

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

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

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

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

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
@ -12,6 +13,8 @@ import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.TwoStatePreference import androidx.preference.TwoStatePreference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import leakcanary.LeakCanary import leakcanary.LeakCanary
import org.koin.android.ext.android.inject 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.local.data.LocalStorageManager
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.SliderPreference 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.getStorageName
import org.koitharu.kotatsu.utils.ext.names import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
@ -37,6 +41,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
StorageSelectDialog.OnStorageSelectListener { StorageSelectDialog.OnStorageSelectListener {
private val storageManager by inject<LocalStorageManager>() private val storageManager by inject<LocalStorageManager>()
private val shikimoriRepository by inject<ShikimoriRepository>(mode = LazyThreadSafetyMode.NONE)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -165,6 +170,14 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
} }
true true
} }
AppSettings.KEY_SHIKIMORI -> {
if (!shikimoriRepository.isAuthorized) {
showShikimoriDialog()
true
} else {
super.onPreferenceTreeClick(preference)
}
}
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) 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()
}
} }

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

@ -266,4 +266,6 @@
<string name="always">Always</string> <string name="always">Always</string>
<string name="preload_pages">Preload pages</string> <string name="preload_pages">Preload pages</string>
<string name="logged_in_as">Logged in as %s</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> </resources>

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

@ -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…
Cancel
Save