Handle scrobbler authorization errors

This commit is contained in:
Koitharu
2024-09-03 11:09:58 +03:00
parent 861ca63ea9
commit 22643bf9cc
26 changed files with 319 additions and 170 deletions

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
@@ -28,7 +29,6 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -180,13 +180,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
}
}
class Contract : ActivityResultContract<CloudFlareProtectedException, TaggedActivityResult>() {
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
return newIntent(context, input)
}
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
return TaggedActivityResult(TAG, resultCode)
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_OK
}
}

View File

@@ -2,14 +2,14 @@ package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.Context
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultCaller
import androidx.annotation.StringRes
import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.EntryPointAccessors
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity
@@ -18,53 +18,39 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseActivity.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException
import javax.inject.Provider
import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
class ExceptionResolver @AssistedInject constructor(
@Assisted private val host: Host,
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) {
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
private val activity: FragmentActivity?
private val fragment: Fragment?
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
val context: Context?
get() = activity ?: fragment?.context
constructor(activity: FragmentActivity) {
this.activity = activity
fragment = null
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
handleActivityResult(SourceAuthActivity.TAG, it)
}
constructor(fragment: Fragment) {
this.fragment = fragment
activity = null
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this)
}
override fun onActivityResult(result: TaggedActivityResult) {
continuations.remove(result.tag)?.resume(result.isSuccess)
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
handleActivityResult(CloudFlareActivity.TAG, it)
}
fun showDetails(e: Throwable, url: String?) {
ErrorDetailsDialog.show(getFragmentManager(), e, url)
ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url)
}
suspend fun resolve(e: Throwable): Boolean = when (e) {
@@ -77,7 +63,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
}
is ProxyConfigException -> {
context?.run {
host.withContext {
startActivity(SettingsActivity.newProxySettingsIntent(this))
}
false
@@ -93,6 +79,20 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
false
}
is ScrobblerAuthRequiredException -> {
val authHelper = scrobblerAuthHelperProvider.get()
if (authHelper.isAuthorized(e.scrobbler)) {
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure {
showDetails(it, null)
}
}
false
}
}
else -> false
}
@@ -106,21 +106,20 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
sourceAuthContract.launch(source)
}
private fun openInBrowser(url: String) {
context?.run {
startActivity(BrowserActivity.newIntent(this, url, null, null))
}
private fun openInBrowser(url: String) = host.withContext {
startActivity(BrowserActivity.newIntent(this, url, null, null))
}
private fun openAlternatives(manga: Manga) {
context?.run {
startActivity(AlternativesActivity.newIntent(this, manga))
}
private fun openAlternatives(manga: Manga) = host.withContext {
startActivity(AlternativesActivity.newIntent(this, manga))
}
private fun handleActivityResult(tag: String, result: Boolean) {
continuations.remove(tag)?.resume(result)
}
private fun showSslErrorDialog() {
val ctx = context ?: return
val settings = getAppSettings(ctx)
val ctx = host.getContext() ?: return
if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return
@@ -136,18 +135,31 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
.show()
}
private fun getAppSettings(context: Context): AppSettings {
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
private inline fun Host.withContext(block: Context.() -> Unit) {
getContext()?.apply(block)
}
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
interface Host : ActivityResultCaller {
fun getChildFragmentManager(): FragmentManager
fun getContext(): Context?
}
@AssistedFactory
interface Factory {
fun create(host: Host): ExceptionResolver
}
companion object {
@StringRes
fun getResolveStringId(e: Throwable) = when (e) {
is CloudFlareProtectedException -> R.string.captcha_solve
is ScrobblerAuthRequiredException,
is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
is SSLException,

View File

@@ -14,25 +14,22 @@ import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.view.WindowCompat
import androidx.fragment.app.FragmentManager
import androidx.viewbinding.ViewBinding
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
@Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
ExceptionResolver.Host,
ScreenshotPolicyHelper.ContentContainer,
WindowInsetsDelegate.WindowInsetsListener {
@@ -41,8 +38,8 @@ abstract class BaseActivity<B : ViewBinding> :
lateinit var viewBinding: B
private set
@JvmField
protected val exceptionResolver = ExceptionResolver(this)
protected lateinit var exceptionResolver: ExceptionResolver
private set
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
@@ -53,13 +50,15 @@ abstract class BaseActivity<B : ViewBinding> :
private var defaultStatusBarColor = Color.TRANSPARENT
override fun onCreate(savedInstanceState: Bundle?) {
val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(this)
val settings = entryPoint.settings
isAmoledTheme = settings.isAmoledTheme
setTheme(settings.colorScheme.styleResId)
if (isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
}
putDataToExtras(intent)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true
@@ -88,6 +87,10 @@ abstract class BaseActivity<B : ViewBinding> :
setupToolbar()
}
override fun getContext() = this
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
protected fun setContentView(binding: B) {
this.viewBinding = binding
super.setContentView(binding.root)
@@ -178,12 +181,6 @@ abstract class BaseActivity<B : ViewBinding> :
protected fun hasViewBinding() = ::viewBinding.isInitialized
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint {
val settings: AppSettings
}
companion object {
const val EXTRA_DATA = "data"

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.ui
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint {
val settings: AppSettings
val exceptionResolverFactory: ExceptionResolver.Factory
}

View File

@@ -1,25 +1,27 @@
package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
@Suppress("LeakingThis")
abstract class BaseFragment<B : ViewBinding> :
Fragment(),
ExceptionResolver.Host,
WindowInsetsDelegate.WindowInsetsListener {
var viewBinding: B? = null
private set
@JvmField
protected val exceptionResolver = ExceptionResolver(this)
protected lateinit var exceptionResolver: ExceptionResolver
private set
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
@@ -27,6 +29,12 @@ abstract class BaseFragment<B : ViewBinding> :
protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
override fun onAttach(context: Context) {
super.onAttach(context)
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
}
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.ui
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
@@ -12,7 +13,9 @@ import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
@@ -25,7 +28,11 @@ import javax.inject.Inject
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
WindowInsetsDelegate.WindowInsetsListener,
RecyclerViewOwner {
RecyclerViewOwner,
ExceptionResolver.Host {
protected lateinit var exceptionResolver: ExceptionResolver
private set
@Inject
lateinit var settings: AppSettings
@@ -36,6 +43,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
override val recyclerView: RecyclerView
get() = listView
override fun onAttach(context: Context) {
super.onAttach(context)
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val themedContext = (view.parentView ?: view).context

View File

@@ -21,16 +21,22 @@ import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.sidesheet.SideSheetDialog
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(), ExceptionResolver.Host {
private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false
protected lateinit var exceptionResolver: ExceptionResolver
private set
var viewBinding: B? = null
private set
@@ -50,6 +56,12 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
private set
private var lockCounter = 0
override fun onAttach(context: Context) {
super.onAttach(context)
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
}
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.core.util
import android.app.Activity
class TaggedActivityResult(
val tag: String,
val result: Int,
) {
val isSuccess: Boolean
get() = result == Activity.RESULT_OK
}

View File

@@ -33,6 +33,7 @@ import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@@ -40,6 +41,11 @@ private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId),
)
is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)

View File

@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.GenericSortOrder
import org.koitharu.kotatsu.core.model.SortDirection
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.model.direction
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
@@ -25,7 +24,6 @@ import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner
@@ -34,9 +32,6 @@ import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.EnumSet
import java.util.Locale
import com.google.android.material.R as materialR
@@ -106,7 +101,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
}
is ContentRating -> filter.setContentRating(data, !chip.isChecked)
null -> TagsCatalogSheet.show(childFragmentManager, chip.parentView?.id == R.id.chips_genresExclude)
null -> TagsCatalogSheet.show(getChildFragmentManager(), chip.parentView?.id == R.id.chips_genresExclude)
}
}

View File

@@ -299,7 +299,7 @@ abstract class MangaListFragment :
}
R.id.action_favourite -> {
FavoriteSheet.show(childFragmentManager, selectedItems)
FavoriteSheet.show(getChildFragmentManager(), selectedItems)
mode.finish()
true
}

View File

@@ -66,11 +66,11 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
}
override fun onEmptyActionClick() {
ImportDialogFragment.show(childFragmentManager)
ImportDialogFragment.show(getChildFragmentManager())
}
override fun onFilterClick(view: View?) {
FilterSheetFragment.show(childFragmentManager)
FilterSheetFragment.show(getChildFragmentManager())
}
override fun onPrimaryButtonClick(tipView: TipView) {

View File

@@ -66,7 +66,7 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
}
override fun onFilterClick(view: View?) {
FilterSheetFragment.show(childFragmentManager)
FilterSheetFragment.show(getChildFragmentManager())
}
override fun onEmptyActionClick() {

View File

@@ -4,6 +4,9 @@ import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import java.net.HttpURLConnection
private const val JSON = "application/json"
@@ -14,11 +17,16 @@ class AniListInterceptor(private val storage: ScrobblerStorage) : Interceptor {
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.CONTENT_TYPE, JSON)
request.header(CommonHeaders.ACCEPT, JSON)
if (!sourceRequest.url.pathSegments.contains("oauth")) {
val isAuthRequest = sourceRequest.url.pathSegments.contains("oauth")
if (!isAuthRequest) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
return chain.proceed(request.build())
val response = chain.proceed(request.build())
if (!isAuthRequest && response.code == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw ScrobblerAuthRequiredException(ScrobblerService.ANILIST)
}
return response
}
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.scrobbling.common.domain
import okio.IOException
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
class ScrobblerAuthRequiredException(
val scrobbler: ScrobblerService,
) : IOException()

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.scrobbling.common.domain
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuRepository
import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import javax.inject.Inject
import javax.inject.Provider
class ScrobblerRepositoryMap @Inject constructor(
private val shikimoriRepository: Provider<ShikimoriRepository>,
private val aniListRepository: Provider<AniListRepository>,
private val malRepository: Provider<MALRepository>,
private val kitsuRepository: Provider<KitsuRepository>,
) {
operator fun get(scrobblerService: ScrobblerService): ScrobblerRepository = when (scrobblerService) {
ScrobblerService.SHIKIMORI -> shikimoriRepository
ScrobblerService.ANILIST -> aniListRepository
ScrobblerService.MAL -> malRepository
ScrobblerService.KITSU -> kitsuRepository
}.get()
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.scrobbling.common.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerRepositoryMap
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.kitsu.ui.KitsuAuthActivity
import javax.inject.Inject
class ScrobblerAuthHelper @Inject constructor(
private val repositoriesMap: ScrobblerRepositoryMap,
) {
fun isAuthorized(scrobbler: ScrobblerService) = repositoriesMap[scrobbler].isAuthorized
fun getCachedUser(scrobbler: ScrobblerService): ScrobblerUser? {
return repositoriesMap[scrobbler].cachedUser
}
suspend fun getUser(scrobbler: ScrobblerService): ScrobblerUser {
return repositoriesMap[scrobbler].loadUser()
}
@SuppressLint("UnsafeImplicitIntentLaunch")
fun startAuth(context: Context, scrobbler: ScrobblerService) = runCatching {
if (scrobbler == ScrobblerService.KITSU) {
launchKitsuAuth(context)
} else {
val repository = repositoriesMap[scrobbler]
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(repository.oauthUrl)
context.startActivity(intent)
}
}
private fun launchKitsuAuth(context: Context) {
context.startActivity(Intent(context, KitsuAuthActivity::class.java))
}
}

View File

@@ -14,7 +14,9 @@ import androidx.recyclerview.widget.RecyclerView.NO_ID
import coil.ImageLoader
import com.google.android.material.tabs.TabLayout
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -28,6 +30,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -50,7 +53,8 @@ class ScrobblingSelectorSheet :
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
TabLayout.OnTabSelectedListener,
ListStateHolderListener, AsyncListDiffer.ListListener<ListModel> {
ListStateHolderListener,
AsyncListDiffer.ListListener<ListModel> {
@Inject
lateinit var coil: ImageLoader
@@ -134,7 +138,15 @@ class ScrobblingSelectorSheet :
}
override fun onRetryClick(error: Throwable) {
viewModel.retry()
if (ExceptionResolver.canResolve(error)) {
viewLifecycleScope.launch {
if (exceptionResolver.resolve(error)) {
viewModel.retry()
}
}
} else {
viewModel.retry()
}
}
override fun onEmptyActionClick() {

View File

@@ -14,11 +14,13 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.ifZero
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.requireValue
@@ -79,8 +81,8 @@ class ScrobblingSelectorViewModel @Inject constructor(
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
val selectedItemId = MutableStateFlow(NO_ID)
val searchQuery = MutableStateFlow(manga.title)
val onClose = MutableEventFlow<Unit>()
private val searchQuery = MutableStateFlow(manga.title)
val isEmpty: Boolean
get() = scrobblerMangaList.value.isEmpty()
@@ -201,11 +203,14 @@ class ScrobblingSelectorViewModel @Inject constructor(
actionStringRes = R.string.search,
)
private fun errorHint(e: Throwable) = ScrobblerHint(
icon = R.drawable.ic_error_large,
textPrimary = R.string.error_occurred,
error = e,
textSecondary = 0,
actionStringRes = R.string.try_again,
)
private fun errorHint(e: Throwable): ScrobblerHint {
val resolveAction = ExceptionResolver.getResolveStringId(e)
return ScrobblerHint(
icon = R.drawable.ic_error_large,
textPrimary = R.string.error_occurred,
error = e,
textSecondary = if (resolveAction == 0) 0 else R.string.try_again,
actionStringRes = resolveAction.ifZero { R.string.try_again },
)
}
}

View File

@@ -4,6 +4,9 @@ import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import java.net.HttpURLConnection
class KitsuInterceptor(private val storage: ScrobblerStorage) : Interceptor {
@@ -12,12 +15,17 @@ class KitsuInterceptor(private val storage: ScrobblerStorage) : Interceptor {
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.CONTENT_TYPE, VND_JSON)
request.header(CommonHeaders.ACCEPT, VND_JSON)
if (!sourceRequest.url.pathSegments.contains("oauth")) {
val isAuthRequest = sourceRequest.url.pathSegments.contains("oauth")
if (!isAuthRequest) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
return chain.proceed(request.build())
val response = chain.proceed(request.build())
if (!isAuthRequest && response.code == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw ScrobblerAuthRequiredException(ScrobblerService.KITSU)
}
return response
}
companion object {

View File

@@ -7,6 +7,9 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.parsers.util.mimeType
import org.koitharu.kotatsu.parsers.util.parseHtml
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import java.net.HttpURLConnection
private const val JSON = "application/json"
private const val HTML = "text/html"
@@ -18,12 +21,16 @@ class MALInterceptor(private val storage: ScrobblerStorage) : Interceptor {
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.CONTENT_TYPE, JSON)
request.header(CommonHeaders.ACCEPT, JSON)
if (!sourceRequest.url.pathSegments.contains("oauth")) {
val isAuthRequest = sourceRequest.url.pathSegments.contains("oauth")
if (!isAuthRequest) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
val response = chain.proceed(request.build())
if (!isAuthRequest && response.code == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw ScrobblerAuthRequiredException(ScrobblerService.MAL)
}
if (response.mimeType == HTML) {
throw IOException(response.parseHtml().title())
}

View File

@@ -5,6 +5,9 @@ import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import java.net.HttpURLConnection
private const val USER_AGENT_SHIKIMORI = "Kotatsu"
@@ -14,12 +17,16 @@ class ShikimoriInterceptor(private val storage: ScrobblerStorage) : Interceptor
val sourceRequest = chain.request()
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
if (!sourceRequest.url.pathSegments.contains("oauth")) {
val isAuthRequest = sourceRequest.url.pathSegments.contains("oauth")
if (!isAuthRequest) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
val response = chain.proceed(request.build())
if (!isAuthRequest && response.code == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw ScrobblerAuthRequiredException(ScrobblerService.SHIKIMORI)
}
if (!response.isSuccessful && !response.isRedirect) {
throw IOException("${response.code} ${response.message}")
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.settings
import android.accounts.AccountManager
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.preference.Preference
@@ -15,43 +14,29 @@ import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
import org.koitharu.kotatsu.scrobbling.kitsu.data.KitsuRepository
import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.scrobbling.kitsu.ui.KitsuAuthActivity
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
import org.koitharu.kotatsu.stats.ui.StatsActivity
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent
import javax.inject.Inject
@AndroidEntryPoint
class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
SharedPreferences.OnSharedPreferenceChangeListener {
@Inject
lateinit var shikimoriRepository: ShikimoriRepository
@Inject
lateinit var aniListRepository: AniListRepository
@Inject
lateinit var malRepository: MALRepository
@Inject
lateinit var kitsuRepository: KitsuRepository
@Inject
lateinit var syncController: SyncController
@Inject
lateinit var scrobblerAuthHelper: ScrobblerAuthHelper
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_services)
findPreference<SplitSwitchPreference>(AppSettings.KEY_STATS_ENABLED)?.let {
@@ -76,10 +61,10 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
override fun onResume() {
super.onResume()
bindScrobblerSummary(AppSettings.KEY_SHIKIMORI, shikimoriRepository)
bindScrobblerSummary(AppSettings.KEY_ANILIST, aniListRepository)
bindScrobblerSummary(AppSettings.KEY_MAL, malRepository)
bindScrobblerSummary(AppSettings.KEY_KITSU, kitsuRepository)
bindScrobblerSummary(AppSettings.KEY_SHIKIMORI, ScrobblerService.SHIKIMORI)
bindScrobblerSummary(AppSettings.KEY_ANILIST, ScrobblerService.ANILIST)
bindScrobblerSummary(AppSettings.KEY_MAL, ScrobblerService.MAL)
bindScrobblerSummary(AppSettings.KEY_KITSU, ScrobblerService.KITSU)
bindSyncSummary()
}
@@ -94,38 +79,22 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_SHIKIMORI -> {
if (!shikimoriRepository.isAuthorized) {
launchScrobblerAuth(shikimoriRepository)
} else {
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.SHIKIMORI))
}
handleScrobblerClick(ScrobblerService.SHIKIMORI)
true
}
AppSettings.KEY_MAL -> {
if (!malRepository.isAuthorized) {
launchScrobblerAuth(malRepository)
} else {
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.MAL))
}
handleScrobblerClick(ScrobblerService.MAL)
true
}
AppSettings.KEY_ANILIST -> {
if (!aniListRepository.isAuthorized) {
launchScrobblerAuth(aniListRepository)
} else {
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.ANILIST))
}
handleScrobblerClick(ScrobblerService.ANILIST)
true
}
AppSettings.KEY_KITSU -> {
if (!kitsuRepository.isAuthorized) {
startActivity(Intent(preference.context, KitsuAuthActivity::class.java))
} else {
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.KITSU))
}
handleScrobblerClick(ScrobblerService.KITSU)
true
}
@@ -147,14 +116,14 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
private fun bindScrobblerSummary(
key: String,
repository: ScrobblerRepository
scrobblerService: ScrobblerService
) {
val pref = findPreference<Preference>(key) ?: return
if (!repository.isAuthorized) {
if (!scrobblerAuthHelper.isAuthorized(scrobblerService)) {
pref.setSummary(R.string.disabled)
return
}
val username = repository.cachedUser?.nickname
val username = scrobblerAuthHelper.getCachedUser(scrobblerService)?.nickname
if (username != null) {
pref.summary = getString(R.string.logged_in_as, username)
} else {
@@ -162,7 +131,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
viewLifecycleScope.launch {
pref.summary = withContext(Dispatchers.Default) {
runCatching {
val user = repository.loadUser()
val user = scrobblerAuthHelper.getUser(scrobblerService)
getString(R.string.logged_in_as, user.nickname)
}.getOrElse {
it.printStackTraceDebug()
@@ -173,13 +142,11 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
}
}
private fun launchScrobblerAuth(repository: ScrobblerRepository) {
runCatching {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(repository.oauthUrl)
startActivity(intent)
}.onFailure {
Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
private fun handleScrobblerClick(scrobblerService: ScrobblerService) {
if (!scrobblerAuthHelper.isAuthorized(scrobblerService)) {
confirmScrobblerAuth(scrobblerService)
} else {
startActivity(ScrobblerConfigActivity.newIntent(context ?: return, scrobblerService))
}
}
@@ -211,4 +178,18 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
if (settings.isStatsEnabled) R.string.enabled else R.string.disabled,
)
}
private fun confirmScrobblerAuth(scrobblerService: ScrobblerService) {
buildAlertDialog(context ?: return, isCentered = true) {
setIcon(scrobblerService.iconResId)
setTitle(scrobblerService.titleResId)
setMessage(context.getString(R.string.scrobbler_auth_intro, context.getString(scrobblerService.titleResId)))
setPositiveButton(R.string.sign_in) { _, _ ->
scrobblerAuthHelper.startAuth(context, scrobblerService).onFailure {
Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
}

View File

@@ -7,7 +7,6 @@ import androidx.preference.Preference
import androidx.preference.SwitchPreferenceCompat
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
@@ -27,7 +26,6 @@ import java.io.File
class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenceChangeListener {
private val viewModel: SourceSettingsViewModel by viewModels()
private val exceptionResolver = ExceptionResolver(this)
override fun onResume() {
super.onResume()

View File

@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
@@ -132,13 +131,13 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
viewBinding.webView.updatePadding(bottom = insets.bottom)
}
class Contract : ActivityResultContract<MangaSource, TaggedActivityResult>() {
class Contract : ActivityResultContract<MangaSource, Boolean>() {
override fun createIntent(context: Context, input: MangaSource): Intent {
return newIntent(context, input)
}
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
return TaggedActivityResult(TAG, resultCode)
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == RESULT_OK
}
}

View File

@@ -694,4 +694,6 @@
<string name="sort_order_desc">Descending</string>
<string name="by_date">Date</string>
<string name="popularity">Popularity</string>
<string name="scrobbler_auth_required">Sign in to %s to continue</string>
<string name="scrobbler_auth_intro">Sign in to set up integration with %s. This will allow you to track your manga reading progress and status</string>
</resources>