[新增] 合并了Sigma版本的一些更改(@Luoyacheng),具体合并了什么其实我也不知道,因为我头晕了

This commit is contained in:
HapeLee
2026-01-25 06:12:21 +08:00
parent e2959bb3b4
commit cfa63797c9
49 changed files with 4753 additions and 393 deletions
File diff suppressed because it is too large Load Diff
@@ -34,5 +34,6 @@ object EventBus {
const val EXPORT_BOOK = "exportBook"
const val UP_MANGA_CONFIG = "upMangaConfig"
const val PLAY_MODE_CHANGED = "playModeChanged"
const val REFRESH_BOOK_INFO = "refreshBookInfo"
const val UP_TOC = "upToc"
}
@@ -160,6 +160,7 @@ object PreferKey {
const val showBookshelfFastScroller = "showBookshelfFastScroller"
const val importKeepEnable = "importKeepEnable"
const val previewImageByClick = "previewImageByClick"
const val clickImgWay = "clickImgWay"
const val keyPageOnLongPress = "keyPageOnLongPress"
const val volumeKeyPage = "volumeKeyPage"
const val volumeKeyPageOnPlay = "volumeKeyPageOnPlay"
@@ -39,7 +39,6 @@ import io.legado.app.data.entities.Cookie
import io.legado.app.data.entities.DictRule
import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.entities.KeyboardAssist
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.entities.RssArticle
import io.legado.app.data.entities.RssReadRecord
@@ -50,6 +49,7 @@ import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.SearchKeyword
import io.legado.app.data.entities.Server
import io.legado.app.data.entities.TxtTocRule
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.help.DefaultData
@@ -67,7 +67,7 @@ val appDb by lazy {
}
@Database(
version = 80,
version = 81,
exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -113,7 +113,8 @@ val appDb by lazy {
AutoMigration(from = 76, to = 77),
AutoMigration(from = 77, to = 78),
AutoMigration(from = 78, to = 79),
AutoMigration(from = 79, to = 80)
AutoMigration(from = 79, to = 80),
AutoMigration(from = 80, to = 81)
]
)
abstract class AppDatabase : RoomDatabase() {
@@ -1,20 +1,27 @@
package io.legado.app.data.dao
import androidx.room.*
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import io.legado.app.data.entities.RssArticle
import kotlinx.coroutines.flow.Flow
@Dao
interface RssArticleDao {
@Query("select * from rssArticles where origin = :origin and link = :link and sort = :sort")
fun get(origin: String, link: String, sort: String): RssArticle?
@Query("select * from rssArticles where origin = :origin and link = :link")
fun get(origin: String, link: String): RssArticle?
fun getByLink(origin: String, link: String): RssArticle?
@Query(
"""select t1.link, t1.sort, t1.origin, t1.`order`, t1.title, t1.content,
t1.description, t1.image, t1.`group`, t1.pubDate, t1.variable, ifNull(t2.read, 0) as read
t1.description, t1.image, t1.`group`, t1.pubDate, t1.variable, t1.type, t1.durPos, ifNull(t2.read, 0) as read
from rssArticles as t1 left join rssReadRecords as t2
on t1.link = t2.record where origin = :origin and sort = :sort
on t1.link = t2.record where t1.origin = :origin and t1.sort = :sort
order by `order` desc"""
)
fun flowByOriginSort(origin: String, sort: String): Flow<List<RssArticle>>
@@ -1,5 +1,6 @@
package io.legado.app.data.entities
import android.webkit.JavascriptInterface
import cn.hutool.crypto.symmetric.AES
import com.script.ScriptBindings
import com.script.buildScriptBindings
@@ -56,7 +57,7 @@ interface BaseSource : JsExtensions {
*/
var jsLib: String?
fun getTag(): String
override fun getTag(): String
fun getKey(): String
@@ -64,6 +65,32 @@ interface BaseSource : JsExtensions {
return this
}
fun getLoginJs(): String? {
val loginJs = loginUrl
return when {
loginJs == null -> null
loginJs.startsWith("@js:") -> loginJs.substring(4)
loginJs.startsWith("<js>") -> loginJs.substring(4, loginJs.lastIndexOf("<"))
else -> loginJs
}
}
@JavascriptInterface
fun login() {
val loginJs = getLoginJs()
if (!loginJs.isNullOrBlank()) {
@Language("js")
val js = """$loginJs
if(typeof login=='function'){
login.apply(this);
} else {
throw('Function login not implements!!!')
}
""".trimIndent()
evalJS(js)
}
}
fun loginUi(): List<RowUi>? {
val json = loginUi?.let {
val loginJS = getLoginJs() ?: ""
@@ -88,34 +115,6 @@ interface BaseSource : JsExtensions {
}.getOrNull()
}
fun getLoginJs(): String? {
val loginJs = loginUrl
return when {
loginJs == null -> null
loginJs.startsWith("@js:") -> loginJs.substring(4)
loginJs.startsWith("<js>") -> loginJs.substring(4, loginJs.lastIndexOf("<"))
else -> loginJs
}
}
/**
* 调用login函数 实现登录请求
*/
fun login() {
val loginJs = getLoginJs()
if (!loginJs.isNullOrBlank()) {
@Language("js")
val js = """$loginJs
if(typeof login=='function'){
login.apply(this);
} else {
throw('Function login not implements!!!')
}
""".trimIndent()
evalJS(js)
}
}
/**
* 解析header规则
*/
@@ -194,9 +193,40 @@ interface BaseSource : JsExtensions {
}
}
fun getLoginInfoMap(): Map<String, String> {
val json = getLoginInfo() ?: return emptyMap()
return GSON.fromJsonObject<Map<String, String>>(json).getOrNull() ?: emptyMap()
private fun configureScriptBindings(): ScriptBindings.() -> Unit = {
put("result", mutableMapOf<String, String>())
put("book", null)
put("chapter", null)
}
fun getLoginInfoMap(): MutableMap<String, String> {
val json = getLoginInfo() ?: if (loginUi.isNullOrBlank()) {
return mutableMapOf()
} else {
val loginUiJson = loginUi?.let {
when {
it.startsWith("@js:") -> evalJS(
"${getLoginJs() ?: ""}\n${it.substring(4)}",
configureScriptBindings()
).toString()
it.startsWith("<js>") -> evalJS(
"${getLoginJs() ?: ""}\n${it.substring(4, it.lastIndexOf("<"))}",
configureScriptBindings()
).toString()
else -> it
}
}
val longinInfo = GSON.fromJsonArray<RowUi>(loginUiJson).getOrNull()
?.filter { it.type != "button" }
?.associate { it.name to (it.default ?: "") }
?.takeIf { it.isNotEmpty() }?.also {
putLoginInfo(GSON.toJson(it))
}
return longinInfo?.toMutableMap() ?: mutableMapOf()
}
return GSON.fromJsonObject<MutableMap<String, String>>(json).getOrNull() ?: mutableMapOf()
}
/**
@@ -9,7 +9,7 @@ import kotlinx.parcelize.IgnoredOnParcel
@Entity(
tableName = "rssArticles",
primaryKeys = ["origin", "link"]
primaryKeys = ["origin", "link", "sort"]
)
data class RssArticle(
override var origin: String = "",
@@ -24,14 +24,20 @@ data class RssArticle(
@ColumnInfo(defaultValue = "默认分组")
var group: String = "默认分组",
var read: Boolean = false,
override var variable: String? = null
override var variable: String? = null,
/**类型 0网页,1图片,2视频**/
@ColumnInfo(defaultValue = "0")
var type: Int = 0,
/**阅读进度**/
@ColumnInfo(defaultValue = "0")
var durPos: Int = 0
) : BaseRssArticle {
override fun hashCode() = link.hashCode()
override fun equals(other: Any?): Boolean {
other ?: return false
return if (other is RssArticle) origin == other.origin && link == other.link else false
return if (other is RssArticle) origin == other.origin && link == other.link && sort == other.sort else false
}
@delegate:Transient
@@ -52,6 +58,20 @@ data class RssArticle(
content = content,
image = image,
group = group,
variable = variable
variable = variable,
type = type,
durPos = durPos
)
fun toRecord() = RssReadRecord(
origin = origin,
sort = sort,
title = title,
readTime = System.currentTimeMillis(),
record = link,
image = image,
type = type,
durPos = durPos,
pubDate = pubDate
)
}
@@ -1,13 +1,53 @@
package io.legado.app.data.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "rssReadRecords")
@Entity(tableName = "rssReadRecords", indices = [Index(value = ["origin"], unique = false)])
data class RssReadRecord(
@PrimaryKey
val record: String,
val title: String? = null,
val readTime: Long? = null,
val read: Boolean = true
)
val read: Boolean = true,
@ColumnInfo(defaultValue = "")
val origin: String = "",
@ColumnInfo(defaultValue = "")
var sort: String = "",
var image: String? = null,
/**类型 0网页,1图片,2视频**/
@ColumnInfo(defaultValue = "0")
var type: Int = 0,
/**阅读进度**/
@ColumnInfo(defaultValue = "0")
var durPos: Int = 0,
var pubDate: String? = null
) {
fun toRssArticle(): RssArticle {
return RssArticle(
title = title ?: "",
origin = origin,
link = record,
sort = sort,
image = image,
type = type,
durPos = durPos,
pubDate = pubDate
)
}
fun toStar(): RssStar {
return RssStar(
title = title ?: "",
origin = origin,
link = record,
sort = sort,
image = image,
type = type,
durPos = durPos,
pubDate = pubDate
)
}
}
@@ -24,7 +24,13 @@ data class RssStar(
var image: String? = null,
@ColumnInfo(defaultValue = "默认分组")
var group: String = "默认分组",
override var variable: String? = null
override var variable: String? = null,
/**类型 0网页,1图片,2视频**/
@ColumnInfo(defaultValue = "0")
var type: Int = 0,
/**阅读进度**/
@ColumnInfo(defaultValue = "0")
var durPos: Int = 0
) : BaseRssArticle {
@delegate:Transient
@@ -44,6 +50,20 @@ data class RssStar(
content = content,
image = image,
group = group,
variable = variable
variable = variable,
type = type,
durPos = durPos
)
fun toRecord() = RssReadRecord(
origin = origin,
sort = sort,
title = title,
readTime = System.currentTimeMillis(),
record = link,
image = image,
type = type,
durPos = durPos,
pubDate = pubDate
)
}
@@ -9,6 +9,8 @@ data class FlexChildStyle(
val layout_alignSelf: String = "auto",
val layout_flexBasisPercent: Float = -1F,
val layout_wrapBefore: Boolean = false,
/** 自定义的内部水平对齐属性 **/
val layout_justifySelf: String = "auto"
) {
fun alignSelf(): Int {
@@ -1,10 +1,13 @@
package io.legado.app.data.entities.rule
data class RowUi(
var name: String = "",
var type: String = "text",
var action: String? = null,
var style: FlexChildStyle? = null
val name: String = "",
val type: String = "text",
val action: String? = null,
val chars: Array<String?>? = null,
val default: String? = null,
var viewName: String? = null,
val style: FlexChildStyle? = null
) {
@Suppress("ConstPropertyName")
@@ -13,6 +16,8 @@ data class RowUi(
const val text = "text"
const val password = "password"
const val button = "button"
const val toggle = "toggle"
const val select = "select"
}
@@ -20,4 +25,21 @@ data class RowUi(
return style ?: FlexChildStyle.defaultStyle
}
override fun equals(other: Any?): Boolean {
if (other is RowUi) {
return other.name == name
&& other.type == type
&& other.action == action
&& other.default == default
}
return false
}
override fun hashCode(): Int {
var result = name.hashCode() + type.hashCode()
result = 31 * result + (action?.hashCode() ?: 0)
result = 31 * result + (default?.hashCode() ?: 0)
return result
}
}
@@ -12,6 +12,7 @@ import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.explore.ExploreShowViewModel
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.dict.rule.DictRuleViewModel
import io.legado.app.ui.main.my.MyViewModel
import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleViewModel
@@ -43,6 +44,7 @@ val appModule = module {
)
}
viewModel { ReplaceRuleViewModel(androidApplication()) }
viewModel { DictRuleViewModel(androidApplication()) }
viewModel { ExploreShowViewModel(get()) }
viewModel { SearchContentViewModel(get(), get()) }
viewModel { MyViewModel(get()) }
@@ -5,4 +5,4 @@ package io.legado.app.exception
/**
* 并发限制
*/
class ConcurrentException(msg: String, val waitTime: Int) : NoStackTraceException(msg)
class ConcurrentException(msg: String, val waitTime: Long) : NoStackTraceException(msg)
@@ -1,5 +1,6 @@
package io.legado.app.help
import android.webkit.JavascriptInterface
import androidx.annotation.Keep
import androidx.collection.LruCache
import io.legado.app.data.appDb
@@ -60,8 +61,9 @@ object CacheManager {
when (value) {
is ByteArray -> ACache.get().put(key, value, saveTime)
else -> {
val cache = Cache(key, value.toString(), deadline)
putMemory(key, value)
val valueStr = value.toString()
putMemory(key, valueStr)
val cache = Cache(key, valueStr, deadline)
appDb.cacheDao.insert(cache)
}
}
@@ -86,26 +88,50 @@ object CacheManager {
}
val cache = appDb.cacheDao.get(key)
if (cache != null && (cache.deadline == 0L || cache.deadline > System.currentTimeMillis())) {
putMemory(key, cache.value ?: "")
return cache.value?.also {
putMemory(key, it)
}
}
return null
}
fun get(key: String, onlyDisk: Boolean): String? {
if (!onlyDisk) {
return get(key)
}
val cache = appDb.cacheDao.get(key)
if (cache != null && (cache.deadline == 0L || cache.deadline > System.currentTimeMillis())) {
return cache.value
}
return null
}
fun getInt(key: String): Int? {
return get(key)?.toIntOrNull()
getFromMemory(key)?.let {
if (it is Int) return it
}
return get(key, true)?.toIntOrNull()
}
fun getLong(key: String): Long? {
return get(key)?.toLongOrNull()
getFromMemory(key)?.let {
if (it is Long) return it
}
return get(key, true)?.toLongOrNull()
}
fun getDouble(key: String): Double? {
return get(key)?.toDoubleOrNull()
getFromMemory(key)?.let {
if (it is Double) return it
}
return get(key, true)?.toDoubleOrNull()
}
fun getFloat(key: String): Float? {
return get(key)?.toFloatOrNull()
getFromMemory(key)?.let {
if (it is Float) return it
}
return get(key, true)?.toFloatOrNull()
}
fun getByteArray(key: String): ByteArray? {
@@ -125,4 +151,51 @@ object CacheManager {
deleteMemory(key)
ACache.get().remove(key)
}
}
object WebCacheManager {
@JavascriptInterface
fun put(key: String, value: String, saveTime: Int = 0) {
CacheManager.put(key, value, saveTime)
}
@JavascriptInterface
fun putMemory(key: String, value: String) {
memoryLruCache.put(key, value)
}
@JavascriptInterface
fun getFromMemory(key: String): String? {
return memoryLruCache[key] as? String
}
@JavascriptInterface
fun deleteMemory(key: String) {
memoryLruCache.remove(key)
}
@JavascriptInterface
fun get(key: String): String? {
return CacheManager.get(key)
}
@JavascriptInterface
fun get(key: String, onlyDisk: Boolean): String? {
return CacheManager.get(key, onlyDisk)
}
@JavascriptInterface
fun putFile(key: String, value: String, saveTime: Int = 0) {
CacheManager.putFile(key, value, saveTime)
}
@JavascriptInterface
fun getFile(key: String): String? {
return CacheManager.getFile(key)
}
@JavascriptInterface
fun delete(key: String) {
CacheManager.delete(key)
}
}
@@ -4,72 +4,96 @@ import io.legado.app.data.entities.BaseSource
import io.legado.app.exception.ConcurrentException
import io.legado.app.model.analyzeRule.AnalyzeUrl.ConcurrentRecord
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
class ConcurrentRateLimiter(val source: BaseSource?) {
class ConcurrentRateLimiter(source: BaseSource?) {
companion object {
private val concurrentRecordMap = hashMapOf<String, ConcurrentRecord>()
val concurrentRecordMap = ConcurrentHashMap<String, ConcurrentRecord>()
/**
* 更新并发率
*/
fun updateConcurrentRate(key: String, concurrentRate: String) {
concurrentRecordMap.compute(key) { _, record ->
try {
val rateIndex = concurrentRate.indexOf("/")
when {
rateIndex > 0 -> {
val accessLimit = concurrentRate.take(rateIndex).toInt()
val interval = concurrentRate.substring(rateIndex + 1).toInt()
if (accessLimit <= 0 || interval <= 0) throw NumberFormatException()
ConcurrentRecord(
record?.time ?: System.currentTimeMillis(),
accessLimit,
interval,
record?.frequency ?: 0
)
}
concurrentRate.toInt() > 0 -> {
ConcurrentRecord(
record?.time ?: System.currentTimeMillis(),
1,
concurrentRate.toInt(),
record?.frequency ?: 0
)
}
else -> record
}
} catch (_: NumberFormatException) {
record
}
}
}
}
private val concurrentRate = source?.concurrentRate
private val key = source?.getKey()
/**
* 开始访问,并发判断
*/
@Throws(ConcurrentException::class)
private fun fetchStart(): ConcurrentRecord? {
source ?: return null
val concurrentRate = source.concurrentRate
if (concurrentRate.isNullOrEmpty() || concurrentRate == "0") {
return null
}
val rateIndex = concurrentRate.indexOf("/")
var fetchRecord = concurrentRecordMap[source.getKey()]
if (fetchRecord == null) {
synchronized(concurrentRecordMap) {
fetchRecord = concurrentRecordMap[source.getKey()]
if (fetchRecord == null) {
fetchRecord = ConcurrentRecord(rateIndex > 0, System.currentTimeMillis(), 1)
concurrentRecordMap[source.getKey()] = fetchRecord
return fetchRecord
}
val key = key ?: return null
var isNewRecord = false
val fetchRecord = concurrentRecordMap.computeIfAbsent(key) {
isNewRecord = true
val rateIndex = concurrentRate.indexOf("/")
if (rateIndex > 0) {
val accessLimit = concurrentRate.take(rateIndex).toIntOrNull() ?: 1
val interval = concurrentRate.substring(rateIndex + 1).toIntOrNull() ?: 0
ConcurrentRecord(System.currentTimeMillis(), accessLimit, interval, 1)
} else {
ConcurrentRecord(
System.currentTimeMillis(),
1,
concurrentRate.toIntOrNull() ?: 0,
1
)
}
}
val waitTime: Int = synchronized(fetchRecord!!) {
try {
if (!fetchRecord.isConcurrent) {
//并发控制非 次数/毫秒
if (fetchRecord.frequency > 0) {
//已经有访问线程,直接等待
return@synchronized concurrentRate.toInt()
}
//没有线程访问,判断还剩多少时间可以访问
val nextTime = fetchRecord.time + concurrentRate.toInt()
if (System.currentTimeMillis() >= nextTime) {
fetchRecord.time = System.currentTimeMillis()
fetchRecord.frequency = 1
return@synchronized 0
}
return@synchronized (nextTime - System.currentTimeMillis()).toInt()
} else {
//并发控制为 次数/毫秒
val sj = concurrentRate.substring(rateIndex + 1)
val nextTime = fetchRecord.time + sj.toInt()
if (System.currentTimeMillis() >= nextTime) {
//已经过了限制时间,重置开始时间
fetchRecord.time = System.currentTimeMillis()
fetchRecord.frequency = 1
return@synchronized 0
}
val cs = concurrentRate.substring(0, rateIndex)
if (fetchRecord.frequency > cs.toInt()) {
return@synchronized (nextTime - System.currentTimeMillis()).toInt()
} else {
fetchRecord.frequency += 1
return@synchronized 0
}
}
} catch (_: Exception) {
if (isNewRecord) return fetchRecord
val waitTime: Long = synchronized(fetchRecord) {
//并发控制为 次数/毫秒 , 非并发实际为1/毫秒
val nextTime = fetchRecord.time + fetchRecord.interval.toLong()
val nowTime = System.currentTimeMillis()
if (nowTime >= nextTime) {
//已经过了限制时间,重置开始时间
fetchRecord.time = nowTime
fetchRecord.frequency = 1
return@synchronized 0
}
if (fetchRecord.frequency < fetchRecord.accessLimit) {
fetchRecord.frequency++
return@synchronized 0
} else {
return@synchronized nextTime - nowTime
}
}
if (waitTime > 0) {
throw ConcurrentException(
@@ -80,17 +104,6 @@ class ConcurrentRateLimiter(val source: BaseSource?) {
return fetchRecord
}
/**
* 访问结束
*/
fun fetchEnd(concurrentRecord: ConcurrentRecord?) {
if (concurrentRecord != null && !concurrentRecord.isConcurrent) {
synchronized(concurrentRecord) {
concurrentRecord.frequency -= 1
}
}
}
/**
* 获取并发记录,若处于并发限制状态下则会等待
*/
@@ -99,7 +112,7 @@ class ConcurrentRateLimiter(val source: BaseSource?) {
try {
return fetchStart()
} catch (e: ConcurrentException) {
delay(e.waitTime.toLong())
delay(e.waitTime)
}
}
}
@@ -109,27 +122,19 @@ class ConcurrentRateLimiter(val source: BaseSource?) {
try {
return fetchStart()
} catch (e: ConcurrentException) {
Thread.sleep(e.waitTime.toLong())
Thread.sleep(e.waitTime)
}
}
}
suspend inline fun <T> withLimit(block: () -> T): T {
val concurrentRecord = getConcurrentRecord()
try {
return block()
} finally {
fetchEnd(concurrentRecord)
}
getConcurrentRecord()
return block()
}
inline fun <T> withLimitBlocking(block: () -> T): T {
val concurrentRecord = getConcurrentRecordBlocking()
try {
return block()
} finally {
fetchEnd(concurrentRecord)
}
getConcurrentRecordBlocking()
return block()
}
}
@@ -1,7 +1,9 @@
package io.legado.app.help
import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import androidx.annotation.Keep
import androidx.core.net.toUri
import cn.hutool.core.codec.Base64
import cn.hutool.core.util.HexUtil
import com.script.rhino.rhinoContext
@@ -13,6 +15,7 @@ import io.legado.app.constant.AppPattern
import io.legado.app.data.entities.BaseSource
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.http.BackstageWebView
import io.legado.app.help.http.CookieManager.cookieJarHeader
import io.legado.app.help.http.CookieStore
@@ -23,6 +26,7 @@ import io.legado.app.help.source.getSourceType
import io.legado.app.model.Debug
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.QueryTTF
import io.legado.app.ui.association.OnLineImportActivity
import io.legado.app.ui.association.OpenUrlConfirmActivity
import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.ChineseUtils
@@ -72,6 +76,7 @@ import java.util.zip.ZipInputStream
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
/**
* js扩展类, 在js中通过java变量调用
* 添加方法,请更新文档/legado/app/src/main/assets/help/JsHelp.md
@@ -83,24 +88,34 @@ import kotlin.coroutines.EmptyCoroutineContext
interface JsExtensions : JsEncodeUtils {
fun getSource(): BaseSource?
fun getTag(): String?
private val context: CoroutineContext
get() = rhinoContext.coroutineContext ?: EmptyCoroutineContext
get() = rhinoContextOrNull?.coroutineContext ?: EmptyCoroutineContext
/**
* 访问网络,返回String
*/
fun ajax(url: Any): String? {
return ajax(url, null)
}
fun ajax(url: Any, callTimeout: Long?): String? {
val urlStr = if (url is List<*>) {
url.firstOrNull().toString()
} else {
url.toString()
}
val analyzeUrl = AnalyzeUrl(urlStr, source = getSource(), coroutineContext = context)
val analyzeUrl = AnalyzeUrl(
urlStr,
source = getSource(),
callTimeout = callTimeout,
coroutineContext = context
)
return kotlin.runCatching {
analyzeUrl.getStrResponse().body
}.onFailure {
rhinoContext.ensureActive()
rhinoContextOrNull?.ensureActive()
AppLog.put("ajax(${urlStr}) error\n${it.localizedMessage}", it)
}.getOrElse {
it.stackTraceStr
@@ -111,6 +126,10 @@ interface JsExtensions : JsEncodeUtils {
* 并发访问网络
*/
fun ajaxAll(urlList: Array<String>): Array<StrResponse> {
return ajaxAll(urlList, false)
}
fun ajaxAll(urlList: Array<String>, skipRateLimit: Boolean): Array<StrResponse> {
return runBlocking(context) {
urlList.asFlow().mapAsync(AppConfig.threadCount) { url ->
val analyzeUrl = AnalyzeUrl(
@@ -118,11 +137,37 @@ interface JsExtensions : JsEncodeUtils {
source = getSource(),
coroutineContext = coroutineContext
)
analyzeUrl.getStrResponseAwait()
analyzeUrl.getStrResponseAwait(skipRateLimit = skipRateLimit)
}.flowOn(IO).toList().toTypedArray()
}
}
/**
* 并发测试网络
*/
fun ajaxTestAll(urlList: Array<String>, timeout: Int): Array<StrResponse> {
return ajaxTestAll(urlList, timeout, false)
}
fun ajaxTestAll(
urlList: Array<String>,
timeout: Int,
skipRateLimit: Boolean
): Array<StrResponse> {
return runBlocking(context) {
urlList.asFlow().mapAsync(AppConfig.threadCount) { url ->
val analyzeUrl = AnalyzeUrl(
url,
source = getSource(),
coroutineContext = coroutineContext,
callTimeout = timeout.toLong()
)
analyzeUrl.getStrResponseAwait(isTest = true, skipRateLimit = skipRateLimit)
}.flowOn(IO).toList().toTypedArray()
}
}
/**
* 访问网络,返回Response<String>
*/
@@ -135,7 +180,7 @@ interface JsExtensions : JsEncodeUtils {
return kotlin.runCatching {
analyzeUrl.getStrResponse()
}.onFailure {
rhinoContext.ensureActive()
rhinoContextOrNull?.ensureActive()
AppLog.put("connect(${urlStr}) error\n${it.localizedMessage}", it)
}.getOrElse {
StrResponse(analyzeUrl.url, it.stackTraceStr)
@@ -143,31 +188,41 @@ interface JsExtensions : JsEncodeUtils {
}
fun connect(urlStr: String, header: String?): StrResponse {
return connect(urlStr, header, null)
}
fun connect(urlStr: String, header: String?, callTimeout: Long?): StrResponse {
val headerMap = GSON.fromJsonObject<Map<String, String>>(header).getOrNull()
val analyzeUrl = AnalyzeUrl(
urlStr,
headerMapF = headerMap,
source = getSource(),
callTimeout = callTimeout,
coroutineContext = context
)
return kotlin.runCatching {
analyzeUrl.getStrResponse()
}.onFailure {
rhinoContext.ensureActive()
AppLog.put("ajax($urlStr,$header) error\n${it.localizedMessage}", it)
rhinoContextOrNull?.ensureActive()
AppLog.put("connect($urlStr,$header) error\n${it.localizedMessage}", it)
}.getOrElse {
StrResponse(analyzeUrl.url, it.stackTraceStr)
}
}
fun webView(html: String?, url: String?, js: String?): String? {
return webView(html, url, js, false)
}
/**
* 使用webView访问网络
* @param html 直接用webView载入的html, 如果html为空直接访问url
* @param url html内如果有相对路径的资源不传入url访问不了
* @param js 用来取返回值的js语句, 没有就返回整个源代码
* @param cacheFirst 优先使用缓存,为true能提高访问速度
* @return 返回js获取的内容
*/
fun webView(html: String?, url: String?, js: String?): String? {
fun webView(html: String?, url: String?, js: String?, cacheFirst: Boolean): String? {
if (isMainThread) {
error("webView must be called on a background thread")
}
@@ -182,10 +237,20 @@ interface JsExtensions : JsEncodeUtils {
}
}
fun webViewGetSource(html: String?, url: String?, js: String?, sourceRegex: String): String? {
return webViewGetSource(html, url, js, sourceRegex, false)
}
/**
* 使用webView获取资源url
*/
fun webViewGetSource(html: String?, url: String?, js: String?, sourceRegex: String): String? {
fun webViewGetSource(
html: String?,
url: String?,
js: String?,
sourceRegex: String,
cacheFirst: Boolean
): String? {
if (isMainThread) {
error("webViewGetSource must be called on a background thread")
}
@@ -201,6 +266,15 @@ interface JsExtensions : JsEncodeUtils {
}
}
fun webViewGetOverrideUrl(
html: String?,
url: String?,
js: String?,
overrideUrlRegex: String
): String? {
return webViewGetOverrideUrl(html, url, js, overrideUrlRegex, false)
}
/**
* 使用webView获取跳转url
*/
@@ -208,7 +282,8 @@ interface JsExtensions : JsEncodeUtils {
html: String?,
url: String?,
js: String?,
overrideUrlRegex: String
overrideUrlRegex: String,
cacheFirst: Boolean
): String? {
if (isMainThread) {
error("webViewGetOverrideUrl must be called on a background thread")
@@ -231,23 +306,37 @@ interface JsExtensions : JsEncodeUtils {
* @param title 浏览器页面的标题
*/
fun startBrowser(url: String, title: String) {
return startBrowser(url, title, null)
}
fun startBrowser(url: String, title: String, html: String?) {
rhinoContext.ensureActive()
SourceVerificationHelp.startBrowser(getSource(), url, title)
SourceVerificationHelp.startBrowser(getSource(), url, title, html = html)
}
/**
* 使用内置浏览器打开链接,并等待网页结果
*/
fun startBrowserAwait(url: String, title: String, refetchAfterSuccess: Boolean): StrResponse {
rhinoContext.ensureActive()
val body = SourceVerificationHelp.getVerificationResult(
getSource(), url, title, true, refetchAfterSuccess
)
return StrResponse(url, body)
fun startBrowserAwait(url: String, title: String): StrResponse {
return startBrowserAwait(url, title, true, null)
}
fun startBrowserAwait(url: String, title: String): StrResponse {
return startBrowserAwait(url, title, true)
fun startBrowserAwait(url: String, title: String, refetchAfterSuccess: Boolean): StrResponse {
return startBrowserAwait(url, title, refetchAfterSuccess, null)
}
fun startBrowserAwait(
url: String,
title: String,
refetchAfterSuccess: Boolean,
html: String?
): StrResponse {
rhinoContext.ensureActive()
val pair = SourceVerificationHelp.getVerificationResult(
getSource(), url, title, true, refetchAfterSuccess, html
)
val (url2, body) = pair
return StrResponse(url2.ifEmpty { url }, body)
}
/**
@@ -255,12 +344,13 @@ interface JsExtensions : JsEncodeUtils {
*/
fun getVerificationCode(imageUrl: String): String {
rhinoContext.ensureActive()
return SourceVerificationHelp.getVerificationResult(getSource(), imageUrl, "", false)
return SourceVerificationHelp.getVerificationResult(getSource(), imageUrl, "", false).second
}
/**
* 可从网络,本地文件(阅读私有数据目录相对路径)导入JavaScript脚本
*/
@JavascriptInterface
fun importScript(path: String): String {
val result = when {
path.startsWith("http") -> cacheFile(path)
@@ -275,6 +365,7 @@ interface JsExtensions : JsEncodeUtils {
* @param urlStr 网络文件的链接
* @return 返回缓存后的文件内容
*/
@JavascriptInterface
fun cacheFile(urlStr: String): String {
return cacheFile(urlStr, 0)
}
@@ -283,6 +374,7 @@ interface JsExtensions : JsEncodeUtils {
* 缓存以文本方式保存的文件 如.js .txt等
* @param saveTime 缓存时间,单位:秒
*/
@JavascriptInterface
fun cacheFile(urlStr: String, saveTime: Int): String {
val key = md5Encode16(urlStr)
val cachePath = CacheManager.get(key)
@@ -302,10 +394,12 @@ interface JsExtensions : JsEncodeUtils {
/**
*js实现读取cookie
*/
@JavascriptInterface
fun getCookie(tag: String): String {
return getCookie(tag, null)
}
@JavascriptInterface
fun getCookie(tag: String, key: String?): String {
return if (key != null) {
CookieStore.getKey(tag, key)
@@ -319,8 +413,9 @@ interface JsExtensions : JsEncodeUtils {
* @param url 下载地址:可带参数type
* @return 下载的文件相对路径
*/
@JavascriptInterface
fun downloadFile(url: String): String {
rhinoContext.ensureActive()
rhinoContextOrNull?.ensureActive()
val analyzeUrl = AnalyzeUrl(url, source = getSource(), coroutineContext = context)
val type = analyzeUrl.type ?: UrlUtil.getSuffix(url)
val path = FileUtils.getPath(
@@ -354,8 +449,9 @@ interface JsExtensions : JsEncodeUtils {
"Deprecated",
ReplaceWith("downloadFile(url)")
)
@JavascriptInterface
fun downloadFile(content: String, url: String): String {
rhinoContext.ensureActive()
rhinoContextOrNull?.ensureActive()
val type = AnalyzeUrl(url, source = getSource(), coroutineContext = context).type
?: return ""
val path = FileUtils.getPath(
@@ -376,14 +472,19 @@ interface JsExtensions : JsEncodeUtils {
* js实现重定向拦截,网络访问get
*/
fun get(urlStr: String, headers: Map<String, String>): Connection.Response {
return get(urlStr, headers, null)
}
fun get(urlStr: String, headers: Map<String, String>, timeout: Int?): Connection.Response {
val requestHeaders = if (getSource()?.enabledCookieJar == true) {
headers.toMutableMap().apply { put(cookieJarHeader, "1") }
} else headers
val rateLimiter = ConcurrentRateLimiter(getSource())
val response = rateLimiter.withLimitBlocking {
rhinoContext.ensureActive()
rhinoContextOrNull?.ensureActive()
Jsoup.connect(urlStr)
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
.timeout(timeout ?: 30000)
.ignoreContentType(true)
.followRedirects(false)
.headers(requestHeaders)
@@ -397,14 +498,19 @@ interface JsExtensions : JsEncodeUtils {
* js实现重定向拦截,网络访问head,不返回Response Body更省流量
*/
fun head(urlStr: String, headers: Map<String, String>): Connection.Response {
return head(urlStr, headers, null)
}
fun head(urlStr: String, headers: Map<String, String>, timeout: Int?): Connection.Response {
val requestHeaders = if (getSource()?.enabledCookieJar == true) {
headers.toMutableMap().apply { put(cookieJarHeader, "1") }
} else headers
val rateLimiter = ConcurrentRateLimiter(getSource())
val response = rateLimiter.withLimitBlocking {
rhinoContext.ensureActive()
rhinoContextOrNull?.ensureActive()
Jsoup.connect(urlStr)
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
.timeout(timeout ?: 30000)
.ignoreContentType(true)
.followRedirects(false)
.headers(requestHeaders)
@@ -418,14 +524,24 @@ interface JsExtensions : JsEncodeUtils {
* 网络访问post
*/
fun post(urlStr: String, body: String, headers: Map<String, String>): Connection.Response {
return post(urlStr, body, headers, null)
}
fun post(
urlStr: String,
body: String,
headers: Map<String, String>,
timeout: Int?
): Connection.Response {
val requestHeaders = if (getSource()?.enabledCookieJar == true) {
headers.toMutableMap().apply { put(cookieJarHeader, "1") }
} else headers
val rateLimiter = ConcurrentRateLimiter(getSource())
val response = rateLimiter.withLimitBlocking {
rhinoContext.ensureActive()
rhinoContextOrNull?.ensureActive()
Jsoup.connect(urlStr)
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
.timeout(timeout ?: 30000)
.ignoreContentType(true)
.followRedirects(false)
.requestBody(body)
@@ -457,14 +573,17 @@ interface JsExtensions : JsEncodeUtils {
/**
* js实现base64解码,不能删
*/
@JavascriptInterface
fun base64Decode(str: String?): String {
return Base64.decodeStr(str)
}
@JavascriptInterface
fun base64Decode(str: String?, charset: String): String {
return Base64.decodeStr(str, charset(charset))
}
@JavascriptInterface
fun base64Decode(str: String, flags: Int): String {
return EncoderUtils.base64Decode(str, flags)
}
@@ -483,10 +602,12 @@ interface JsExtensions : JsEncodeUtils {
return EncoderUtils.base64DecodeToByteArray(str, flags)
}
@JavascriptInterface
fun base64Encode(str: String): String? {
return EncoderUtils.base64Encode(str, 2)
}
@JavascriptInterface
fun base64Encode(str: String, flags: Int): String? {
return EncoderUtils.base64Encode(str, flags)
}
@@ -497,11 +618,13 @@ interface JsExtensions : JsEncodeUtils {
}
/* hexString 解码为utf8String*/
@JavascriptInterface
fun hexDecodeToString(hex: String): String? {
return HexUtil.decodeHexStr(hex)
}
/* utf8 编码为hexString */
@JavascriptInterface
fun hexEncodeToString(utf8: String): String? {
return HexUtil.encodeHexStr(utf8)
}
@@ -509,6 +632,7 @@ interface JsExtensions : JsEncodeUtils {
/**
* 格式化时间
*/
@JavascriptInterface
fun timeFormatUTC(time: Long, format: String, sh: Int): String? {
val utc = SimpleTimeZone(sh, "UTC")
return SimpleDateFormat(format, Locale.getDefault()).run {
@@ -520,10 +644,12 @@ interface JsExtensions : JsEncodeUtils {
/**
* 时间格式化
*/
@JavascriptInterface
fun timeFormat(time: Long): String {
return dateFormat.format(Date(time))
}
@JavascriptInterface
fun encodeURI(str: String): String {
return try {
URLEncoder.encode(str, "UTF-8")
@@ -532,6 +658,7 @@ interface JsExtensions : JsEncodeUtils {
}
}
@JavascriptInterface
fun encodeURI(str: String, enc: String): String {
return try {
URLEncoder.encode(str, enc)
@@ -540,18 +667,22 @@ interface JsExtensions : JsEncodeUtils {
}
}
@JavascriptInterface
fun htmlFormat(str: String): String {
return HtmlFormatter.formatKeepImg(str)
}
@JavascriptInterface
fun t2s(text: String): String {
return ChineseUtils.t2s(text)
}
@JavascriptInterface
fun s2t(text: String): String {
return ChineseUtils.s2t(text)
}
@JavascriptInterface
fun getWebViewUA(): String {
return WebSettings.getDefaultUserAgent(appCtx)
}
@@ -586,6 +717,7 @@ interface JsExtensions : JsEncodeUtils {
return null
}
@JavascriptInterface
fun readTxtFile(path: String): String {
val file = getFile(path)
if (file.exists()) {
@@ -595,6 +727,7 @@ interface JsExtensions : JsEncodeUtils {
return ""
}
@JavascriptInterface
fun readTxtFile(path: String, charsetName: String): String {
val file = getFile(path)
if (file.exists()) {
@@ -606,6 +739,7 @@ interface JsExtensions : JsEncodeUtils {
/**
* 删除本地文件
*/
@JavascriptInterface
fun deleteFile(path: String): Boolean {
val file = getFile(path)
return FileUtils.delete(file, true)
@@ -616,6 +750,7 @@ interface JsExtensions : JsEncodeUtils {
* @param zipPath 相对路径
* @return 相对路径
*/
@JavascriptInterface
fun unzipFile(zipPath: String): String {
return unArchiveFile(zipPath)
}
@@ -625,6 +760,7 @@ interface JsExtensions : JsEncodeUtils {
* @param zipPath 相对路径
* @return 相对路径
*/
@JavascriptInterface
fun un7zFile(zipPath: String): String {
return unArchiveFile(zipPath)
}
@@ -634,6 +770,7 @@ interface JsExtensions : JsEncodeUtils {
* @param zipPath 相对路径
* @return 相对路径
*/
@JavascriptInterface
fun unrarFile(zipPath: String): String {
return unArchiveFile(zipPath)
}
@@ -643,6 +780,7 @@ interface JsExtensions : JsEncodeUtils {
* @param zipPath 相对路径
* @return 相对路径
*/
@JavascriptInterface
fun unArchiveFile(zipPath: String): String {
if (zipPath.isEmpty()) return ""
val zipFile = getFile(zipPath)
@@ -656,6 +794,7 @@ interface JsExtensions : JsEncodeUtils {
* @param path 文件夹相对路径
* @return 所有文件字符串换行连接
*/
@JavascriptInterface
fun getTxtInFolder(path: String): String {
if (path.isEmpty()) return ""
val folder = getFile(path)
@@ -680,12 +819,14 @@ interface JsExtensions : JsEncodeUtils {
* @param path 所需获取文件在zip内的路径
* @return zip指定文件的数据
*/
@JavascriptInterface
fun getZipStringContent(url: String, path: String): String {
val byteArray = getZipByteArrayContent(url, path) ?: return ""
val charsetName = EncodingDetect.getEncode(byteArray)
return String(byteArray, Charset.forName(charsetName))
}
@JavascriptInterface
fun getZipStringContent(url: String, path: String, charsetName: String): String {
val byteArray = getZipByteArrayContent(url, path) ?: return ""
return String(byteArray, Charset.forName(charsetName))
@@ -697,12 +838,14 @@ interface JsExtensions : JsEncodeUtils {
* @param path 所需获取文件在zip内的路径
* @return zip指定文件的数据
*/
@JavascriptInterface
fun getRarStringContent(url: String, path: String): String {
val byteArray = getRarByteArrayContent(url, path) ?: return ""
val charsetName = EncodingDetect.getEncode(byteArray)
return String(byteArray, Charset.forName(charsetName))
}
@JavascriptInterface
fun getRarStringContent(url: String, path: String, charsetName: String): String {
val byteArray = getRarByteArrayContent(url, path) ?: return ""
return String(byteArray, Charset.forName(charsetName))
@@ -714,12 +857,14 @@ interface JsExtensions : JsEncodeUtils {
* @param path 所需获取文件在7zip内的路径
* @return zip指定文件的数据
*/
@JavascriptInterface
fun get7zStringContent(url: String, path: String): String {
val byteArray = get7zByteArrayContent(url, path) ?: return ""
val charsetName = EncodingDetect.getEncode(byteArray)
return String(byteArray, Charset.forName(charsetName))
}
@JavascriptInterface
fun get7zStringContent(url: String, path: String, charsetName: String): String {
val byteArray = get7zByteArrayContent(url, path) ?: return ""
return String(byteArray, Charset.forName(charsetName))
@@ -913,6 +1058,7 @@ interface JsExtensions : JsEncodeUtils {
/**
* 章节数转数字
*/
@JavascriptInterface
fun toNumChapter(s: String?): String? {
s ?: return null
val matcher = AppPattern.titleNumPattern.matcher(s)
@@ -956,7 +1102,7 @@ interface JsExtensions : JsEncodeUtils {
getSource()?.let {
Debug.log(it.getKey(), msg.toString())
} ?: Debug.log(msg.toString())
AppLog.putDebug("${getSource()?.getTag() ?: "源"}调试输出: $msg")
AppLog.putDebug("${getTag() ?: "源"}调试输出: $msg")
return msg
}
@@ -974,22 +1120,32 @@ interface JsExtensions : JsEncodeUtils {
/**
* 生成UUID
*/
@JavascriptInterface
fun randomUUID(): String {
return UUID.randomUUID().toString()
}
@JavascriptInterface
fun androidId(): String {
return AppConst.androidId
}
@JavascriptInterface
fun openUrl(url: String) {
openUrl(url, null)
}
// 新增 mimeType 参数,默认为 null(保持兼容性)
@JavascriptInterface
fun openUrl(url: String, mimeType: String? = null) {
require(url.length < 64 * 1024) { "openUrl parameter url too long" }
rhinoContext.ensureActive()
rhinoContextOrNull?.ensureActive()
if (url.startsWith("legado://") || url.startsWith("yuedu://")) {
appCtx.startActivity<OnLineImportActivity> {
data = url.toUri()
}
return
}
val source = getSource() ?: throw NoStackTraceException("openUrl source cannot be null")
appCtx.startActivity<OpenUrlConfirmActivity> {
putExtra("uri", url)
@@ -1000,4 +1156,37 @@ interface JsExtensions : JsEncodeUtils {
}
}
/**
* 获取阅读配置
*/
@JavascriptInterface
fun getReadBookConfig(): String {
return GSON.toJson(ReadBookConfig.durConfig)
}
/* fun getReadBookConfigMap(): Map<String, Any> {
return ReadBookConfig.durConfig.toMap()
} */
/**
* 获取主题模式
*/
@JavascriptInterface
fun getThemeMode(): String {
return AppConfig.themeMode ?: "0"
}
/**
* 获取主题配置
*/
//@JavascriptInterface
/*fun getThemeConfig(): String {
val themeConfig = ThemeConfig.getDurConfig(appCtx)
return GSON.toJson(themeConfig)
}*/
/* fun getThemeConfigMap(): Map<String, Any?> {
return ThemeConfig.getDurConfig(appCtx).toMap()
} */
}
@@ -566,6 +566,8 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
appCtx.putPrefBoolean(PreferKey.previewImageByClick, value)
}
val clickImgWay: String?
get() = appCtx.getPrefString(PreferKey.clickImgWay)
var preDownloadNum
get() = appCtx.getPrefInt(PreferKey.preDownloadNum, 10)
set(value) {
@@ -20,6 +20,7 @@ class StrResponse {
private set
var errorBody: ResponseBody? = null
private set
var callTime = 0
constructor(rawResponse: Response, body: String?) {
this.raw = rawResponse
@@ -46,7 +47,11 @@ class StrResponse {
this.errorBody = errorBody
}
fun putCallTime(callTime: Int) {
this.callTime = callTime
}
fun raw() = raw
fun callTime() = callTime
fun url(): String {
raw.networkResponse?.let {
@@ -35,8 +35,9 @@ object SourceVerificationHelp {
url: String,
title: String,
useBrowser: Boolean,
refetchAfterSuccess: Boolean = true
): String {
refetchAfterSuccess: Boolean = true,
html: String? = null
): Pair<String, String> {
source
?: throw NoStackTraceException("getVerificationResult parameter source cannot be null")
require(url.length < 64 * 1024) { "getVerificationResult parameter url too long" }
@@ -53,23 +54,21 @@ object SourceVerificationHelp {
IntentData.put(getVerificationResultKey(source), Thread.currentThread())
}
} else {
startBrowser(source, url, title, true, refetchAfterSuccess)
startBrowser(source, url, title, true, refetchAfterSuccess, html)
}
var waitUserInput = false
while (getResult(source.getKey()) == null) {
if (!waitUserInput) {
if (!waitUserInput && html == null) {
AppLog.putDebug("等待返回验证结果...")
waitUserInput = true
}
LockSupport.parkNanos(this, waitTime)
}
return getResult(source.getKey())!!.let {
it.ifBlank {
throw NoStackTraceException("验证结果为空")
}
}
val result = getResult(source.getKey()) ?: throw NoStackTraceException("验证结果为空")
clearResult(source.getKey())
if (result.second.isEmpty()) throw NoStackTraceException("验证结果为空")
return result
}
/**
@@ -81,7 +80,8 @@ object SourceVerificationHelp {
url: String,
title: String,
saveResult: Boolean? = false,
refetchAfterSuccess: Boolean? = true
refetchAfterSuccess: Boolean? = true,
html: String? = null
) {
source ?: throw NoStackTraceException("startBrowser parameter source cannot be null")
require(url.length < 64 * 1024) { "startBrowser parameter url too long" }
@@ -93,6 +93,7 @@ object SourceVerificationHelp {
putExtra("sourceType", source.getSourceType())
putExtra("sourceVerificationEnable", saveResult)
putExtra("refetchAfterSuccess", refetchAfterSuccess)
putExtra("html", html)
IntentData.put(getVerificationResultKey(source), Thread.currentThread())
}
}
@@ -104,12 +105,17 @@ object SourceVerificationHelp {
LockSupport.unpark(thread)
}
fun setResult(sourceKey: String, result: String?) {
CacheManager.putMemory(getVerificationResultKey(sourceKey), result ?: "")
fun setResult(sourceKey: String, result: String, url: String = "") {
CacheManager.putMemory(getVerificationResultKey(sourceKey), (url to result))
}
fun getResult(sourceKey: String): String? {
return CacheManager.get(getVerificationResultKey(sourceKey))
fun getResult(sourceKey: String): Pair<String, String>? {
val pair = CacheManager.getFromMemory(getVerificationResultKey(sourceKey)) as? Pair<*, *>
?: return null
if (pair.first is String && pair.second is String) {
return pair.first as String to pair.second as String
}
return null
}
fun clearResult(sourceKey: String) {
@@ -0,0 +1,384 @@
package io.legado.app.help.webView
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import io.legado.app.constant.BookType
import io.legado.app.data.appDb
import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.AudioPlay
import io.legado.app.model.ReadBook
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
import io.legado.app.ui.rss.read.RssJsExtensions
import io.legado.app.utils.GSON
import io.legado.app.utils.escapeForJs
import io.legado.app.utils.fromJsonObject
import java.util.UUID
@Suppress("unused")
class WebJsExtensions(
source: BaseSource,
activity: AppCompatActivity,
private val webView: WebView,
private val bookType: Int = 0
) : RssJsExtensions(activity, source) {
private val bookAndChapter by lazy {
var book: Book? = null
var chapter: BookChapter? = null
when (bookType) {
BookType.text -> {
book = ReadBook.book?.also {
chapter = appDb.bookChapterDao.getChapter(
it.bookUrl,
ReadBook.durChapterIndex
)
}
}
BookType.audio -> {
book = AudioPlay.book
chapter = AudioPlay.durChapter
}
}
Pair(book, chapter)
}
private val book: Book? get() = bookAndChapter.first
private val chapter: BookChapter? get() = bookAndChapter.second
/**
* 由软件主动注入的js函数调用
*/
@JavascriptInterface
fun request(funName: String, jsParam: Array<String>, id: String) {
val activity = activityRef.get() ?: return
Coroutine.async(activity.lifecycleScope) {
when (funName) {
"run" -> AnalyzeRule(book, getSource()).run {
setCoroutineContext(coroutineContext)
setChapter(chapter)
evalJS(jsParam[0]).toString()
}
"ajaxAwait" -> {
ajax(jsParam[0], jsParam[1].toIntOrNull()).toString()
}
"connectAwait" -> {
connect(jsParam[0], jsParam[1], jsParam[2].toIntOrNull())
}
"getAwait" -> {
get(jsParam[0], jsParam[1], jsParam[2].toIntOrNull())
}
"headAwait" -> {
head(jsParam[0], jsParam[1], jsParam[2].toIntOrNull())
}
"postAwait" -> {
post(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toIntOrNull())
}
"webViewAwait" -> {
webView(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toBoolean()).toString()
}
"decryptStrAwait" -> {
createSymmetricCrypto(jsParam[0], jsParam[1], jsParam[2]).decryptStr(jsParam[3])
}
"encryptBase64Await" -> {
createSymmetricCrypto(
jsParam[0],
jsParam[1],
jsParam[2]
).encryptBase64(jsParam[3])
}
"encryptHexAwait" -> {
createSymmetricCrypto(jsParam[0], jsParam[1], jsParam[2]).encryptHex(jsParam[3])
}
"createSignHexAwait" -> {
createSign(jsParam[0]).setPublicKey(jsParam[1]).setPrivateKey(jsParam[2])
.signHex(jsParam[3])
}
"downloadFileAwait" -> {
downloadFile(jsParam[0])
}
"readTxtFileAwait" -> {
readTxtFile(jsParam[0])
}
"importScriptAwait" -> {
importScript(jsParam[0])
}
"getStringAwait" -> AnalyzeRule(book, getSource()).run {
setCoroutineContext(coroutineContext)
setChapter(chapter)
getString(jsParam[0], jsParam[1])
}
else -> "error funName"
}
}.onSuccess { data ->
webView.evaluateJavascript(
"window.$JSBridgeResult('$id', '${data.escapeForJs()}', null);",
null
)
}.onError {
webView.evaluateJavascript(
"window.$JSBridgeResult('$id', null, '${it.localizedMessage?.escapeForJs()}');",
null
)
}
}
@JavascriptInterface
fun toast(msg: String?) {
super.toast(msg)
}
@JavascriptInterface
fun longToast(msg: String?) {
super.longToast(msg)
}
@JavascriptInterface
fun log(msg: String?): String {
return super.log(msg).toString()
}
@JavascriptInterface
fun ajax(url: String): String? {
return super.ajax(url, 9000)
}
@JavascriptInterface
fun ajax(url: String, callTimeout: Int?): String? {
return super.ajax(url, callTimeout?.toLong())
}
@JavascriptInterface
fun connect(urlStr: String?): String {
if (urlStr.isNullOrEmpty()) return "error empty url"
return super.connect(urlStr, null, 9000).toString()
}
@JavascriptInterface
fun connect(urlStr: String, header: String): String {
return super.connect(urlStr, header, 9000).toString()
}
@JavascriptInterface
fun connect(urlStr: String, header: String, callTimeout: Int?): String {
return super.connect(urlStr, header, callTimeout?.toLong()).toString()
}
@JavascriptInterface
fun get(urlStr: String, headers: String): String {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
return super.get(urlStr, headerMap, 9000).body()
}
@JavascriptInterface
fun get(urlStr: String, headers: String, timeout: Int?): String {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
return super.get(urlStr, headerMap, timeout).body()
}
@JavascriptInterface
fun post(urlStr: String, body: String, headers: String): String {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
return super.post(urlStr, body, headerMap, 9000).body()
}
@JavascriptInterface
fun post(urlStr: String, body: String, headers: String, timeout: Int?): String {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
return super.post(urlStr, body, headerMap, timeout).body()
}
@JavascriptInterface
fun head(urlStr: String, headers: String): String {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
return GSON.toJson(super.head(urlStr, headerMap, 9000).headers())
}
@JavascriptInterface
fun head(urlStr: String, headers: String, timeout: Int?): String {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
return GSON.toJson(super.head(urlStr, headerMap, timeout).headers())
}
companion object {
private fun getRandomLetter(): Char {
val letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_"
return letters.random()
}
val uuid by lazy { UUID.randomUUID().toString().split("-") }
val uuid2 by lazy { UUID.randomUUID().toString().split("-") }
val nameJava by lazy { getRandomLetter() + uuid[0] + uuid[1] }
val nameCache by lazy { getRandomLetter() + uuid[2] + uuid[3] }
val nameSource by lazy { getRandomLetter() + uuid[4] }
val nameBasic by lazy { getRandomLetter() + uuid2[1] + uuid2[2] }
val JSBridgeResult by lazy { getRandomLetter() + uuid2[3] + uuid2[4] }
val JS_INJECTION by lazy {
"""
const requestId = n => 'req_' + n + '_' + Date.now() + '_' + Math.random().toString(36).slice(-3);
const JSBridgeCallbacks = {};
const java = window.$nameJava;
const source = window.$nameSource;
const cache = window.$nameCache;
function run(jsCode) {
return new Promise((resolve, reject) => {
const id = requestId("run");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("run", [String(jsCode)], id);
});
};
function ajaxAwait(url, callTimeout) {
return new Promise((resolve, reject) => {
const id = requestId("ajaxAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("ajaxAwait", [String(url), String(callTimeout)], id);
});
};
function connectAwait(url, header, callTimeout) {
return new Promise((resolve, reject) => {
const id = requestId("connectAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("connectAwait", [String(url), String(header), String(callTimeout)], id);
});
};
function getAwait(url, header, callTimeout) {
return new Promise((resolve, reject) => {
const id = requestId("getAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("getAwait", [String(url), String(header), String(callTimeout)], id);
});
};
function headAwait(url, header, callTimeout) {
return new Promise((resolve, reject) => {
const id = requestId("headAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("headAwait", [String(url), String(header), String(callTimeout)], id);
});
};
function postAwait(url, body, header, callTimeout) {
return new Promise((resolve, reject) => {
const id = requestId("postAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("postAwait", [String(url), String(body), String(header), String(callTimeout)], id);
});
};
function webViewAwait(html, url, js, cacheFirst) {
return new Promise((resolve, reject) => {
const id = requestId("webViewAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("webViewAwait", [String(html), String(url), String(js), String(cacheFirst)], id);
});
};
function decryptStrAwait(transformation, key, iv, data) {
return new Promise((resolve, reject) => {
const id = requestId("decryptStrAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("decryptStrAwait", [String(transformation), String(key), String(iv), String(data)], id);
});
};
function encryptBase64Await(transformation, key, iv, data) {
return new Promise((resolve, reject) => {
const id = requestId("encryptBase64Await");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("encryptBase64Await", [String(transformation), String(key), String(iv), String(data)], id);
});
};
function encryptHexAwait(transformation, key, iv, data) {
return new Promise((resolve, reject) => {
const id = requestId("encryptHexAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("encryptHexAwait", [String(transformation), String(key), String(iv), String(data)], id);
});
};
function createSignHexAwait(algorithm, publicKey, privateKey, data) {
return new Promise((resolve, reject) => {
const id = requestId("createSignHexAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("createSignHexAwait", [String(algorithm), String(publicKey), String(privateKey), String(data)], id);
});
};
function downloadFileAwait(url) {
return new Promise((resolve, reject) => {
const id = requestId("downloadFileAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("downloadFileAwait", [String(url)], id);
});
};
function readTxtFileAwait(path) {
return new Promise((resolve, reject) => {
const id = requestId("readTxtFileAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("readTxtFileAwait", [String(path)], id);
});
};
function importScriptAwait(url) {
return new Promise((resolve, reject) => {
const id = requestId("importScriptAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("importScriptAwait", [String(url)], id);
});
};
function getStringAwait(ruleStr, mContent) {
return new Promise((resolve, reject) => {
const id = requestId("getStringAwait");
JSBridgeCallbacks[id] = { resolve, reject };
window.$nameJava?.request("getStringAwait", [String(ruleStr), String(mContent)], id);
});
};
window.$JSBridgeResult = function(requestId, result, error) {
if (JSBridgeCallbacks[requestId]) {
if (error) {
JSBridgeCallbacks[requestId].reject(error);
} else {
JSBridgeCallbacks[requestId].resolve(result);
}
delete JSBridgeCallbacks[requestId];
}
};"""
}
val basicJs by lazy {
"""
(function() {
if (screen.orientation && !screen.orientation.__patched) {
screen.orientation.lock = function(orientation) {
return new Promise((resolve, reject) => {
window.$nameBasic?.lockOrientation(orientation)
resolve()
});
};
screen.orientation.unlock = function() {
return new Promise((resolve, reject) => {
window.$nameBasic?.lockOrientation('unlock')
resolve()
});
};
screen.orientation.__patched = true;
};
window.close = function() {
window.$nameBasic?.onCloseRequested();
};
})();"""
}
}
}
@@ -52,7 +52,8 @@ import kotlin.coroutines.EmptyCoroutineContext
class AnalyzeRule(
private var ruleData: RuleDataInterface? = null,
private val source: BaseSource? = null,
private val preUpdateJs: Boolean = false
private val preUpdateJs: Boolean = false,
private var isFromBookInfo: Boolean = false
) : JsExtensions {
private val book get() = ruleData as? BaseBook
@@ -79,6 +80,12 @@ class AnalyzeRule(
private var coroutineContext: CoroutineContext = EmptyCoroutineContext
private var loggedNonStandardJSON = false
private var ruleName: String? = null
fun setRuleName(name: String) {
if (name.isNotBlank()) {
ruleName = name
}
}
@JvmOverloads
fun setContent(content: Any?, baseUrl: String? = null): AnalyzeRule {
@@ -782,6 +789,7 @@ class AnalyzeRule(
bindings["src"] = content
bindings["nextChapterUrl"] = nextChapterUrl
bindings["rssArticle"] = rssArticle
bindings["fromBookInfo"] = isFromBookInfo
}
val topScope = source?.getShareScope(coroutineContext) ?: topScopeRef?.get()
val scope = if (topScope == null) {
@@ -810,6 +818,10 @@ class AnalyzeRule(
return source
}
override fun getTag(): String? {
return source?.getTag() ?: ruleName
}
/**
* js实现跨域访问,不能删
*/
@@ -841,6 +853,9 @@ class AnalyzeRule(
*/
fun reGetBook() {
if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用")
if (isFromBookInfo) {
log("重新获取book")
}
val bookSource = source as? BookSource
val book = book as? Book
if (bookSource == null || book == null) return
@@ -863,6 +878,10 @@ class AnalyzeRule(
*/
fun refreshTocUrl() {
if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用")
if (isFromBookInfo) {
log("已跳过重复加载详情页,请优化代码")
return
}
val bookSource = source as? BookSource
val book = book as? Book
if (bookSource == null || book == null) return
@@ -13,8 +13,6 @@ import com.script.rhino.RhinoScriptEngine
import com.script.rhino.runScriptWithContext
import io.legado.app.constant.AppConst.UA_NAME
import io.legado.app.constant.AppPattern
import io.legado.app.constant.AppPattern.JS_PATTERN
import io.legado.app.constant.AppPattern.dataUriRegex
import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
@@ -50,15 +48,21 @@ import io.legado.app.utils.isJson
import io.legado.app.utils.isJsonArray
import io.legado.app.utils.isJsonObject
import io.legado.app.utils.isXml
import io.legado.app.utils.parseIpsFromString
import io.legado.app.utils.stackTraceStr
import kotlinx.coroutines.runBlocking
import okhttp3.Dns
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.net.URLEncoder
import java.nio.charset.Charset
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern
import kotlin.coroutines.ContinuationInterceptor
@@ -87,8 +91,10 @@ class AnalyzeUrl(
private val callTimeout: Long? = null,
private var coroutineContext: CoroutineContext = EmptyCoroutineContext,
headerMapF: Map<String, String>? = null,
hasLoginHeader: Boolean = true
hasLoginHeader: Boolean = true,
private val infoMap: MutableMap<String, String>? = null
) : JsExtensions {
constructor(mUrl: String) : this(mUrl, null)
var ruleUrl = ""
private set
@@ -98,7 +104,8 @@ class AnalyzeUrl(
private set
val headerMap = LinkedHashMap<String, String>()
private var body: String? = null
private var urlNoQuery: String = ""
var urlNoQuery: String = ""
private set
private var encodedForm: String? = null
private var encodedQuery: String? = null
private var charset: String? = null
@@ -107,6 +114,8 @@ class AnalyzeUrl(
private var retry: Int = 0
private var useWebView: Boolean = false
private var webJs: String? = null
private var bodyJs: String? = null
private var dnsIp: String? = null
private val enabledCookieJar = source?.enabledCookieJar == true
private val domain: String
private var webViewDelayTime: Long = 0
@@ -151,7 +160,7 @@ class AnalyzeUrl(
*/
private fun analyzeJs() {
var start = 0
val jsMatcher = JS_PATTERN.matcher(ruleUrl)
val jsMatcher = AppPattern.JS_PATTERN.matcher(ruleUrl)
var result = ruleUrl
while (jsMatcher.find()) {
if (jsMatcher.start() > start) {
@@ -184,9 +193,9 @@ class AnalyzeUrl(
//替换所有内嵌{{js}}
val url = analyze.innerRule("{{", "}}") {
val jsEval = evalJS(it) ?: ""
when {
jsEval is String -> jsEval
jsEval is Double && jsEval % 1.0 == 0.0 -> String.format("%.0f", jsEval)
when (jsEval) {
is String -> jsEval
is Double if jsEval % 1.0 == 0.0 -> String.format("%.0f", jsEval)
else -> jsEval.toString()
}
}
@@ -242,13 +251,15 @@ class AnalyzeUrl(
retry = option.getRetry()
useWebView = option.useWebView()
webJs = option.getWebJs()
bodyJs = option.getBodyJs()
dnsIp = option.getDnsIp()
option.getJs()?.let { jsStr ->
evalJS(jsStr, url)?.toString()?.let {
url = it
}
serverID = option.getServerID()
webViewDelayTime = max(0, option.getWebViewDelayTime() ?: 0)
}
serverID = option.getServerID()
webViewDelayTime = max(0, option.getWebViewDelayTime() ?: 0)
}
}
urlNoQuery = url
@@ -341,6 +352,7 @@ class AnalyzeUrl(
}
}
/**
* 执行JS
*/
@@ -357,6 +369,7 @@ class AnalyzeUrl(
bindings["book"] = ruleData as? Book
bindings["source"] = source
bindings["result"] = result
bindings["infoMap"] = infoMap
}
val sharedScope = source?.getShareScope(coroutineContext)
val scope = if (sharedScope == null) {
@@ -397,13 +410,30 @@ class AnalyzeUrl(
jsStr: String? = null,
sourceRegex: String? = null,
useWebView: Boolean = true,
isTest: Boolean = false,
skipRateLimit: Boolean = false
): StrResponse {
if (type != null) {
return StrResponse(url, HexUtil.encodeHexStr(getByteArrayAwait()))
}
if (skipRateLimit) {
return executeStrRequest(jsStr, sourceRegex, useWebView, isTest)
}
concurrentRateLimiter.withLimit {
setCookie()
val strResponse: StrResponse
return executeStrRequest(jsStr, sourceRegex, useWebView, isTest)
}
}
private suspend fun executeStrRequest(
jsStr: String? = null,
sourceRegex: String? = null,
useWebView: Boolean = true,
isTest: Boolean = false
): StrResponse {
setCookie()
val startTime = System.currentTimeMillis()
val strResponse: StrResponse
try {
if (this.useWebView && useWebView) {
strResponse = when (method) {
RequestMethod.POST -> {
@@ -461,10 +491,36 @@ class AnalyzeUrl(
?.matches(AppPattern.xmlContentTypeRegex) == true
if (isXml && it.body?.trim()?.startsWith("<?xml", true) == false) {
StrResponse(it.raw, "<?xml version=\"1.0\"?>" + it.body)
} else if (bodyJs != null) {
val body = evalJS(bodyJs!!, it.body).toString()
StrResponse(it.raw, body)
} else it
}
}
val connectionTime = System.currentTimeMillis() - startTime
strResponse.putCallTime(connectionTime.toInt())
return strResponse
} catch (e: Exception) {
if (!isTest) {
throw e
}
val errorCode = when (e) {
is java.net.SocketTimeoutException -> -2 // 超时错误
is java.net.UnknownHostException -> -3 // 未找到域名
is java.net.ConnectException -> -4 // 连接被拒绝
is java.net.SocketException -> -5 // Socket错误(包括连接重置)
is javax.net.ssl.SSLException -> -6 // SSL证书或握手错误
is java.io.InterruptedIOException -> {
if (e.message?.contains("timeout") == true) {
-1 // 超过设定时间
} else -7
}
else -> -7 // 其它错误
}
return StrResponse(url, e.message).apply {
putCallTime(errorCode)
}
}
}
@@ -509,11 +565,31 @@ class AnalyzeUrl(
}
}
/**
* 返回一个errResponse
*/
fun getErrResponse(e: Throwable): Response = Response.Builder()
.request(Request.Builder().url(url).build())
.protocol(okhttp3.Protocol.HTTP_1_1)
.code(500)
.message(e.message ?: "Error Response")
.body(e.stackTraceStr.toResponseBody(null))
.build()
/**
* 返回一个errStrResponse
*/
fun getErrStrResponse(e: Throwable): StrResponse =
StrResponse(getErrResponse(e), e.stackTraceStr)
private fun getClient(): OkHttpClient {
val client = getProxyClient(proxy)
if (readTimeout == null && callTimeout == null) {
if (readTimeout == null && callTimeout == null && dnsIp == null) {
return client
}
if (AppConfig.isCronet && dnsIp != null) {
customIp[urlNoQuery] = dnsIp!!
}
return client.newBuilder().run {
if (readTimeout != null) {
readTimeout(readTimeout, TimeUnit.MILLISECONDS)
@@ -522,10 +598,21 @@ class AnalyzeUrl(
if (callTimeout != null) {
callTimeout(callTimeout, TimeUnit.MILLISECONDS)
}
if (dnsIp != null) {
val inetAddress = dnsIp!!.parseIpsFromString()
dns { hostname ->
inetAddress ?: Dns.SYSTEM.lookup(hostname)
}
}
build()
}
}
private fun extractHostFromUrl(url: String): String? {
return AppPattern.domainRegex.find(url)?.groupValues?.getOrNull(1)
}
fun getResponse(): Response {
return runBlocking(coroutineContext) {
getResponseAwait()
@@ -536,7 +623,7 @@ class AnalyzeUrl(
if (!urlNoQuery.startsWith("data:")) {
return null
}
val dataUriFindResult = dataUriRegex.find(urlNoQuery)
val dataUriFindResult = AppPattern.dataUriRegex.find(urlNoQuery)
if (dataUriFindResult != null) {
val dataUriBase64 = dataUriFindResult.groupValues[1]
val byteArray = Base64.decode(dataUriBase64, Base64.DEFAULT)
@@ -666,12 +753,16 @@ class AnalyzeUrl(
return source
}
override fun getTag(): String? {
return source?.getTag()
}
companion object {
val paramPattern: Pattern = Pattern.compile("\\s*,\\s*(?=\\{)")
private val pagePattern = Pattern.compile("<(.*?)>")
private val queryEncoder =
RFC3986.UNRESERVED.orNew(PercentCodec.of("!$%&()*+,/:;=?@[\\]^`{|}"))
val customIp by lazy { ConcurrentHashMap<String, String>() }
fun AnalyzeUrl.getMediaItem(): MediaItem {
setCookie()
return ExoPlayerHelper.createMediaItem(url, headerMap)
@@ -705,11 +796,20 @@ class AnalyzeUrl(
* webView中执行的js
**/
private var webJs: String? = null,
/**
* 自定义的域名ip
**/
private var dnsIp: String? = null,
/**
* 解析完url参数时执行的js
* 执行结果会赋值给url
*/
private var js: String? = null,
/**
* 得到访问结果后执行的js,对结果进行二次处理
* 执行结果返回为body
*/
private var bodyJs: String? = null,
/**
* 服务器id
*/
@@ -808,6 +908,13 @@ class AnalyzeUrl(
fun getWebJs(): String? {
return webJs
}
fun setDnsIp(value: String?) {
dnsIp = if (value.isNullOrBlank()) null else value
}
fun getDnsIp(): String? {
return dnsIp
}
fun setJs(value: String?) {
js = if (value.isNullOrBlank()) null else value
@@ -817,6 +924,14 @@ class AnalyzeUrl(
return js
}
fun setBodyJs(value: String?) {
bodyJs = if (value.isNullOrBlank()) null else value
}
fun getBodyJs(): String? {
return bodyJs
}
fun setServerID(value: String?) {
serverID = if (value.isNullOrBlank()) null else value.toLong()
}
@@ -835,14 +950,18 @@ class AnalyzeUrl(
}
data class ConcurrentRecord(
/**
* 是否按频率
*/
val isConcurrent: Boolean,
/**
* 开始访问时间
*/
var time: Long,
/**
* 限制次数
*/
var accessLimit: Int,
/**
* 间隔时间
*/
var interval: Int,
/**
* 正在访问的个数
*/
@@ -850,3 +969,4 @@ class AnalyzeUrl(
)
}
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.transition.platform.MaterialContainerTransform
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import com.script.rhino.runScriptWithContext
import io.legado.app.BuildConfig
import io.legado.app.R
import io.legado.app.constant.AppLog
@@ -63,6 +64,7 @@ import io.legado.app.model.ReadBook
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
import io.legado.app.model.localBook.EpubFile
import io.legado.app.model.localBook.MobiFile
import io.legado.app.receiver.NetworkChangedListener
@@ -102,6 +104,7 @@ import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog
import io.legado.app.ui.browser.WebViewActivity
import io.legado.app.ui.dict.DictDialog
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.login.SourceLoginJsExtensions
import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleActivity
import io.legado.app.ui.widget.PopupAction
@@ -121,7 +124,6 @@ import io.legado.app.utils.hexString
import io.legado.app.utils.iconItemOnLongClick
import io.legado.app.utils.invisible
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonObject
import io.legado.app.utils.isTrue
import io.legado.app.utils.navigationBarGravity
import io.legado.app.utils.observeEvent
@@ -1521,45 +1523,75 @@ class ReadBookActivity : BaseReadBookActivity(),
showHelp("readMenuHelp")
}
override fun clickImg(clickjs: String) {
val braceIndex = clickjs.indexOf(",{")
val braceIndex2 = if (braceIndex == -1) clickjs.indexOf(", {") else -1
if (braceIndex != -1 || braceIndex2 != -1) {
val book = ReadBook.book ?: return
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
if (chapter == null) {
toastOnUi("章节不存在")
return
}
val (result, urlOptionStr) = when {
braceIndex != -1 -> {
clickjs.take(braceIndex) to clickjs.substring(braceIndex + 1)
}
else -> {
clickjs.take(braceIndex2) to clickjs.substring(braceIndex2 + 2)
}
}
if (urlOptionStr.isJsonObject()) {
val urlOptionMap = GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrThrow()
val jsStr = urlOptionMap["js"]
jsStr?.let {
Coroutine.async(lifecycleScope) {
val source = ReadBook.bookSource ?: throw Exception("书源不存在")
AnalyzeRule(book, source).apply {
setCoroutineContext(coroutineContext)
setBaseUrl(chapter.url)
setChapter(chapter)
evalJS(jsStr, result).toString()
/**
* 点击图片
*/
override fun oldClickImg(src: String): Boolean {
val urlMatcher = paramPattern.matcher(src)
if (urlMatcher.find()) {
val urlOptionStr = src.substring(urlMatcher.end())
val urlOptionMap = GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()
val click = urlOptionMap?.get("click")
if (click != null) {
Coroutine.async(lifecycleScope, IO) {
val source = ReadBook.bookSource ?: return@async
val java = SourceLoginJsExtensions(this@ReadBookActivity, source, BookType.text)
val book = ReadBook.book ?: return@async
val chapter =
appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
?: throw Exception("no find chapter")
runScriptWithContext {
source.evalJS(click) {
put("java", java)
put("book", book)
put("chapter", chapter)
put("result", src)
}
}.onError {
AppLog.put("图片点击执行出错\n${it.localizedMessage}", it, true)
}
}.onError {
AppLog.put("执行图片链接click键值出错\n${it.localizedMessage}", it, true)
}
return true
}
val jsStr = urlOptionMap?.get("js") ?: return false
Coroutine.async(lifecycleScope, IO) {
val source = ReadBook.bookSource ?: return@async
val book = ReadBook.book ?: return@async
val chapter =
appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
?: throw Exception("no find chapter")
val urlNoOption = src.take(urlMatcher.start())
AnalyzeRule(book, source).apply {
setCoroutineContext(coroutineContext)
setBaseUrl(chapter.url)
setChapter(chapter)
evalJS(jsStr, urlNoOption)
}
}.onError {
AppLog.put("执行图片链接js键值出错\n${it.localizedMessage}", it, true)
}
return true
}
return false
}
override fun clickImg(click: String, src: String) {
Coroutine.async(lifecycleScope, IO) {
val source = ReadBook.bookSource ?: return@async
val java = SourceLoginJsExtensions(this@ReadBookActivity, source, BookType.text)
val book = ReadBook.book ?: return@async
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
?: throw Exception("no find chapter")
runScriptWithContext {
source.evalJS(click) {
put("java", java)
put("book", book)
put("chapter", chapter)
put("result", src)
}
}
else {
toastOnUi("链接格式错误")
return
}
}.onError {
AppLog.put("执行图片链接click键值出错\n${it.localizedMessage}", it, true)
}
}
@@ -11,6 +11,7 @@ import io.legado.app.data.entities.Bookmark
import io.legado.app.help.book.isOnLineTxt
import io.legado.app.help.config.AppConfig
import io.legado.app.model.ReadBook
import io.legado.app.ui.association.OpenUrlConfirmActivity
import io.legado.app.ui.book.read.page.delegate.PageDelegate
import io.legado.app.ui.book.read.page.entities.TextLine
import io.legado.app.ui.book.read.page.entities.TextPage
@@ -20,6 +21,7 @@ import io.legado.app.ui.book.read.page.entities.column.ButtonColumn
import io.legado.app.ui.book.read.page.entities.column.ImageColumn
import io.legado.app.ui.book.read.page.entities.column.ReviewColumn
import io.legado.app.ui.book.read.page.entities.column.TextColumn
import io.legado.app.ui.book.read.page.entities.column.TextHtmlColumn
import io.legado.app.ui.book.read.page.provider.ChapterProvider
import io.legado.app.ui.book.read.page.provider.TextPageFactory
import io.legado.app.ui.widget.dialog.PhotoDialog
@@ -27,6 +29,7 @@ import io.legado.app.utils.activity
import io.legado.app.utils.dpToPx
import io.legado.app.utils.getCompatColor
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import java.util.concurrent.Executors
import kotlin.math.max
@@ -246,18 +249,51 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
handled = true
}
is ImageColumn -> if (AppConfig.previewImageByClick) {
activity?.showDialogFragment(PhotoDialog(column.src))
handled = true
} else {
if (ReadBook.book?.isOnLineTxt == true) {
val src = column.src
if (src.contains("\"js\"") || src.contains("'js'")) {
callBack.clickImg(src)
is ImageColumn -> when (AppConfig.clickImgWay) {
"1" -> { //预览图片
activity?.showDialogFragment(PhotoDialog(column.src))
handled = true
}
"2" -> { //兼容处理
if (ReadBook.book?.isOnLineTxt == true) {
val click = column.click
val src = column.src
if (!click.isNullOrBlank()) {
callBack.clickImg(click, src)
handled = true
} else {
handled = callBack.oldClickImg(src)
}
}
}
"3" -> { //关闭
handled = false
}
else -> { //默认点击
val click = column.click
if (!click.isNullOrBlank()) {
callBack.clickImg(click, column.src)
handled = true
}
}
}
is TextHtmlColumn -> {
column.linkUrl?.let {
activity?.startActivity<OpenUrlConfirmActivity> {
putExtra("uri", it)
// putExtra("mimeType", mimeType)
// putExtra("sourceOrigin", source.getKey())
// putExtra("sourceName", source.getTag())
// putExtra("sourceType", source.getSourceType())
}
handled = true
}
}
}
}
return handled
@@ -723,6 +759,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
fun onImageLongPress(x: Float, y: Float, src: String)
fun onCancelSelect()
fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean
fun clickImg(clickjs: String)
fun oldClickImg(src: String): Boolean
fun clickImg(click: String, src: String)
}
}
@@ -8,6 +8,7 @@ import io.legado.app.model.ReadBook
import io.legado.app.ui.book.read.page.ContentTextView
import io.legado.app.ui.book.read.page.entities.TextLine
import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
import io.legado.app.utils.dpToPx
import io.legado.app.utils.toastOnUi
import splitties.init.appCtx
@@ -18,7 +19,8 @@ import splitties.init.appCtx
data class ImageColumn(
override var start: Float,
override var end: Float,
var src: String
var src: String,
var click: String? = null
) : BaseColumn {
override var textLine: TextLine = emptyTextLine
@@ -49,4 +51,8 @@ data class ImageColumn(
}
}
}
override fun isTouch(x: Float): Boolean {
return x > start && x < end + 20.dpToPx()
}
}
@@ -27,6 +27,7 @@ import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.ImageProvider
import io.legado.app.model.ReadBook
import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
import io.legado.app.ui.book.read.page.entities.TextChapter
import io.legado.app.ui.book.read.page.entities.TextLine
import io.legado.app.ui.book.read.page.entities.TextPage
@@ -37,9 +38,11 @@ import io.legado.app.ui.book.read.page.provider.ChapterProvider.reviewChar
import io.legado.app.ui.book.read.page.provider.ChapterProvider.srcReplaceChar
import io.legado.app.ui.book.read.page.provider.ChapterProvider.srcReplaceCharC
import io.legado.app.ui.book.read.page.provider.ChapterProvider.srcReplaceCharD
import io.legado.app.utils.GSON
import io.legado.app.utils.StringUtils
import io.legado.app.utils.dpToPx
import io.legado.app.utils.fastSum
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getTextWidthsCompat
import io.legado.app.utils.splitNotBlank
import kotlinx.coroutines.CancellationException
@@ -340,6 +343,7 @@ class TextChapterLayout(
}
var start = 0
val srcList = LinkedList<String>()
val clickList = LinkedList<String?>()
sb.setLength(0)
var isFirstLine = true
if (content.contains("<img")) {
@@ -347,51 +351,58 @@ class TextChapterLayout(
while (matcher.find()) {
currentCoroutineContext().ensureActive()
val imgSrc = matcher.group(1)!!
var iStyle = imageStyle
var isSmallImage = true
val matchResult = AppPattern.imgStyRegex.find(imgSrc)
var imgSize: Size? = null
if (matchResult != null) {
val styleValue = matchResult.groupValues[1].trim()
if (styleValue.equals("text", true)) { //忽略大小写
iStyle = styleValue
} else {
imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
iStyle = styleValue
isSmallImage = false
}
} else {
imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
if (imgSize.width < 80 && imgSize.height < 80) {
iStyle = "text"
} else {
isSmallImage = false
var iStyle: String? = null
var click: String? = null
var imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
val urlMatcher = paramPattern.matcher(imgSrc)
if (urlMatcher.find()) {
var width: String? = null
val urlOptionStr = imgSrc.substring(urlMatcher.end())
GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()
?.let { map ->
map.forEach { (key, value) ->
when (key) {
"style" -> iStyle = value
"width" -> width = value
"click" -> click = value
}
}
}
width?.let {
if (it.endsWith("%")) {
it.dropLast(1).toIntOrNull()?.let { percentage ->
val imgWidth = visibleWidth * percentage / 100
val newHeight = imgSize.height * imgWidth / imgSize.width
imgSize = Size(imgWidth, newHeight)
}
} else {
it.toIntOrNull()?.let { w ->
val newHeight = imgSize.height * w / imgSize.width
imgSize = Size(w, newHeight)
}
}
}
}
if (iStyle == null) {
iStyle =
if (imgSize.width < 80 && imgSize.height < 80) "text" else imageStyle
}
if (start < matcher.start()) {
sb.append(text.substring(start, matcher.start()))
}
if (isSmallImage) {
sb.append(
if (iStyle == "TEXT")
reviewChar
else
srcReplaceChar
)
if (iStyle == "text" || iStyle == "TEXT") {
sb.append(if (iStyle == "TEXT") reviewChar else srcReplaceChar)
srcList.add(imgSrc)
clickList.add(click)
} else {
val textBefore = sb.toString()
if (textBefore.isNotBlank()) {
wordCount += textBefore.replace(noWordCountRegex,"").length
setTypeText(
book,
sb.toString(),
contentPaint,
contentPaintTextHeight,
contentPaintFontMetrics,
"TEXT",
isFirstLine = isFirstLine,
srcList = srcList
book, sb.toString(), contentPaint, contentPaintTextHeight,
contentPaintFontMetrics, "TEXT", isFirstLine = isFirstLine,
srcList = srcList, clickList = clickList
)
sb.setLength(0)
isFirstLine = false
@@ -401,8 +412,9 @@ class TextChapterLayout(
imgSrc,
contentPaintTextHeight,
iStyle,
imgSize!!
)
imgSize,
click
) // 传递点击信息
isSetTypedImage = true
}
start = matcher.end()
@@ -427,7 +439,8 @@ class TextChapterLayout(
contentPaintFontMetrics,
"TEXT",
isFirstLine = isFirstLine,
srcList = srcList.ifEmpty { null }
srcList = srcList.ifEmpty { null },
clickList = clickList.ifEmpty { null }
)
}
}
@@ -459,7 +472,8 @@ class TextChapterLayout(
src: String,
textHeight: Float,
imageStyle: String?,
size: Size
size: Size,
click: String? = null
) {
if (size.width > 0 && size.height > 0) {
prepareNextPageIfNeed(durY)
@@ -776,7 +790,8 @@ class TextChapterLayout(
isFirstLine: Boolean = true,
emptyContent: Boolean = false,
isVolumeTitle: Boolean = false,
srcList: LinkedList<String>? = null
srcList: LinkedList<String>? = null,
clickList: LinkedList<String?>? = null
) {
val widthsArray = allocateFloatArray(text.length)
textPaint.getTextWidthsCompat(text, widthsArray)
@@ -836,7 +851,7 @@ class TextChapterLayout(
//多行的第一行 非标题
addCharsToLineFirst(
book, absStartX, textLine, words, textPaint,
desiredWidth, widths, srcList
desiredWidth, widths, srcList, clickList
)
}
@@ -854,7 +869,7 @@ class TextChapterLayout(
}
addCharsToLineNatural(
book, absStartX, textLine, words,
startX, !isTitle && lineIndex == 0, widths, srcList
startX, !isTitle && lineIndex == 0, widths, srcList, clickList
)
}
else -> {
@@ -867,13 +882,13 @@ class TextChapterLayout(
val startX = (visibleWidth - desiredWidth) / 2
addCharsToLineNatural(
book, absStartX, textLine, words,
startX, false, widths, srcList
startX, false, widths, srcList, clickList
)
} else {
//中间行
addCharsToLineMiddle(
book, absStartX, textLine, words, textPaint,
desiredWidth, 0f, widths, srcList
desiredWidth, 0f, widths, srcList, clickList
)
}
}
@@ -926,13 +941,14 @@ class TextChapterLayout(
/**自然排版长度**/
desiredWidth: Float,
textWidths: List<Float>,
srcList: LinkedList<String>?
srcList: LinkedList<String>?,
clickList: LinkedList<String?>?
) {
var x = 0f
if (!textFullJustify) {
addCharsToLineNatural(
book, absStartX, textLine, words,
x, true, textWidths, srcList
x, true, textWidths, srcList, clickList
)
return
}
@@ -955,7 +971,7 @@ class TextChapterLayout(
val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size)
addCharsToLineMiddle(
book, absStartX, textLine, text1, textPaint,
desiredWidth, x, textWidths1, srcList
desiredWidth, x, textWidths1, srcList, clickList
)
}
}
@@ -974,12 +990,13 @@ class TextChapterLayout(
/**起始x坐标**/
startX: Float,
textWidths: List<Float>,
srcList: LinkedList<String>?
srcList: LinkedList<String>?,
clickList: LinkedList<String?>?
) {
if (!textFullJustify) {
addCharsToLineNatural(
book, absStartX, textLine, words,
startX, false, textWidths, srcList
startX, false, textWidths, srcList, clickList
)
return
}
@@ -1000,7 +1017,7 @@ class TextChapterLayout(
}
addCharToLine(
book, absStartX, textLine, char,
x, x1, index + 1 == words.size, srcList
x, x1, index + 1 == words.size, srcList, clickList
)
x = x1
}
@@ -1016,7 +1033,7 @@ class TextChapterLayout(
val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw)
addCharToLine(
book, absStartX, textLine, char,
x, x1, index + 1 == words.size, srcList
x, x1, index + 1 == words.size, srcList, clickList
)
x = x1
}
@@ -1035,7 +1052,8 @@ class TextChapterLayout(
startX: Float,
hasIndent: Boolean,
textWidths: List<Float>,
srcList: LinkedList<String>?
srcList: LinkedList<String>?,
clickList: LinkedList<String?>?
) {
val indentLength = paragraphIndent.length
var x = startX
@@ -1044,7 +1062,17 @@ class TextChapterLayout(
val char = words[index]
val cw = textWidths[index]
val x1 = x + cw
addCharToLine(book, absStartX, textLine, char, x, x1, index + 1 == words.size, srcList)
addCharToLine(
book,
absStartX,
textLine,
char,
x,
x1,
index + 1 == words.size,
srcList,
clickList
)
x = x1
if (hasIndent && index == indentLength - 1) {
textLine.indentWidth = x
@@ -1064,16 +1092,19 @@ class TextChapterLayout(
xStart: Float,
xEnd: Float,
isLineEnd: Boolean,
srcList: LinkedList<String>?
srcList: LinkedList<String>?,
clickList: LinkedList<String?>?
) {
val column = when {
!srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> {
val src = srcList.removeFirst()
val click = clickList?.removeFirst()
ImageProvider.cacheImage(book, src, ReadBook.bookSource)
ImageColumn(
start = absStartX + xStart,
end = absStartX + xEnd,
src = src
src = src,
click = click
)
}
// isLineEnd && char == ChapterProvider.reviewChar -> {
@@ -663,9 +663,10 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
companion object {
fun start(context: Context, key: String?) {
fun start(context: Context, key: String?, searchScope: String? = null) {
context.startActivity<SearchActivity> {
putExtra("key", key)
putExtra("searchScope", searchScope)
}
}
@@ -1,10 +1,20 @@
package io.legado.app.ui.login
//import io.legado.app.lib.theme.primaryColor
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.graphics.Rect
import android.os.Bundle
import android.text.InputType
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatSpinner
import androidx.core.view.setPadding
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
@@ -14,18 +24,25 @@ import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.AppLog
import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.rule.RowUi
import io.legado.app.data.entities.rule.RowUi.Type
import io.legado.app.databinding.DialogLoginBinding
import io.legado.app.databinding.ItemFilletTextBinding
import io.legado.app.databinding.ItemSelectorSingleBinding
import io.legado.app.databinding.ItemSourceEditBinding
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.widget.text.TextInputLayout
import io.legado.app.utils.GSON
import io.legado.app.utils.applyTint
import io.legado.app.utils.dpToPx
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isTrue
import io.legado.app.utils.openUrl
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.sendToClip
import io.legado.app.utils.setLayout
import io.legado.app.utils.setSelectionSafely
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -35,72 +52,543 @@ import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
import splitties.views.onClick
import kotlin.math.abs
class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
private val binding by viewBinding(DialogLoginBinding::bind)
private val viewModel by activityViewModels<SourceLoginViewModel>()
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
val source = viewModel.source ?: return
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.title = getString(R.string.login_source, source.getTag())
val loginInfo = source.getLoginInfoMap()
val loginUi = source.loginUi()
try {
loginUi?.forEachIndexed { index, rowUi ->
when (rowUi.type) {
RowUi.Type.text -> ItemSourceEditBinding.inflate(
layoutInflater,
binding.root,
false
).let {
binding.flexbox.addView(it.root)
it.root.id = index + 1000
it.textInputLayout.hint = rowUi.name
it.editText.setText(loginInfo?.get(rowUi.name))
private var lastClickTime: Long = 0
private var oKToClose = false
private var rowUis: List<RowUi>? = null
private var rowUiName = arrayListOf<String>()
private var hasChange = false
private val sourceLoginJsExtensions by lazy {
SourceLoginJsExtensions(
activity as AppCompatActivity,
viewModel.source,
viewModel.bookType,
callback = object : SourceLoginJsExtensions.Callback {
override fun upUiData(data: Map<String, String?>?) {
activity?.runOnUiThread { // 在主线程中更新 UI
handleUpUiData(data)
}
}
RowUi.Type.password -> ItemSourceEditBinding.inflate(
layoutInflater,
binding.root,
false
).let {
binding.flexbox.addView(it.root)
it.root.id = index + 1000
it.textInputLayout.hint = rowUi.name
it.editText.inputType =
InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
it.editText.setText(loginInfo?.get(rowUi.name))
}
RowUi.Type.button -> ItemFilletTextBinding.inflate(
layoutInflater,
binding.root,
false
).let {
binding.flexbox.addView(it.root)
rowUi.style().apply(it.root)
it.root.id = index + 1000
it.textView.text = rowUi.name
it.textView.setPadding(16.dpToPx())
it.root.onClick {
handleButtonClick(source, rowUi, loginUi)
}
override fun reUiView() {
activity?.runOnUiThread {
handleReUiView()
}
}
}
} catch (e: NullPointerException) {
AppLog.put("登录UI JSON 数据错误", e, true)
)
}
private fun handleReUiView() {
val source = viewModel.source ?: return
val loginUiStr = source.loginUi ?: return
val codeStr = loginUiStr.let {
when {
it.startsWith("@js:") -> it.substring(4)
it.startsWith("<js>") -> it.substring(4, it.lastIndexOf("<"))
else -> null
}
}
binding.toolBar.inflateMenu(R.menu.source_login)
binding.toolBar.menu.applyTint(requireContext())
if (codeStr != null) {
hasChange = true
lifecycleScope.launch(Main) {
val loginUiJson = evalUiJs(codeStr)
rowUis = loginUi(loginUiJson)
binding.flexbox.removeAllViews()
rowUiBuilder(source, rowUis)
}
} else {
rowUis = loginUi(loginUiStr)
binding.flexbox.removeAllViews()
rowUiBuilder(source, rowUis)
}
}
@SuppressLint("SetTextI18n")
private fun handleUpUiData(data: Map<String, String?>?) {
hasChange = true
if (data == null) {
val newLoginInfo: MutableMap<String, String> = mutableMapOf()
rowUis?.forEachIndexed { index, rowUi ->
val default = rowUi.default
when (val rowView = binding.root.findViewById<View>(index + 1000)) {
is TextInputLayout -> {
newLoginInfo[rowUi.name] = default ?: ""
rowView.editText?.setText(default ?: "")
}
is TextView -> {
when (rowUi.type) {
Type.button -> {
rowView.text = rowUi.viewName ?: rowUi.name
}
Type.toggle -> {
val char = default ?: run {
val chars =
rowUi.chars?.filterNotNull() ?: listOf("chars is null")
chars.getOrNull(0) ?: ""
}
newLoginInfo[rowUi.name] = char
val name = rowUi.viewName ?: rowUi.name
val left = rowUi.style?.layout_justifySelf != "right"
rowView.text = if (left) char + name else name + char
}
}
}
is LinearLayout -> {
val chars = rowUi.chars?.filterNotNull() ?: listOf("chars", "is null")
val index = chars.indexOf(default)
newLoginInfo[rowUi.name] = default ?: run {
chars.getOrNull(0) ?: ""
}
rowView.findViewById<AppCompatSpinner>(R.id.sp_type)
?.setSelectionSafely(index)
}
}
}
viewModel.loginInfo = newLoginInfo
return
}
val loginInfo = viewModel.loginInfo
data.forEach { (key, value) ->
val index = rowUiName.indexOf(key)
if (index != -1) {
when (val rowView = binding.root.findViewById<View>(index + 1000)) {
is TextInputLayout -> {
val value = value ?: run {
val rowUi = rowUis?.getOrNull(index) ?: return@forEach
rowUi.default ?: ""
}
rowView.editText?.setText(value)
}
is TextView -> {
val rowUi = rowUis?.getOrNull(index) ?: return@forEach
when (rowUi.type) {
Type.button -> {
rowView.text = value ?: key
}
Type.toggle -> {
val char = value ?: run {
val chars =
rowUi.chars?.filterNotNull() ?: listOf("chars is null")
chars.getOrNull(0) ?: ""
}
loginInfo[rowUi.name] = char
val name = rowUi.viewName ?: rowUi.name
val left = rowUi.style?.layout_justifySelf != "right"
rowView.text = if (left) char + name else name + char
}
}
}
is LinearLayout -> {
val rowUi = rowUis?.getOrNull(index) ?: return@forEach
val items = rowUi.chars?.filterNotNull() ?: listOf("chars", "is null")
val index = items.indexOf(value)
loginInfo[rowUi.name] = value ?: run {
items.getOrNull(0) ?: ""
}
rowView.findViewById<AppCompatSpinner>(R.id.sp_type)
?.setSelectionSafely(index)
}
}
} else {
loginInfo[key] = value ?: ""
}
}
}
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
suspend fun evalUiJs(jsStr: String): String? = withContext(IO) {
val source = viewModel.source ?: return@withContext null
val loginJS = source.getLoginJs() ?: ""
val result = rowUis?.let {
getLoginData(it)
} ?: viewModel.loginInfo.toMutableMap()
try {
runScriptWithContext {
source.evalJS("$loginJS\n$jsStr") {
put("result", result)
put("book", viewModel.book)
put("chapter", viewModel.chapter)
}.toString()
}
} catch (e: Exception) {
AppLog.put(source.getTag() + " loginUi err:" + (e.localizedMessage ?: e.toString()), e)
null
}
}
fun loginUi(json: String?): List<RowUi>? {
return GSON.fromJsonArray<RowUi>(json).onFailure {
it.printOnDebug()
}.getOrNull()
}
@SuppressLint("SetTextI18n", "ClickableViewAccessibility")
private fun rowUiBuilder(source: BaseSource, rowUis: List<RowUi>?) {
val loginInfo = viewModel.loginInfo
rowUiName.clear()
rowUis?.forEachIndexed { index, rowUi ->
val type = rowUi.type
val name = rowUi.name
val viewName = rowUi.viewName
val action = rowUi.action
rowUiName.add(name)
when (type) {
Type.text -> ItemSourceEditBinding.inflate(
layoutInflater,
binding.root,
false
).let {
val editText = it.editText
binding.flexbox.addView(it.root)
rowUi.style().apply {
when (this.layout_justifySelf) {
"center" -> editText.gravity = Gravity.CENTER
"flex_end" -> editText.gravity = Gravity.END
}
apply(it.root)
}
it.root.id = index + 1000
if (viewName == null) {
it.textInputLayout.hint = name
} else if (viewName.length in 3..19 && viewName.first() == '\'' && viewName.last() == '\'') {
it.textInputLayout.hint = viewName.substring(1, viewName.length - 1)
} else {
it.textInputLayout.hint = name
execute {
evalUiJs(viewName)
}.onSuccess { n ->
if (n.isNullOrEmpty()) {
it.textInputLayout.hint = "null"
} else {
it.textInputLayout.hint = n
}
}.onError { _ ->
it.textInputLayout.hint = "err"
}
}
editText.setText(loginInfo[name])
action?.let { jsStr ->
var content: String? = null
editText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
content = editText.text.toString()
} else {
val reContent = editText.text.toString()
if (content != reContent) {
execute {
evalUiJs(jsStr)
}.onSuccess { result ->
if (result.isTrue()) {
loginInfo[name] = reContent
hasChange = true
}
}.onError { e ->
AppLog.put("LoginUI Text $name JavaScript error", e)
}
}
}
}
editText.viewTreeObserver.addOnGlobalLayoutListener {
if (!editText.hasFocus()) {
return@addOnGlobalLayoutListener
}
val rect = Rect()
binding.root.getWindowVisibleDisplayFrame(rect)
val screenHeight = binding.root.height
val keypadHeight = screenHeight - rect.bottom
if (abs(keypadHeight) < screenHeight / 5) {
editText.clearFocus()
}
}
}
}
Type.password -> ItemSourceEditBinding.inflate(
layoutInflater,
binding.root,
false
).let {
val editText = it.editText
binding.flexbox.addView(it.root)
rowUi.style().apply {
when (this.layout_justifySelf) {
"center" -> editText.gravity = Gravity.CENTER
"flex_end" -> editText.gravity = Gravity.END
}
apply(it.root)
}
it.root.id = index + 1000
if (viewName == null) {
it.textInputLayout.hint = name
} else if (viewName.length in 3..19 && viewName.first() == '\'' && viewName.last() == '\'') {
it.textInputLayout.hint = viewName.substring(1, viewName.length - 1)
} else {
it.textInputLayout.hint = name
execute {
evalUiJs(viewName)
}.onSuccess { n ->
if (n.isNullOrEmpty()) {
it.textInputLayout.hint = "null"
} else {
it.textInputLayout.hint = n
}
}.onError { _ ->
it.textInputLayout.hint = "err"
}
}
editText.inputType =
InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
editText.setText(loginInfo[name])
action?.let { jsStr ->
var content: String? = null
editText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
content = editText.text.toString()
} else {
val reContent = editText.text.toString()
if (content != reContent) {
execute {
evalUiJs(jsStr)
}.onSuccess { result ->
if (result.isTrue()) {
loginInfo[name] = reContent
hasChange = true
}
}.onError { e ->
AppLog.put("LoginUI Text $name JavaScript error", e)
}
}
}
}
editText.viewTreeObserver.addOnGlobalLayoutListener {
if (!editText.hasFocus()) {
return@addOnGlobalLayoutListener
}
val rect = Rect()
binding.root.getWindowVisibleDisplayFrame(rect)
val screenHeight = binding.root.height
val keypadHeight = screenHeight - rect.bottom
if (abs(keypadHeight) < screenHeight / 5) {
editText.clearFocus()
}
}
}
}
Type.select -> ItemSelectorSingleBinding.inflate(
layoutInflater,
binding.root,
false
).let {
if (viewName == null) {
it.spName.text = name
} else if (viewName.length in 3..19 && viewName.first() == '\'' && viewName.last() == '\'') {
it.spName.text = viewName.substring(1, viewName.length - 1)
} else {
it.spName.text = name
execute {
evalUiJs(viewName)
}.onSuccess { n ->
if (n.isNullOrEmpty()) {
it.spName.text = "null"
} else {
it.spName.text = n
}
}.onError { _ ->
it.spName.text = "err"
}
}
val chars = rowUi.chars?.filterNotNull() ?: listOf("chars", "is null")
val adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_list_item_1,
chars
)
val selector = it.spType
selector.adapter = adapter
val infoV = loginInfo[name]
val char = if (infoV.isNullOrEmpty()) {
hasChange = true
rowUi.default ?: chars[0]
} else {
infoV
}
loginInfo[name] = char
val i = chars.indexOf(char)
selector.setSelectionSafely(i)
selector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
var isInitializing = true
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
if (isInitializing) { //忽略初始化选择
isInitializing = false
return
}
hasChange = true
loginInfo[name] = chars[position]
if (action != null) {
execute {
handleButtonClick(source, action, name, rowUis, false)
}.onError { e ->
AppLog.put("LoginUI Select $name JavaScript error", e)
}
}
}
override fun onNothingSelected(parent: AdapterView<*>?) {
}
}
binding.flexbox.addView(it.root)
rowUi.style().apply {
when (this.layout_justifySelf) {
"flex_start" -> selector.gravity = Gravity.START
"flex_end" -> selector.gravity = Gravity.END
}
apply(it.root)
}
it.root.id = index + 1000
}
Type.button -> ItemFilletTextBinding.inflate(
layoutInflater,
binding.root,
false
).let {
binding.flexbox.addView(it.root)
rowUi.style().apply {
when (this.layout_justifySelf) {
"flex_start" -> it.textView.gravity = Gravity.START
"flex_end" -> it.textView.gravity = Gravity.END
}
apply(it.root)
}
it.root.id = index + 1000
it.textView.text = viewName ?: name
it.textView.setPadding(16.dpToPx())
it.root.setOnClickListener {
val upTime = System.currentTimeMillis()
if (upTime - lastClickTime < 200) return@setOnClickListener
lastClickTime = upTime
handleButtonClick(source, action, name, rowUis, false)
}
it.root.setOnLongClickListener {
handleButtonClick(source, action, name, rowUis, true)
true
}
}
Type.toggle -> ItemFilletTextBinding.inflate(
layoutInflater,
binding.root,
false
).let { itBinding ->
var newName = name
var left = true
binding.flexbox.addView(itBinding.root)
rowUi.style().apply {
when (this.layout_justifySelf) {
"flex_start" -> itBinding.textView.gravity = Gravity.START
"flex_end" -> itBinding.textView.gravity = Gravity.END
"right" -> left = false
}
apply(itBinding.root)
}
itBinding.root.id = index + 1000
val chars = rowUi.chars?.filterNotNull() ?: listOf("chars is null")
val infoV = loginInfo[name]
var char = if (infoV.isNullOrEmpty()) {
hasChange = true
rowUi.default ?: chars[0]
} else {
infoV
}
loginInfo[name] = char
val updateText = { currentChar: String, displayName: String ->
itBinding.textView.text =
if (left) currentChar + displayName else displayName + currentChar
}
if (viewName == null) {
updateText(char, name)
} else if (viewName.length in 3..19 && viewName.first() == '\'' && viewName.last() == '\'') {
val n = viewName.substring(1, viewName.length - 1)
rowUi.viewName = n
newName = n
updateText(char, n)
} else {
updateText(char, name)
execute {
evalUiJs(viewName)
}.onSuccess { n ->
if (!n.isNullOrEmpty()) {
rowUi.viewName = n
newName = n
updateText(char, n)
}
}
}
itBinding.textView.setPadding(16.dpToPx())
val toggleAction = { isLong: Boolean ->
val currentIndex = chars.indexOf(char)
val nextIndex = (currentIndex + 1) % chars.size
char = chars.getOrNull(nextIndex) ?: ""
hasChange = true
loginInfo[name] = char
updateText(char, newName)
handleButtonClick(source, action, name, rowUis, isLong)
}
itBinding.root.setOnClickListener {
val upTime = System.currentTimeMillis()
if (upTime - lastClickTime < 200) return@setOnClickListener
lastClickTime = upTime
toggleAction(false)
}
itBinding.root.setOnLongClickListener {
toggleAction(true)
true
}
}
}
}
}
private fun buttonUi(source: BaseSource, rowUis: List<RowUi>?) {
rowUiBuilder(source, rowUis)
binding.toolBar.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.menu_ok -> {
val loginData = getLoginData(loginUi)
oKToClose = true
val loginData = getLoginData(rowUis, true)
login(source, loginData)
}
@@ -121,44 +609,86 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
}
}
private fun handleButtonClick(source: BaseSource, rowUi: RowUi, loginUi: List<RowUi>) {
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
val source = viewModel.source ?: return
val loginUiStr = source.loginUi ?: return
val codeStr = loginUiStr.let {
when {
it.startsWith("@js:") -> it.substring(4)
it.startsWith("<js>") -> it.substring(4, it.lastIndexOf("<"))
else -> null
}
}
if (codeStr != null) {
lifecycleScope.launch(Main) {
val loginUiJson = evalUiJs(codeStr)
rowUis = loginUi(loginUiJson)
buttonUi(source, rowUis)
}
} else {
rowUis = loginUi(loginUiStr)
buttonUi(source, rowUis)
}
binding.toolBar.title = getString(R.string.login_source, source.getTag())
binding.toolBar.inflateMenu(R.menu.source_login)
binding.toolBar.menu.applyTint(requireContext())
}
private fun handleButtonClick(
source: BaseSource,
action: String?,
name: String,
rowUis: List<RowUi>,
isLongClick: Boolean
) {
lifecycleScope.launch(IO) {
if (rowUi.action.isAbsUrl()) {
context?.openUrl(rowUi.action!!)
} else if (rowUi.action != null) {
if (action.isAbsUrl()) {
context?.openUrl(action!!)
} else if (action != null) {
// JavaScript
val buttonFunctionJS = rowUi.action!!
val buttonFunctionJS = action
val loginJS = source.getLoginJs() ?: return@launch
kotlin.runCatching {
runScriptWithContext {
source.evalJS("$loginJS\n$buttonFunctionJS") {
put("result", getLoginData(loginUi))
put("java", sourceLoginJsExtensions)
put("result", getLoginData(rowUis))
put("book", viewModel.book)
put("chapter", viewModel.chapter)
put("isLongClick", isLongClick)
}
}
}.onFailure { e ->
ensureActive()
AppLog.put("LoginUI Button ${rowUi.name} JavaScript error", e)
AppLog.put("LoginUI Button $name JavaScript error", e)
}
}
}
}
private fun getLoginData(loginUi: List<RowUi>?): HashMap<String, String> {
private fun getLoginData(
rowUis: List<RowUi>?,
save: Boolean = false
): MutableMap<String, String> {
val loginData = hashMapOf<String, String>()
loginUi?.forEachIndexed { index, rowUi ->
rowUis?.forEachIndexed { index, rowUi ->
when (rowUi.type) {
"text", "password" -> {
Type.text, Type.password -> {
val rowView = binding.root.findViewById<View>(index + 1000)
ItemSourceEditBinding.bind(rowView).editText.text?.let {
loginData[rowUi.name] = it.toString()
ItemSourceEditBinding.bind(rowView).editText.text.let {
loginData[rowUi.name] =
it?.toString() ?: rowUi.default ?: "" //没文本的时候存空字符串,而不是删除loginInfo
}
}
}
}
return loginData
if (save) {
return viewModel.loginInfo.apply { putAll(loginData) }
}
return viewModel.loginInfo.toMutableMap().apply { putAll(loginData) }
}
private fun login(source: BaseSource, loginData: HashMap<String, String>) {
private fun login(source: BaseSource, loginData: MutableMap<String, String>) {
lifecycleScope.launch(IO) {
if (loginData.isEmpty()) {
source.removeLoginInfo()
@@ -184,6 +714,14 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
}
override fun onDismiss(dialog: DialogInterface) {
if (!oKToClose && hasChange) {
val loginInfo = viewModel.loginInfo
if (loginInfo.isEmpty()) {
viewModel.source?.removeLoginInfo()
} else {
viewModel.source?.putLoginInfo(GSON.toJson(loginInfo))
}
}
super.onDismiss(dialog)
activity?.finish()
}
@@ -0,0 +1,82 @@
package io.legado.app.ui.login
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import io.legado.app.R
import io.legado.app.constant.EventBus
import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.HttpTTS
import io.legado.app.model.ReadAloud
import io.legado.app.ui.rss.read.RssJsExtensions
import io.legado.app.ui.widget.dialog.BottomWebViewDialog
import io.legado.app.utils.FileUtils
import io.legado.app.utils.postEvent
import io.legado.app.utils.sendToClip
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import java.io.File
@Suppress("unused")
class SourceLoginJsExtensions(
activity: AppCompatActivity?, source: BaseSource?,
private val bookType: Int = 0,
private val callback: Callback? = null
) : RssJsExtensions(activity, source) {
interface Callback {
fun upUiData(data: Map<String, String?>?)
fun reUiView()
}
fun upLoginData(data: Map<String, String?>?) {
callback?.upUiData(data)
}
fun reLoginView() {
callback?.reUiView()
}
fun refreshExplore() {
callback?.reUiView()
}
fun refreshBookInfo() {
postEvent(EventBus.REFRESH_BOOK_INFO, true)
}
fun copyText(text: String) {
activityRef.get()?.sendToClip(text)
}
fun clearTtsCache() {
if (getSource() !is HttpTTS) return
val activity = activityRef.get() ?: return
activity.lifecycleScope.launch(IO) {
ReadAloud.upReadAloudClass()
val ttsFolderPath =
"${activity.cacheDir.absolutePath}${File.separator}httpTTS${File.separator}"
FileUtils.listDirsAndFiles(ttsFolderPath)?.forEach {
FileUtils.delete(it.absolutePath)
}
activity.toastOnUi(R.string.clear_cache_success)
}
}
@JvmOverloads
fun showBrowser(url: String, html: String, preloadJs: String? = null) {
val activity = activityRef.get() ?: return
val source = getSource() ?: return
activity.showDialogFragment(
BottomWebViewDialog(
source.getKey(),
bookType,
url,
html,
preloadJs
)
)
}
}
@@ -5,28 +5,63 @@ import android.content.Intent
import com.script.rhino.runScriptWithContext
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType
import io.legado.app.data.appDb
import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.AudioPlay
import io.legado.app.model.ReadBook
import io.legado.app.utils.toastOnUi
class SourceLoginViewModel(application: Application) : BaseViewModel(application) {
var source: BaseSource? = null
var headerMap: Map<String, String> = emptyMap()
var book: Book? = null
var bookType: Int = 0
var chapter: BookChapter? = null
var loginInfo: MutableMap<String, String> = mutableMapOf()
fun initData(intent: Intent, success: (bookSource: BaseSource) -> Unit, error: () -> Unit) {
execute {
val sourceKey = intent.getStringExtra("key")
?: throw NoStackTraceException("没有参数")
when (intent.getStringExtra("type")) {
"bookSource" -> source = appDb.bookSourceDao.getBookSource(sourceKey)
"rssSource" -> source = appDb.rssSourceDao.getByKey(sourceKey)
"httpTts" -> source = appDb.httpTTSDao.get(sourceKey.toLong())
bookType = intent.getIntExtra("bookType", 0)
when (bookType) {
BookType.text -> {
source = ReadBook.bookSource
book = ReadBook.book?.also {
chapter =
appDb.bookChapterDao.getChapter(it.bookUrl, ReadBook.durChapterIndex)
}
}
BookType.audio -> {
source = AudioPlay.bookSource
book = AudioPlay.book
chapter = AudioPlay.durChapter
}
else -> {
val sourceKey = intent.getStringExtra("key")
?: throw NoStackTraceException("没有参数")
val type = intent.getStringExtra("type")
source = when (type) {
"bookSource" -> appDb.bookSourceDao.getBookSource(sourceKey)
"rssSource" -> appDb.rssSourceDao.getByKey(sourceKey)
"httpTts" -> appDb.httpTTSDao.get(sourceKey.toLong())
else -> null
}
val bookUrl = intent.getStringExtra("bookUrl")
book = bookUrl?.let {
appDb.bookDao.getBook(it) ?: appDb.searchBookDao.getSearchBook(it)?.toBook()
}
}
}
headerMap = runScriptWithContext {
source?.getHeaderMap(true) ?: emptyMap()
}
source?.let { loginInfo = it.getLoginInfoMap() }
source
}.onSuccess {
if (it != null) {
@@ -121,7 +121,6 @@ fun ReplaceRuleScreen(
val uploadRepository: UploadRepository = koinInject()
//TODO: 期望换为Navigation
val context = LocalContext.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@@ -12,7 +12,6 @@ import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.repository.ReplaceRuleRepository
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.ReplaceAnalyzer
import io.legado.app.help.http.decompressed
import io.legado.app.help.http.newCallResponseBody
@@ -80,9 +80,9 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
return
}
val firstArticle = articles.first()
val dbFirstArticle = appDb.rssArticleDao.get(firstArticle.origin, firstArticle.link)
val dbFirstArticle = appDb.rssArticleDao.getByLink(firstArticle.origin, firstArticle.link)
val lastArticle = articles.last()
val dbLastArticle = appDb.rssArticleDao.get(lastArticle.origin, lastArticle.link)
val dbLastArticle = appDb.rssArticleDao.getByLink(lastArticle.origin, lastArticle.link)
if (dbFirstArticle != null && dbLastArticle != null) {
loadFinallyLiveData.postValue(false)
} else {
@@ -2,6 +2,7 @@
package io.legado.app.ui.rss.article
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
@@ -23,8 +24,14 @@ import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.rss.read.ReadRssActivity.RedirectPolicy
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
import io.legado.app.ui.widget.dialog.VariableDialog
import io.legado.app.utils.*
import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.gone
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.themeColor
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -245,4 +252,14 @@ class RssSortActivity : VMBaseActivity<ActivityRssArtivlesBinding, RssSortViewMo
return RssArticlesFragment(sort.first, sort.second)
}
}
companion object {
fun start(context: Context, sortUrl: String?, sourceUrl: String, key: String? = null) {
context.startActivity<RssSortActivity> {
putExtra("sortUrl", sortUrl)
putExtra("sourceUrl", sourceUrl)
putExtra("key", key)
}
}
}
}
@@ -1,6 +1,7 @@
package io.legado.app.ui.rss.read
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.net.Uri
@@ -17,7 +18,6 @@ import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
@@ -55,7 +55,6 @@ import io.legado.app.utils.setDarkeningAllowed
import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
import io.legado.app.utils.share
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.splitNotBlank
import io.legado.app.utils.startActivity
import io.legado.app.utils.textArray
import io.legado.app.utils.toastOnUi
@@ -66,9 +65,7 @@ import kotlinx.coroutines.launch
import org.apache.commons.text.StringEscapeUtils
import org.jsoup.Jsoup
import splitties.views.bottomPadding
import java.io.ByteArrayInputStream
import java.net.URLDecoder
import java.util.regex.PatternSyntaxException
/**
* rss阅读界面
@@ -102,7 +99,7 @@ class ReadRssActivity : VMBaseActivity<ActivityRssReadBinding, ReadRssViewModel>
private var redirectPolicyMenu: MenuItem? = null
private var isFullScreen = false
private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null
private val rssJsExtensions by lazy { RssJsExtensions(this) }
private val rssJsExtensions by lazy { RssJsExtensions(this, viewModel.rssSource) }
fun getSource(): RssSource? {
return viewModel.rssSource
@@ -615,4 +612,16 @@ class ReadRssActivity : VMBaseActivity<ActivityRssReadBinding, ReadRssViewModel>
}
}
companion object {
fun start(context: Context, title: String?, url: String, origin: String) {
context.startActivity<ReadRssActivity> {
putExtra("title", title ?: "")
putExtra("origin", origin)
putExtra("openUrl", url)
}
}
private val webCookieManager by lazy { android.webkit.CookieManager.getInstance() }
}
}
@@ -46,7 +46,7 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
}
if (link != null) {
rssStar = appDb.rssStarDao.get(origin, link)
rssArticle = rssStar?.toRssArticle() ?: appDb.rssArticleDao.get(origin, link)
rssArticle = rssStar?.toRssArticle() ?: appDb.rssArticleDao.getByLink(origin, link)
val rssArticle = rssArticle ?: return@execute
if (!rssArticle.description.isNullOrBlank()) {
contentLiveData.postValue(rssArticle.description!!)
@@ -1,24 +1,178 @@
package io.legado.app.ui.rss.read
import android.webkit.JavascriptInterface
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import io.legado.app.data.appDb
import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.RssReadRecord
import io.legado.app.data.entities.RssSource
import io.legado.app.help.JsExtensions
import io.legado.app.ui.association.AddToBookshelfDialog
import io.legado.app.ui.book.explore.ExploreShowActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.rss.article.RssSortActivity
import io.legado.app.utils.isJsonObject
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.lang.ref.WeakReference
@Suppress("unused")
class RssJsExtensions(private val activity: ReadRssActivity) : JsExtensions {
open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) : JsExtensions {
val activityRef: WeakReference<AppCompatActivity> = WeakReference(activity)
val sourceRef: WeakReference<BaseSource?> = WeakReference(source)
override fun getSource(): BaseSource? {
return activity.getSource()
return sourceRef.get()
}
override fun getTag(): String? {
return sourceRef.get()?.getTag()
}
@JavascriptInterface
fun put(key: String, value: String): String {
getSource()?.put(key, value)
return value
}
@JavascriptInterface
fun get(key: String): String {
return getSource()?.get(key) ?: ""
}
@JavascriptInterface
fun searchBook(key: String) {
SearchActivity.start(activity, key)
searchBook(key, null)
}
@JavascriptInterface
fun searchBook(key: String, searchScope: String?) {
activityRef.get()?.let {
SearchActivity.start(it, key, searchScope)
}
}
@JavascriptInterface
fun addBook(bookUrl: String) {
activity.showDialogFragment(AddToBookshelfDialog(bookUrl))
activityRef.get()?.showDialogFragment(AddToBookshelfDialog(bookUrl))
}
@JavascriptInterface
@JvmOverloads
fun open(name: String, url: String? = null, title: String? = null, origin: String? = null) {
val activity = activityRef.get() ?: return
activity.lifecycleScope.launch(IO) {
val source = getSource() ?: return@launch
when (name) {
"login" -> {
if (activity is SourceLoginActivity) {
activity.toastOnUi("已在登录界面")
return@launch
}
val toSource = origin?.let { o ->
appDb.bookSourceDao.getBookSource(o)
} ?: source
if (toSource.loginUrl.isNullOrBlank()) {
activity.toastOnUi("源未配置登录")
return@launch
}
when (toSource) {
is BookSource -> {
withContext(Main) {
activity.startActivity<SourceLoginActivity> {
putExtra("type", "bookSource")
putExtra("key", toSource.bookSourceUrl)
}
}
}
is RssSource -> {
withContext(Main) {
activity.startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", toSource.sourceUrl)
}
}
}
}
}
"sort" -> {
val toSource = origin?.let { o ->
appDb.rssSourceDao.getByKey(o)
} ?: (source as? RssSource) ?: return@launch
val sortUrl = if (url.isJsonObject()) {
url
} else {
title?.let {
JSONObject().put(title, url).toString()
} ?: url
}
val sourceUrl = toSource.sourceUrl
withContext(Main) {
RssSortActivity.start(activity, sortUrl, sourceUrl)
}
}
"rss" -> {
val toSource = origin?.let { o ->
appDb.rssSourceDao.getByKey(o)
} ?: (source as? RssSource) ?: return@launch
val title = title ?: toSource.sourceName
val sourceUrl = toSource.sourceUrl
val link = url ?: return@launch
val rss = appDb.rssStarDao.get(sourceUrl, link)?.toRecord()
?: appDb.rssArticleDao.getByLink(sourceUrl, link)?.toRecord()
val rssReadRecord = rss ?: RssReadRecord(
record = link,
title = title,
origin = sourceUrl,
readTime = System.currentTimeMillis()
)
appDb.rssReadRecordDao.insertRecord(rssReadRecord) //留下历史记录
withContext(Main) {
ReadRssActivity.start(activity, title, url, sourceUrl)
}
}
"search" -> {
title?.let {
val searchScope = origin?.let { o ->
appDb.bookSourceDao.getBookSource(o)?.let { s ->
"${s.bookSourceName.replace(":", "")}::${o}"
}
}
withContext(Main) {
searchBook(it, searchScope)
}
}
}
"explore" -> {
val toSource = origin?.let { o ->
appDb.bookSourceDao.getBookSource(o)
} ?: (source as? BookSource) ?: return@launch
val sourceUrl = toSource.bookSourceUrl
withContext(Main) {
activity.startActivity<ExploreShowActivity> {
putExtra("exploreName", title)
putExtra("sourceUrl", sourceUrl)
putExtra("exploreUrl", url)
}
}
}
}
}
}
}
@@ -28,6 +28,7 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -44,7 +45,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -148,7 +148,7 @@ fun <T> BatchImportDialog(
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f),
containerColor = Color.Transparent,
topBar = {
TopAppBar(
CenterAlignedTopAppBar(
title = {
AnimatedText(
if (selectedCount > 0)
@@ -0,0 +1,358 @@
package io.legado.app.ui.widget.dialog
import android.annotation.SuppressLint
import android.content.pm.ActivityInfo
import android.graphics.Bitmap
import android.net.Uri
import android.net.http.SslError
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.WebChromeClient
import android.webkit.WebChromeClient.CustomViewCallback
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.core.view.isNotEmpty
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetBehavior
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
import io.legado.app.data.entities.BaseSource
import io.legado.app.databinding.DialogWebViewBinding
import io.legado.app.help.WebCacheManager
import io.legado.app.help.config.AppConfig
import io.legado.app.help.webView.WebJsExtensions
import io.legado.app.help.webView.WebJsExtensions.Companion.JS_INJECTION
import io.legado.app.help.webView.WebJsExtensions.Companion.basicJs
import io.legado.app.help.webView.WebJsExtensions.Companion.nameBasic
import io.legado.app.help.webView.WebJsExtensions.Companion.nameCache
import io.legado.app.help.webView.WebJsExtensions.Companion.nameJava
import io.legado.app.help.webView.WebJsExtensions.Companion.nameSource
import io.legado.app.lib.theme.ThemeStore
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.ui.association.OnLineImportActivity
import io.legado.app.utils.get
import io.legado.app.utils.invisible
import io.legado.app.utils.longSnackbar
import io.legado.app.utils.openUrl
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
class BottomWebViewDialog() :
BaseBottomSheetDialogFragment(R.layout.dialog_web_view) {
constructor(
sourceKey: String,
bookType: Int,
url: String,
html: String,
preloadJs: String? = null
) : this() {
arguments = Bundle().apply {
putString("sourceKey", sourceKey)
putInt("bookType", bookType)
putString("url", url)
putString("html", html)
putString("preloadJs", preloadJs)
}
}
private val binding by viewBinding(DialogWebViewBinding::bind)
private val bottomSheet by lazy {
dialog?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
}
private val behavior by lazy {
bottomSheet?.let { BottomSheetBehavior.from(it) }
}
private lateinit var currentWebView: WebView
private var source: BaseSource? = null
private var isFullScreen = false
private var customWebViewCallback: CustomViewCallback? = null
private var originOrientation: Int? = null
private var needClearHistory = true
override fun show(manager: FragmentManager, tag: String?) {
runCatching {
manager.beginTransaction().remove(this).commit()
super.show(manager, tag)
}.onFailure {
AppLog.put("显示对话框失败 tag:$tag", it)
}
}
override fun onFragmentCreated(
view: View,
savedInstanceState: Bundle?
) {
if (!AppConfig.isEInkMode) {
view.setBackgroundColor(ThemeStore.backgroundColor())
}
currentWebView = createWebView()
binding.webViewContainer.addView(currentWebView)
lifecycleScope.launch {
val args = arguments ?: run {
dismiss(); return@launch
}
val sourceKey = args.getString("sourceKey") ?: return@launch
val url = args.getString("url") ?: return@launch
var html = args.getString("html") ?: return@launch
args.getString("preloadJs")?.let { preloadJs ->
html = if (html.contains("<head>")) {
html.replaceFirst(
"<head>",
"<head><script>(() => {$JS_INJECTION\n$preloadJs\n})();</script>"
)
} else {
"<head><script>(() => {$JS_INJECTION\n$preloadJs\n})();</script></head>$html"
}
}
source = appDb.bookSourceDao.getBookSource(sourceKey) ?: run {
activity?.toastOnUi("no find bookSource")
dismiss()
return@launch
}
val bookType = args.getInt("bookType", 0)
val analyzeUrl = AnalyzeUrl(url, source = source, coroutineContext = coroutineContext)
currentWebView.setOnScrollChangeListener { _, _, scrollY, _, _ ->
behavior?.isDraggable = scrollY == 0
}
currentWebView.post {
initWebView(
analyzeUrl.url,
html,
analyzeUrl.headerMap,
bookType
)
currentWebView.clearHistory()
}
}
dialog?.setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
when {
binding.customWebView.isNotEmpty() -> {
customWebViewCallback?.onCustomViewHidden()
true
}
currentWebView.canGoBack() -> {
currentWebView.goBack()
true
}
else -> {
dismiss()
true
}
}
} else false
}
}
private fun createWebView(): WebView =
WebView(requireContext()).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
private fun initWebView(
url: String,
html: String,
headerMap: HashMap<String, String>,
bookType: Int
) {
currentWebView.apply {
webChromeClient = CustomWebChromeClient()
webViewClient = CustomWebViewClient()
settings.userAgentString =
headerMap.get(AppConst.UA_NAME, true)
addJavascriptInterface(JSInterface(this@BottomWebViewDialog), nameBasic)
source?.let { source ->
(activity as? AppCompatActivity)?.let { act ->
addJavascriptInterface(
WebJsExtensions(source, act, this, bookType),
nameJava
)
}
addJavascriptInterface(source, nameSource)
addJavascriptInterface(WebCacheManager, nameCache)
}
loadDataWithBaseURL(url, html, "text/html", "utf-8", url)
}
}
override fun onDestroyView() {
customWebViewCallback?.onCustomViewHidden()
currentWebView.apply {
stopLoading()
loadUrl("about:blank")
clearHistory()
removeAllViews()
webChromeClient = WebChromeClient()
webViewClient = WebViewClient()
destroy()
}
originOrientation?.let {
activity?.requestedOrientation = it
}
super.onDestroyView()
}
private class JSInterface(dialog: BottomWebViewDialog) {
private val ref = WeakReference(dialog)
@JavascriptInterface
fun lockOrientation(orientation: String) {
val fra = ref.get() ?: return
val act = fra.activity ?: return
if (fra.isFullScreen && fra.dialog?.isShowing == true) {
act.runOnUiThread {
act.requestedOrientation = when (orientation) {
"portrait", "portrait-primary" ->
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
"portrait-secondary" ->
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
"landscape", "landscape-primary" ->
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
"landscape-secondary" ->
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
"any", "unspecified" ->
ActivityInfo.SCREEN_ORIENTATION_SENSOR
else ->
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}
}
@JavascriptInterface
fun onCloseRequested() {
ref.get()?.activity?.runOnUiThread {
ref.get()?.dismiss()
}
}
}
inner class CustomWebChromeClient : WebChromeClient() {
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
isFullScreen = true
binding.webViewContainer.invisible()
binding.customWebView.addView(view)
customWebViewCallback = callback
behavior?.state = BottomSheetBehavior.STATE_EXPANDED
originOrientation = activity?.requestedOrientation
}
override fun onHideCustomView() {
isFullScreen = false
binding.webViewContainer.visible()
binding.customWebView.removeAllViews()
customWebViewCallback = null
originOrientation?.let {
activity?.requestedOrientation = it
}
}
override fun onCloseWindow(window: WebView?) {
dismiss()
}
}
inner class CustomWebViewClient : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
if (needClearHistory) {
needClearHistory = false
currentWebView.clearHistory()
}
super.onPageStarted(view, url, favicon)
currentWebView.evaluateJavascript(basicJs, null)
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
return request?.url?.let(::handleUri) ?: true
}
@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
return url?.toUri()?.let(::handleUri) ?: true
}
private fun handleUri(uri: Uri): Boolean =
when (uri.scheme) {
"http", "https" -> false
"legado", "yuedu" -> {
startActivity<OnLineImportActivity> { data = uri }
true
}
else -> {
binding.root.longSnackbar(
R.string.jump_to_another_app,
R.string.confirm
) {
activity?.openUrl(uri)
}
true
}
}
@SuppressLint("WebViewClientOnReceivedSslError")
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?
) {
handler?.proceed()
}
}
}
@@ -7,15 +7,18 @@ import android.icu.text.Collator
import android.icu.util.ULocale
import android.net.Uri
import android.text.Editable
import androidx.core.net.toUri
import cn.hutool.core.net.URLEncodeUtil
import io.legado.app.constant.AppPattern
import io.legado.app.constant.AppPattern.dataUriRegex
import java.io.File
import java.lang.Character.codePointCount
import java.lang.Character.offsetByCodePoints
import java.net.InetAddress
import java.util.Locale
import java.util.regex.Pattern
fun String?.safeTrim() = if (this.isNullOrBlank()) null else this.trim()
fun String?.isContentScheme(): Boolean = this?.startsWith("content://") == true
@@ -23,7 +26,7 @@ fun String?.isContentScheme(): Boolean = this?.startsWith("content://") == true
fun String.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this)
fun String.parseToUri(): Uri {
return if (isUri()) Uri.parse(this) else {
return if (isUri()) this.toUri() else {
Uri.fromFile(File(this))
}
}
@@ -75,7 +78,7 @@ fun String?.isTrue(nullIsTrue: Boolean = false): Boolean {
if (this.isNullOrBlank() || this == "null") {
return nullIsTrue
}
return !this.trim().matches("(?i)^(false|no|not|0)$".toRegex())
return !this.trim().matches("(?i)^(?:false|no|not|0|0.0)$".toRegex())
}
fun String.isHex(): Boolean {
@@ -144,3 +147,41 @@ fun String.encodeURI(): String = URLEncodeUtil.encodeQuery(this)
fun String.normalizeFileName(): String {
return replace(AppPattern.fileNameRegex2, "_")
}
/**
* 将字符串加上转义,方便传递字符串到浏览器
*/
fun String.escapeForJs(): String {
return this.replace("\\", "\\\\")
.replace("\"", "\\\"").replace("'", "\\'")
.replace("\n", "\\n").replace("\r", "\\r")
.replace("\t", "\\t")
.replace("\u2028", "\\u2028")
.replace("\u2029", "\\u2029")
}
/**
* 将ip字符串转为InetAddress
*/
fun String.parseIpsFromString(): List<InetAddress>? =
split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.mapNotNull { it.runCatching { InetAddress.getByName(this) }.getOrNull() }
.takeIf { it.isNotEmpty() }
fun String.quoteReplacementJs(): String {
if (!this.contains('\\')) {
return this
}
val sb = StringBuilder()
for (c in this) {
if (c == '\\') {
sb.append("\\\\")
} else {
sb.append(c)
}
}
return sb.toString()
}
@@ -22,6 +22,7 @@ import android.widget.EdgeEffect
import android.widget.EditText
import android.widget.RadioGroup
import android.widget.SeekBar
import android.widget.Spinner
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
@@ -330,3 +331,9 @@ fun View.setOnApplyWindowInsetsListenerCompat(listener: (View, WindowInsetsCompa
}
}
fun Spinner.setSelectionSafely(position: Int) {
val count = adapter?.count ?: 0
if (count > 0) {
setSelection(position.coerceIn(0, count - 1))
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/web_view_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/custom_web_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="4dp">
<TextView
android:id="@+id/sp_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="3dp"
android:text="@string/book_type" />
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/sp_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center" />
</LinearLayout>
@@ -1382,4 +1382,5 @@
<string name="audio_cache_clean_time">音频缓存保留时间(分钟)</string>
<string name="audio_cache_clean_time_summary">当前%d, 输入 0 代表退出即刻清理。</string>
<string name="show_swipe_animation">启用滑动时动画</string>
<string name="click_image_way">点击图片行为</string>
</resources>
+7
View File
@@ -52,6 +52,13 @@
<item>4</item>
</string-array>
<string-array name="click_image_way_value">
<item>0</item>
<item>1</item>
<item>2</item>
<item>3</item>
</string-array>
<string-array name="double_page_value">
<item>0</item>
<item>1</item>
+8 -1
View File
@@ -116,7 +116,14 @@
<item>@string/screen_portrait_reversed</item>
</string-array>
<string-array name="double_page_title">
<string-array name="click_image_way_title">
<item>新包名阅读行为</item>
<item>预览图片</item>
<item>经典阅读行为</item>
<item>不响应</item>
</string-array>
<string-array name="double_page_title">
<item>Single page</item>
<item>Double page</item>
<item>Double page in landscape</item>
+1
View File
@@ -1386,4 +1386,5 @@
<string name="audio_cache_clean_time">音频缓存保留时间(分钟)</string>
<string name="audio_cache_clean_time_summary">当前%d, 输入 0 代表退出即刻清理。</string>
<string name="show_swipe_animation">启用滑动时动画</string>
<string name="click_image_way">点击图片行为</string>
</resources>
+7 -4
View File
@@ -196,10 +196,13 @@
android:title="@string/no_anim_scroll_page"
app:isBottomBackground="true" />
<io.legado.app.lib.prefs.SwitchPreference
android:defaultValue="false"
android:key="previewImageByClick"
android:title="@string/preview_image_by_click"
<io.legado.app.lib.prefs.NameListPreference
android:defaultValue="2"
android:entries="@array/click_image_way_title"
android:entryValues="@array/click_image_way_value"
android:key="clickImgWay"
android:title="@string/click_image_way"
app:iconSpaceReserved="false"
app:isBottomBackground="true" />
<io.legado.app.lib.prefs.SwitchPreference