Merge branch 'feature/nextgen' into feature/sync

This commit is contained in:
Koitharu
2022-07-18 15:05:02 +03:00
578 changed files with 17246 additions and 4645 deletions

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.utils
import android.util.ArrayMap
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -33,11 +35,13 @@ class CompositeMutex<T : Any> : Set<T> {
}
suspend fun lock(element: T) {
waitForRemoval(element)
mutex.withLock {
val lastValue = data.put(element, LinkedList<CancellableContinuation<Unit>>())
check(lastValue == null) {
"CompositeMutex is double-locked for $element"
while (currentCoroutineContext().isActive) {
waitForRemoval(element)
mutex.withLock {
if (data[element] == null) {
data[element] = LinkedList<CancellableContinuation<Unit>>()
return
}
}
}
}

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.utils
import android.content.Context
import android.text.Editable
import android.text.TextWatcher
import android.widget.EditText
import androidx.annotation.CallSuper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import java.lang.ref.WeakReference
abstract class EditTextValidator : TextWatcher {
private var editTextRef: WeakReference<EditText>? = null
protected val context: Context
get() = checkNotNull(editTextRef?.get()?.context) {
"EditTextValidator is not attached to EditText"
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
@CallSuper
override fun afterTextChanged(s: Editable?) {
val editText = editTextRef?.get() ?: return
val newText = s?.toString().orEmpty()
val result = runCatching {
validate(newText)
}.getOrElse { e ->
ValidationResult.Failed(e.getDisplayMessage(editText.resources))
}
editText.error = when (result) {
is ValidationResult.Failed -> result.message
ValidationResult.Success -> null
}
}
fun attachToEditText(editText: EditText) {
editTextRef = WeakReference(editText)
editText.removeTextChangedListener(this)
editText.addTextChangedListener(this)
afterTextChanged(editText.text)
}
abstract fun validate(text: String): ValidationResult
sealed class ValidationResult {
object Success : ValidationResult()
class Failed(val message: CharSequence) : ValidationResult()
}
}

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.utils
import android.content.Context
import android.content.res.Resources
object InternalResourceHelper {
fun getBoolean(context: Context, resName: String, defaultValue: Boolean): Boolean {
val id = getResourceId(resName, "bool")
return if (id != 0) {
context.createPackageContext("android", 0).resources.getBoolean(id)
} else {
defaultValue
}
}
/**
* Get resource id from system resources
* @param resName resource name to get
* @param type resource type of [resName] to get
* @return 0 if not available
*/
private fun getResourceId(resName: String, type: String): Int {
return Resources.getSystem().getIdentifier(resName, type, "android")
}
}

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.utils
import android.graphics.drawable.Drawable
import androidx.preference.Preference
import coil.target.Target
class PreferenceIconTarget(
private val preference: Preference,
) : Target {
override fun onError(error: Drawable?) {
preference.icon = error
}
override fun onStart(placeholder: Drawable?) {
preference.icon = placeholder
}
override fun onSuccess(result: Drawable) {
preference.icon = result
}
}

View File

@@ -35,7 +35,7 @@ class ScreenOrientationHelper(private val activity: Activity) {
isLandscape = !isLandscape
}
fun observeAutoOrientation() = callbackFlow<Boolean> {
fun observeAutoOrientation() = callbackFlow {
val observer = object : ContentObserver(Handler(activity.mainLooper)) {
override fun onChange(selfChange: Boolean) {
trySendBlocking(isAutoRotationEnabled)

View File

@@ -1,28 +1,53 @@
package org.koitharu.kotatsu.utils.ext
import android.app.ActivityManager
import android.app.ActivityOptions
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.OperationApplicationException
import android.content.SharedPreferences
import android.content.SyncResult
import android.content.pm.ResolveInfo
import android.database.SQLException
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.view.Window
import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.view.children
import androidx.core.view.descendants
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker
import com.google.android.material.elevation.ElevationOverlayProvider
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import okio.IOException
import org.json.JSONException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.utils.InternalResourceHelper
import kotlin.coroutines.resume
val Context.activityManager: ActivityManager?
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -67,6 +92,25 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(input: I, options: ActivityOptionsCo
}.isSuccess
}
fun SharedPreferences.observe() = callbackFlow<String> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}
registerOnSharedPreferenceChangeListener(listener)
awaitClose {
unregisterOnSharedPreferenceChangeListener(listener)
}
}
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
emit(valueProducer())
observe().collect { upstreamKey ->
if (upstreamKey == key) {
emit(valueProducer())
}
}
}.distinctUntilChanged()
fun Lifecycle.postDelayed(runnable: Runnable, delay: Long) {
coroutineScope.launch {
delay(delay)
@@ -83,4 +127,45 @@ fun SyncResult.onError(error: Throwable) {
else -> if (BuildConfig.DEBUG) throw error
}
error.printStackTraceDebug()
}
}
fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float = 0F) {
navigationBarColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
!InternalResourceHelper.getBoolean(context, "config_navBarNeedsScrim", true)
) {
Color.TRANSPARENT
} else {
// Set navbar scrim 70% of navigationBarColor
ElevationOverlayProvider(context).compositeOverlayIfNeeded(
context.getResourceColor(android.R.attr.navigationBarColor, 0.7F),
elevation,
)
}
}
val Context.animatorDurationScale: Float
get() = Settings.Global.getFloat(this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
fun ViewPropertyAnimator.applySystemAnimatorScale(context: Context): ViewPropertyAnimator = apply {
this.duration = (this.duration * context.animatorDurationScale).toLong()
}
inline fun <reified T> ViewGroup.findChild(): T? {
return children.find { it is T } as? T
}
inline fun <reified T> ViewGroup.findDescendant(): T? {
return descendants.find { it is T } as? T
}
fun isLowRamDevice(context: Context): Boolean {
return context.activityManager?.isLowRamDevice ?: false
}
fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation(
view,
0,
0,
view.width,
view.height,
)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Context
import android.widget.ImageView
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
@@ -7,14 +8,27 @@ import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.ImageResult
import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.target(this)
fun ImageView.newImageRequest(url: Any?): ImageRequest.Builder? {
val current = CoilUtils.result(this)
if (current != null && current.request.data == url) {
return null
}
return ImageRequest.Builder(context)
.data(url)
.crossfade(context)
.target(this)
}
fun ImageView.disposeImageRequest() {
CoilUtils.dispose(this)
setImageDrawable(null)
}
fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())
@@ -38,4 +52,14 @@ fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder {
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
return listener(ImageRequestIndicatorListener(indicator))
}
@Suppress("SpellCheckingInspection")
fun ImageRequest.Builder.crossfade(context: Context?): ImageRequest.Builder {
if (context == null) {
crossfade(true)
return this
}
val duration = context.resources.getInteger(R.integer.config_defaultAnimTime) * context.animatorDurationScale
return crossfade(duration.toInt())
}

View File

@@ -18,20 +18,24 @@ inline fun <T> MutableSet(size: Int, init: (index: Int) -> T): MutableSet<T> {
return set
}
inline fun <T> createSet(size: Int, init: (index: Int) -> T): Set<T> = when (size) {
@Suppress("FunctionName")
inline fun <T> Set(size: Int, init: (index: Int) -> T): Set<T> = when (size) {
0 -> emptySet()
1 -> Collections.singleton(init(0))
else -> MutableSet(size, init)
}
inline fun <T> createList(size: Int, init: (index: Int) -> T): List<T> = when (size) {
0 -> emptyList()
1 -> Collections.singletonList(init(0))
else -> MutableList(size, init)
}
fun <T> List<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
this as ArrayList<T>
} else {
ArrayList(this)
}
fun <K, V> Map<K, V>.findKeyByValue(value: V): K? {
for ((k, v) in entries) {
if (v == value) {
return k
}
}
return null
}

View File

@@ -17,4 +17,14 @@ fun Date.daysDiff(other: Long): Int {
val thisDay = time / TimeUnit.DAYS.toMillis(1L)
val otherDay = other / TimeUnit.DAYS.toMillis(1L)
return (thisDay - otherDay).toInt()
}
fun Date.startOfDay(): Long {
val calendar = Calendar.getInstance()
calendar.time = this
calendar[Calendar.HOUR_OF_DAY] = 0
calendar[Calendar.MINUTE] = 0
calendar[Calendar.SECOND] = 0
calendar[Calendar.MILLISECOND] = 0
return calendar.timeInMillis
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Context
import android.os.Build
import android.view.Display
import android.view.WindowManager
import androidx.core.content.getSystemService
val Context.displayCompat: Display?
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
display
} else {
@Suppress("DEPRECATION")
getSystemService<WindowManager>()?.defaultDisplay
}

View File

@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.utils.ext
import android.os.Bundle
import android.os.Parcelable
import androidx.core.view.MenuProvider
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import java.io.Serializable
@@ -43,4 +45,8 @@ fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
if (!manager.isStateSaved) {
show(manager, tag)
}
}
fun Fragment.addMenuProvider(provider: MenuProvider) {
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED)
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.utils.ext
import android.graphics.Rect
import kotlin.math.roundToInt
fun Rect.scale(factor: Double) {
val newWidth = (width() * factor).roundToInt()
val newHeight = (height() * factor).roundToInt()
inset(
(width() - newWidth) / 2,
(height() - newHeight) / 2,
)
}

View File

@@ -17,4 +17,4 @@ fun Response.parseJsonOrNull(): JSONObject? {
} else {
parseJson()
}
}
}

View File

@@ -3,18 +3,10 @@ package org.koitharu.kotatsu.utils.ext
import android.view.View
import androidx.core.graphics.Insets
fun Insets.getStart(view: View): Int {
return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
right
} else {
left
}
fun Insets.end(view: View): Int {
return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) left else right
}
fun Insets.getEnd(view: View): Int {
return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
left
} else {
right
}
fun Insets.start(view: View): Int {
return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) right else left
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.utils.ext
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
internal val RecyclerView.LayoutManager?.firstVisibleItemPosition
get() = when (this) {
is LinearLayoutManager -> findFirstVisibleItemPosition()
is StaggeredGridLayoutManager -> findFirstVisibleItemPositions(null)[0]
else -> 0
}
internal val RecyclerView.LayoutManager?.isLayoutReversed
get() = when (this) {
is LinearLayoutManager -> reverseLayout
is StaggeredGridLayoutManager -> reverseLayout
else -> false
}

View File

@@ -2,20 +2,15 @@ package org.koitharu.kotatsu.utils.ext
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.liveData
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.utils.BufferedObserver
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.utils.BufferedObserver
fun <T> LiveData<T?>.observeNotNull(owner: LifecycleOwner, observer: Observer<T>) {
this.observe(owner) {
if (it != null) {
observer.onChanged(it)
}
}
fun <T> LiveData<T>.requireValue(): T = checkNotNull(value) {
"LiveData value is null"
}
fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver<T>) {
@@ -26,20 +21,14 @@ fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: Buffere
}
}
fun <T> Flow<T>.asLiveDataDistinct(
fun <T> StateFlow<T>.asLiveDataDistinct(
context: CoroutineContext = EmptyCoroutineContext
): LiveData<T> = liveData(context) {
collect {
if (it != latestValue) {
emit(it)
}
}
}
): LiveData<T> = asLiveDataDistinct(context, value)
fun <T> Flow<T>.asLiveDataDistinct(
context: CoroutineContext = EmptyCoroutineContext,
defaultValue: T
): LiveData<T> = liveData(context, 0L) {
): LiveData<T> = liveData(context) {
if (latestValue == null) {
emit(defaultValue)
}
@@ -48,18 +37,4 @@ fun <T> Flow<T>.asLiveDataDistinct(
emit(it)
}
}
}
fun <T> Flow<T>.asLiveDataDistinct(
context: CoroutineContext = EmptyCoroutineContext,
defaultValue: suspend () -> T
): LiveData<T> = liveData(context) {
if (latestValue == null) {
emit(defaultValue())
}
collect {
if (it != latestValue) {
emit(it)
}
}
}

View File

@@ -1,32 +0,0 @@
package org.koitharu.kotatsu.utils.ext
import androidx.core.os.LocaleListCompat
import java.util.*
fun LocaleListCompat.toList(): List<Locale> = createList(size()) { i -> get(i) }
operator fun LocaleListCompat.iterator() = object : Iterator<Locale> {
private var index = 0
override fun hasNext(): Boolean = index < size()
override fun next(): Locale = get(index++)
}
inline fun <R, C : MutableCollection<in R>> LocaleListCompat.mapTo(
destination: C,
block: (Locale) -> R,
): C {
val len = size()
for (i in 0 until len) {
val item = get(i)
destination.add(block(item))
}
return destination
}
inline fun <T> LocaleListCompat.map(block: (Locale) -> T): List<T> {
return mapTo(ArrayList(size()), block)
}
inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
return mapTo(LinkedHashSet(size()), block)
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.utils.ext
import androidx.core.os.LocaleListCompat
import java.util.*
operator fun LocaleListCompat.iterator(): ListIterator<Locale> = LocaleListCompatIterator(this)
fun LocaleListCompat.toList(): List<Locale> = List(size()) { i -> getOrThrow(i) }
inline fun <T> LocaleListCompat.map(block: (Locale) -> T): List<T> {
return List(size()) { i -> block(getOrThrow(i)) }
}
inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
return Set(size()) { i -> block(getOrThrow(i)) }
}
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {
private var index = 0
override fun hasNext() = index < list.size()
override fun hasPrevious() = index > 0
override fun next() = list.get(index++) ?: throw NoSuchElementException()
override fun nextIndex() = index
override fun previous() = list.get(--index) ?: throw NoSuchElementException()
override fun previousIndex() = index - 1
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.utils.ext
import android.icu.lang.UCharacter.GraphemeClusterBreak.T
@Suppress("UNCHECKED_CAST")
fun <T> Class<T>.castOrNull(obj: Any?): T? {
if (obj == null || !isInstance(obj)) {
return null
}
return obj as T
}

View File

@@ -1,11 +1,33 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Context
import android.content.res.Resources
import android.graphics.Color
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.core.graphics.alpha
import androidx.core.graphics.blue
import androidx.core.graphics.green
import androidx.core.graphics.red
import kotlin.math.roundToInt
@Px
fun Resources.resolveDp(dp: Int) = (dp * displayMetrics.density).roundToInt()
@Px
fun Resources.resolveDp(dp: Float) = dp * displayMetrics.density
fun Resources.resolveDp(dp: Float) = dp * displayMetrics.density
@ColorInt
fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int {
val typedArray = obtainStyledAttributes(intArrayOf(resource))
val color = typedArray.getColor(0, 0)
typedArray.recycle()
if (alphaFactor < 1f) {
val alpha = (color.alpha * alphaFactor).roundToInt()
return Color.argb(alpha, color.red, color.green, color.blue)
}
return color
}

View File

@@ -2,4 +2,13 @@ package org.koitharu.kotatsu.utils.ext
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String {
return if (this.isNullOrEmpty()) defaultValue() else this
}
fun String.longHashCode(): Long {
var h = 1125899906842597L
val len: Int = this.length
for (i in 0 until len) {
h = 31 * h + this[i].code
}
return h
}

View File

@@ -1,18 +1,24 @@
package org.koitharu.kotatsu.utils.ext
import android.content.ActivityNotFoundException
import android.content.res.Resources
import okio.FileNotFoundException
import org.acra.ACRA
import org.acra.ktx.sendWithAcra
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import java.io.FileNotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga
import java.net.SocketTimeoutException
fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
is ActivityNotFoundException,
is UnsupportedOperationException -> resources.getString(R.string.operation_not_supported)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is FileNotFoundException -> resources.getString(R.string.file_not_found)
@@ -20,4 +26,19 @@ fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is SocketTimeoutException -> resources.getString(R.string.network_error)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
else -> localizedMessage ?: resources.getString(R.string.error_occurred)
}
}
fun Throwable.isReportable(): Boolean {
if (this !is Exception) {
return true
}
return this is ParseException || this is IllegalArgumentException || this is IllegalStateException
}
fun Throwable.report(message: String?) {
CaughtException(this, message).sendWithAcra()
}
fun ACRA.setCurrentManga(manga: Manga?) = errorReporter.putCustomData("manga", manga?.publicUrl.toString())
private class CaughtException(cause: Throwable, override val message: String?) : RuntimeException(cause)

View File

@@ -3,7 +3,10 @@ package org.koitharu.kotatsu.utils.ext
import android.app.Activity
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager
import androidx.core.view.ViewCompat
import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -90,22 +93,6 @@ fun View.resetTransformations() {
rotationY = 0f
}
inline fun RecyclerView.doOnCurrentItemChanged(crossinline callback: (Int) -> Unit) {
addOnScrollListener(object : RecyclerView.OnScrollListener() {
private var lastItem = -1
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val item = recyclerView.findCenterViewPosition()
if (item != RecyclerView.NO_POSITION && item != lastItem) {
lastItem = item
callback(item)
}
}
})
}
fun RecyclerView.findCenterViewPosition(): Int {
val centerX = width / 2f
val centerY = height / 2f
@@ -138,4 +125,36 @@ val RecyclerView.isScrolledToTop: Boolean
}
val holder = findViewHolderForAdapterPosition(0)
return holder != null && holder.itemView.top >= 0
}
fun <T : View> ViewGroup.findViewsByType(clazz: Class<T>): Sequence<T> {
if (childCount == 0) {
return emptySequence()
}
return sequence {
for (view in children) {
if (clazz.isInstance(view)) {
yield(clazz.cast(view)!!)
} else if (view is ViewGroup && view.childCount != 0) {
yieldAll(view.findViewsByType(clazz))
}
}
}
}
fun RecyclerView.invalidateNestedItemDecorations() {
findViewsByType(RecyclerView::class.java).forEach {
it.invalidateItemDecorations()
}
}
internal val View.compatPaddingStart get() = ViewCompat.getPaddingStart(this)
val View.parents: Sequence<ViewParent>
get() = sequence {
var p: ViewParent? = parent
while (p != null) {
yield(p)
p = p.parent
}
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.utils.image
import android.content.Context
import android.graphics.drawable.Drawable
import android.text.Html
import coil.ImageLoader
import coil.executeBlocking
import coil.request.ImageRequest
class CoilImageGetter(
private val context: Context,
private val coil: ImageLoader,
) : Html.ImageGetter {
override fun getDrawable(source: String?): Drawable? {
return coil.executeBlocking(
ImageRequest.Builder(context)
.data(source)
.allowHardware(false)
.build()
).drawable?.apply {
setBounds(0, 0, intrinsicHeight, intrinsicHeight)
}
}
}

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.utils.image
import android.content.Context
import android.graphics.*
import android.graphics.drawable.Drawable
import androidx.core.graphics.ColorUtils
import com.google.android.material.color.MaterialColors
import kotlin.math.absoluteValue
class FaviconFallbackDrawable(
context: Context,
name: String,
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val letter = name.take(1).uppercase()
private val color = MaterialColors.harmonizeWithPrimary(context, colorOfString(name))
private val textBounds = Rect()
private val tempRect = Rect()
init {
paint.style = Paint.Style.FILL
paint.textAlign = Paint.Align.CENTER
paint.isFakeBoldText = true
}
override fun draw(canvas: Canvas) {
val cx = bounds.exactCenterX()
paint.color = color
canvas.drawPaint(paint)
paint.color = Color.WHITE
val ty = bounds.height() / 2f + textBounds.height() / 2f - textBounds.bottom
canvas.drawText(letter, cx, ty, paint)
}
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
val innerWidth = bounds.width() - (paint.strokeWidth * 2f)
paint.textSize = getTextSizeForWidth(innerWidth, "100%")
paint.getTextBounds(letter, 0, letter.length, textBounds)
invalidateSelf()
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity() = PixelFormat.TRANSPARENT
private fun getTextSizeForWidth(width: Float, text: String): Float {
val testTextSize = 48f
paint.textSize = testTextSize
paint.getTextBounds(text, 0, text.length, tempRect)
return testTextSize * width / tempRect.width()
}
private fun colorOfString(str: String): Int {
val hue = (str.hashCode() % 360).absoluteValue.toFloat()
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
}
}

View File

@@ -0,0 +1,96 @@
package org.koitharu.kotatsu.utils.image
import android.graphics.Bitmap
import androidx.core.graphics.get
import coil.size.Size
import coil.transform.Transformation
class TrimTransformation : Transformation {
override val cacheKey: String = javaClass.name
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
var left = 0
var top = 0
var right = 0
var bottom = 0
// Left
for (x in 0 until input.width) {
var isColBlank = true
val prevColor = input[x, 0]
for (y in 1 until input.height) {
if (input[x, y] != prevColor) {
isColBlank = false
break
}
}
if (isColBlank) {
left++
} else {
break
}
}
if (left == input.width) {
return input
}
// Right
for (x in (left until input.width).reversed()) {
var isColBlank = true
val prevColor = input[x, 0]
for (y in 1 until input.height) {
if (input[x, y] != prevColor) {
isColBlank = false
break
}
}
if (isColBlank) {
right++
} else {
break
}
}
// Top
for (y in 0 until input.height) {
var isRowBlank = true
val prevColor = input[0, y]
for (x in 1 until input.width) {
if (input[x, y] != prevColor) {
isRowBlank = false
break
}
}
if (isRowBlank) {
top++
} else {
break
}
}
// Bottom
for (y in (top until input.height).reversed()) {
var isRowBlank = true
val prevColor = input[0, y]
for (x in 1 until input.width) {
if (input[x, y] != prevColor) {
isRowBlank = false
break
}
}
if (isRowBlank) {
bottom++
} else {
break
}
}
return if (left != 0 || right != 0 || top != 0 || bottom != 0) {
Bitmap.createBitmap(input, left, top, input.width - left - right, input.height - top - bottom)
} else {
input
}
}
override fun equals(other: Any?) = other is TrimTransformation
override fun hashCode() = javaClass.hashCode()
}