Merge branch 'devel' into Instant_history
commit
667c9ccfda
@ -0,0 +1,3 @@
|
|||||||
|
[weblate]
|
||||||
|
url = https://hosted.weblate.org/api/
|
||||||
|
translation = kotatsu/strings
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.util
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
|
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
|
||||||
@ -1,3 +1,3 @@
|
|||||||
package org.koitharu.kotatsu.util.ext
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
fun Throwable.printStackTraceDebug() = printStackTrace()
|
fun Throwable.printStackTraceDebug() = printStackTrace()
|
||||||
@ -0,0 +1,175 @@
|
|||||||
|
package org.koitharu.kotatsu.browser.cloudflare
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
||||||
|
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.catchingWebViewUnavailability
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
|
import javax.inject.Inject
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
|
||||||
|
|
||||||
|
private var pendingResult = RESULT_CANCELED
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var cookieJar: MutableCookieJar
|
||||||
|
|
||||||
|
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
supportActionBar?.run {
|
||||||
|
setDisplayHomeAsUpEnabled(true)
|
||||||
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
|
}
|
||||||
|
val url = intent?.dataString.orEmpty()
|
||||||
|
with(viewBinding.webView.settings) {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
cacheMode = WebSettings.LOAD_DEFAULT
|
||||||
|
domStorageEnabled = true
|
||||||
|
databaseEnabled = true
|
||||||
|
userAgentString = intent?.getStringExtra(ARG_UA) ?: CommonHeadersInterceptor.userAgentFallback
|
||||||
|
}
|
||||||
|
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
||||||
|
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||||
|
onBackPressedDispatcher.addCallback(it)
|
||||||
|
}
|
||||||
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
finishAfterTransition()
|
||||||
|
} else {
|
||||||
|
onTitleChanged(getString(R.string.loading_), url)
|
||||||
|
viewBinding.webView.loadUrl(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
viewBinding.webView.run {
|
||||||
|
stopLoading()
|
||||||
|
destroy()
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
viewBinding.webView.saveState(outState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
super.onRestoreInstanceState(savedInstanceState)
|
||||||
|
viewBinding.webView.restoreState(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
viewBinding.appbar.updatePadding(
|
||||||
|
top = insets.top,
|
||||||
|
)
|
||||||
|
viewBinding.root.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
bottom = insets.bottom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||||
|
android.R.id.home -> {
|
||||||
|
viewBinding.webView.stopLoading()
|
||||||
|
finishAfterTransition()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
viewBinding.webView.onResume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
viewBinding.webView.onPause()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun finish() {
|
||||||
|
setResult(pendingResult)
|
||||||
|
super.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageLoaded() {
|
||||||
|
viewBinding.progressBar.isInvisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCheckPassed() {
|
||||||
|
pendingResult = RESULT_OK
|
||||||
|
finishAfterTransition()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
|
viewBinding.progressBar.isVisible = isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHistoryChanged() {
|
||||||
|
onBackPressedCallback?.onHistoryChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||||
|
setTitle(title)
|
||||||
|
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
||||||
|
}
|
||||||
|
|
||||||
|
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
|
||||||
|
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
|
||||||
|
return newIntent(context, input.first, input.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
||||||
|
return TaggedActivityResult(TAG, resultCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val TAG = "CloudFlareActivity"
|
||||||
|
private const val ARG_UA = "ua"
|
||||||
|
|
||||||
|
fun newIntent(
|
||||||
|
context: Context,
|
||||||
|
url: String,
|
||||||
|
headers: Headers?,
|
||||||
|
) = Intent(context, CloudFlareActivity::class.java).apply {
|
||||||
|
data = url.toUri()
|
||||||
|
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
||||||
|
putExtra(ARG_UA, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,123 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
|
||||||
|
|
||||||
import android.content.DialogInterface
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.webkit.CookieManager
|
|
||||||
import android.webkit.WebSettings
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.core.view.isInvisible
|
|
||||||
import androidx.fragment.app.setFragmentResult
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import okhttp3.Headers
|
|
||||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
|
||||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
|
||||||
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
|
|
||||||
|
|
||||||
private lateinit var url: String
|
|
||||||
private val pendingResult = Bundle(1)
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var cookieJar: MutableCookieJar
|
|
||||||
|
|
||||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
url = requireArguments().getString(ARG_URL).orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewBinding(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
) = FragmentCloudflareBinding.inflate(inflater, container, false)
|
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: FragmentCloudflareBinding, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
|
||||||
with(binding.webView.settings) {
|
|
||||||
javaScriptEnabled = true
|
|
||||||
cacheMode = WebSettings.LOAD_DEFAULT
|
|
||||||
domStorageEnabled = true
|
|
||||||
databaseEnabled = true
|
|
||||||
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
|
|
||||||
}
|
|
||||||
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
|
|
||||||
if (url.isEmpty()) {
|
|
||||||
dismissAllowingStateLoss()
|
|
||||||
} else {
|
|
||||||
binding.webView.loadUrl(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
requireViewBinding().webView.stopLoading()
|
|
||||||
requireViewBinding().webView.destroy()
|
|
||||||
onBackPressedCallback = null
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
|
||||||
return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDialogCreated(dialog: AlertDialog) {
|
|
||||||
super.onDialogCreated(dialog)
|
|
||||||
onBackPressedCallback = WebViewBackPressedCallback(requireViewBinding().webView).also {
|
|
||||||
dialog.onBackPressedDispatcher.addCallback(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
requireViewBinding().webView.onResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
requireViewBinding().webView.onPause()
|
|
||||||
super.onPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDismiss(dialog: DialogInterface) {
|
|
||||||
setFragmentResult(TAG, pendingResult)
|
|
||||||
super.onDismiss(dialog)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPageLoaded() {
|
|
||||||
viewBinding?.progressBar?.isInvisible = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCheckPassed() {
|
|
||||||
pendingResult.putBoolean(EXTRA_RESULT, true)
|
|
||||||
dismissAllowingStateLoss()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onHistoryChanged() {
|
|
||||||
onBackPressedCallback?.onHistoryChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val TAG = "CloudFlareDialog"
|
|
||||||
const val EXTRA_RESULT = "result"
|
|
||||||
private const val ARG_URL = "url"
|
|
||||||
private const val ARG_UA = "ua"
|
|
||||||
|
|
||||||
fun newInstance(url: String, headers: Headers?) = CloudFlareDialog().withArgs(2) {
|
|
||||||
putString(ARG_URL, url)
|
|
||||||
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
|
||||||
putString(ARG_UA, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration15To16 : Migration(15, 16) {
|
||||||
|
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import coil.intercept.Interceptor
|
||||||
|
import coil.request.ErrorResult
|
||||||
|
import coil.request.ImageResult
|
||||||
|
import coil.request.SuccessResult
|
||||||
|
import coil.size.Dimension
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
||||||
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import java.util.Collections
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ImageProxyInterceptor @Inject constructor(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||||
|
|
||||||
|
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||||
|
val request = chain.request
|
||||||
|
if (!settings.isImagesProxyEnabled) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
val url: HttpUrl? = when (val data = request.data) {
|
||||||
|
is HttpUrl -> data
|
||||||
|
is String -> data.toHttpUrlOrNull()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
val newUrl = HttpUrl.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host("wsrv.nl")
|
||||||
|
.addQueryParameter("url", url.toString())
|
||||||
|
.addQueryParameter("fit", "outside")
|
||||||
|
.addQueryParameter("we", null)
|
||||||
|
val size = request.sizeResolver.size()
|
||||||
|
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||||
|
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||||
|
|
||||||
|
val newRequest = request.newBuilder()
|
||||||
|
.data(newUrl.build())
|
||||||
|
.build()
|
||||||
|
val result = chain.proceed(newRequest)
|
||||||
|
return if (result is SuccessResult) {
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
logDebug((result as? ErrorResult)?.throwable)
|
||||||
|
chain.proceed(request).also {
|
||||||
|
if (it is SuccessResult) {
|
||||||
|
blacklist.add(url.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||||
|
if (!settings.isImagesProxyEnabled) {
|
||||||
|
return okHttp.newCall(request).await()
|
||||||
|
}
|
||||||
|
val sourceUrl = request.url
|
||||||
|
val targetUrl = HttpUrl.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host("wsrv.nl")
|
||||||
|
.addQueryParameter("url", sourceUrl.toString())
|
||||||
|
.addQueryParameter("we", null)
|
||||||
|
val newRequest = request.newBuilder()
|
||||||
|
.url(targetUrl.build())
|
||||||
|
.build()
|
||||||
|
return runCatchingCancellable {
|
||||||
|
okHttp.doCall(newRequest)
|
||||||
|
}.recover {
|
||||||
|
logDebug(it)
|
||||||
|
okHttp.doCall(request).also {
|
||||||
|
blacklist.add(sourceUrl.host)
|
||||||
|
}
|
||||||
|
}.getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||||
|
return newCall(request).await().ensureSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logDebug(e: Throwable?) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.w("ImageProxy", e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
|
import okhttp3.Authenticator
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.Route
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import java.net.PasswordAuthentication
|
||||||
|
import java.net.Proxy
|
||||||
|
|
||||||
|
class ProxyAuthenticator(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) : Authenticator, java.net.Authenticator() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
setDefault(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun authenticate(route: Route?, response: Response): Request? {
|
||||||
|
if (!isProxyEnabled()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val login = settings.proxyLogin ?: return null
|
||||||
|
val password = settings.proxyPassword ?: return null
|
||||||
|
val credential = Credentials.basic(login, password)
|
||||||
|
return response.request.newBuilder()
|
||||||
|
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||||
|
if (!isProxyEnabled()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val login = settings.proxyLogin ?: return null
|
||||||
|
val password = settings.proxyPassword ?: return null
|
||||||
|
return PasswordAuthentication(login, password.toCharArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
|
||||||
|
}
|
||||||
@ -1,37 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.ColorRes
|
|
||||||
import dagger.Reusable
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@Reusable
|
|
||||||
class MangaTagHighlighter @Inject constructor(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val dict by lazy {
|
|
||||||
context.resources.openRawResource(R.raw.tags_redlist).use {
|
|
||||||
val set = HashSet<String>()
|
|
||||||
it.bufferedReader().forEachLine { x ->
|
|
||||||
val line = x.trim()
|
|
||||||
if (line.isNotEmpty()) {
|
|
||||||
set.add(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ColorRes
|
|
||||||
fun getTint(tag: MangaTag): Int {
|
|
||||||
return if (tag.title.lowercase() in dict) {
|
|
||||||
R.color.warning
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.image
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.media.ThumbnailUtils
|
||||||
|
import coil.size.Size
|
||||||
|
import coil.size.pxOrElse
|
||||||
|
import coil.transform.Transformation
|
||||||
|
|
||||||
|
class ThumbnailTransformation : Transformation {
|
||||||
|
|
||||||
|
override val cacheKey: String = javaClass.name
|
||||||
|
|
||||||
|
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
|
||||||
|
return ThumbnailUtils.extractThumbnail(
|
||||||
|
input,
|
||||||
|
size.width.pxOrElse { input.width },
|
||||||
|
size.height.pxOrElse { input.height },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?) = other is ThumbnailTransformation
|
||||||
|
|
||||||
|
override fun hashCode() = javaClass.hashCode()
|
||||||
|
}
|
||||||
@ -1,30 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
|
|
||||||
class ScrollListenerInvalidationObserver(
|
|
||||||
private val recyclerView: RecyclerView,
|
|
||||||
private val scrollListener: RecyclerView.OnScrollListener,
|
|
||||||
) : RecyclerView.AdapterDataObserver() {
|
|
||||||
|
|
||||||
override fun onChanged() {
|
|
||||||
super.onChanged()
|
|
||||||
invalidateScroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
|
||||||
super.onItemRangeInserted(positionStart, itemCount)
|
|
||||||
invalidateScroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
|
|
||||||
super.onItemRangeRemoved(positionStart, itemCount)
|
|
||||||
invalidateScroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invalidateScroll() {
|
|
||||||
recyclerView.post {
|
|
||||||
scrollListener.onScrolled(recyclerView, 0, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.sheet
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.view.View
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.sidesheet.SideSheetBehavior
|
||||||
|
import com.google.android.material.sidesheet.SideSheetCallback
|
||||||
|
import com.google.android.material.sidesheet.SideSheetDialog
|
||||||
|
import java.util.LinkedList
|
||||||
|
|
||||||
|
sealed class AdaptiveSheetBehavior {
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
protected val callbacks = LinkedList<AdaptiveSheetCallback>()
|
||||||
|
|
||||||
|
abstract var state: Int
|
||||||
|
|
||||||
|
abstract var isDraggable: Boolean
|
||||||
|
|
||||||
|
open val isHideable: Boolean = true
|
||||||
|
|
||||||
|
fun addCallback(callback: AdaptiveSheetCallback) {
|
||||||
|
callbacks.add(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeCallback(callback: AdaptiveSheetCallback) {
|
||||||
|
callbacks.remove(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Bottom(
|
||||||
|
private val delegate: BottomSheetBehavior<*>,
|
||||||
|
) : AdaptiveSheetBehavior() {
|
||||||
|
|
||||||
|
override var state: Int
|
||||||
|
get() = delegate.state
|
||||||
|
set(value) {
|
||||||
|
delegate.state = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override var isDraggable: Boolean
|
||||||
|
get() = delegate.isDraggable
|
||||||
|
set(value) {
|
||||||
|
delegate.isDraggable = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override val isHideable: Boolean
|
||||||
|
get() = delegate.isHideable
|
||||||
|
|
||||||
|
var isFitToContents: Boolean
|
||||||
|
get() = delegate.isFitToContents
|
||||||
|
set(value) {
|
||||||
|
delegate.isFitToContents = value
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
delegate.addBottomSheetCallback(
|
||||||
|
object : BottomSheetCallback() {
|
||||||
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||||
|
callbacks.forEach { it.onStateChanged(bottomSheet, newState) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||||
|
callbacks.forEach { it.onSlide(bottomSheet, slideOffset) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Side(
|
||||||
|
private val delegate: SideSheetBehavior<*>,
|
||||||
|
) : AdaptiveSheetBehavior() {
|
||||||
|
|
||||||
|
override var state: Int
|
||||||
|
get() = delegate.state
|
||||||
|
set(value) {
|
||||||
|
delegate.state = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override var isDraggable: Boolean
|
||||||
|
get() = delegate.isDraggable
|
||||||
|
set(value) {
|
||||||
|
delegate.isDraggable = value
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
delegate.addCallback(
|
||||||
|
object : SideSheetCallback() {
|
||||||
|
override fun onStateChanged(sheet: View, newState: Int) {
|
||||||
|
callbacks.forEach { it.onStateChanged(sheet, newState) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSlide(sheet: View, slideOffset: Float) {
|
||||||
|
callbacks.forEach { it.onSlide(sheet, slideOffset) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED
|
||||||
|
const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING
|
||||||
|
const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING
|
||||||
|
const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN
|
||||||
|
|
||||||
|
fun from(dialog: Dialog?): AdaptiveSheetBehavior? = when (dialog) {
|
||||||
|
is BottomSheetDialog -> Bottom(dialog.behavior)
|
||||||
|
is SideSheetDialog -> Side(dialog.behavior)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? = when (val behavior = lp.behavior) {
|
||||||
|
is BottomSheetBehavior<*> -> Bottom(behavior)
|
||||||
|
is SideSheetBehavior<*> -> Side(behavior)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.sheet
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
|
||||||
|
interface AdaptiveSheetCallback {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the sheet changes its state.
|
||||||
|
*
|
||||||
|
* @param sheet The sheet view.
|
||||||
|
* @param newState The new state.
|
||||||
|
*/
|
||||||
|
fun onStateChanged(sheet: View, newState: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the sheet is being dragged.
|
||||||
|
*
|
||||||
|
* @param sheet The sheet view.
|
||||||
|
* @param slideOffset The new offset of this sheet.
|
||||||
|
*/
|
||||||
|
fun onSlide(sheet: View, slideOffset: Float) = Unit
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.sheet
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.parents
|
||||||
|
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding
|
||||||
|
|
||||||
|
class AdaptiveSheetHeaderBar @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = 0,
|
||||||
|
) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback {
|
||||||
|
|
||||||
|
private val binding = LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this)
|
||||||
|
private var sheetBehavior: AdaptiveSheetBehavior? = null
|
||||||
|
|
||||||
|
var title: CharSequence?
|
||||||
|
get() = binding.shTextViewTitle.text
|
||||||
|
set(value) {
|
||||||
|
binding.shTextViewTitle.text = value
|
||||||
|
}
|
||||||
|
|
||||||
|
val isTitleVisible: Boolean
|
||||||
|
get() = binding.shLayoutSidesheet.isVisible
|
||||||
|
|
||||||
|
init {
|
||||||
|
orientation = VERTICAL
|
||||||
|
binding.shButtonClose.setOnClickListener { dismissSheet() }
|
||||||
|
context.withStyledAttributes(
|
||||||
|
attrs,
|
||||||
|
R.styleable.AdaptiveSheetHeaderBar, defStyleAttr,
|
||||||
|
) {
|
||||||
|
title = getText(R.styleable.AdaptiveSheetHeaderBar_title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
if (isInEditMode) {
|
||||||
|
val isTabled = resources.getBoolean(R.bool.is_tablet)
|
||||||
|
binding.shDragHandle.isGone = isTabled
|
||||||
|
binding.shLayoutSidesheet.isVisible = isTabled
|
||||||
|
} else {
|
||||||
|
setBottomSheetBehavior(findParentSheetBehavior())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
setBottomSheetBehavior(null)
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStateChanged(sheet: View, newState: Int) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTitle(@StringRes resId: Int) {
|
||||||
|
binding.shTextViewTitle.setText(resId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setBottomSheetBehavior(behavior: AdaptiveSheetBehavior?) {
|
||||||
|
binding.shDragHandle.isVisible = behavior is AdaptiveSheetBehavior.Bottom
|
||||||
|
binding.shLayoutSidesheet.isVisible = behavior is AdaptiveSheetBehavior.Side
|
||||||
|
sheetBehavior?.removeCallback(this)
|
||||||
|
sheetBehavior = behavior
|
||||||
|
behavior?.addCallback(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dismissSheet() {
|
||||||
|
sheetBehavior?.state = AdaptiveSheetBehavior.STATE_HIDDEN
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findParentSheetBehavior(): AdaptiveSheetBehavior? {
|
||||||
|
for (p in parents) {
|
||||||
|
val layoutParams = (p as? View)?.layoutParams
|
||||||
|
if (layoutParams is CoordinatorLayout.LayoutParams) {
|
||||||
|
AdaptiveSheetBehavior.from(layoutParams)?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,174 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.sheet
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewGroup.LayoutParams
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.appcompat.app.AppCompatDialogFragment
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
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 org.koitharu.kotatsu.R
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
||||||
|
|
||||||
|
private var waitingForDismissAllowingStateLoss = false
|
||||||
|
private var isFitToContentsDisabled = false
|
||||||
|
|
||||||
|
var viewBinding: B? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Deprecated("", ReplaceWith("requireViewBinding()"))
|
||||||
|
protected val binding: B
|
||||||
|
get() = requireViewBinding()
|
||||||
|
|
||||||
|
protected val behavior: AdaptiveSheetBehavior?
|
||||||
|
get() = AdaptiveSheetBehavior.from(dialog)
|
||||||
|
|
||||||
|
val isExpanded: Boolean
|
||||||
|
get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED
|
||||||
|
|
||||||
|
val onBackPressedDispatcher: OnBackPressedDispatcher
|
||||||
|
get() = requireComponentDialog().onBackPressedDispatcher
|
||||||
|
|
||||||
|
final override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
): View {
|
||||||
|
val binding = onCreateViewBinding(inflater, container)
|
||||||
|
viewBinding = binding
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
val binding = requireViewBinding()
|
||||||
|
onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
viewBinding = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val context = requireContext()
|
||||||
|
return if (context.resources.getBoolean(R.bool.is_tablet)) {
|
||||||
|
SideSheetDialog(context, theme)
|
||||||
|
} else {
|
||||||
|
BottomSheetDialog(context, theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addSheetCallback(callback: AdaptiveSheetCallback) {
|
||||||
|
val b = behavior ?: return
|
||||||
|
b.addCallback(callback)
|
||||||
|
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
|
||||||
|
?: dialog?.findViewById(materialR.id.coordinator)
|
||||||
|
if (rootView != null) {
|
||||||
|
callback.onStateChanged(rootView, b.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
|
||||||
|
|
||||||
|
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
|
||||||
|
|
||||||
|
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
|
||||||
|
val b = behavior ?: return
|
||||||
|
if (isExpanded) {
|
||||||
|
b.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
}
|
||||||
|
if (b is AdaptiveSheetBehavior.Bottom) {
|
||||||
|
b.isFitToContents = !isFitToContentsDisabled && !isExpanded
|
||||||
|
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
|
||||||
|
rootView?.updateLayoutParams {
|
||||||
|
height = if (isFitToContentsDisabled || isExpanded) {
|
||||||
|
LayoutParams.MATCH_PARENT
|
||||||
|
} else {
|
||||||
|
LayoutParams.WRAP_CONTENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.isDraggable = !isLocked
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun disableFitToContents() {
|
||||||
|
isFitToContentsDisabled = true
|
||||||
|
val b = behavior as? AdaptiveSheetBehavior.Bottom ?: return
|
||||||
|
b.isFitToContents = false
|
||||||
|
dialog?.findViewById<View>(materialR.id.design_bottom_sheet)?.updateLayoutParams {
|
||||||
|
height = LayoutParams.MATCH_PARENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requireViewBinding(): B = checkNotNull(viewBinding) {
|
||||||
|
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dismiss() {
|
||||||
|
if (!tryDismissWithAnimation(false)) {
|
||||||
|
super.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dismissAllowingStateLoss() {
|
||||||
|
if (!tryDismissWithAnimation(true)) {
|
||||||
|
super.dismissAllowingStateLoss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible,
|
||||||
|
* false otherwise.
|
||||||
|
*/
|
||||||
|
private fun tryDismissWithAnimation(allowingStateLoss: Boolean): Boolean {
|
||||||
|
val shouldDismissWithAnimation = when (val dialog = dialog) {
|
||||||
|
is BottomSheetDialog -> dialog.dismissWithAnimation
|
||||||
|
is SideSheetDialog -> dialog.isDismissWithSheetAnimationEnabled
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
val behavior = behavior ?: return false
|
||||||
|
return if (shouldDismissWithAnimation && behavior.isHideable) {
|
||||||
|
dismissWithAnimation(behavior, allowingStateLoss)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dismissWithAnimation(behavior: AdaptiveSheetBehavior, allowingStateLoss: Boolean) {
|
||||||
|
waitingForDismissAllowingStateLoss = allowingStateLoss
|
||||||
|
if (behavior.state == AdaptiveSheetBehavior.STATE_HIDDEN) {
|
||||||
|
dismissAfterAnimation()
|
||||||
|
} else {
|
||||||
|
behavior.addCallback(SheetDismissCallback())
|
||||||
|
behavior.state = AdaptiveSheetBehavior.STATE_HIDDEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dismissAfterAnimation() {
|
||||||
|
if (waitingForDismissAllowingStateLoss) {
|
||||||
|
super.dismissAllowingStateLoss()
|
||||||
|
} else {
|
||||||
|
super.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class SheetDismissCallback : AdaptiveSheetCallback {
|
||||||
|
override fun onStateChanged(sheet: View, newState: Int) {
|
||||||
|
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
|
||||||
|
dismissAfterAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSlide(sheet: View, slideOffset: Float) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,31 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.util
|
|
||||||
|
|
||||||
import androidx.annotation.AnyThread
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
|
|
||||||
class CountedBooleanLiveData : LiveData<Boolean>(false) {
|
|
||||||
|
|
||||||
private val counter = AtomicInteger(0)
|
|
||||||
|
|
||||||
@AnyThread
|
|
||||||
fun increment() {
|
|
||||||
if (counter.getAndIncrement() == 0) {
|
|
||||||
postValue(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@AnyThread
|
|
||||||
fun decrement() {
|
|
||||||
if (counter.decrementAndGet() == 0) {
|
|
||||||
postValue(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@AnyThread
|
|
||||||
fun reset() {
|
|
||||||
if (counter.getAndSet(0) != 0) {
|
|
||||||
postValue(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks
|
||||||
|
import org.acra.ACRA
|
||||||
|
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks {
|
||||||
|
|
||||||
|
private val timeFormat = SimpleDateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.ROOT)
|
||||||
|
|
||||||
|
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
|
||||||
|
super.onFragmentAttached(fm, f, context)
|
||||||
|
ACRA.errorReporter.putCustomData(f.key(), "${time()}: ${f.arguments}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFragmentDetached(fm: FragmentManager, f: Fragment) {
|
||||||
|
super.onFragmentDetached(fm, f)
|
||||||
|
ACRA.errorReporter.removeCustomData(f.key())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||||
|
super.onActivityCreated(activity, savedInstanceState)
|
||||||
|
ACRA.errorReporter.putCustomData(activity.key(), "${time()}: ${activity.intent}")
|
||||||
|
(activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(this, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityDestroyed(activity: Activity) {
|
||||||
|
super.onActivityDestroyed(activity)
|
||||||
|
ACRA.errorReporter.removeCustomData(activity.key())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Activity.key() = "Activity[${javaClass.simpleName}]"
|
||||||
|
|
||||||
|
private fun Fragment.key() = "Fragment[${javaClass.simpleName}]"
|
||||||
|
|
||||||
|
private fun time() = timeFormat.format(Date())
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
|
class CompositeRunnable(
|
||||||
|
private val children: List<Runnable>,
|
||||||
|
) : Runnable, Collection<Runnable> by children {
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
for (child in children) {
|
||||||
|
child.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
|
||||||
|
class Event<T>(
|
||||||
|
private val data: T,
|
||||||
|
) {
|
||||||
|
private var isConsumed = false
|
||||||
|
|
||||||
|
suspend fun consume(collector: FlowCollector<T>) {
|
||||||
|
if (!isConsumed) {
|
||||||
|
collector.emit(data)
|
||||||
|
isConsumed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as Event<*>
|
||||||
|
|
||||||
|
if (data != other.data) return false
|
||||||
|
return isConsumed == other.isConsumed
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = data?.hashCode() ?: 0
|
||||||
|
result = 31 * result + isConsumed.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "Event(data=$data, isConsumed=$isConsumed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,86 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlin.coroutines.CoroutineContext
|
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
|
||||||
|
|
||||||
private const val DEFAULT_TIMEOUT = 5_000L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Similar to a CoroutineLiveData but optimized for using within infinite flows
|
|
||||||
*/
|
|
||||||
class FlowLiveData<T>(
|
|
||||||
private val flow: Flow<T>,
|
|
||||||
defaultValue: T,
|
|
||||||
context: CoroutineContext = EmptyCoroutineContext,
|
|
||||||
private val timeoutInMs: Long = DEFAULT_TIMEOUT,
|
|
||||||
) : LiveData<T>(defaultValue) {
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Main.immediate + context + SupervisorJob(context[Job]))
|
|
||||||
private var job: Job? = null
|
|
||||||
private var cancellationJob: Job? = null
|
|
||||||
|
|
||||||
override fun onActive() {
|
|
||||||
super.onActive()
|
|
||||||
cancellationJob?.cancel()
|
|
||||||
cancellationJob = null
|
|
||||||
if (job?.isActive == true) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
job = scope.launch {
|
|
||||||
flow.collect(Collector())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onInactive() {
|
|
||||||
super.onInactive()
|
|
||||||
cancellationJob?.cancel()
|
|
||||||
cancellationJob = scope.launch(Dispatchers.Main.immediate) {
|
|
||||||
delay(timeoutInMs)
|
|
||||||
if (!hasActiveObservers()) {
|
|
||||||
job?.cancel()
|
|
||||||
job = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class Collector : FlowCollector<T> {
|
|
||||||
|
|
||||||
private var previousValue: Any? = value
|
|
||||||
private val dispatcher = Dispatchers.Main.immediate
|
|
||||||
|
|
||||||
override suspend fun emit(value: T) {
|
|
||||||
if (previousValue != value) {
|
|
||||||
previousValue = value
|
|
||||||
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
|
|
||||||
withContext(dispatcher) {
|
|
||||||
setValue(value)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setValue(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> Flow<T>.asFlowLiveData(
|
|
||||||
context: CoroutineContext = EmptyCoroutineContext,
|
|
||||||
defaultValue: T,
|
|
||||||
timeoutInMs: Long = DEFAULT_TIMEOUT,
|
|
||||||
): LiveData<T> = FlowLiveData(this, defaultValue, context, timeoutInMs)
|
|
||||||
|
|
||||||
fun <T> StateFlow<T>.asFlowLiveData(
|
|
||||||
context: CoroutineContext = EmptyCoroutineContext,
|
|
||||||
timeoutInMs: Long = DEFAULT_TIMEOUT,
|
|
||||||
): LiveData<T> = FlowLiveData(this, value, context, timeoutInMs)
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
|
||||||
|
|
||||||
import androidx.annotation.AnyThread
|
|
||||||
import androidx.annotation.MainThread
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.Observer
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
|
||||||
|
|
||||||
class SingleLiveEvent<T> : LiveData<T>() {
|
|
||||||
|
|
||||||
private val pending = AtomicBoolean(false)
|
|
||||||
|
|
||||||
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
|
|
||||||
super.observe(owner) {
|
|
||||||
if (pending.compareAndSet(true, false)) {
|
|
||||||
observer.onChanged(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setValue(value: T) {
|
|
||||||
pending.set(true)
|
|
||||||
super.setValue(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainThread
|
|
||||||
fun call(newValue: T) {
|
|
||||||
setValue(newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@AnyThread
|
|
||||||
fun postCall(newValue: T) {
|
|
||||||
postValue(newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun emitCall(newValue: T) {
|
|
||||||
val dispatcher = Dispatchers.Main.immediate
|
|
||||||
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
|
|
||||||
withContext(dispatcher) {
|
|
||||||
setValue(newValue)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setValue(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
|
||||||
|
|
||||||
|
fun BottomSheetBehavior<*>.doOnExpansionsChanged(callback: (isExpanded: Boolean) -> Unit) {
|
||||||
|
var isExpended = state == BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
callback(isExpended)
|
||||||
|
addBottomSheetCallback(
|
||||||
|
object : BottomSheetCallback() {
|
||||||
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||||
|
val expanded = newState == BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
if (expanded != isExpended) {
|
||||||
|
isExpended = expanded
|
||||||
|
callback(expanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
import androidx.annotation.AnyThread
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.koitharu.kotatsu.core.util.Event
|
||||||
|
|
||||||
|
@Suppress("FunctionName")
|
||||||
|
fun <T> MutableEventFlow() = MutableStateFlow<Event<T>?>(null)
|
||||||
|
|
||||||
|
typealias EventFlow<T> = StateFlow<Event<T>?>
|
||||||
|
|
||||||
|
typealias MutableEventFlow<T> = MutableStateFlow<Event<T>?>
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun <T> MutableEventFlow<T>.call(data: T) {
|
||||||
|
value = Event(data)
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koitharu.kotatsu.core.util.Event
|
||||||
|
|
||||||
|
fun <T> Flow<T>.observe(owner: LifecycleOwner, collector: FlowCollector<T>) {
|
||||||
|
val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT
|
||||||
|
owner.lifecycleScope.launch(start = start) {
|
||||||
|
collect(collector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<Event<T>?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector<T>) {
|
||||||
|
owner.lifecycleScope.launch {
|
||||||
|
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
collect {
|
||||||
|
it?.consume(collector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,32 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.koitharu.kotatsu.core.util.BufferedObserver
|
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
|
||||||
|
|
||||||
fun <T> LiveData<T>.requireValue(): T = checkNotNull(value) {
|
|
||||||
"LiveData value is null"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver<T>) {
|
|
||||||
var previous: T? = null
|
|
||||||
this.observe(owner) {
|
|
||||||
observer.onChanged(it, previous)
|
|
||||||
previous = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun <T> MutableLiveData<T>.emitValue(newValue: T) {
|
|
||||||
val dispatcher = Dispatchers.Main.immediate
|
|
||||||
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
|
|
||||||
withContext(dispatcher) {
|
|
||||||
value = newValue
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
value = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package org.koitharu.kotatsu.details.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||||
|
import org.koitharu.kotatsu.details.domain.model.DoubleManga
|
||||||
|
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||||
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@Deprecated("")
|
||||||
|
class DetailsInteractor @Inject constructor(
|
||||||
|
private val historyRepository: HistoryRepository,
|
||||||
|
private val favouritesRepository: FavouritesRepository,
|
||||||
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
|
private val trackingRepository: TrackingRepository,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun observeIsFavourite(mangaId: Long): Flow<Boolean> {
|
||||||
|
return favouritesRepository.observeCategoriesIds(mangaId)
|
||||||
|
.map { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeNewChapters(mangaId: Long): Flow<Int> {
|
||||||
|
return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
|
||||||
|
.flatMapLatest { isEnabled ->
|
||||||
|
if (isEnabled) {
|
||||||
|
trackingRepository.observeNewChaptersCount(mangaId)
|
||||||
|
} else {
|
||||||
|
flowOf(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeScrobblingInfo(mangaId: Long): Flow<List<ScrobblingInfo>> {
|
||||||
|
return combine(
|
||||||
|
scrobblers.map { it.observeScrobblingInfo(mangaId) },
|
||||||
|
) { scrobblingInfo ->
|
||||||
|
scrobblingInfo.filterNotNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeIncognitoMode(mangaFlow: Flow<Manga?>): Flow<Boolean> {
|
||||||
|
return mangaFlow
|
||||||
|
.distinctUntilChangedBy { it?.isNsfw }
|
||||||
|
.flatMapLatest { manga ->
|
||||||
|
if (manga != null) {
|
||||||
|
historyRepository.observeShouldSkip(manga)
|
||||||
|
} else {
|
||||||
|
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateLocal(subject: DoubleManga?, localManga: LocalManga): DoubleManga? {
|
||||||
|
return if (subject?.any?.id == localManga.manga.id) {
|
||||||
|
subject.copy(
|
||||||
|
localManga = runCatchingCancellable {
|
||||||
|
localMangaRepository.getDetails(localManga.manga)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
subject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
package org.koitharu.kotatsu.details.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.details.domain.model.DoubleManga
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DoubleMangaLoadUseCase @Inject constructor(
|
||||||
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend operator fun invoke(manga: Manga): DoubleManga = coroutineScope {
|
||||||
|
val remoteDeferred = async(Dispatchers.Default) { loadRemote(manga) }
|
||||||
|
val localDeferred = async(Dispatchers.Default) { loadLocal(manga) }
|
||||||
|
DoubleManga(
|
||||||
|
remoteManga = remoteDeferred.await(),
|
||||||
|
localManga = localDeferred.await(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend operator fun invoke(mangaId: Long): DoubleManga {
|
||||||
|
val manga = mangaDataRepository.findMangaById(mangaId) ?: throwNFE()
|
||||||
|
return invoke(manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend operator fun invoke(intent: MangaIntent): DoubleManga {
|
||||||
|
val manga = mangaDataRepository.resolveIntent(intent) ?: throwNFE()
|
||||||
|
return invoke(manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadLocal(manga: Manga): Result<Manga>? {
|
||||||
|
return runCatchingCancellable {
|
||||||
|
if (manga.isLocal) {
|
||||||
|
localMangaRepository.getDetails(manga)
|
||||||
|
} else {
|
||||||
|
localMangaRepository.findSavedManga(manga)?.manga
|
||||||
|
} ?: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadRemote(manga: Manga): Result<Manga>? {
|
||||||
|
return runCatchingCancellable {
|
||||||
|
val seed = if (manga.isLocal) {
|
||||||
|
localMangaRepository.getRemoteManga(manga)
|
||||||
|
} else {
|
||||||
|
manga
|
||||||
|
} ?: return null
|
||||||
|
val repository = mangaRepositoryFactory.create(seed.source)
|
||||||
|
repository.getDetails(seed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun throwNFE(): Nothing = throw NotFoundException("Cannot find manga", "")
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
package org.koitharu.kotatsu.details.domain.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.reader.data.filterChapters
|
||||||
|
|
||||||
|
data class DoubleManga(
|
||||||
|
private val remoteManga: Result<Manga>?,
|
||||||
|
private val localManga: Result<Manga>?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(manga: Manga) : this(
|
||||||
|
remoteManga = if (manga.source != MangaSource.LOCAL) Result.success(manga) else null,
|
||||||
|
localManga = if (manga.source == MangaSource.LOCAL) Result.success(manga) else null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val remote: Manga?
|
||||||
|
get() = remoteManga?.getOrNull()
|
||||||
|
|
||||||
|
val local: Manga?
|
||||||
|
get() = localManga?.getOrNull()
|
||||||
|
|
||||||
|
val any: Manga?
|
||||||
|
get() = remote ?: local
|
||||||
|
|
||||||
|
val hasRemote: Boolean
|
||||||
|
get() = remoteManga?.isSuccess == true
|
||||||
|
|
||||||
|
val hasLocal: Boolean
|
||||||
|
get() = localManga?.isSuccess == true
|
||||||
|
|
||||||
|
val chapters: List<MangaChapter>? by lazy(LazyThreadSafetyMode.PUBLICATION) {
|
||||||
|
mergeChapters()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requireAny(): Manga {
|
||||||
|
val result = remoteManga?.getOrNull() ?: localManga?.getOrNull()
|
||||||
|
if (result != null) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
throw (
|
||||||
|
remoteManga?.exceptionOrNull()
|
||||||
|
?: localManga?.exceptionOrNull()
|
||||||
|
?: IllegalStateException("No online either local manga available")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun filterChapters(branch: String?) = DoubleManga(
|
||||||
|
remoteManga?.map { it.filterChapters(branch) },
|
||||||
|
localManga?.map { it.filterChapters(branch) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun mergeChapters(): List<MangaChapter>? {
|
||||||
|
val remoteChapters = remote?.chapters
|
||||||
|
val localChapters = local?.chapters
|
||||||
|
if (localChapters == null && remoteChapters == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val localMap = if (!localChapters.isNullOrEmpty()) {
|
||||||
|
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val result = ArrayList<MangaChapter>(maxOf(remoteChapters?.size ?: 0, localChapters?.size ?: 0))
|
||||||
|
remoteChapters?.forEach { r ->
|
||||||
|
localMap?.remove(r.id)?.let { l ->
|
||||||
|
result.add(l)
|
||||||
|
} ?: result.add(r)
|
||||||
|
}
|
||||||
|
localMap?.values?.let {
|
||||||
|
result.addAll(it)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package org.koitharu.kotatsu.details.ui
|
||||||
|
|
||||||
|
import android.transition.TransitionManager
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.constraintlayout.widget.ConstraintSet
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.setMargins
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelSize
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemTipBinding
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
class ButtonTip(
|
||||||
|
private val root: ViewGroup,
|
||||||
|
private val insetsDelegate: WindowInsetsDelegate,
|
||||||
|
private val viewModel: DetailsViewModel,
|
||||||
|
) : View.OnClickListener, WindowInsetsDelegate.WindowInsetsListener {
|
||||||
|
|
||||||
|
private var selfBinding = ItemTipBinding.inflate(LayoutInflater.from(root.context), root, false)
|
||||||
|
private val actionBarSize = root.context.getThemeDimensionPixelSize(materialR.attr.actionBarSize)
|
||||||
|
|
||||||
|
init {
|
||||||
|
selfBinding.textView.setText(R.string.details_button_tip)
|
||||||
|
selfBinding.imageViewIcon.setImageResource(R.drawable.ic_tap)
|
||||||
|
selfBinding.root.id = R.id.layout_tip
|
||||||
|
selfBinding.buttonClose.setOnClickListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View?) {
|
||||||
|
remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
if (root is CoordinatorLayout) {
|
||||||
|
selfBinding.root.updateLayoutParams<CoordinatorLayout.LayoutParams> {
|
||||||
|
bottomMargin = topMargin + insets.bottom + insets.top + actionBarSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addToRoot() {
|
||||||
|
val lp: ViewGroup.LayoutParams = when (root) {
|
||||||
|
is CoordinatorLayout -> CoordinatorLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
).apply {
|
||||||
|
// anchorId = R.id.layout_bottom
|
||||||
|
// anchorGravity = Gravity.TOP
|
||||||
|
gravity = Gravity.BOTTOM
|
||||||
|
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
|
||||||
|
bottomMargin += actionBarSize
|
||||||
|
}
|
||||||
|
|
||||||
|
is ConstraintLayout -> ConstraintLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
).apply {
|
||||||
|
width = root.resources.getDimensionPixelSize(R.dimen.m3_side_sheet_width)
|
||||||
|
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
}
|
||||||
|
root.addView(selfBinding.root, lp)
|
||||||
|
if (root is ConstraintLayout) {
|
||||||
|
val cs = ConstraintSet()
|
||||||
|
cs.clone(root)
|
||||||
|
cs.connect(R.id.layout_tip, ConstraintSet.TOP, R.id.appbar, ConstraintSet.BOTTOM)
|
||||||
|
cs.connect(R.id.layout_tip, ConstraintSet.START, R.id.card_chapters, ConstraintSet.START)
|
||||||
|
cs.connect(R.id.layout_tip, ConstraintSet.END, R.id.card_chapters, ConstraintSet.END)
|
||||||
|
cs.applyTo(root)
|
||||||
|
}
|
||||||
|
insetsDelegate.addInsetsListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove() {
|
||||||
|
if (root.context.isAnimationsEnabled) {
|
||||||
|
TransitionManager.beginDelayedTransition(root)
|
||||||
|
}
|
||||||
|
insetsDelegate.removeInsetsListener(this)
|
||||||
|
root.removeView(selfBinding.root)
|
||||||
|
viewModel.onButtonTipClosed()
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue