Merge branch 'devel' into feature/shikimori

This commit is contained in:
Koitharu
2022-04-11 10:41:22 +03:00
505 changed files with 7390 additions and 9457 deletions

View File

@@ -1,87 +0,0 @@
package org.koitharu.kotatsu.utils
import android.app.DownloadManager
import android.app.DownloadManager.Request.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.ext.toFileNameSafe
import java.io.File
import kotlin.coroutines.resume
class DownloadManagerHelper(
private val context: Context,
private val cookieJar: CookieJar,
) {
private val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
private val subDir = context.getString(R.string.app_name).toFileNameSafe()
fun downloadPage(page: MangaPage, fullUrl: String): Long {
val uri = fullUrl.toUri()
val cookies = cookieJar.loadForRequest(fullUrl.toHttpUrl())
val dest = subDir + File.separator + uri.lastPathSegment
val request = DownloadManager.Request(uri)
.addRequestHeader(CommonHeaders.REFERER, page.referer)
.addRequestHeader(CommonHeaders.COOKIE, cookieHeader(cookies))
.setAllowedOverMetered(true)
.setAllowedNetworkTypes(NETWORK_WIFI or NETWORK_MOBILE)
.setNotificationVisibility(VISIBILITY_VISIBLE)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, dest)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
request.allowScanningByMediaScanner()
}
return manager.enqueue(request)
}
suspend fun awaitDownload(id: Long): Uri {
getUriForDownloadedFile(id)?.let { return it } // fast path
suspendCancellableCoroutine<Unit> { cont ->
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (
intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE &&
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) == id
) {
context.unregisterReceiver(this)
cont.resume(Unit)
}
}
}
context.registerReceiver(
receiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
)
cont.invokeOnCancellation {
context.unregisterReceiver(receiver)
}
}
return checkNotNull(getUriForDownloadedFile(id))
}
private suspend fun getUriForDownloadedFile(id: Long) = withContext(Dispatchers.IO) {
manager.getUriForDownloadedFile(id)
}
private fun cookieHeader(cookies: List<Cookie>): String = buildString {
cookies.forEachIndexed { index, cookie ->
if (index > 0) append("; ")
append(cookie.name).append('=').append(cookie.value)
}
}
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.utils
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
class ExternalStorageHelper(context: Context) {
private val contentResolver = context.contentResolver
suspend fun savePage(page: MangaPage, destination: Uri) {
val pageLoader = PageLoader()
val pageFile = pageLoader.loadPage(page, force = false)
runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.use { output ->
pageFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IOException("Output stream is null")
}
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.utils
import androidx.annotation.CheckResult
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
@@ -44,7 +43,6 @@ open class MutableZipFile(val file: File) {
dir.deleteRecursively()
}
@CheckResult
suspend fun flush(): Boolean = runInterruptible(Dispatchers.IO) {
val tempFile = File(file.path + ".tmp")
if (tempFile.exists()) {

View File

@@ -1,8 +0,0 @@
package org.koitharu.kotatsu.utils
import androidx.recyclerview.widget.RecyclerView
interface RecycledViewPoolHolder {
val recycledViewPool: RecyclerView.RecycledViewPool
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.utils
import androidx.annotation.Px
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import java.lang.ref.WeakReference
class RecyclerViewScrollCallback(
recyclerView: RecyclerView,
private val position: Int,
@Px private val offset: Int,
) : Runnable {
private val recyclerViewRef = WeakReference(recyclerView)
override fun run() {
val rv = recyclerViewRef.get() ?: return
val lm = rv.layoutManager ?: return
rv.stopScroll()
if (lm is LinearLayoutManager) {
lm.scrollToPositionWithOffset(position, offset)
} else {
lm.scrollToPosition(position)
}
}
}

View File

@@ -1,62 +1,82 @@
package org.koitharu.kotatsu.utils
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import java.io.File
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import java.io.File
import org.koitharu.kotatsu.parsers.model.Manga
private const val TYPE_TEXT = "text/plain"
private const val TYPE_IMAGE = "image/*"
private const val TYPE_CBZ = "application/x-cbz"
class ShareHelper(private val context: Context) {
fun shareMangaLink(manga: Manga) {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
intent.putExtra(Intent.EXTRA_TEXT, buildString {
val text = buildString {
append(manga.title)
append("\n \n")
append(manga.publicUrl)
})
val shareIntent =
Intent.createChooser(intent, context.getString(R.string.share_s, manga.title))
context.startActivity(shareIntent)
}
ShareCompat.IntentBuilder(context)
.setText(text)
.setType(TYPE_TEXT)
.setChooserTitle(context.getString(R.string.share_s, manga.title))
.startChooser()
}
fun shareCbz(file: File) {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
val intent = Intent(Intent.ACTION_SEND)
intent.setDataAndType(uri, context.contentResolver.getType(uri))
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val shareIntent =
Intent.createChooser(intent, context.getString(R.string.share_s, file.name))
context.startActivity(shareIntent)
fun shareMangaLinks(manga: Collection<Manga>) {
if (manga.isEmpty()) {
return
}
if (manga.size == 1) {
shareMangaLink(manga.first())
return
}
val text = manga.joinToString("\n \n") {
"${it.title} - ${it.publicUrl}"
}
ShareCompat.IntentBuilder(context)
.setText(text)
.setType(TYPE_TEXT)
.setChooserTitle(R.string.share)
.startChooser()
}
fun shareBackup(file: File) {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
val intent = Intent(Intent.ACTION_SEND)
intent.setDataAndType(uri, context.contentResolver.getType(uri))
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val shareIntent =
Intent.createChooser(intent, context.getString(R.string.share_s, file.name))
context.startActivity(shareIntent)
fun shareCbz(files: Collection<File>) {
if (files.isEmpty()) {
return
}
val intentBuilder = ShareCompat.IntentBuilder(context)
.setType(TYPE_CBZ)
for (file in files) {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
intentBuilder.addStream(uri)
}
files.singleOrNull()?.let {
intentBuilder.setChooserTitle(context.getString(R.string.share_s, it.name))
} ?: run {
intentBuilder.setChooserTitle(R.string.share)
}
intentBuilder.startChooser()
}
fun shareImage(uri: Uri) {
val intent = Intent(Intent.ACTION_SEND)
intent.setDataAndType(uri, context.contentResolver.getType(uri) ?: "image/*")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_image))
context.startActivity(shareIntent)
ShareCompat.IntentBuilder(context)
.setStream(uri)
.setType(context.contentResolver.getType(uri) ?: TYPE_IMAGE)
.setChooserTitle(R.string.share_image)
.startChooser()
}
fun shareText(text: String) {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
intent.putExtra(Intent.EXTRA_TEXT, text)
val shareIntent = Intent.createChooser(intent, context.getString(R.string.share))
context.startActivity(shareIntent)
ShareCompat.IntentBuilder(context)
.setText(text)
.setType(TYPE_TEXT)
.setChooserTitle(R.string.share)
.startChooser()
}
}

View File

@@ -4,22 +4,22 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.os.Bundle
import android.os.Parcelable
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.suspendCancellableCoroutine
import android.net.Uri
import androidx.work.CoroutineWorker
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
suspend fun ConnectivityManager.waitForNetwork(): Network {
val request = NetworkRequest.Builder().build()
return suspendCancellableCoroutine<Network> { cont ->
return suspendCancellableCoroutine { cont ->
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
cont.resume(network)
if (cont.isActive) {
cont.resume(network)
}
}
}
registerNetworkCallback(request, callback)
@@ -29,12 +29,9 @@ suspend fun ConnectivityManager.waitForNetwork(): Network {
}
}
inline fun buildAlertDialog(context: Context, block: MaterialAlertDialogBuilder.() -> Unit): AlertDialog {
return MaterialAlertDialogBuilder(context).apply(block).create()
}
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
fun <T : Parcelable> Bundle.requireParcelable(key: String): T {
return checkNotNull(getParcelable(key)) {
"Value for key $key not found"
}
}
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching {
val info = getForegroundInfo()
setForeground(info)
}.isSuccess

View File

@@ -1,79 +1,12 @@
package org.koitharu.kotatsu.utils.ext
import android.util.SparseArray
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import androidx.collection.LongSparseArray
import java.util.*
fun <T> MutableCollection<T>.replaceWith(subject: Iterable<T>) {
clear()
addAll(subject)
}
fun LongArray.toArraySet(): Set<Long> = createSet(size) { i -> this[i] }
fun <T> List<T>.medianOrNull(): T? = when {
isEmpty() -> null
else -> get((size / 2).coerceIn(indices))
}
inline fun <T, R> Collection<T>.mapToSet(transform: (T) -> R): Set<R> {
return mapTo(ArraySet(size), transform)
}
inline fun <T, R> Collection<T>.mapNotNullToSet(transform: (T) -> R?): Set<R> {
val destination = ArraySet<R>(size)
for (item in this) {
destination.add(transform(item) ?: continue)
}
return destination
}
fun LongArray.toArraySet(): Set<Long> {
return when (size) {
0 -> emptySet()
1 -> setOf(this[0])
else -> ArraySet<Long>(size).also { set ->
for (item in this) {
set.add(item)
}
}
}
}
fun <K, V> List<Pair<K, V>>.toMutableMap(): MutableMap<K, V> = toMap(ArrayMap(size))
inline fun <T> Collection<T>.associateByLong(selector: (T) -> Long): LongSparseArray<T> {
val result = LongSparseArray<T>(size)
for (item in this) {
result.put(selector(item), item)
}
return result
}
inline fun <T, reified R> Array<T>.mapToArray(transform: (T) -> R): Array<R> = Array(size) { i ->
transform(get(i))
}
fun <T : Enum<T>> Array<T>.names() = mapToArray { it.name }
fun <T> Collection<T>.isDistinct(): Boolean {
val set = HashSet<T>(size)
for (item in this) {
if (!set.add(item)) {
return false
}
}
return set.size == size
}
fun <T, K> Collection<T>.isDistinctBy(selector: (T) -> K): Boolean {
val set = HashSet<K>(size)
for (item in this) {
if (!set.add(selector(item))) {
return false
}
}
return set.size == size
fun <T : Enum<T>> Array<T>.names() = Array(size) { i ->
this[i].name
}
fun <T> MutableList<T>.move(sourceIndex: Int, targetIndex: Int) {
@@ -82,4 +15,29 @@ fun <T> MutableList<T>.move(sourceIndex: Int, targetIndex: Int) {
} else {
Collections.rotate(subList(targetIndex, sourceIndex + 1), 1)
}
}
@Suppress("FunctionName")
inline fun <T> MutableSet(size: Int, init: (index: Int) -> T): MutableSet<T> {
val set = ArraySet<T>(size)
repeat(size) { index -> set.add(init(index)) }
return set
}
inline fun <T> createSet(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)
}

View File

@@ -2,11 +2,15 @@ package org.koitharu.kotatsu.utils.ext
import android.content.res.Resources
import android.util.Log
import kotlinx.coroutines.delay
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.*
import java.io.FileNotFoundException
import java.net.SocketTimeoutException
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 org.koitharu.kotatsu.parsers.util.format
fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)

View File

@@ -1,37 +0,0 @@
package org.koitharu.kotatsu.utils.ext
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
private const val SCHEME_HTTPS = "https"
fun CookieJar.insertCookies(domain: String, vararg cookies: String) {
val url = HttpUrl.Builder()
.scheme(SCHEME_HTTPS)
.host(domain)
.build()
saveFromResponse(url, cookies.mapNotNull {
Cookie.parse(url, it)
})
}
fun CookieJar.getCookies(domain: String): List<Cookie> {
val url = HttpUrl.Builder()
.scheme(SCHEME_HTTPS)
.host(domain)
.build()
return loadForRequest(url)
}
fun CookieJar.copyCookies(oldDomain: String, newDomain: String, names: Array<String>? = null) {
val url = HttpUrl.Builder()
.scheme(SCHEME_HTTPS)
.host(oldDomain)
var cookies = loadForRequest(url.build())
if (names != null) {
cookies = cookies.filter { c -> c.name in names }
}
url.host(newDomain)
saveFromResponse(url.build(), cookies)
}

View File

@@ -9,10 +9,6 @@ import java.util.concurrent.TimeUnit
@SuppressLint("SimpleDateFormat")
fun Date.format(pattern: String): String = SimpleDateFormat(pattern).format(this)
fun Date.calendar(): Calendar = Calendar.getInstance().also {
it.time = this
}
fun Date.formatRelative(minResolution: Long): CharSequence = DateUtils.getRelativeTimeSpanString(
time, System.currentTimeMillis(), minResolution
)

View File

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.utils.ext
import android.os.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
var isFirstCall = true
@@ -19,8 +21,17 @@ inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<
return map { list -> list.map(transform) }
}
inline fun <T> Flow<T?>.filterNotNull(
crossinline predicate: suspend (T) -> Boolean,
): Flow<T> = transform { value ->
if (value != null && predicate(value)) return@transform emit(value)
fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
var lastEmittedAt = 0L
return transformLatest { value ->
val delay = timeoutMillis(value)
val now = SystemClock.elapsedRealtime()
if (delay > 0L) {
if (lastEmittedAt + delay < now) {
delay(lastEmittedAt + delay - now)
}
}
emit(value)
lastEmittedAt = now
}
}

View File

@@ -1,12 +1,12 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.LifecycleOwner
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.coroutineScope
import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection
import java.io.Serializable
inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val b = Bundle(size)
@@ -18,8 +18,7 @@ inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val Fragment.viewLifecycleScope
inline get() = viewLifecycleOwner.lifecycle.coroutineScope
@Suppress("NOTHING_TO_INLINE")
inline fun <T : Parcelable> Fragment.parcelableArgument(name: String): Lazy<T> {
fun <T : Parcelable> Fragment.parcelableArgument(name: String): Lazy<T> {
return lazy(LazyThreadSafetyMode.NONE) {
requireNotNull(arguments?.getParcelable(name)) {
"No argument $name passed into ${javaClass.simpleName}"
@@ -27,13 +26,20 @@ inline fun <T : Parcelable> Fragment.parcelableArgument(name: String): Lazy<T> {
}
}
@Suppress("NOTHING_TO_INLINE")
inline fun Fragment.stringArgument(name: String) = lazy(LazyThreadSafetyMode.NONE) {
inline fun <reified T : Serializable> Fragment.serializableArgument(name: String): Lazy<T> {
return lazy(LazyThreadSafetyMode.NONE) {
requireNotNull(arguments?.getSerializable(name) as? T) {
"No argument $name passed into ${javaClass.simpleName}"
}
}
}
fun Fragment.stringArgument(name: String) = lazy(LazyThreadSafetyMode.NONE) {
arguments?.getString(name)
}
fun Fragment.bindService(
lifecycleOwner: LifecycleOwner,
service: Intent,
flags: Int,
) = LifecycleAwareServiceConnection.bindService(requireActivity(), lifecycleOwner, service, flags)
fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
if (!manager.isStateSaved) {
show(manager, tag)
}
}

View File

@@ -1,35 +0,0 @@
package org.koitharu.kotatsu.utils.ext
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import org.koitharu.kotatsu.core.network.CommonHeaders
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
suspend fun Call.await() = suspendCancellableCoroutine<Response> { cont ->
this.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (cont.isActive) {
cont.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
if (cont.isActive) {
cont.resume(response)
}
}
})
cont.invokeOnCancellation {
this.cancel()
}
}
val Response.mimeType: String?
get() = body?.contentType()?.run { "$type/$subtype" }
val Response.contentDisposition: String?
get() = header(CommonHeaders.CONTENT_DISPOSITION)

View File

@@ -0,0 +1,20 @@
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.getEnd(view: View): Int {
return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
left
} else {
right
}
}

View File

@@ -1,21 +0,0 @@
package org.koitharu.kotatsu.utils.ext
fun <T> Iterator<T>.nextOrNull(): T? = if (hasNext()) next() else null
fun <T> Iterator<T>.toList(): List<T> {
if (!hasNext()) {
return emptyList()
}
val list = ArrayList<T>()
while (hasNext()) list += next()
return list
}
fun <T> Iterator<T>.toSet(): Set<T> {
if (!hasNext()) {
return emptySet()
}
val list = LinkedHashSet<T>()
while (hasNext()) list += next()
return list
}

View File

@@ -1,99 +0,0 @@
package org.koitharu.kotatsu.utils.ext
import androidx.collection.ArraySet
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.utils.json.JSONIterator
import org.koitharu.kotatsu.utils.json.JSONStringIterator
import org.koitharu.kotatsu.utils.json.JSONValuesIterator
import kotlin.contracts.contract
inline fun <R, C : MutableCollection<in R>> JSONArray.mapTo(
destination: C,
block: (JSONObject) -> R
): C {
val len = length()
for (i in 0 until len) {
val jo = getJSONObject(i)
destination.add(block(jo))
}
return destination
}
inline fun <R, C : MutableCollection<in R>> JSONArray.mapNotNullTo(
destination: C,
block: (JSONObject) -> R?
): C {
val len = length()
for (i in 0 until len) {
val jo = getJSONObject(i)
destination.add(block(jo) ?: continue)
}
return destination
}
inline fun <T> JSONArray.map(block: (JSONObject) -> T): List<T> {
return mapTo(ArrayList(length()), block)
}
inline fun <T> JSONArray.mapNotNull(block: (JSONObject) -> T?): List<T> {
return mapNotNullTo(ArrayList(length()), block)
}
fun <T> JSONArray.mapIndexed(block: (Int, JSONObject) -> T): List<T> {
val len = length()
val result = ArrayList<T>(len)
for (i in 0 until len) {
val jo = getJSONObject(i)
result.add(block(i, jo))
}
return result
}
fun JSONObject.getStringOrNull(name: String): String? = opt(name)?.takeUnless {
it === JSONObject.NULL
}?.toString()?.takeUnless {
it.isEmpty()
}
fun JSONObject.getBooleanOrDefault(name: String, defaultValue: Boolean): Boolean = opt(name)?.takeUnless {
it === JSONObject.NULL
} as? Boolean ?: defaultValue
fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long = opt(name)?.takeUnless {
it === JSONObject.NULL
} as? Long ?: defaultValue
operator fun JSONArray.iterator(): Iterator<JSONObject> = JSONIterator(this)
fun JSONArray.stringIterator(): Iterator<String> = JSONStringIterator(this)
fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
val len = length()
val result = ArraySet<T>(len)
for (i in 0 until len) {
val jo = getJSONObject(i)
result.add(block(jo))
}
return result
}
fun JSONObject.values(): Iterator<Any> = JSONValuesIterator(this)
fun JSONArray.associateByKey(key: String): Map<String, JSONObject> {
val destination = LinkedHashMap<String, JSONObject>(length())
repeat(length()) { i ->
val item = getJSONObject(i)
val keyValue = item.getString(key)
destination[keyValue] = item
}
return destination
}
fun JSONArray?.isNullOrEmpty(): Boolean {
contract {
returns(false) implies (this@isNullOrEmpty != null)
}
return this == null || this.length() == 0
}

View File

@@ -1,19 +0,0 @@
package org.koitharu.kotatsu.utils.ext
import android.content.ComponentCallbacks
import org.koin.android.ext.android.get
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.qualifier.named
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository
@Suppress("NOTHING_TO_INLINE")
inline fun ComponentCallbacks.mangaRepositoryOf(source: MangaSource): MangaRepository {
return get(named(source))
}
@Suppress("NOTHING_TO_INLINE")
inline fun KoinComponent.mangaRepositoryOf(source: MangaSource): MangaRepository {
return get(named(source))
}

View File

@@ -17,16 +17,6 @@ fun <T> LiveData<T?>.observeNotNull(owner: LifecycleOwner, observer: Observer<T>
}
}
fun <T> LiveData<T>.observeDistinct(owner: LifecycleOwner, observer: Observer<T>) {
var previousValue: T? = null
this.observe(owner) {
if (it != previousValue) {
observer.onChanged(it)
previousValue = it
}
}
}
fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver<T>) {
var previous: T? = null
this.observe(owner) {

View File

@@ -3,13 +3,7 @@ package org.koitharu.kotatsu.utils.ext
import androidx.core.os.LocaleListCompat
import java.util.*
fun LocaleListCompat.toList(): List<Locale> {
val list = ArrayList<Locale>(size())
for (i in 0 until size()) {
list += get(i)
}
return list
}
fun LocaleListCompat.toList(): List<Locale> = createList(size()) { i -> get(i) }
operator fun LocaleListCompat.iterator() = object : Iterator<Locale> {
private var index = 0

View File

@@ -1,109 +0,0 @@
package org.koitharu.kotatsu.utils.ext
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.json.JSONArray
import org.json.JSONObject
import org.jsoup.Jsoup
import org.jsoup.internal.StringUtil
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.select.Elements
import java.text.DateFormat
fun Response.parseHtml(): Document {
try {
(body?.byteStream() ?: throw NullPointerException("Response body is null")).use { stream ->
val charset = body!!.contentType()?.charset()?.name()
return Jsoup.parse(
stream,
charset,
request.url.toString()
)
}
} finally {
closeQuietly()
}
}
fun Response.parseJson(): JSONObject {
try {
val string = body?.string() ?: throw NullPointerException("Response body is null")
return JSONObject(string)
} finally {
closeQuietly()
}
}
fun Response.parseJsonArray(): JSONArray {
try {
val string = body?.string() ?: throw NullPointerException("Response body is null")
return JSONArray(string)
} finally {
closeQuietly()
}
}
inline fun Elements.findOwnText(predicate: (String) -> Boolean): String? {
for (x in this) {
val ownText = x.ownText()
if (predicate(ownText)) {
return ownText
}
}
return null
}
inline fun Elements.findText(predicate: (String) -> Boolean): String? {
for (x in this) {
val text = x.text()
if (predicate(text)) {
return text
}
}
return null
}
fun String.inContextOf(node: Node): String {
return if (this.isEmpty()) {
""
} else {
StringUtil.resolve(node.baseUri(), this)
}
}
fun String.toRelativeUrl(domain: String): String {
if (isEmpty() || startsWith("/")) {
return this
}
return replace(Regex("^[^/]{2,6}://${Regex.escape(domain)}+/", RegexOption.IGNORE_CASE), "/")
}
fun Element.relUrl(attributeKey: String): String {
val attr = attr(attributeKey).trim()
if (attr.isEmpty()) {
return ""
}
if (attr.startsWith("/")) {
return attr
}
val baseUrl = REGEX_URL_BASE.find(baseUri())?.value ?: return attr
return attr.removePrefix(baseUrl.dropLast(1))
}
private val REGEX_URL_BASE = Regex("^[^/]{2,6}://[^/]+/", RegexOption.IGNORE_CASE)
fun Element.css(property: String): String? {
val regex = Regex("${Regex.escape(property)}\\s*:\\s*[^;]+")
val css = attr("style").find(regex) ?: return null
return css.substringAfter(':').removeSuffix(';').trim()
}
fun DateFormat.tryParse(str: String?): Long = if (str.isNullOrEmpty()) {
0L
} else {
runCatching {
parse(str)?.time ?: 0L
}.getOrDefault(0L)
}

View File

@@ -1,9 +1,25 @@
package org.koitharu.kotatsu.utils.ext
import android.content.SharedPreferences
import androidx.preference.ListPreference
fun ListPreference.setDefaultValueCompat(defaultValue: String) {
if (value == null) {
value = defaultValue
}
}
fun <E: Enum<E>> SharedPreferences.getEnumValue(key: String, enumClass: Class<E>): E? {
val stringValue = getString(key, null) ?: return null
return enumClass.enumConstants?.find {
it.name == stringValue
}
}
fun <E: Enum<E>> SharedPreferences.getEnumValue(key: String, defaultValue: E): E {
return getEnumValue(key, defaultValue.javaClass) ?: defaultValue
}
fun <E: Enum<E>> SharedPreferences.Editor.putEnumValue(key: String, value: E?) {
putString(key, value?.name)
}

View File

@@ -1,43 +1,3 @@
package org.koitharu.kotatsu.utils.ext
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.*
fun Number.format(decimals: Int = 0, decPoint: Char = '.', thousandsSep: Char? = ' '): String {
val formatter = NumberFormat.getInstance(Locale.US) as DecimalFormat
val symbols = formatter.decimalFormatSymbols
if (thousandsSep != null) {
symbols.groupingSeparator = thousandsSep
formatter.isGroupingUsed = true
} else {
formatter.isGroupingUsed = false
}
symbols.decimalSeparator = decPoint
formatter.decimalFormatSymbols = symbols
formatter.minimumFractionDigits = decimals
formatter.maximumFractionDigits = decimals
return when (this) {
is Float,
is Double -> formatter.format(this.toDouble())
else -> formatter.format(this.toLong())
}
}
fun Float.toIntUp(): Int {
val intValue = toInt()
return if (this == intValue.toFloat()) {
intValue
} else {
intValue + 1
}
}
infix fun Int.upBy(step: Int): Int {
val mod = this % step
return if (mod == 0) {
this
} else {
this - mod + step
}
}
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.utils.ext
import android.os.Build
import android.widget.ProgressBar
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import com.google.android.material.progressindicator.BaseProgressIndicator
fun ProgressBar.setProgressCompat(progress: Int, animate: Boolean) = when {
this is BaseProgressIndicator<*> -> setProgressCompat(progress, animate)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> setProgress(progress, animate)
else -> setProgress(progress)
}
fun ProgressBar.showCompat() = when (this) {
is BaseProgressIndicator<*> -> show()
is ContentLoadingProgressBar -> show()
else -> isVisible = true
}
fun ProgressBar.hideCompat() = when (this) {
is BaseProgressIndicator<*> -> hide()
is ContentLoadingProgressBar -> hide()
else -> isVisible = false
}

View File

@@ -1,240 +1,5 @@
package org.koitharu.kotatsu.utils.ext
import android.net.Uri
import androidx.collection.arraySetOf
import java.math.BigInteger
import java.net.URLEncoder
import java.security.MessageDigest
import java.util.*
import kotlin.math.min
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
}
fun String.removeSurrounding(vararg chars: Char): String {
if (isEmpty()) {
return this
}
for (c in chars) {
if (first() == c && last() == c) {
return substring(1, length - 1)
}
}
return this
}
fun String.toCamelCase(): String {
if (isEmpty()) {
return this
}
val result = StringBuilder(length)
var capitalize = true
for (char in this) {
result.append(
if (capitalize) {
char.uppercase()
} else {
char.lowercase()
}
)
capitalize = char.isWhitespace()
}
return result.toString()
}
fun String.toTitleCase(): String {
return replaceFirstChar { x -> x.uppercase() }
}
fun String.toTitleCase(locale: Locale): String {
return replaceFirstChar { x -> x.uppercase(locale) }
}
fun String.transliterate(skipMissing: Boolean): String {
val cyr = charArrayOf(
'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п',
'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ъ', 'ы', 'ь', 'э', 'ю', 'я', 'ё', 'ў'
)
val lat = arrayOf(
"a", "b", "v", "g", "d", "e", "zh", "z", "i", "y", "k", "l", "m", "n", "o", "p",
"r", "s", "t", "u", "f", "h", "ts", "ch", "sh", "sch", "", "i", "", "e", "ju", "ja", "jo", "w"
)
return buildString(length + 5) {
for (c in this@transliterate) {
val p = cyr.binarySearch(c.lowercaseChar())
if (p in lat.indices) {
if (c.isUpperCase()) {
append(lat[p].uppercase())
} else {
append(lat[p])
}
} else if (!skipMissing) {
append(c)
}
}
}
}
fun String.toFileNameSafe() = this.transliterate(false)
.replace(Regex("[^a-z0-9_\\-]", arraySetOf(RegexOption.IGNORE_CASE)), " ")
.replace(Regex("\\s+"), "_")
fun String.ellipsize(maxLength: Int) = if (this.length > maxLength) {
this.take(maxLength - 1) + Typography.ellipsis
} else this
fun String.splitTwoParts(delimiter: Char): Pair<String, String>? {
val indices = ArrayList<Int>(4)
for ((i, c) in this.withIndex()) {
if (c == delimiter) {
indices += i
}
}
if (indices.isEmpty() || indices.size and 1 == 0) {
return null
}
val index = indices[indices.size / 2]
return substring(0, index) to substring(index + 1)
}
fun String.urlEncoded(): String = URLEncoder.encode(this, Charsets.UTF_8.name())
fun String.toUriOrNull(): Uri? = if (isEmpty()) {
null
} else {
Uri.parse(this)
}
fun ByteArray.byte2HexFormatted(): String {
val str = StringBuilder(size * 2)
for (i in indices) {
var h = Integer.toHexString(this[i].toInt())
val l = h.length
if (l == 1) {
h = "0$h"
}
if (l > 2) {
h = h.substring(l - 2, l)
}
str.append(h.uppercase(Locale.ROOT))
if (i < size - 1) {
str.append(':')
}
}
return str.toString()
}
fun String.md5(): String {
val md = MessageDigest.getInstance("MD5")
return BigInteger(1, md.digest(toByteArray()))
.toString(16)
.padStart(32, '0')
}
fun String.substringBetween(from: String, to: String, fallbackValue: String = this): String {
val fromIndex = indexOf(from)
if (fromIndex == -1) {
return fallbackValue
}
val toIndex = lastIndexOf(to)
return if (toIndex == -1) {
fallbackValue
} else {
substring(fromIndex + from.length, toIndex)
}
}
fun String.substringBetweenFirst(from: String, to: String): String? {
val fromIndex = indexOf(from)
if (fromIndex == -1) {
return null
}
val toIndex = indexOf(to, fromIndex)
return if (toIndex == -1) {
null
} else {
substring(fromIndex + from.length, toIndex)
}
}
fun String.substringBetweenLast(from: String, to: String, fallbackValue: String = this): String {
val fromIndex = lastIndexOf(from)
if (fromIndex == -1) {
return fallbackValue
}
val toIndex = lastIndexOf(to)
return if (toIndex == -1) {
fallbackValue
} else {
substring(fromIndex + from.length, toIndex)
}
}
fun String.find(regex: Regex) = regex.find(this)?.value
fun String.removeSuffix(suffix: Char): String {
if (lastOrNull() == suffix) {
return substring(0, length - 1)
}
return this
}
fun String.levenshteinDistance(other: String): Int {
if (this == other) {
return 0
}
if (this.isEmpty()) {
return other.length
}
if (other.isEmpty()) {
return this.length
}
val lhsLength = this.length + 1
val rhsLength = other.length + 1
var cost = Array(lhsLength) { it }
var newCost = Array(lhsLength) { 0 }
for (i in 1 until rhsLength) {
newCost[0] = i
for (j in 1 until lhsLength) {
val match = if (this[j - 1] == other[i - 1]) 0 else 1
val costReplace = cost[j - 1] + match
val costInsert = cost[j] + 1
val costDelete = newCost[j - 1] + 1
newCost[j] = min(min(costInsert, costDelete), costReplace)
}
val swap = cost
cost = newCost
newCost = swap
}
return cost[lhsLength - 1]
}
inline fun <T> Appendable.appendAll(
items: Iterable<T>,
separator: CharSequence,
transform: (T) -> CharSequence = { it.toString() },
) {
var isFirst = true
for (item in items) {
if (isFirst) {
isFirst = false
} else {
append(separator)
}
append(transform(item))
}
}
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String {
return if (this.isNullOrEmpty()) defaultValue() else this
}

View File

@@ -3,7 +3,9 @@ package org.koitharu.kotatsu.utils.ext
import android.graphics.drawable.Drawable
import android.view.View
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import androidx.core.content.res.use
import androidx.core.view.isGone
var TextView.textAndVisible: CharSequence?
@@ -35,4 +37,11 @@ fun TextView.setTextAndVisible(@StringRes textResId: Int) {
setText(textResId)
isGone = text.isNullOrEmpty()
}
}
fun TextView.setTextColorAttr(@AttrRes attrResId: Int) {
val colors = context.obtainStyledAttributes(intArrayOf(attrResId)).use {
it.getColorStateList(0)
}
setTextColor(colors)
}

View File

@@ -5,19 +5,15 @@ import android.graphics.Rect
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.annotation.LayoutRes
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.Slider
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
import kotlin.math.roundToInt
@@ -65,23 +61,6 @@ inline fun View.showPopupMenu(
menu.show()
}
fun ViewGroup.hitTest(x: Int, y: Int): Set<View> {
val result = HashSet<View>(4)
val rect = Rect()
for (child in children) {
if (child.isVisible && child.getGlobalVisibleRect(rect)) {
if (rect.contains(x, y)) {
if (child is ViewGroup) {
result += child.hitTest(x, y)
} else {
result += child
}
}
}
}
return result
}
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
if (visibility != View.VISIBLE) {
return false
@@ -91,14 +70,6 @@ fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
return rect.contains(x, y)
}
fun DrawerLayout.toggleDrawer(gravity: Int) {
if (isDrawerOpen(gravity)) {
closeDrawer(gravity)
} else {
openDrawer(gravity)
}
}
fun View.measureHeight(): Int {
val vh = height
return if (vh == 0) {
@@ -166,46 +137,16 @@ inline fun <reified T> RecyclerView.ViewHolder.getItem(): T? {
return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T)
}
@Deprecated("Useless")
fun BaseProgressIndicator<*>.setIndeterminateCompat(indeterminate: Boolean) {
if (isIndeterminate != indeterminate) {
if (indeterminate && visibility == View.VISIBLE) {
visibility = View.INVISIBLE
isIndeterminate = indeterminate
visibility = View.VISIBLE
} else {
isIndeterminate = indeterminate
}
}
}
fun resolveAdjustedSize(
desiredSize: Int,
maxSize: Int,
measureSpec: Int,
): Int {
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
return when (specMode) {
MeasureSpec.UNSPECIFIED ->
// Parent says we can be as big as we want. Just don't be larger
// than max size imposed on ourselves.
desiredSize.coerceAtMost(maxSize)
MeasureSpec.AT_MOST ->
// Parent says we can be as big as we want, up to specSize.
// Don't be larger than specSize, and don't be larger than
// the max size imposed on ourselves.
desiredSize.coerceAtMost(specSize).coerceAtMost(maxSize)
MeasureSpec.EXACTLY ->
// No choice. Do what we are told.
specSize
else ->
// This should not happen
desiredSize
}
}
fun Slider.setValueRounded(newValue: Float) {
val step = stepSize
value = (newValue / step).roundToInt() * step
}
}
val RecyclerView.isScrolledToTop: Boolean
get() {
if (childCount == 0) {
return true
}
val holder = findViewHolderForAdapterPosition(0)
return holder != null && holder.itemView.top >= 0
}

View File

@@ -1,14 +0,0 @@
package org.koitharu.kotatsu.utils.json
import org.json.JSONArray
import org.json.JSONObject
class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
private val total = array.length()
private var index = 0
override fun hasNext() = index < total
override fun next(): JSONObject = array.getJSONObject(index++)
}

View File

@@ -1,13 +0,0 @@
package org.koitharu.kotatsu.utils.json
import org.json.JSONArray
class JSONStringIterator(private val array: JSONArray) : Iterator<String> {
private val total = array.length()
private var index = 0
override fun hasNext() = index < total
override fun next(): String = array.getString(index++)
}

View File

@@ -1,17 +0,0 @@
package org.koitharu.kotatsu.utils.json
import org.json.JSONObject
class JSONValuesIterator(
private val jo: JSONObject,
): Iterator<Any> {
private val keyIterator = jo.keys()
override fun hasNext(): Boolean = keyIterator.hasNext()
override fun next(): Any {
val key = keyIterator.next()
return jo.get(key)
}
}