diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cb22f131e..d177b69df 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -61,6 +61,12 @@
+
+
+
+
+
+
() {
@@ -27,6 +32,7 @@ class SimpleSettingsActivity : BaseActivity() {
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() {
}
}
+ 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 =
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt
index 7a2b992ae..e25b01028 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt
@@ -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()
+ private val shikimoriRepository by inject(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()
+ }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt
new file mode 100644
index 000000000..1fd1d2b05
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ShikimoriModule.kt
@@ -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())
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt
new file mode 100644
index 000000000..b62d5bff0
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriAuthenticator.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt
new file mode 100644
index 000000000..33ff454c3
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriInterceptor.kt
@@ -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())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt
new file mode 100644
index 000000000..cd2f3ad8d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriRepository.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt
new file mode 100644
index 000000000..2edc946ba
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/ShikimoriStorage.kt
@@ -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) }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt
new file mode 100644
index 000000000..eff1464e6
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/data/model/ShikimoriUser.kt
@@ -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"),
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt
new file mode 100644
index 000000000..ed4e3a600
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsFragment.kt
@@ -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 {
+ parametersOf(arguments?.getString(ARG_AUTH_CODE))
+ }
+ private val coil by inject(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(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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt
new file mode 100644
index 000000000..3fd4186d5
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/shikimori/ui/ShikimoriSettingsViewModel.kt
@@ -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()
+
+ 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())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt b/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt
new file mode 100644
index 000000000..edece17d7
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a5dc3b73a..139c22abe 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -266,4 +266,6 @@
Always
Preload pages
Logged in as %s
+ Shikimori
+ Sign in into your Shikimori account to get more features
\ No newline at end of file
diff --git a/app/src/main/res/xml/pref_main.xml b/app/src/main/res/xml/pref_main.xml
index 0f4d7d6e3..9b2770caa 100644
--- a/app/src/main/res/xml/pref_main.xml
+++ b/app/src/main/res/xml/pref_main.xml
@@ -95,6 +95,12 @@
android:title="@string/check_for_new_chapters"
app:iconSpaceReserved="false" />
+
+
+
+
+
+
+