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