Merge branch 'devel' into feature/shikimori
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
interface RecycledViewPoolHolder {
|
||||
|
||||
val recycledViewPool: RecyclerView.RecycledViewPool
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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++)
|
||||
}
|
||||
@@ -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++)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user