lots of background work for manga extensions
This commit is contained in:
parent
dbe573131e
commit
57a584a820
123 changed files with 2676 additions and 553 deletions
|
@ -0,0 +1,26 @@
|
|||
package eu.kanade.tachiyomi.util
|
||||
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
fun Element.selectText(css: String, defaultValue: String? = null): String? {
|
||||
return select(css).first()?.text() ?: defaultValue
|
||||
}
|
||||
|
||||
fun Element.selectInt(css: String, defaultValue: Int = 0): Int {
|
||||
return select(css).first()?.text()?.toInt() ?: defaultValue
|
||||
}
|
||||
|
||||
fun Element.attrOrText(css: String): String {
|
||||
return if (css != "text") attr(css) else text()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Jsoup document for this response.
|
||||
* @param html the body of the response. Use only if the body was read before calling this method.
|
||||
*/
|
||||
fun Response.asJsoup(html: String? = null): Document {
|
||||
return Jsoup.parse(html ?: body.string(), request.url.toString())
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package eu.kanade.tachiyomi.util
|
||||
|
||||
//expect suspend fun <T> Observable<T>.awaitSingle(): T
|
|
@ -0,0 +1,31 @@
|
|||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import java.io.Closeable
|
||||
|
||||
/**
|
||||
* Executes the given block function on this resources and then closes it down correctly whether an exception is
|
||||
* thrown or not.
|
||||
*
|
||||
* @param block a function to process with given Closeable resources.
|
||||
* @return the result of block function invoked on this resource.
|
||||
*/
|
||||
inline fun <T : Closeable?> Array<T>.use(block: () -> Unit) {
|
||||
var blockException: Throwable? = null
|
||||
try {
|
||||
return block()
|
||||
} catch (e: Throwable) {
|
||||
blockException = e
|
||||
throw e
|
||||
} finally {
|
||||
when (blockException) {
|
||||
null -> forEach { it?.close() }
|
||||
else -> forEach {
|
||||
try {
|
||||
it?.close()
|
||||
} catch (closeException: Throwable) {
|
||||
blockException.addSuppressed(closeException)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
44
app/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt
Normal file
44
app/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt
Normal file
|
@ -0,0 +1,44 @@
|
|||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
object Hash {
|
||||
|
||||
private val chars = charArrayOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||
'a', 'b', 'c', 'd', 'e', 'f',
|
||||
)
|
||||
|
||||
private val MD5 get() = MessageDigest.getInstance("MD5")
|
||||
|
||||
private val SHA256 get() = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
fun sha256(bytes: ByteArray): String {
|
||||
return encodeHex(SHA256.digest(bytes))
|
||||
}
|
||||
|
||||
fun sha256(string: String): String {
|
||||
return sha256(string.toByteArray())
|
||||
}
|
||||
|
||||
fun md5(bytes: ByteArray): String {
|
||||
return encodeHex(MD5.digest(bytes))
|
||||
}
|
||||
|
||||
fun md5(string: String): String {
|
||||
return md5(string.toByteArray())
|
||||
}
|
||||
|
||||
private fun encodeHex(data: ByteArray): String {
|
||||
val l = data.size
|
||||
val out = CharArray(l shl 1)
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i < l) {
|
||||
out[j++] = chars[(240 and data[i].toInt()).ushr(4)]
|
||||
out[j++] = chars[15 and data[i].toInt()]
|
||||
i++
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.InternalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import rx.Observable
|
||||
import rx.Subscriber
|
||||
import rx.Subscription
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/*
|
||||
* Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY.
|
||||
*/
|
||||
|
||||
suspend fun <T> Observable<T>.awaitSingle(): T = single().awaitOne()
|
||||
|
||||
@OptIn(InternalCoroutinesApi::class)
|
||||
private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutine { cont ->
|
||||
cont.unsubscribeOnCancellation(
|
||||
subscribe(
|
||||
object : Subscriber<T>() {
|
||||
override fun onStart() {
|
||||
request(1)
|
||||
}
|
||||
|
||||
override fun onNext(t: T) {
|
||||
cont.resume(t)
|
||||
}
|
||||
|
||||
override fun onCompleted() {
|
||||
if (cont.isActive) {
|
||||
cont.resumeWithException(
|
||||
IllegalStateException(
|
||||
"Should have invoked onNext",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
/*
|
||||
* Rx1 observable throws NoSuchElementException if cancellation happened before
|
||||
* element emission. To mitigate this we try to atomically resume continuation with exception:
|
||||
* if resume failed, then we know that continuation successfully cancelled itself
|
||||
*/
|
||||
val token = cont.tryResumeWithException(e)
|
||||
if (token != null) {
|
||||
cont.completeResume(token)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> CancellableContinuation<T>.unsubscribeOnCancellation(sub: Subscription) =
|
||||
invokeOnCancellation { sub.unsubscribe() }
|
|
@ -0,0 +1,67 @@
|
|||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import androidx.core.text.parseAsHtml
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* Replaces the given string to have at most [count] characters using [replacement] at its end.
|
||||
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
|
||||
*/
|
||||
fun String.chop(count: Int, replacement: String = "…"): String {
|
||||
return if (length > count) {
|
||||
take(count - replacement.length) + replacement
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the given string to have at most [count] characters using [replacement] near the center.
|
||||
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
|
||||
*/
|
||||
fun String.truncateCenter(count: Int, replacement: String = "..."): String {
|
||||
if (length <= count) {
|
||||
return this
|
||||
}
|
||||
|
||||
val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt()
|
||||
|
||||
return "${take(pieceLength)}$replacement${takeLast(pieceLength)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive natural comparator for strings.
|
||||
*/
|
||||
fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return comparator.compare(this, other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the string as the number of bytes.
|
||||
*/
|
||||
fun String.byteSize(): Int {
|
||||
return toByteArray(StandardCharsets.UTF_8).size
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string containing the first [n] bytes from this string, or the entire string if this
|
||||
* string is shorter.
|
||||
*/
|
||||
fun String.takeBytes(n: Int): String {
|
||||
val bytes = toByteArray(StandardCharsets.UTF_8)
|
||||
return if (bytes.size <= n) {
|
||||
this
|
||||
} else {
|
||||
bytes.decodeToString(endIndex = n).replace("\uFFFD", "")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-decode the string
|
||||
*/
|
||||
fun String.htmlDecode(): String {
|
||||
return this.parseAsHtml().toString()
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package eu.kanade.tachiyomi.util.preference
|
||||
|
||||
import android.widget.CompoundButton
|
||||
import eu.kanade.core.preference.PreferenceMutableState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import tachiyomi.core.preference.Preference
|
||||
|
||||
/**
|
||||
* Binds a checkbox or switch view with a boolean preference.
|
||||
*/
|
||||
fun CompoundButton.bindToPreference(pref: Preference<Boolean>) {
|
||||
isChecked = pref.get()
|
||||
setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) }
|
||||
}
|
||||
|
||||
operator fun <T> Preference<Set<T>>.plusAssign(item: T) {
|
||||
set(get() + item)
|
||||
}
|
||||
|
||||
operator fun <T> Preference<Set<T>>.minusAssign(item: T) {
|
||||
set(get() - item)
|
||||
}
|
||||
|
||||
fun Preference<Boolean>.toggle(): Boolean {
|
||||
set(!get())
|
||||
return get()
|
||||
}
|
||||
|
||||
fun <T> Preference<T>.asState(presenterScope: CoroutineScope): PreferenceMutableState<T> {
|
||||
return PreferenceMutableState(this, presenterScope)
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package eu.kanade.tachiyomi.util.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import java.io.File
|
||||
|
||||
val Context.cacheImageDir: File
|
||||
get() = File(cacheDir, "shared_image")
|
||||
|
||||
/**
|
||||
* Returns the uri of a file
|
||||
*
|
||||
* @param context context of application
|
||||
*/
|
||||
fun File.getUriCompat(context: Context): Uri {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(context, context.packageName + ".provider", this)
|
||||
} else {
|
||||
this.toUri()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.TypedValue
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.PermissionChecker
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.util.lang.truncateCenter
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import ani.dantotsu.toast
|
||||
import com.hippo.unifile.UniFile
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Copies a string to clipboard
|
||||
*
|
||||
* @param label Label to show to the user describing the content
|
||||
* @param content the actual text to copy to the board
|
||||
*/
|
||||
fun Context.copyToClipboard(label: String, content: String) {
|
||||
if (content.isBlank()) return
|
||||
|
||||
try {
|
||||
val clipboard = getSystemService<ClipboardManager>()!!
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, content))
|
||||
|
||||
// Android 13 and higher shows a visual confirmation of copied contents
|
||||
// https://developer.android.com/about/versions/13/features/copy-paste
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
toast("Copied to clipboard: " + content.truncateCenter(50))
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
toast("Failed to copy to clipboard")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the give permission is granted.
|
||||
*
|
||||
* @param permission the permission to check.
|
||||
* @return true if it has permissions.
|
||||
*/
|
||||
fun Context.hasPermission(permission: String) = PermissionChecker.checkSelfPermission(this, permission) == PermissionChecker.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Returns the color for the given attribute.
|
||||
*
|
||||
* @param resource the attribute.
|
||||
* @param alphaFactor the alpha number [0,1].
|
||||
*/
|
||||
@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
|
||||
}
|
||||
|
||||
@ColorInt fun Context.getThemeColor(attr: Int): Int {
|
||||
val tv = TypedValue()
|
||||
return if (this.theme.resolveAttribute(attr, tv, true)) {
|
||||
if (tv.resourceId != 0) {
|
||||
getColor(tv.resourceId)
|
||||
} else {
|
||||
tv.data
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
val Context.powerManager: PowerManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
/**
|
||||
* Convenience method to acquire a partial wake lock.
|
||||
*/
|
||||
fun Context.acquireWakeLock(tag: String): PowerManager.WakeLock {
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag:WakeLock")
|
||||
wakeLock.acquire()
|
||||
return wakeLock
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given service class is running.
|
||||
*/
|
||||
fun Context.isServiceRunning(serviceClass: Class<*>): Boolean {
|
||||
val className = serviceClass.name
|
||||
val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
@Suppress("DEPRECATION")
|
||||
return manager.getRunningServices(Integer.MAX_VALUE)
|
||||
.any { className == it.service.className }
|
||||
}
|
||||
|
||||
fun Context.openInBrowser(url: String, forceDefaultBrowser: Boolean = false) {
|
||||
this.openInBrowser(url.toUri(), forceDefaultBrowser)
|
||||
}
|
||||
|
||||
fun Context.openInBrowser(uri: Uri, forceDefaultBrowser: Boolean = false) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
// Force default browser so that verified extensions don't re-open Tachiyomi
|
||||
if (forceDefaultBrowser) {
|
||||
defaultBrowserPackageName()?.let { setPackage(it) }
|
||||
}
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
toast(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.defaultBrowserPackageName(): String? {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, "http://".toUri())
|
||||
val resolveInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.resolveActivity(browserIntent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
}
|
||||
return resolveInfo
|
||||
?.activityInfo?.packageName
|
||||
?.takeUnless { it in DeviceUtil.invalidDefaultBrowsers }
|
||||
}
|
||||
|
||||
fun Context.createFileInCacheDir(name: String): File {
|
||||
val file = File(externalCacheDir, name)
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
file.createNewFile()
|
||||
return file
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns true if [packageName] is installed.
|
||||
*/
|
||||
fun Context.isPackageInstalled(packageName: String): Boolean {
|
||||
return try {
|
||||
packageManager.getApplicationInfo(packageName, 0)
|
||||
true
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets document size of provided [Uri]
|
||||
*
|
||||
* @return document size of [uri] or null if size can't be obtained
|
||||
*/
|
||||
fun Context.getUriSize(uri: Uri): Long? {
|
||||
return UniFile.fromUri(this, uri).length().takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
|
||||
val Context.hasMiuiPackageInstaller get() = isPackageInstalled("com.miui.packageinstaller")
|
||||
|
||||
val Context.isShizukuInstalled get() = false
|
||||
|
||||
|
||||
|
||||
fun Context.getApplicationIcon(pkgName: String): Drawable? {
|
||||
return try {
|
||||
packageManager.getApplicationIcon(pkgName)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
|
||||
object DeviceUtil {
|
||||
|
||||
val isMiui by lazy {
|
||||
getSystemProperty("ro.miui.ui.version.name")?.isNotEmpty() ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the MIUI major version code from a string like "V12.5.3.0.QFGMIXM".
|
||||
*
|
||||
* @return MIUI major version code (e.g., 13) or null if can't be parsed.
|
||||
*/
|
||||
val miuiMajorVersion by lazy {
|
||||
if (!isMiui) return@lazy null
|
||||
|
||||
Build.VERSION.INCREMENTAL
|
||||
.substringBefore('.')
|
||||
.trimStart('V')
|
||||
.toIntOrNull()
|
||||
}
|
||||
|
||||
@SuppressLint("PrivateApi")
|
||||
fun isMiuiOptimizationDisabled(): Boolean {
|
||||
val sysProp = getSystemProperty("persist.sys.miui_optimization")
|
||||
if (sysProp == "0" || sysProp == "false") {
|
||||
return true
|
||||
}
|
||||
|
||||
return try {
|
||||
Class.forName("android.miui.AppOpsUtils")
|
||||
.getDeclaredMethod("isXOptMode")
|
||||
.invoke(null) as Boolean
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val isSamsung by lazy {
|
||||
Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
}
|
||||
|
||||
val oneUiVersion by lazy {
|
||||
try {
|
||||
val semPlatformIntField = Build.VERSION::class.java.getDeclaredField("SEM_PLATFORM_INT")
|
||||
val version = semPlatformIntField.getInt(null) - 90000
|
||||
if (version < 0) {
|
||||
1.0
|
||||
} else {
|
||||
((version / 10000).toString() + "." + version % 10000 / 100).toDouble()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val invalidDefaultBrowsers = listOf(
|
||||
"android",
|
||||
"com.huawei.android.internal.app",
|
||||
"com.zui.resolver",
|
||||
)
|
||||
|
||||
@SuppressLint("PrivateApi")
|
||||
private fun getSystemProperty(key: String?): String? {
|
||||
return try {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getDeclaredMethod("get", String::class.java)
|
||||
.invoke(null, key) as String
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.WARN, e) { "Unable to use SystemProperties.get()" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.IntentCompat
|
||||
import java.io.Serializable
|
||||
|
||||
fun Uri.toShareIntent(context: Context, type: String = "image/*", message: String? = null): Intent {
|
||||
val uri = this
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
when (uri.scheme) {
|
||||
"http", "https" -> {
|
||||
putExtra(Intent.EXTRA_TEXT, uri.toString())
|
||||
}
|
||||
"content" -> {
|
||||
message?.let { putExtra(Intent.EXTRA_TEXT, it) }
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
}
|
||||
}
|
||||
clipData = ClipData.newRawUri(null, uri)
|
||||
setType(type)
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
|
||||
return Intent.createChooser(shareIntent, "Share").apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> Intent.getParcelableExtraCompat(name: String): T? {
|
||||
return IntentCompat.getParcelableExtra(this, name, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(name: String): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getSerializableExtra(name, T::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getSerializableExtra(name) as? T
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Utility class to change the application's language in runtime.
|
||||
*/
|
||||
object LocaleHelper {
|
||||
|
||||
val comparator = compareBy<String>(
|
||||
{ getDisplayName(it) },
|
||||
{ it == "all" },
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns display name of a string language code.
|
||||
*/
|
||||
fun getSourceDisplayName(lang: String?, context: Context): String {
|
||||
return when (lang) {
|
||||
LAST_USED_KEY -> "Last used"
|
||||
PINNED_KEY -> "Pinned"
|
||||
"other" -> "Other"
|
||||
"all" -> "Multi"
|
||||
else -> getDisplayName(lang)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns display name of a string language code.
|
||||
*
|
||||
* @param lang empty for system language
|
||||
*/
|
||||
fun getDisplayName(lang: String?): String {
|
||||
if (lang == null) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val locale = when (lang) {
|
||||
"" -> LocaleListCompat.getAdjustedDefault()[0]
|
||||
"zh-CN" -> Locale.forLanguageTag("zh-Hans")
|
||||
"zh-TW" -> Locale.forLanguageTag("zh-Hant")
|
||||
else -> Locale.forLanguageTag(lang)
|
||||
}
|
||||
return locale!!.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default languages enabled for the sources.
|
||||
*/
|
||||
fun getDefaultEnabledLanguages(): Set<String> {
|
||||
return setOf("all", "en", Locale.getDefault().language)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return English display string from string language code
|
||||
*/
|
||||
fun getSimpleLocaleDisplayName(): String {
|
||||
val sp = Locale.getDefault().language.split("_", "-")
|
||||
return Locale(sp[0]).getDisplayLanguage(LocaleListCompat.getDefault()[0]!!)
|
||||
}
|
||||
}
|
||||
|
||||
internal const val PINNED_KEY = "pinned"
|
||||
internal const val LAST_USED_KEY = "last_used"
|
|
@ -0,0 +1,92 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationChannelGroupCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag
|
||||
import androidx.core.content.PermissionChecker
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
val Context.notificationManager: NotificationManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
fun Context.notify(id: Int, channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null) {
|
||||
val notification = notificationBuilder(channelId, block).build()
|
||||
this.notify(id, notification)
|
||||
}
|
||||
|
||||
fun Context.notify(id: Int, notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(this).notify(id, notification)
|
||||
}
|
||||
|
||||
fun Context.notify(notificationWithIdAndTags: List<NotificationWithIdAndTag>) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(this).notify(notificationWithIdAndTags)
|
||||
}
|
||||
|
||||
fun Context.cancelNotification(id: Int) {
|
||||
NotificationManagerCompat.from(this).cancel(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a notification builder.
|
||||
*
|
||||
* @param id the channel id.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification to be displayed or updated.
|
||||
*/
|
||||
fun Context.notificationBuilder(channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null): NotificationCompat.Builder {
|
||||
val builder = NotificationCompat.Builder(this, channelId)
|
||||
.setColor(getColor(android.R.color.holo_blue_dark))
|
||||
if (block != null) {
|
||||
builder.block()
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build a notification channel group.
|
||||
*
|
||||
* @param channelId the channel id.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification channel group to be displayed or updated.
|
||||
*/
|
||||
fun buildNotificationChannelGroup(
|
||||
channelId: String,
|
||||
block: (NotificationChannelGroupCompat.Builder.() -> Unit),
|
||||
): NotificationChannelGroupCompat {
|
||||
val builder = NotificationChannelGroupCompat.Builder(channelId)
|
||||
builder.block()
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build a notification channel.
|
||||
*
|
||||
* @param channelId the channel id.
|
||||
* @param channelImportance the channel importance.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification channel to be displayed or updated.
|
||||
*/
|
||||
fun buildNotificationChannel(
|
||||
channelId: String,
|
||||
channelImportance: Int,
|
||||
block: (NotificationChannelCompat.Builder.() -> Unit),
|
||||
): NotificationChannelCompat {
|
||||
val builder = NotificationChannelCompat.Builder(channelId, channelImportance)
|
||||
builder.block()
|
||||
return builder.build()
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
/**
|
||||
* Display a toast in this context.
|
||||
*
|
||||
* @param resource the text resource.
|
||||
* @param duration the duration of the toast. Defaults to short.
|
||||
*/
|
||||
fun Context.toast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast {
|
||||
return toast(getString(resource), duration, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a toast in this context.
|
||||
*
|
||||
* @param text the text to display.
|
||||
* @param duration the duration of the toast. Defaults to short.
|
||||
*/
|
||||
fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast {
|
||||
return Toast.makeText(applicationContext, text.orEmpty(), duration).also {
|
||||
block(it)
|
||||
it.show()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.os.Build
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
@Suppress("OverridingDeprecatedMember")
|
||||
abstract class WebViewClientCompat : WebViewClient() {
|
||||
|
||||
open fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
open fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? {
|
||||
return null
|
||||
}
|
||||
|
||||
open fun onReceivedErrorCompat(
|
||||
view: WebView,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String,
|
||||
isMainFrame: Boolean,
|
||||
) {
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.N)
|
||||
final override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
return shouldOverrideUrlCompat(view, request.url.toString())
|
||||
}
|
||||
|
||||
final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
return shouldOverrideUrlCompat(view, url)
|
||||
}
|
||||
|
||||
final override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): WebResourceResponse? {
|
||||
return shouldInterceptRequestCompat(view, request.url.toString())
|
||||
}
|
||||
|
||||
final override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
url: String,
|
||||
): WebResourceResponse? {
|
||||
return shouldInterceptRequestCompat(view, url)
|
||||
}
|
||||
|
||||
final override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
onReceivedErrorCompat(
|
||||
view,
|
||||
error.errorCode,
|
||||
error.description?.toString(),
|
||||
request.url.toString(),
|
||||
request.isForMainFrame,
|
||||
)
|
||||
}
|
||||
|
||||
final override fun onReceivedError(
|
||||
view: WebView,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String,
|
||||
) {
|
||||
onReceivedErrorCompat(view, errorCode, description, failingUrl, failingUrl == view.url)
|
||||
}
|
||||
|
||||
final override fun onReceivedHttpError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceResponse,
|
||||
) {
|
||||
onReceivedErrorCompat(
|
||||
view,
|
||||
error.statusCode,
|
||||
error.reasonPhrase,
|
||||
request.url
|
||||
.toString(),
|
||||
request.isForMainFrame,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.util.system.logcat
|
||||
|
||||
object WebViewUtil {
|
||||
const val SPOOF_PACKAGE_NAME = "org.chromium.chrome"
|
||||
|
||||
const val MINIMUM_WEBVIEW_VERSION = 108
|
||||
|
||||
fun supportsWebView(context: Context): Boolean {
|
||||
try {
|
||||
// May throw android.webkit.WebViewFactory$MissingWebViewPackageException if WebView
|
||||
// is not installed
|
||||
CookieManager.getInstance()
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
return false
|
||||
}
|
||||
|
||||
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_WEBVIEW)
|
||||
}
|
||||
}
|
||||
|
||||
fun WebView.isOutdated(): Boolean {
|
||||
return getWebViewMajorVersion() < WebViewUtil.MINIMUM_WEBVIEW_VERSION
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
fun WebView.setDefaultSettings() {
|
||||
with(settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
|
||||
// Allow zooming
|
||||
setSupportZoom(true)
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun WebView.getWebViewMajorVersion(): Int {
|
||||
val uaRegexMatch = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(getDefaultUserAgentString())
|
||||
return if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) {
|
||||
uaRegexMatch.groupValues[1].toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
// Based on https://stackoverflow.com/a/29218966
|
||||
private fun WebView.getDefaultUserAgentString(): String {
|
||||
val originalUA: String = settings.userAgentString
|
||||
|
||||
// Next call to getUserAgentString() will get us the default
|
||||
settings.userAgentString = null
|
||||
val defaultUserAgentString = settings.userAgentString
|
||||
|
||||
// Revert to original UA string
|
||||
settings.userAgentString = originalUA
|
||||
|
||||
return defaultUserAgentString
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue