[新增] 合并了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 EXPORT_BOOK = "exportBook"
const val UP_MANGA_CONFIG = "upMangaConfig" const val UP_MANGA_CONFIG = "upMangaConfig"
const val PLAY_MODE_CHANGED = "playModeChanged" const val PLAY_MODE_CHANGED = "playModeChanged"
const val REFRESH_BOOK_INFO = "refreshBookInfo"
const val UP_TOC = "upToc" const val UP_TOC = "upToc"
} }
@@ -160,6 +160,7 @@ object PreferKey {
const val showBookshelfFastScroller = "showBookshelfFastScroller" const val showBookshelfFastScroller = "showBookshelfFastScroller"
const val importKeepEnable = "importKeepEnable" const val importKeepEnable = "importKeepEnable"
const val previewImageByClick = "previewImageByClick" const val previewImageByClick = "previewImageByClick"
const val clickImgWay = "clickImgWay"
const val keyPageOnLongPress = "keyPageOnLongPress" const val keyPageOnLongPress = "keyPageOnLongPress"
const val volumeKeyPage = "volumeKeyPage" const val volumeKeyPage = "volumeKeyPage"
const val volumeKeyPageOnPlay = "volumeKeyPageOnPlay" 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.DictRule
import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.entities.KeyboardAssist 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.ReplaceRule
import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssArticle
import io.legado.app.data.entities.RssReadRecord 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.SearchKeyword
import io.legado.app.data.entities.Server import io.legado.app.data.entities.Server
import io.legado.app.data.entities.TxtTocRule 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.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.help.DefaultData import io.legado.app.help.DefaultData
@@ -67,7 +67,7 @@ val appDb by lazy {
} }
@Database( @Database(
version = 80, version = 81,
exportSchema = true, exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -113,7 +113,8 @@ val appDb by lazy {
AutoMigration(from = 76, to = 77), AutoMigration(from = 76, to = 77),
AutoMigration(from = 77, to = 78), AutoMigration(from = 77, to = 78),
AutoMigration(from = 78, to = 79), AutoMigration(from = 78, to = 79),
AutoMigration(from = 79, to = 80) AutoMigration(from = 79, to = 80),
AutoMigration(from = 80, to = 81)
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -1,20 +1,27 @@
package io.legado.app.data.dao 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 io.legado.app.data.entities.RssArticle
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface RssArticleDao { 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") @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( @Query(
"""select t1.link, t1.sort, t1.origin, t1.`order`, t1.title, t1.content, """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 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""" order by `order` desc"""
) )
fun flowByOriginSort(origin: String, sort: String): Flow<List<RssArticle>> fun flowByOriginSort(origin: String, sort: String): Flow<List<RssArticle>>
@@ -1,5 +1,6 @@
package io.legado.app.data.entities package io.legado.app.data.entities
import android.webkit.JavascriptInterface
import cn.hutool.crypto.symmetric.AES import cn.hutool.crypto.symmetric.AES
import com.script.ScriptBindings import com.script.ScriptBindings
import com.script.buildScriptBindings import com.script.buildScriptBindings
@@ -56,7 +57,7 @@ interface BaseSource : JsExtensions {
*/ */
var jsLib: String? var jsLib: String?
fun getTag(): String override fun getTag(): String
fun getKey(): String fun getKey(): String
@@ -64,6 +65,32 @@ interface BaseSource : JsExtensions {
return this 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>? { fun loginUi(): List<RowUi>? {
val json = loginUi?.let { val json = loginUi?.let {
val loginJS = getLoginJs() ?: "" val loginJS = getLoginJs() ?: ""
@@ -88,34 +115,6 @@ interface BaseSource : JsExtensions {
}.getOrNull() }.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规则 * 解析header规则
*/ */
@@ -194,9 +193,40 @@ interface BaseSource : JsExtensions {
} }
} }
fun getLoginInfoMap(): Map<String, String> { private fun configureScriptBindings(): ScriptBindings.() -> Unit = {
val json = getLoginInfo() ?: return emptyMap() put("result", mutableMapOf<String, String>())
return GSON.fromJsonObject<Map<String, String>>(json).getOrNull() ?: emptyMap() 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( @Entity(
tableName = "rssArticles", tableName = "rssArticles",
primaryKeys = ["origin", "link"] primaryKeys = ["origin", "link", "sort"]
) )
data class RssArticle( data class RssArticle(
override var origin: String = "", override var origin: String = "",
@@ -24,14 +24,20 @@ data class RssArticle(
@ColumnInfo(defaultValue = "默认分组") @ColumnInfo(defaultValue = "默认分组")
var group: String = "默认分组", var group: String = "默认分组",
var read: Boolean = false, 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 { ) : BaseRssArticle {
override fun hashCode() = link.hashCode() override fun hashCode() = link.hashCode()
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
other ?: return false 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 @delegate:Transient
@@ -52,6 +58,20 @@ data class RssArticle(
content = content, content = content,
image = image, image = image,
group = group, 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 package io.legado.app.data.entities
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity(tableName = "rssReadRecords") @Entity(tableName = "rssReadRecords", indices = [Index(value = ["origin"], unique = false)])
data class RssReadRecord( data class RssReadRecord(
@PrimaryKey @PrimaryKey
val record: String, val record: String,
val title: String? = null, val title: String? = null,
val readTime: Long? = 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, var image: String? = null,
@ColumnInfo(defaultValue = "默认分组") @ColumnInfo(defaultValue = "默认分组")
var group: String = "默认分组", 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 { ) : BaseRssArticle {
@delegate:Transient @delegate:Transient
@@ -44,6 +50,20 @@ data class RssStar(
content = content, content = content,
image = image, image = image,
group = group, 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_alignSelf: String = "auto",
val layout_flexBasisPercent: Float = -1F, val layout_flexBasisPercent: Float = -1F,
val layout_wrapBefore: Boolean = false, val layout_wrapBefore: Boolean = false,
/** 自定义的内部水平对齐属性 **/
val layout_justifySelf: String = "auto"
) { ) {
fun alignSelf(): Int { fun alignSelf(): Int {
@@ -1,10 +1,13 @@
package io.legado.app.data.entities.rule package io.legado.app.data.entities.rule
data class RowUi( data class RowUi(
var name: String = "", val name: String = "",
var type: String = "text", val type: String = "text",
var action: String? = null, val action: String? = null,
var style: FlexChildStyle? = null val chars: Array<String?>? = null,
val default: String? = null,
var viewName: String? = null,
val style: FlexChildStyle? = null
) { ) {
@Suppress("ConstPropertyName") @Suppress("ConstPropertyName")
@@ -13,6 +16,8 @@ data class RowUi(
const val text = "text" const val text = "text"
const val password = "password" const val password = "password"
const val button = "button" const val button = "button"
const val toggle = "toggle"
const val select = "select"
} }
@@ -20,4 +25,21 @@ data class RowUi(
return style ?: FlexChildStyle.defaultStyle 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.explore.ExploreShowViewModel
import io.legado.app.ui.book.readRecord.ReadRecordViewModel import io.legado.app.ui.book.readRecord.ReadRecordViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel 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.main.my.MyViewModel
import io.legado.app.ui.replace.ReplaceEditRoute import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleViewModel import io.legado.app.ui.replace.ReplaceRuleViewModel
@@ -43,6 +44,7 @@ val appModule = module {
) )
} }
viewModel { ReplaceRuleViewModel(androidApplication()) } viewModel { ReplaceRuleViewModel(androidApplication()) }
viewModel { DictRuleViewModel(androidApplication()) }
viewModel { ExploreShowViewModel(get()) } viewModel { ExploreShowViewModel(get()) }
viewModel { SearchContentViewModel(get(), get()) } viewModel { SearchContentViewModel(get(), get()) }
viewModel { MyViewModel(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 package io.legado.app.help
import android.webkit.JavascriptInterface
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.collection.LruCache import androidx.collection.LruCache
import io.legado.app.data.appDb import io.legado.app.data.appDb
@@ -60,8 +61,9 @@ object CacheManager {
when (value) { when (value) {
is ByteArray -> ACache.get().put(key, value, saveTime) is ByteArray -> ACache.get().put(key, value, saveTime)
else -> { else -> {
val cache = Cache(key, value.toString(), deadline) val valueStr = value.toString()
putMemory(key, value) putMemory(key, valueStr)
val cache = Cache(key, valueStr, deadline)
appDb.cacheDao.insert(cache) appDb.cacheDao.insert(cache)
} }
} }
@@ -86,26 +88,50 @@ object CacheManager {
} }
val cache = appDb.cacheDao.get(key) val cache = appDb.cacheDao.get(key)
if (cache != null && (cache.deadline == 0L || cache.deadline > System.currentTimeMillis())) { 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 cache.value
} }
return null return null
} }
fun getInt(key: String): Int? { 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? { 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? { 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? { 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? { fun getByteArray(key: String): ByteArray? {
@@ -126,3 +152,50 @@ object CacheManager {
ACache.get().remove(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,71 +4,95 @@ import io.legado.app.data.entities.BaseSource
import io.legado.app.exception.ConcurrentException import io.legado.app.exception.ConcurrentException
import io.legado.app.model.analyzeRule.AnalyzeUrl.ConcurrentRecord import io.legado.app.model.analyzeRule.AnalyzeUrl.ConcurrentRecord
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
class ConcurrentRateLimiter(val source: BaseSource?) { class ConcurrentRateLimiter(source: BaseSource?) {
companion object { 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) @Throws(ConcurrentException::class)
private fun fetchStart(): ConcurrentRecord? { private fun fetchStart(): ConcurrentRecord? {
source ?: return null
val concurrentRate = source.concurrentRate
if (concurrentRate.isNullOrEmpty() || concurrentRate == "0") { if (concurrentRate.isNullOrEmpty() || concurrentRate == "0") {
return null return null
} }
val key = key ?: return null
var isNewRecord = false
val fetchRecord = concurrentRecordMap.computeIfAbsent(key) {
isNewRecord = true
val rateIndex = concurrentRate.indexOf("/") val rateIndex = concurrentRate.indexOf("/")
var fetchRecord = concurrentRecordMap[source.getKey()] if (rateIndex > 0) {
if (fetchRecord == null) { val accessLimit = concurrentRate.take(rateIndex).toIntOrNull() ?: 1
synchronized(concurrentRecordMap) { val interval = concurrentRate.substring(rateIndex + 1).toIntOrNull() ?: 0
fetchRecord = concurrentRecordMap[source.getKey()] ConcurrentRecord(System.currentTimeMillis(), accessLimit, interval, 1)
if (fetchRecord == null) {
fetchRecord = ConcurrentRecord(rateIndex > 0, System.currentTimeMillis(), 1)
concurrentRecordMap[source.getKey()] = fetchRecord
return fetchRecord
}
}
}
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 { } else {
//并发控制为 次数/毫秒 ConcurrentRecord(
val sj = concurrentRate.substring(rateIndex + 1) System.currentTimeMillis(),
val nextTime = fetchRecord.time + sj.toInt() 1,
if (System.currentTimeMillis() >= nextTime) { concurrentRate.toIntOrNull() ?: 0,
1
)
}
}
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 = System.currentTimeMillis() fetchRecord.time = nowTime
fetchRecord.frequency = 1 fetchRecord.frequency = 1
return@synchronized 0 return@synchronized 0
} }
val cs = concurrentRate.substring(0, rateIndex) if (fetchRecord.frequency < fetchRecord.accessLimit) {
if (fetchRecord.frequency > cs.toInt()) { fetchRecord.frequency++
return@synchronized (nextTime - System.currentTimeMillis()).toInt() return@synchronized 0
} else { } else {
fetchRecord.frequency += 1 return@synchronized nextTime - nowTime
return@synchronized 0
}
}
} catch (_: Exception) {
return@synchronized 0
} }
} }
if (waitTime > 0) { if (waitTime > 0) {
@@ -80,17 +104,6 @@ class ConcurrentRateLimiter(val source: BaseSource?) {
return fetchRecord 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 { try {
return fetchStart() return fetchStart()
} catch (e: ConcurrentException) { } catch (e: ConcurrentException) {
delay(e.waitTime.toLong()) delay(e.waitTime)
} }
} }
} }
@@ -109,27 +122,19 @@ class ConcurrentRateLimiter(val source: BaseSource?) {
try { try {
return fetchStart() return fetchStart()
} catch (e: ConcurrentException) { } catch (e: ConcurrentException) {
Thread.sleep(e.waitTime.toLong()) Thread.sleep(e.waitTime)
} }
} }
} }
suspend inline fun <T> withLimit(block: () -> T): T { suspend inline fun <T> withLimit(block: () -> T): T {
val concurrentRecord = getConcurrentRecord() getConcurrentRecord()
try {
return block() return block()
} finally {
fetchEnd(concurrentRecord)
}
} }
inline fun <T> withLimitBlocking(block: () -> T): T { inline fun <T> withLimitBlocking(block: () -> T): T {
val concurrentRecord = getConcurrentRecordBlocking() getConcurrentRecordBlocking()
try {
return block() return block()
} finally {
fetchEnd(concurrentRecord)
}
} }
} }
@@ -1,7 +1,9 @@
package io.legado.app.help package io.legado.app.help
import android.webkit.JavascriptInterface
import android.webkit.WebSettings import android.webkit.WebSettings
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.core.net.toUri
import cn.hutool.core.codec.Base64 import cn.hutool.core.codec.Base64
import cn.hutool.core.util.HexUtil import cn.hutool.core.util.HexUtil
import com.script.rhino.rhinoContext 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.data.entities.BaseSource
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.AppConfig 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.BackstageWebView
import io.legado.app.help.http.CookieManager.cookieJarHeader import io.legado.app.help.http.CookieManager.cookieJarHeader
import io.legado.app.help.http.CookieStore 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.Debug
import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.QueryTTF 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.ui.association.OpenUrlConfirmActivity
import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.ChineseUtils import io.legado.app.utils.ChineseUtils
@@ -72,6 +76,7 @@ import java.util.zip.ZipInputStream
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
/** /**
* js扩展类, 在js中通过java变量调用 * js扩展类, 在js中通过java变量调用
* 添加方法,请更新文档/legado/app/src/main/assets/help/JsHelp.md * 添加方法,请更新文档/legado/app/src/main/assets/help/JsHelp.md
@@ -83,24 +88,34 @@ import kotlin.coroutines.EmptyCoroutineContext
interface JsExtensions : JsEncodeUtils { interface JsExtensions : JsEncodeUtils {
fun getSource(): BaseSource? fun getSource(): BaseSource?
fun getTag(): String?
private val context: CoroutineContext private val context: CoroutineContext
get() = rhinoContext.coroutineContext ?: EmptyCoroutineContext get() = rhinoContextOrNull?.coroutineContext ?: EmptyCoroutineContext
/** /**
* 访问网络,返回String * 访问网络,返回String
*/ */
fun ajax(url: Any): String? { fun ajax(url: Any): String? {
return ajax(url, null)
}
fun ajax(url: Any, callTimeout: Long?): String? {
val urlStr = if (url is List<*>) { val urlStr = if (url is List<*>) {
url.firstOrNull().toString() url.firstOrNull().toString()
} else { } else {
url.toString() url.toString()
} }
val analyzeUrl = AnalyzeUrl(urlStr, source = getSource(), coroutineContext = context) val analyzeUrl = AnalyzeUrl(
urlStr,
source = getSource(),
callTimeout = callTimeout,
coroutineContext = context
)
return kotlin.runCatching { return kotlin.runCatching {
analyzeUrl.getStrResponse().body analyzeUrl.getStrResponse().body
}.onFailure { }.onFailure {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
AppLog.put("ajax(${urlStr}) error\n${it.localizedMessage}", it) AppLog.put("ajax(${urlStr}) error\n${it.localizedMessage}", it)
}.getOrElse { }.getOrElse {
it.stackTraceStr it.stackTraceStr
@@ -111,6 +126,10 @@ interface JsExtensions : JsEncodeUtils {
* 并发访问网络 * 并发访问网络
*/ */
fun ajaxAll(urlList: Array<String>): Array<StrResponse> { fun ajaxAll(urlList: Array<String>): Array<StrResponse> {
return ajaxAll(urlList, false)
}
fun ajaxAll(urlList: Array<String>, skipRateLimit: Boolean): Array<StrResponse> {
return runBlocking(context) { return runBlocking(context) {
urlList.asFlow().mapAsync(AppConfig.threadCount) { url -> urlList.asFlow().mapAsync(AppConfig.threadCount) { url ->
val analyzeUrl = AnalyzeUrl( val analyzeUrl = AnalyzeUrl(
@@ -118,11 +137,37 @@ interface JsExtensions : JsEncodeUtils {
source = getSource(), source = getSource(),
coroutineContext = coroutineContext coroutineContext = coroutineContext
) )
analyzeUrl.getStrResponseAwait() analyzeUrl.getStrResponseAwait(skipRateLimit = skipRateLimit)
}.flowOn(IO).toList().toTypedArray() }.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> * 访问网络,返回Response<String>
*/ */
@@ -135,7 +180,7 @@ interface JsExtensions : JsEncodeUtils {
return kotlin.runCatching { return kotlin.runCatching {
analyzeUrl.getStrResponse() analyzeUrl.getStrResponse()
}.onFailure { }.onFailure {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
AppLog.put("connect(${urlStr}) error\n${it.localizedMessage}", it) AppLog.put("connect(${urlStr}) error\n${it.localizedMessage}", it)
}.getOrElse { }.getOrElse {
StrResponse(analyzeUrl.url, it.stackTraceStr) StrResponse(analyzeUrl.url, it.stackTraceStr)
@@ -143,31 +188,41 @@ interface JsExtensions : JsEncodeUtils {
} }
fun connect(urlStr: String, header: String?): StrResponse { 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 headerMap = GSON.fromJsonObject<Map<String, String>>(header).getOrNull()
val analyzeUrl = AnalyzeUrl( val analyzeUrl = AnalyzeUrl(
urlStr, urlStr,
headerMapF = headerMap, headerMapF = headerMap,
source = getSource(), source = getSource(),
callTimeout = callTimeout,
coroutineContext = context coroutineContext = context
) )
return kotlin.runCatching { return kotlin.runCatching {
analyzeUrl.getStrResponse() analyzeUrl.getStrResponse()
}.onFailure { }.onFailure {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
AppLog.put("ajax($urlStr,$header) error\n${it.localizedMessage}", it) AppLog.put("connect($urlStr,$header) error\n${it.localizedMessage}", it)
}.getOrElse { }.getOrElse {
StrResponse(analyzeUrl.url, it.stackTraceStr) StrResponse(analyzeUrl.url, it.stackTraceStr)
} }
} }
fun webView(html: String?, url: String?, js: String?): String? {
return webView(html, url, js, false)
}
/** /**
* 使用webView访问网络 * 使用webView访问网络
* @param html 直接用webView载入的html, 如果html为空直接访问url * @param html 直接用webView载入的html, 如果html为空直接访问url
* @param url html内如果有相对路径的资源不传入url访问不了 * @param url html内如果有相对路径的资源不传入url访问不了
* @param js 用来取返回值的js语句, 没有就返回整个源代码 * @param js 用来取返回值的js语句, 没有就返回整个源代码
* @param cacheFirst 优先使用缓存,为true能提高访问速度
* @return 返回js获取的内容 * @return 返回js获取的内容
*/ */
fun webView(html: String?, url: String?, js: String?): String? { fun webView(html: String?, url: String?, js: String?, cacheFirst: Boolean): String? {
if (isMainThread) { if (isMainThread) {
error("webView must be called on a background thread") 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 * 使用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) { if (isMainThread) {
error("webViewGetSource must be called on a background thread") 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 * 使用webView获取跳转url
*/ */
@@ -208,7 +282,8 @@ interface JsExtensions : JsEncodeUtils {
html: String?, html: String?,
url: String?, url: String?,
js: String?, js: String?,
overrideUrlRegex: String overrideUrlRegex: String,
cacheFirst: Boolean
): String? { ): String? {
if (isMainThread) { if (isMainThread) {
error("webViewGetOverrideUrl must be called on a background thread") error("webViewGetOverrideUrl must be called on a background thread")
@@ -231,23 +306,37 @@ interface JsExtensions : JsEncodeUtils {
* @param title 浏览器页面的标题 * @param title 浏览器页面的标题
*/ */
fun startBrowser(url: String, title: String) { fun startBrowser(url: String, title: String) {
return startBrowser(url, title, null)
}
fun startBrowser(url: String, title: String, html: String?) {
rhinoContext.ensureActive() rhinoContext.ensureActive()
SourceVerificationHelp.startBrowser(getSource(), url, title) SourceVerificationHelp.startBrowser(getSource(), url, title, html = html)
} }
/** /**
* 使用内置浏览器打开链接,并等待网页结果 * 使用内置浏览器打开链接,并等待网页结果
*/ */
fun startBrowserAwait(url: String, title: String, refetchAfterSuccess: Boolean): StrResponse { fun startBrowserAwait(url: String, title: String): StrResponse {
rhinoContext.ensureActive() return startBrowserAwait(url, title, true, null)
val body = SourceVerificationHelp.getVerificationResult(
getSource(), url, title, true, refetchAfterSuccess
)
return StrResponse(url, body)
} }
fun startBrowserAwait(url: String, title: String): StrResponse { fun startBrowserAwait(url: String, title: String, refetchAfterSuccess: Boolean): StrResponse {
return startBrowserAwait(url, title, true) 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 { fun getVerificationCode(imageUrl: String): String {
rhinoContext.ensureActive() rhinoContext.ensureActive()
return SourceVerificationHelp.getVerificationResult(getSource(), imageUrl, "", false) return SourceVerificationHelp.getVerificationResult(getSource(), imageUrl, "", false).second
} }
/** /**
* 可从网络,本地文件(阅读私有数据目录相对路径)导入JavaScript脚本 * 可从网络,本地文件(阅读私有数据目录相对路径)导入JavaScript脚本
*/ */
@JavascriptInterface
fun importScript(path: String): String { fun importScript(path: String): String {
val result = when { val result = when {
path.startsWith("http") -> cacheFile(path) path.startsWith("http") -> cacheFile(path)
@@ -275,6 +365,7 @@ interface JsExtensions : JsEncodeUtils {
* @param urlStr 网络文件的链接 * @param urlStr 网络文件的链接
* @return 返回缓存后的文件内容 * @return 返回缓存后的文件内容
*/ */
@JavascriptInterface
fun cacheFile(urlStr: String): String { fun cacheFile(urlStr: String): String {
return cacheFile(urlStr, 0) return cacheFile(urlStr, 0)
} }
@@ -283,6 +374,7 @@ interface JsExtensions : JsEncodeUtils {
* 缓存以文本方式保存的文件 如.js .txt等 * 缓存以文本方式保存的文件 如.js .txt等
* @param saveTime 缓存时间,单位:秒 * @param saveTime 缓存时间,单位:秒
*/ */
@JavascriptInterface
fun cacheFile(urlStr: String, saveTime: Int): String { fun cacheFile(urlStr: String, saveTime: Int): String {
val key = md5Encode16(urlStr) val key = md5Encode16(urlStr)
val cachePath = CacheManager.get(key) val cachePath = CacheManager.get(key)
@@ -302,10 +394,12 @@ interface JsExtensions : JsEncodeUtils {
/** /**
*js实现读取cookie *js实现读取cookie
*/ */
@JavascriptInterface
fun getCookie(tag: String): String { fun getCookie(tag: String): String {
return getCookie(tag, null) return getCookie(tag, null)
} }
@JavascriptInterface
fun getCookie(tag: String, key: String?): String { fun getCookie(tag: String, key: String?): String {
return if (key != null) { return if (key != null) {
CookieStore.getKey(tag, key) CookieStore.getKey(tag, key)
@@ -319,8 +413,9 @@ interface JsExtensions : JsEncodeUtils {
* @param url 下载地址:可带参数type * @param url 下载地址:可带参数type
* @return 下载的文件相对路径 * @return 下载的文件相对路径
*/ */
@JavascriptInterface
fun downloadFile(url: String): String { fun downloadFile(url: String): String {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
val analyzeUrl = AnalyzeUrl(url, source = getSource(), coroutineContext = context) val analyzeUrl = AnalyzeUrl(url, source = getSource(), coroutineContext = context)
val type = analyzeUrl.type ?: UrlUtil.getSuffix(url) val type = analyzeUrl.type ?: UrlUtil.getSuffix(url)
val path = FileUtils.getPath( val path = FileUtils.getPath(
@@ -354,8 +449,9 @@ interface JsExtensions : JsEncodeUtils {
"Deprecated", "Deprecated",
ReplaceWith("downloadFile(url)") ReplaceWith("downloadFile(url)")
) )
@JavascriptInterface
fun downloadFile(content: String, url: String): String { fun downloadFile(content: String, url: String): String {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
val type = AnalyzeUrl(url, source = getSource(), coroutineContext = context).type val type = AnalyzeUrl(url, source = getSource(), coroutineContext = context).type
?: return "" ?: return ""
val path = FileUtils.getPath( val path = FileUtils.getPath(
@@ -376,14 +472,19 @@ interface JsExtensions : JsEncodeUtils {
* js实现重定向拦截,网络访问get * js实现重定向拦截,网络访问get
*/ */
fun get(urlStr: String, headers: Map<String, String>): Connection.Response { 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) { val requestHeaders = if (getSource()?.enabledCookieJar == true) {
headers.toMutableMap().apply { put(cookieJarHeader, "1") } headers.toMutableMap().apply { put(cookieJarHeader, "1") }
} else headers } else headers
val rateLimiter = ConcurrentRateLimiter(getSource()) val rateLimiter = ConcurrentRateLimiter(getSource())
val response = rateLimiter.withLimitBlocking { val response = rateLimiter.withLimitBlocking {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
Jsoup.connect(urlStr) Jsoup.connect(urlStr)
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory) .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
.timeout(timeout ?: 30000)
.ignoreContentType(true) .ignoreContentType(true)
.followRedirects(false) .followRedirects(false)
.headers(requestHeaders) .headers(requestHeaders)
@@ -397,14 +498,19 @@ interface JsExtensions : JsEncodeUtils {
* js实现重定向拦截,网络访问head,不返回Response Body更省流量 * js实现重定向拦截,网络访问head,不返回Response Body更省流量
*/ */
fun head(urlStr: String, headers: Map<String, String>): Connection.Response { 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) { val requestHeaders = if (getSource()?.enabledCookieJar == true) {
headers.toMutableMap().apply { put(cookieJarHeader, "1") } headers.toMutableMap().apply { put(cookieJarHeader, "1") }
} else headers } else headers
val rateLimiter = ConcurrentRateLimiter(getSource()) val rateLimiter = ConcurrentRateLimiter(getSource())
val response = rateLimiter.withLimitBlocking { val response = rateLimiter.withLimitBlocking {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
Jsoup.connect(urlStr) Jsoup.connect(urlStr)
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory) .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
.timeout(timeout ?: 30000)
.ignoreContentType(true) .ignoreContentType(true)
.followRedirects(false) .followRedirects(false)
.headers(requestHeaders) .headers(requestHeaders)
@@ -418,14 +524,24 @@ interface JsExtensions : JsEncodeUtils {
* 网络访问post * 网络访问post
*/ */
fun post(urlStr: String, body: String, headers: Map<String, String>): Connection.Response { 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) { val requestHeaders = if (getSource()?.enabledCookieJar == true) {
headers.toMutableMap().apply { put(cookieJarHeader, "1") } headers.toMutableMap().apply { put(cookieJarHeader, "1") }
} else headers } else headers
val rateLimiter = ConcurrentRateLimiter(getSource()) val rateLimiter = ConcurrentRateLimiter(getSource())
val response = rateLimiter.withLimitBlocking { val response = rateLimiter.withLimitBlocking {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
Jsoup.connect(urlStr) Jsoup.connect(urlStr)
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory) .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
.timeout(timeout ?: 30000)
.ignoreContentType(true) .ignoreContentType(true)
.followRedirects(false) .followRedirects(false)
.requestBody(body) .requestBody(body)
@@ -457,14 +573,17 @@ interface JsExtensions : JsEncodeUtils {
/** /**
* js实现base64解码,不能删 * js实现base64解码,不能删
*/ */
@JavascriptInterface
fun base64Decode(str: String?): String { fun base64Decode(str: String?): String {
return Base64.decodeStr(str) return Base64.decodeStr(str)
} }
@JavascriptInterface
fun base64Decode(str: String?, charset: String): String { fun base64Decode(str: String?, charset: String): String {
return Base64.decodeStr(str, charset(charset)) return Base64.decodeStr(str, charset(charset))
} }
@JavascriptInterface
fun base64Decode(str: String, flags: Int): String { fun base64Decode(str: String, flags: Int): String {
return EncoderUtils.base64Decode(str, flags) return EncoderUtils.base64Decode(str, flags)
} }
@@ -483,10 +602,12 @@ interface JsExtensions : JsEncodeUtils {
return EncoderUtils.base64DecodeToByteArray(str, flags) return EncoderUtils.base64DecodeToByteArray(str, flags)
} }
@JavascriptInterface
fun base64Encode(str: String): String? { fun base64Encode(str: String): String? {
return EncoderUtils.base64Encode(str, 2) return EncoderUtils.base64Encode(str, 2)
} }
@JavascriptInterface
fun base64Encode(str: String, flags: Int): String? { fun base64Encode(str: String, flags: Int): String? {
return EncoderUtils.base64Encode(str, flags) return EncoderUtils.base64Encode(str, flags)
} }
@@ -497,11 +618,13 @@ interface JsExtensions : JsEncodeUtils {
} }
/* hexString 解码为utf8String*/ /* hexString 解码为utf8String*/
@JavascriptInterface
fun hexDecodeToString(hex: String): String? { fun hexDecodeToString(hex: String): String? {
return HexUtil.decodeHexStr(hex) return HexUtil.decodeHexStr(hex)
} }
/* utf8 编码为hexString */ /* utf8 编码为hexString */
@JavascriptInterface
fun hexEncodeToString(utf8: String): String? { fun hexEncodeToString(utf8: String): String? {
return HexUtil.encodeHexStr(utf8) return HexUtil.encodeHexStr(utf8)
} }
@@ -509,6 +632,7 @@ interface JsExtensions : JsEncodeUtils {
/** /**
* 格式化时间 * 格式化时间
*/ */
@JavascriptInterface
fun timeFormatUTC(time: Long, format: String, sh: Int): String? { fun timeFormatUTC(time: Long, format: String, sh: Int): String? {
val utc = SimpleTimeZone(sh, "UTC") val utc = SimpleTimeZone(sh, "UTC")
return SimpleDateFormat(format, Locale.getDefault()).run { return SimpleDateFormat(format, Locale.getDefault()).run {
@@ -520,10 +644,12 @@ interface JsExtensions : JsEncodeUtils {
/** /**
* 时间格式化 * 时间格式化
*/ */
@JavascriptInterface
fun timeFormat(time: Long): String { fun timeFormat(time: Long): String {
return dateFormat.format(Date(time)) return dateFormat.format(Date(time))
} }
@JavascriptInterface
fun encodeURI(str: String): String { fun encodeURI(str: String): String {
return try { return try {
URLEncoder.encode(str, "UTF-8") URLEncoder.encode(str, "UTF-8")
@@ -532,6 +658,7 @@ interface JsExtensions : JsEncodeUtils {
} }
} }
@JavascriptInterface
fun encodeURI(str: String, enc: String): String { fun encodeURI(str: String, enc: String): String {
return try { return try {
URLEncoder.encode(str, enc) URLEncoder.encode(str, enc)
@@ -540,18 +667,22 @@ interface JsExtensions : JsEncodeUtils {
} }
} }
@JavascriptInterface
fun htmlFormat(str: String): String { fun htmlFormat(str: String): String {
return HtmlFormatter.formatKeepImg(str) return HtmlFormatter.formatKeepImg(str)
} }
@JavascriptInterface
fun t2s(text: String): String { fun t2s(text: String): String {
return ChineseUtils.t2s(text) return ChineseUtils.t2s(text)
} }
@JavascriptInterface
fun s2t(text: String): String { fun s2t(text: String): String {
return ChineseUtils.s2t(text) return ChineseUtils.s2t(text)
} }
@JavascriptInterface
fun getWebViewUA(): String { fun getWebViewUA(): String {
return WebSettings.getDefaultUserAgent(appCtx) return WebSettings.getDefaultUserAgent(appCtx)
} }
@@ -586,6 +717,7 @@ interface JsExtensions : JsEncodeUtils {
return null return null
} }
@JavascriptInterface
fun readTxtFile(path: String): String { fun readTxtFile(path: String): String {
val file = getFile(path) val file = getFile(path)
if (file.exists()) { if (file.exists()) {
@@ -595,6 +727,7 @@ interface JsExtensions : JsEncodeUtils {
return "" return ""
} }
@JavascriptInterface
fun readTxtFile(path: String, charsetName: String): String { fun readTxtFile(path: String, charsetName: String): String {
val file = getFile(path) val file = getFile(path)
if (file.exists()) { if (file.exists()) {
@@ -606,6 +739,7 @@ interface JsExtensions : JsEncodeUtils {
/** /**
* 删除本地文件 * 删除本地文件
*/ */
@JavascriptInterface
fun deleteFile(path: String): Boolean { fun deleteFile(path: String): Boolean {
val file = getFile(path) val file = getFile(path)
return FileUtils.delete(file, true) return FileUtils.delete(file, true)
@@ -616,6 +750,7 @@ interface JsExtensions : JsEncodeUtils {
* @param zipPath 相对路径 * @param zipPath 相对路径
* @return 相对路径 * @return 相对路径
*/ */
@JavascriptInterface
fun unzipFile(zipPath: String): String { fun unzipFile(zipPath: String): String {
return unArchiveFile(zipPath) return unArchiveFile(zipPath)
} }
@@ -625,6 +760,7 @@ interface JsExtensions : JsEncodeUtils {
* @param zipPath 相对路径 * @param zipPath 相对路径
* @return 相对路径 * @return 相对路径
*/ */
@JavascriptInterface
fun un7zFile(zipPath: String): String { fun un7zFile(zipPath: String): String {
return unArchiveFile(zipPath) return unArchiveFile(zipPath)
} }
@@ -634,6 +770,7 @@ interface JsExtensions : JsEncodeUtils {
* @param zipPath 相对路径 * @param zipPath 相对路径
* @return 相对路径 * @return 相对路径
*/ */
@JavascriptInterface
fun unrarFile(zipPath: String): String { fun unrarFile(zipPath: String): String {
return unArchiveFile(zipPath) return unArchiveFile(zipPath)
} }
@@ -643,6 +780,7 @@ interface JsExtensions : JsEncodeUtils {
* @param zipPath 相对路径 * @param zipPath 相对路径
* @return 相对路径 * @return 相对路径
*/ */
@JavascriptInterface
fun unArchiveFile(zipPath: String): String { fun unArchiveFile(zipPath: String): String {
if (zipPath.isEmpty()) return "" if (zipPath.isEmpty()) return ""
val zipFile = getFile(zipPath) val zipFile = getFile(zipPath)
@@ -656,6 +794,7 @@ interface JsExtensions : JsEncodeUtils {
* @param path 文件夹相对路径 * @param path 文件夹相对路径
* @return 所有文件字符串换行连接 * @return 所有文件字符串换行连接
*/ */
@JavascriptInterface
fun getTxtInFolder(path: String): String { fun getTxtInFolder(path: String): String {
if (path.isEmpty()) return "" if (path.isEmpty()) return ""
val folder = getFile(path) val folder = getFile(path)
@@ -680,12 +819,14 @@ interface JsExtensions : JsEncodeUtils {
* @param path 所需获取文件在zip内的路径 * @param path 所需获取文件在zip内的路径
* @return zip指定文件的数据 * @return zip指定文件的数据
*/ */
@JavascriptInterface
fun getZipStringContent(url: String, path: String): String { fun getZipStringContent(url: String, path: String): String {
val byteArray = getZipByteArrayContent(url, path) ?: return "" val byteArray = getZipByteArrayContent(url, path) ?: return ""
val charsetName = EncodingDetect.getEncode(byteArray) val charsetName = EncodingDetect.getEncode(byteArray)
return String(byteArray, Charset.forName(charsetName)) return String(byteArray, Charset.forName(charsetName))
} }
@JavascriptInterface
fun getZipStringContent(url: String, path: String, charsetName: String): String { fun getZipStringContent(url: String, path: String, charsetName: String): String {
val byteArray = getZipByteArrayContent(url, path) ?: return "" val byteArray = getZipByteArrayContent(url, path) ?: return ""
return String(byteArray, Charset.forName(charsetName)) return String(byteArray, Charset.forName(charsetName))
@@ -697,12 +838,14 @@ interface JsExtensions : JsEncodeUtils {
* @param path 所需获取文件在zip内的路径 * @param path 所需获取文件在zip内的路径
* @return zip指定文件的数据 * @return zip指定文件的数据
*/ */
@JavascriptInterface
fun getRarStringContent(url: String, path: String): String { fun getRarStringContent(url: String, path: String): String {
val byteArray = getRarByteArrayContent(url, path) ?: return "" val byteArray = getRarByteArrayContent(url, path) ?: return ""
val charsetName = EncodingDetect.getEncode(byteArray) val charsetName = EncodingDetect.getEncode(byteArray)
return String(byteArray, Charset.forName(charsetName)) return String(byteArray, Charset.forName(charsetName))
} }
@JavascriptInterface
fun getRarStringContent(url: String, path: String, charsetName: String): String { fun getRarStringContent(url: String, path: String, charsetName: String): String {
val byteArray = getRarByteArrayContent(url, path) ?: return "" val byteArray = getRarByteArrayContent(url, path) ?: return ""
return String(byteArray, Charset.forName(charsetName)) return String(byteArray, Charset.forName(charsetName))
@@ -714,12 +857,14 @@ interface JsExtensions : JsEncodeUtils {
* @param path 所需获取文件在7zip内的路径 * @param path 所需获取文件在7zip内的路径
* @return zip指定文件的数据 * @return zip指定文件的数据
*/ */
@JavascriptInterface
fun get7zStringContent(url: String, path: String): String { fun get7zStringContent(url: String, path: String): String {
val byteArray = get7zByteArrayContent(url, path) ?: return "" val byteArray = get7zByteArrayContent(url, path) ?: return ""
val charsetName = EncodingDetect.getEncode(byteArray) val charsetName = EncodingDetect.getEncode(byteArray)
return String(byteArray, Charset.forName(charsetName)) return String(byteArray, Charset.forName(charsetName))
} }
@JavascriptInterface
fun get7zStringContent(url: String, path: String, charsetName: String): String { fun get7zStringContent(url: String, path: String, charsetName: String): String {
val byteArray = get7zByteArrayContent(url, path) ?: return "" val byteArray = get7zByteArrayContent(url, path) ?: return ""
return String(byteArray, Charset.forName(charsetName)) return String(byteArray, Charset.forName(charsetName))
@@ -913,6 +1058,7 @@ interface JsExtensions : JsEncodeUtils {
/** /**
* 章节数转数字 * 章节数转数字
*/ */
@JavascriptInterface
fun toNumChapter(s: String?): String? { fun toNumChapter(s: String?): String? {
s ?: return null s ?: return null
val matcher = AppPattern.titleNumPattern.matcher(s) val matcher = AppPattern.titleNumPattern.matcher(s)
@@ -956,7 +1102,7 @@ interface JsExtensions : JsEncodeUtils {
getSource()?.let { getSource()?.let {
Debug.log(it.getKey(), msg.toString()) Debug.log(it.getKey(), msg.toString())
} ?: Debug.log(msg.toString()) } ?: Debug.log(msg.toString())
AppLog.putDebug("${getSource()?.getTag() ?: "源"}调试输出: $msg") AppLog.putDebug("${getTag() ?: "源"}调试输出: $msg")
return msg return msg
} }
@@ -974,22 +1120,32 @@ interface JsExtensions : JsEncodeUtils {
/** /**
* 生成UUID * 生成UUID
*/ */
@JavascriptInterface
fun randomUUID(): String { fun randomUUID(): String {
return UUID.randomUUID().toString() return UUID.randomUUID().toString()
} }
@JavascriptInterface
fun androidId(): String { fun androidId(): String {
return AppConst.androidId return AppConst.androidId
} }
@JavascriptInterface
fun openUrl(url: String) { fun openUrl(url: String) {
openUrl(url, null) openUrl(url, null)
} }
// 新增 mimeType 参数,默认为 null(保持兼容性) // 新增 mimeType 参数,默认为 null(保持兼容性)
@JavascriptInterface
fun openUrl(url: String, mimeType: String? = null) { fun openUrl(url: String, mimeType: String? = null) {
require(url.length < 64 * 1024) { "openUrl parameter url too long" } 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") val source = getSource() ?: throw NoStackTraceException("openUrl source cannot be null")
appCtx.startActivity<OpenUrlConfirmActivity> { appCtx.startActivity<OpenUrlConfirmActivity> {
putExtra("uri", url) 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) appCtx.putPrefBoolean(PreferKey.previewImageByClick, value)
} }
val clickImgWay: String?
get() = appCtx.getPrefString(PreferKey.clickImgWay)
var preDownloadNum var preDownloadNum
get() = appCtx.getPrefInt(PreferKey.preDownloadNum, 10) get() = appCtx.getPrefInt(PreferKey.preDownloadNum, 10)
set(value) { set(value) {
@@ -20,6 +20,7 @@ class StrResponse {
private set private set
var errorBody: ResponseBody? = null var errorBody: ResponseBody? = null
private set private set
var callTime = 0
constructor(rawResponse: Response, body: String?) { constructor(rawResponse: Response, body: String?) {
this.raw = rawResponse this.raw = rawResponse
@@ -46,7 +47,11 @@ class StrResponse {
this.errorBody = errorBody this.errorBody = errorBody
} }
fun putCallTime(callTime: Int) {
this.callTime = callTime
}
fun raw() = raw fun raw() = raw
fun callTime() = callTime
fun url(): String { fun url(): String {
raw.networkResponse?.let { raw.networkResponse?.let {
@@ -35,8 +35,9 @@ object SourceVerificationHelp {
url: String, url: String,
title: String, title: String,
useBrowser: Boolean, useBrowser: Boolean,
refetchAfterSuccess: Boolean = true refetchAfterSuccess: Boolean = true,
): String { html: String? = null
): Pair<String, String> {
source source
?: throw NoStackTraceException("getVerificationResult parameter source cannot be null") ?: throw NoStackTraceException("getVerificationResult parameter source cannot be null")
require(url.length < 64 * 1024) { "getVerificationResult parameter url too long" } require(url.length < 64 * 1024) { "getVerificationResult parameter url too long" }
@@ -53,23 +54,21 @@ object SourceVerificationHelp {
IntentData.put(getVerificationResultKey(source), Thread.currentThread()) IntentData.put(getVerificationResultKey(source), Thread.currentThread())
} }
} else { } else {
startBrowser(source, url, title, true, refetchAfterSuccess) startBrowser(source, url, title, true, refetchAfterSuccess, html)
} }
var waitUserInput = false var waitUserInput = false
while (getResult(source.getKey()) == null) { while (getResult(source.getKey()) == null) {
if (!waitUserInput) { if (!waitUserInput && html == null) {
AppLog.putDebug("等待返回验证结果...") AppLog.putDebug("等待返回验证结果...")
waitUserInput = true waitUserInput = true
} }
LockSupport.parkNanos(this, waitTime) LockSupport.parkNanos(this, waitTime)
} }
val result = getResult(source.getKey()) ?: throw NoStackTraceException("验证结果为空")
return getResult(source.getKey())!!.let { clearResult(source.getKey())
it.ifBlank { if (result.second.isEmpty()) throw NoStackTraceException("验证结果为空")
throw NoStackTraceException("验证结果为空") return result
}
}
} }
/** /**
@@ -81,7 +80,8 @@ object SourceVerificationHelp {
url: String, url: String,
title: String, title: String,
saveResult: Boolean? = false, saveResult: Boolean? = false,
refetchAfterSuccess: Boolean? = true refetchAfterSuccess: Boolean? = true,
html: String? = null
) { ) {
source ?: throw NoStackTraceException("startBrowser parameter source cannot be null") source ?: throw NoStackTraceException("startBrowser parameter source cannot be null")
require(url.length < 64 * 1024) { "startBrowser parameter url too long" } require(url.length < 64 * 1024) { "startBrowser parameter url too long" }
@@ -93,6 +93,7 @@ object SourceVerificationHelp {
putExtra("sourceType", source.getSourceType()) putExtra("sourceType", source.getSourceType())
putExtra("sourceVerificationEnable", saveResult) putExtra("sourceVerificationEnable", saveResult)
putExtra("refetchAfterSuccess", refetchAfterSuccess) putExtra("refetchAfterSuccess", refetchAfterSuccess)
putExtra("html", html)
IntentData.put(getVerificationResultKey(source), Thread.currentThread()) IntentData.put(getVerificationResultKey(source), Thread.currentThread())
} }
} }
@@ -104,12 +105,17 @@ object SourceVerificationHelp {
LockSupport.unpark(thread) LockSupport.unpark(thread)
} }
fun setResult(sourceKey: String, result: String?) { fun setResult(sourceKey: String, result: String, url: String = "") {
CacheManager.putMemory(getVerificationResultKey(sourceKey), result ?: "") CacheManager.putMemory(getVerificationResultKey(sourceKey), (url to result))
} }
fun getResult(sourceKey: String): String? { fun getResult(sourceKey: String): Pair<String, String>? {
return CacheManager.get(getVerificationResultKey(sourceKey)) 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) { 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( class AnalyzeRule(
private var ruleData: RuleDataInterface? = null, private var ruleData: RuleDataInterface? = null,
private val source: BaseSource? = null, private val source: BaseSource? = null,
private val preUpdateJs: Boolean = false private val preUpdateJs: Boolean = false,
private var isFromBookInfo: Boolean = false
) : JsExtensions { ) : JsExtensions {
private val book get() = ruleData as? BaseBook private val book get() = ruleData as? BaseBook
@@ -79,6 +80,12 @@ class AnalyzeRule(
private var coroutineContext: CoroutineContext = EmptyCoroutineContext private var coroutineContext: CoroutineContext = EmptyCoroutineContext
private var loggedNonStandardJSON = false private var loggedNonStandardJSON = false
private var ruleName: String? = null
fun setRuleName(name: String) {
if (name.isNotBlank()) {
ruleName = name
}
}
@JvmOverloads @JvmOverloads
fun setContent(content: Any?, baseUrl: String? = null): AnalyzeRule { fun setContent(content: Any?, baseUrl: String? = null): AnalyzeRule {
@@ -782,6 +789,7 @@ class AnalyzeRule(
bindings["src"] = content bindings["src"] = content
bindings["nextChapterUrl"] = nextChapterUrl bindings["nextChapterUrl"] = nextChapterUrl
bindings["rssArticle"] = rssArticle bindings["rssArticle"] = rssArticle
bindings["fromBookInfo"] = isFromBookInfo
} }
val topScope = source?.getShareScope(coroutineContext) ?: topScopeRef?.get() val topScope = source?.getShareScope(coroutineContext) ?: topScopeRef?.get()
val scope = if (topScope == null) { val scope = if (topScope == null) {
@@ -810,6 +818,10 @@ class AnalyzeRule(
return source return source
} }
override fun getTag(): String? {
return source?.getTag() ?: ruleName
}
/** /**
* js实现跨域访问,不能删 * js实现跨域访问,不能删
*/ */
@@ -841,6 +853,9 @@ class AnalyzeRule(
*/ */
fun reGetBook() { fun reGetBook() {
if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用") if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用")
if (isFromBookInfo) {
log("重新获取book")
}
val bookSource = source as? BookSource val bookSource = source as? BookSource
val book = book as? Book val book = book as? Book
if (bookSource == null || book == null) return if (bookSource == null || book == null) return
@@ -863,6 +878,10 @@ class AnalyzeRule(
*/ */
fun refreshTocUrl() { fun refreshTocUrl() {
if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用") if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用")
if (isFromBookInfo) {
log("已跳过重复加载详情页,请优化代码")
return
}
val bookSource = source as? BookSource val bookSource = source as? BookSource
val book = book as? Book val book = book as? Book
if (bookSource == null || book == null) return if (bookSource == null || book == null) return
@@ -13,8 +13,6 @@ import com.script.rhino.RhinoScriptEngine
import com.script.rhino.runScriptWithContext import com.script.rhino.runScriptWithContext
import io.legado.app.constant.AppConst.UA_NAME import io.legado.app.constant.AppConst.UA_NAME
import io.legado.app.constant.AppPattern 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.BaseSource
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter 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.isJsonArray
import io.legado.app.utils.isJsonObject import io.legado.app.utils.isJsonObject
import io.legado.app.utils.isXml import io.legado.app.utils.isXml
import io.legado.app.utils.parseIpsFromString
import io.legado.app.utils.stackTraceStr
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.Dns
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.Charset import java.nio.charset.Charset
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.ContinuationInterceptor
@@ -87,8 +91,10 @@ class AnalyzeUrl(
private val callTimeout: Long? = null, private val callTimeout: Long? = null,
private var coroutineContext: CoroutineContext = EmptyCoroutineContext, private var coroutineContext: CoroutineContext = EmptyCoroutineContext,
headerMapF: Map<String, String>? = null, headerMapF: Map<String, String>? = null,
hasLoginHeader: Boolean = true hasLoginHeader: Boolean = true,
private val infoMap: MutableMap<String, String>? = null
) : JsExtensions { ) : JsExtensions {
constructor(mUrl: String) : this(mUrl, null)
var ruleUrl = "" var ruleUrl = ""
private set private set
@@ -98,7 +104,8 @@ class AnalyzeUrl(
private set private set
val headerMap = LinkedHashMap<String, String>() val headerMap = LinkedHashMap<String, String>()
private var body: String? = null private var body: String? = null
private var urlNoQuery: String = "" var urlNoQuery: String = ""
private set
private var encodedForm: String? = null private var encodedForm: String? = null
private var encodedQuery: String? = null private var encodedQuery: String? = null
private var charset: String? = null private var charset: String? = null
@@ -107,6 +114,8 @@ class AnalyzeUrl(
private var retry: Int = 0 private var retry: Int = 0
private var useWebView: Boolean = false private var useWebView: Boolean = false
private var webJs: String? = null private var webJs: String? = null
private var bodyJs: String? = null
private var dnsIp: String? = null
private val enabledCookieJar = source?.enabledCookieJar == true private val enabledCookieJar = source?.enabledCookieJar == true
private val domain: String private val domain: String
private var webViewDelayTime: Long = 0 private var webViewDelayTime: Long = 0
@@ -151,7 +160,7 @@ class AnalyzeUrl(
*/ */
private fun analyzeJs() { private fun analyzeJs() {
var start = 0 var start = 0
val jsMatcher = JS_PATTERN.matcher(ruleUrl) val jsMatcher = AppPattern.JS_PATTERN.matcher(ruleUrl)
var result = ruleUrl var result = ruleUrl
while (jsMatcher.find()) { while (jsMatcher.find()) {
if (jsMatcher.start() > start) { if (jsMatcher.start() > start) {
@@ -184,9 +193,9 @@ class AnalyzeUrl(
//替换所有内嵌{{js}} //替换所有内嵌{{js}}
val url = analyze.innerRule("{{", "}}") { val url = analyze.innerRule("{{", "}}") {
val jsEval = evalJS(it) ?: "" val jsEval = evalJS(it) ?: ""
when { when (jsEval) {
jsEval is String -> jsEval is String -> jsEval
jsEval is Double && jsEval % 1.0 == 0.0 -> String.format("%.0f", jsEval) is Double if jsEval % 1.0 == 0.0 -> String.format("%.0f", jsEval)
else -> jsEval.toString() else -> jsEval.toString()
} }
} }
@@ -242,15 +251,17 @@ class AnalyzeUrl(
retry = option.getRetry() retry = option.getRetry()
useWebView = option.useWebView() useWebView = option.useWebView()
webJs = option.getWebJs() webJs = option.getWebJs()
bodyJs = option.getBodyJs()
dnsIp = option.getDnsIp()
option.getJs()?.let { jsStr -> option.getJs()?.let { jsStr ->
evalJS(jsStr, url)?.toString()?.let { evalJS(jsStr, url)?.toString()?.let {
url = it url = it
} }
}
serverID = option.getServerID() serverID = option.getServerID()
webViewDelayTime = max(0, option.getWebViewDelayTime() ?: 0) webViewDelayTime = max(0, option.getWebViewDelayTime() ?: 0)
} }
} }
}
urlNoQuery = url urlNoQuery = url
when (method) { when (method) {
RequestMethod.GET -> { RequestMethod.GET -> {
@@ -341,6 +352,7 @@ class AnalyzeUrl(
} }
} }
/** /**
* 执行JS * 执行JS
*/ */
@@ -357,6 +369,7 @@ class AnalyzeUrl(
bindings["book"] = ruleData as? Book bindings["book"] = ruleData as? Book
bindings["source"] = source bindings["source"] = source
bindings["result"] = result bindings["result"] = result
bindings["infoMap"] = infoMap
} }
val sharedScope = source?.getShareScope(coroutineContext) val sharedScope = source?.getShareScope(coroutineContext)
val scope = if (sharedScope == null) { val scope = if (sharedScope == null) {
@@ -397,13 +410,30 @@ class AnalyzeUrl(
jsStr: String? = null, jsStr: String? = null,
sourceRegex: String? = null, sourceRegex: String? = null,
useWebView: Boolean = true, useWebView: Boolean = true,
isTest: Boolean = false,
skipRateLimit: Boolean = false
): StrResponse { ): StrResponse {
if (type != null) { if (type != null) {
return StrResponse(url, HexUtil.encodeHexStr(getByteArrayAwait())) return StrResponse(url, HexUtil.encodeHexStr(getByteArrayAwait()))
} }
if (skipRateLimit) {
return executeStrRequest(jsStr, sourceRegex, useWebView, isTest)
}
concurrentRateLimiter.withLimit { concurrentRateLimiter.withLimit {
return executeStrRequest(jsStr, sourceRegex, useWebView, isTest)
}
}
private suspend fun executeStrRequest(
jsStr: String? = null,
sourceRegex: String? = null,
useWebView: Boolean = true,
isTest: Boolean = false
): StrResponse {
setCookie() setCookie()
val startTime = System.currentTimeMillis()
val strResponse: StrResponse val strResponse: StrResponse
try {
if (this.useWebView && useWebView) { if (this.useWebView && useWebView) {
strResponse = when (method) { strResponse = when (method) {
RequestMethod.POST -> { RequestMethod.POST -> {
@@ -461,10 +491,36 @@ class AnalyzeUrl(
?.matches(AppPattern.xmlContentTypeRegex) == true ?.matches(AppPattern.xmlContentTypeRegex) == true
if (isXml && it.body?.trim()?.startsWith("<?xml", true) == false) { if (isXml && it.body?.trim()?.startsWith("<?xml", true) == false) {
StrResponse(it.raw, "<?xml version=\"1.0\"?>" + it.body) 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 } else it
} }
} }
val connectionTime = System.currentTimeMillis() - startTime
strResponse.putCallTime(connectionTime.toInt())
return strResponse 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 { private fun getClient(): OkHttpClient {
val client = getProxyClient(proxy) val client = getProxyClient(proxy)
if (readTimeout == null && callTimeout == null) { if (readTimeout == null && callTimeout == null && dnsIp == null) {
return client return client
} }
if (AppConfig.isCronet && dnsIp != null) {
customIp[urlNoQuery] = dnsIp!!
}
return client.newBuilder().run { return client.newBuilder().run {
if (readTimeout != null) { if (readTimeout != null) {
readTimeout(readTimeout, TimeUnit.MILLISECONDS) readTimeout(readTimeout, TimeUnit.MILLISECONDS)
@@ -522,10 +598,21 @@ class AnalyzeUrl(
if (callTimeout != null) { if (callTimeout != null) {
callTimeout(callTimeout, TimeUnit.MILLISECONDS) callTimeout(callTimeout, TimeUnit.MILLISECONDS)
} }
if (dnsIp != null) {
val inetAddress = dnsIp!!.parseIpsFromString()
dns { hostname ->
inetAddress ?: Dns.SYSTEM.lookup(hostname)
}
}
build() build()
} }
} }
private fun extractHostFromUrl(url: String): String? {
return AppPattern.domainRegex.find(url)?.groupValues?.getOrNull(1)
}
fun getResponse(): Response { fun getResponse(): Response {
return runBlocking(coroutineContext) { return runBlocking(coroutineContext) {
getResponseAwait() getResponseAwait()
@@ -536,7 +623,7 @@ class AnalyzeUrl(
if (!urlNoQuery.startsWith("data:")) { if (!urlNoQuery.startsWith("data:")) {
return null return null
} }
val dataUriFindResult = dataUriRegex.find(urlNoQuery) val dataUriFindResult = AppPattern.dataUriRegex.find(urlNoQuery)
if (dataUriFindResult != null) { if (dataUriFindResult != null) {
val dataUriBase64 = dataUriFindResult.groupValues[1] val dataUriBase64 = dataUriFindResult.groupValues[1]
val byteArray = Base64.decode(dataUriBase64, Base64.DEFAULT) val byteArray = Base64.decode(dataUriBase64, Base64.DEFAULT)
@@ -666,12 +753,16 @@ class AnalyzeUrl(
return source return source
} }
override fun getTag(): String? {
return source?.getTag()
}
companion object { companion object {
val paramPattern: Pattern = Pattern.compile("\\s*,\\s*(?=\\{)") val paramPattern: Pattern = Pattern.compile("\\s*,\\s*(?=\\{)")
private val pagePattern = Pattern.compile("<(.*?)>") private val pagePattern = Pattern.compile("<(.*?)>")
private val queryEncoder = private val queryEncoder =
RFC3986.UNRESERVED.orNew(PercentCodec.of("!$%&()*+,/:;=?@[\\]^`{|}")) RFC3986.UNRESERVED.orNew(PercentCodec.of("!$%&()*+,/:;=?@[\\]^`{|}"))
val customIp by lazy { ConcurrentHashMap<String, String>() }
fun AnalyzeUrl.getMediaItem(): MediaItem { fun AnalyzeUrl.getMediaItem(): MediaItem {
setCookie() setCookie()
return ExoPlayerHelper.createMediaItem(url, headerMap) return ExoPlayerHelper.createMediaItem(url, headerMap)
@@ -705,11 +796,20 @@ class AnalyzeUrl(
* webView中执行的js * webView中执行的js
**/ **/
private var webJs: String? = null, private var webJs: String? = null,
/**
* 自定义的域名ip
**/
private var dnsIp: String? = null,
/** /**
* 解析完url参数时执行的js * 解析完url参数时执行的js
* 执行结果会赋值给url * 执行结果会赋值给url
*/ */
private var js: String? = null, private var js: String? = null,
/**
* 得到访问结果后执行的js,对结果进行二次处理
* 执行结果返回为body
*/
private var bodyJs: String? = null,
/** /**
* 服务器id * 服务器id
*/ */
@@ -808,6 +908,13 @@ class AnalyzeUrl(
fun getWebJs(): String? { fun getWebJs(): String? {
return webJs return webJs
} }
fun setDnsIp(value: String?) {
dnsIp = if (value.isNullOrBlank()) null else value
}
fun getDnsIp(): String? {
return dnsIp
}
fun setJs(value: String?) { fun setJs(value: String?) {
js = if (value.isNullOrBlank()) null else value js = if (value.isNullOrBlank()) null else value
@@ -817,6 +924,14 @@ class AnalyzeUrl(
return js return js
} }
fun setBodyJs(value: String?) {
bodyJs = if (value.isNullOrBlank()) null else value
}
fun getBodyJs(): String? {
return bodyJs
}
fun setServerID(value: String?) { fun setServerID(value: String?) {
serverID = if (value.isNullOrBlank()) null else value.toLong() serverID = if (value.isNullOrBlank()) null else value.toLong()
} }
@@ -835,14 +950,18 @@ class AnalyzeUrl(
} }
data class ConcurrentRecord( data class ConcurrentRecord(
/**
* 是否按频率
*/
val isConcurrent: Boolean,
/** /**
* 开始访问时间 * 开始访问时间
*/ */
var time: Long, 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.MaterialContainerTransform
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import com.script.rhino.runScriptWithContext
import io.legado.app.BuildConfig import io.legado.app.BuildConfig
import io.legado.app.R import io.legado.app.R
import io.legado.app.constant.AppLog 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
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext 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.EpubFile
import io.legado.app.model.localBook.MobiFile import io.legado.app.model.localBook.MobiFile
import io.legado.app.receiver.NetworkChangedListener 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.browser.WebViewActivity
import io.legado.app.ui.dict.DictDialog import io.legado.app.ui.dict.DictDialog
import io.legado.app.ui.login.SourceLoginActivity 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.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleActivity import io.legado.app.ui.replace.ReplaceRuleActivity
import io.legado.app.ui.widget.PopupAction 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.iconItemOnLongClick
import io.legado.app.utils.invisible import io.legado.app.utils.invisible
import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonObject
import io.legado.app.utils.isTrue import io.legado.app.utils.isTrue
import io.legado.app.utils.navigationBarGravity import io.legado.app.utils.navigationBarGravity
import io.legado.app.utils.observeEvent import io.legado.app.utils.observeEvent
@@ -1521,45 +1523,75 @@ class ReadBookActivity : BaseReadBookActivity(),
showHelp("readMenuHelp") 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) { override fun oldClickImg(src: String): Boolean {
val book = ReadBook.book ?: return val urlMatcher = paramPattern.matcher(src)
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) if (urlMatcher.find()) {
if (chapter == null) { val urlOptionStr = src.substring(urlMatcher.end())
toastOnUi("章节不存在") val urlOptionMap = GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()
return val click = urlOptionMap?.get("click")
} if (click != null) {
val (result, urlOptionStr) = when { Coroutine.async(lifecycleScope, IO) {
braceIndex != -1 -> { val source = ReadBook.bookSource ?: return@async
clickjs.take(braceIndex) to clickjs.substring(braceIndex + 1) val java = SourceLoginJsExtensions(this@ReadBookActivity, source, BookType.text)
} val book = ReadBook.book ?: return@async
else -> { val chapter =
clickjs.take(braceIndex2) to clickjs.substring(braceIndex2 + 2) 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)
} }
} }
if (urlOptionStr.isJsonObject()) { }.onError {
val urlOptionMap = GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrThrow() AppLog.put("执行图片链接click键值出错\n${it.localizedMessage}", it, true)
val jsStr = urlOptionMap["js"] }
jsStr?.let { return true
Coroutine.async(lifecycleScope) { }
val source = ReadBook.bookSource ?: throw Exception("书源不存在") 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 { AnalyzeRule(book, source).apply {
setCoroutineContext(coroutineContext) setCoroutineContext(coroutineContext)
setBaseUrl(chapter.url) setBaseUrl(chapter.url)
setChapter(chapter) setChapter(chapter)
evalJS(jsStr, result).toString() evalJS(jsStr, urlNoOption)
} }
}.onError { }.onError {
AppLog.put("图片点击执行出错\n${it.localizedMessage}", it, true) 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)
} }
} }
} }.onError {
else { AppLog.put("执行图片链接click键值出错\n${it.localizedMessage}", it, true)
toastOnUi("链接格式错误")
return
}
} }
} }
@@ -11,6 +11,7 @@ import io.legado.app.data.entities.Bookmark
import io.legado.app.help.book.isOnLineTxt import io.legado.app.help.book.isOnLineTxt
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.model.ReadBook 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.delegate.PageDelegate
import io.legado.app.ui.book.read.page.entities.TextLine import io.legado.app.ui.book.read.page.entities.TextLine
import io.legado.app.ui.book.read.page.entities.TextPage 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.ImageColumn
import io.legado.app.ui.book.read.page.entities.column.ReviewColumn 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.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.ChapterProvider
import io.legado.app.ui.book.read.page.provider.TextPageFactory import io.legado.app.ui.book.read.page.provider.TextPageFactory
import io.legado.app.ui.widget.dialog.PhotoDialog 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.dpToPx
import io.legado.app.utils.getCompatColor import io.legado.app.utils.getCompatColor
import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.max import kotlin.math.max
@@ -246,18 +249,51 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
handled = true handled = true
} }
is ImageColumn -> if (AppConfig.previewImageByClick) { is ImageColumn -> when (AppConfig.clickImgWay) {
"1" -> { //预览图片
activity?.showDialogFragment(PhotoDialog(column.src)) activity?.showDialogFragment(PhotoDialog(column.src))
handled = true handled = true
} else { }
"2" -> { //兼容处理
if (ReadBook.book?.isOnLineTxt == true) { if (ReadBook.book?.isOnLineTxt == true) {
val click = column.click
val src = column.src val src = column.src
if (src.contains("\"js\"") || src.contains("'js'")) { if (!click.isNullOrBlank()) {
callBack.clickImg(src) 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 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 return handled
@@ -723,6 +759,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
fun onImageLongPress(x: Float, y: Float, src: String) fun onImageLongPress(x: Float, y: Float, src: String)
fun onCancelSelect() fun onCancelSelect()
fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean 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.ContentTextView
import io.legado.app.ui.book.read.page.entities.TextLine 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.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
import io.legado.app.utils.dpToPx
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import splitties.init.appCtx import splitties.init.appCtx
@@ -18,7 +19,8 @@ import splitties.init.appCtx
data class ImageColumn( data class ImageColumn(
override var start: Float, override var start: Float,
override var end: Float, override var end: Float,
var src: String var src: String,
var click: String? = null
) : BaseColumn { ) : BaseColumn {
override var textLine: TextLine = emptyTextLine 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.help.coroutine.Coroutine
import io.legado.app.model.ImageProvider import io.legado.app.model.ImageProvider
import io.legado.app.model.ReadBook 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.TextChapter
import io.legado.app.ui.book.read.page.entities.TextLine import io.legado.app.ui.book.read.page.entities.TextLine
import io.legado.app.ui.book.read.page.entities.TextPage 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.srcReplaceChar
import io.legado.app.ui.book.read.page.provider.ChapterProvider.srcReplaceCharC 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.ui.book.read.page.provider.ChapterProvider.srcReplaceCharD
import io.legado.app.utils.GSON
import io.legado.app.utils.StringUtils import io.legado.app.utils.StringUtils
import io.legado.app.utils.dpToPx import io.legado.app.utils.dpToPx
import io.legado.app.utils.fastSum import io.legado.app.utils.fastSum
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getTextWidthsCompat import io.legado.app.utils.getTextWidthsCompat
import io.legado.app.utils.splitNotBlank import io.legado.app.utils.splitNotBlank
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -340,6 +343,7 @@ class TextChapterLayout(
} }
var start = 0 var start = 0
val srcList = LinkedList<String>() val srcList = LinkedList<String>()
val clickList = LinkedList<String?>()
sb.setLength(0) sb.setLength(0)
var isFirstLine = true var isFirstLine = true
if (content.contains("<img")) { if (content.contains("<img")) {
@@ -347,51 +351,58 @@ class TextChapterLayout(
while (matcher.find()) { while (matcher.find()) {
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
val imgSrc = matcher.group(1)!! val imgSrc = matcher.group(1)!!
var iStyle = imageStyle var iStyle: String? = null
var isSmallImage = true var click: String? = null
val matchResult = AppPattern.imgStyRegex.find(imgSrc) var imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
var imgSize: Size? = null val urlMatcher = paramPattern.matcher(imgSrc)
if (matchResult != null) { if (urlMatcher.find()) {
val styleValue = matchResult.groupValues[1].trim() var width: String? = null
if (styleValue.equals("text", true)) { //忽略大小写 val urlOptionStr = imgSrc.substring(urlMatcher.end())
iStyle = styleValue GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()
} else { ?.let { map ->
imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource) map.forEach { (key, value) ->
iStyle = styleValue when (key) {
isSmallImage = false "style" -> iStyle = value
} "width" -> width = value
} else { "click" -> click = value
imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
if (imgSize.width < 80 && imgSize.height < 80) {
iStyle = "text"
} else {
isSmallImage = false
} }
} }
}
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()) { if (start < matcher.start()) {
sb.append(text.substring(start, matcher.start())) sb.append(text.substring(start, matcher.start()))
} }
if (isSmallImage) { if (iStyle == "text" || iStyle == "TEXT") {
sb.append( sb.append(if (iStyle == "TEXT") reviewChar else srcReplaceChar)
if (iStyle == "TEXT")
reviewChar
else
srcReplaceChar
)
srcList.add(imgSrc) srcList.add(imgSrc)
clickList.add(click)
} else { } else {
val textBefore = sb.toString() val textBefore = sb.toString()
if (textBefore.isNotBlank()) { if (textBefore.isNotBlank()) {
wordCount += textBefore.replace(noWordCountRegex,"").length wordCount += textBefore.replace(noWordCountRegex,"").length
setTypeText( setTypeText(
book, book, sb.toString(), contentPaint, contentPaintTextHeight,
sb.toString(), contentPaintFontMetrics, "TEXT", isFirstLine = isFirstLine,
contentPaint, srcList = srcList, clickList = clickList
contentPaintTextHeight,
contentPaintFontMetrics,
"TEXT",
isFirstLine = isFirstLine,
srcList = srcList
) )
sb.setLength(0) sb.setLength(0)
isFirstLine = false isFirstLine = false
@@ -401,8 +412,9 @@ class TextChapterLayout(
imgSrc, imgSrc,
contentPaintTextHeight, contentPaintTextHeight,
iStyle, iStyle,
imgSize!! imgSize,
) click
) // 传递点击信息
isSetTypedImage = true isSetTypedImage = true
} }
start = matcher.end() start = matcher.end()
@@ -427,7 +439,8 @@ class TextChapterLayout(
contentPaintFontMetrics, contentPaintFontMetrics,
"TEXT", "TEXT",
isFirstLine = isFirstLine, isFirstLine = isFirstLine,
srcList = srcList.ifEmpty { null } srcList = srcList.ifEmpty { null },
clickList = clickList.ifEmpty { null }
) )
} }
} }
@@ -459,7 +472,8 @@ class TextChapterLayout(
src: String, src: String,
textHeight: Float, textHeight: Float,
imageStyle: String?, imageStyle: String?,
size: Size size: Size,
click: String? = null
) { ) {
if (size.width > 0 && size.height > 0) { if (size.width > 0 && size.height > 0) {
prepareNextPageIfNeed(durY) prepareNextPageIfNeed(durY)
@@ -776,7 +790,8 @@ class TextChapterLayout(
isFirstLine: Boolean = true, isFirstLine: Boolean = true,
emptyContent: Boolean = false, emptyContent: Boolean = false,
isVolumeTitle: Boolean = false, isVolumeTitle: Boolean = false,
srcList: LinkedList<String>? = null srcList: LinkedList<String>? = null,
clickList: LinkedList<String?>? = null
) { ) {
val widthsArray = allocateFloatArray(text.length) val widthsArray = allocateFloatArray(text.length)
textPaint.getTextWidthsCompat(text, widthsArray) textPaint.getTextWidthsCompat(text, widthsArray)
@@ -836,7 +851,7 @@ class TextChapterLayout(
//多行的第一行 非标题 //多行的第一行 非标题
addCharsToLineFirst( addCharsToLineFirst(
book, absStartX, textLine, words, textPaint, book, absStartX, textLine, words, textPaint,
desiredWidth, widths, srcList desiredWidth, widths, srcList, clickList
) )
} }
@@ -854,7 +869,7 @@ class TextChapterLayout(
} }
addCharsToLineNatural( addCharsToLineNatural(
book, absStartX, textLine, words, book, absStartX, textLine, words,
startX, !isTitle && lineIndex == 0, widths, srcList startX, !isTitle && lineIndex == 0, widths, srcList, clickList
) )
} }
else -> { else -> {
@@ -867,13 +882,13 @@ class TextChapterLayout(
val startX = (visibleWidth - desiredWidth) / 2 val startX = (visibleWidth - desiredWidth) / 2
addCharsToLineNatural( addCharsToLineNatural(
book, absStartX, textLine, words, book, absStartX, textLine, words,
startX, false, widths, srcList startX, false, widths, srcList, clickList
) )
} else { } else {
//中间行 //中间行
addCharsToLineMiddle( addCharsToLineMiddle(
book, absStartX, textLine, words, textPaint, book, absStartX, textLine, words, textPaint,
desiredWidth, 0f, widths, srcList desiredWidth, 0f, widths, srcList, clickList
) )
} }
} }
@@ -926,13 +941,14 @@ class TextChapterLayout(
/**自然排版长度**/ /**自然排版长度**/
desiredWidth: Float, desiredWidth: Float,
textWidths: List<Float>, textWidths: List<Float>,
srcList: LinkedList<String>? srcList: LinkedList<String>?,
clickList: LinkedList<String?>?
) { ) {
var x = 0f var x = 0f
if (!textFullJustify) { if (!textFullJustify) {
addCharsToLineNatural( addCharsToLineNatural(
book, absStartX, textLine, words, book, absStartX, textLine, words,
x, true, textWidths, srcList x, true, textWidths, srcList, clickList
) )
return return
} }
@@ -955,7 +971,7 @@ class TextChapterLayout(
val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size) val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size)
addCharsToLineMiddle( addCharsToLineMiddle(
book, absStartX, textLine, text1, textPaint, book, absStartX, textLine, text1, textPaint,
desiredWidth, x, textWidths1, srcList desiredWidth, x, textWidths1, srcList, clickList
) )
} }
} }
@@ -974,12 +990,13 @@ class TextChapterLayout(
/**起始x坐标**/ /**起始x坐标**/
startX: Float, startX: Float,
textWidths: List<Float>, textWidths: List<Float>,
srcList: LinkedList<String>? srcList: LinkedList<String>?,
clickList: LinkedList<String?>?
) { ) {
if (!textFullJustify) { if (!textFullJustify) {
addCharsToLineNatural( addCharsToLineNatural(
book, absStartX, textLine, words, book, absStartX, textLine, words,
startX, false, textWidths, srcList startX, false, textWidths, srcList, clickList
) )
return return
} }
@@ -1000,7 +1017,7 @@ class TextChapterLayout(
} }
addCharToLine( addCharToLine(
book, absStartX, textLine, char, book, absStartX, textLine, char,
x, x1, index + 1 == words.size, srcList x, x1, index + 1 == words.size, srcList, clickList
) )
x = x1 x = x1
} }
@@ -1016,7 +1033,7 @@ class TextChapterLayout(
val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw) val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw)
addCharToLine( addCharToLine(
book, absStartX, textLine, char, book, absStartX, textLine, char,
x, x1, index + 1 == words.size, srcList x, x1, index + 1 == words.size, srcList, clickList
) )
x = x1 x = x1
} }
@@ -1035,7 +1052,8 @@ class TextChapterLayout(
startX: Float, startX: Float,
hasIndent: Boolean, hasIndent: Boolean,
textWidths: List<Float>, textWidths: List<Float>,
srcList: LinkedList<String>? srcList: LinkedList<String>?,
clickList: LinkedList<String?>?
) { ) {
val indentLength = paragraphIndent.length val indentLength = paragraphIndent.length
var x = startX var x = startX
@@ -1044,7 +1062,17 @@ class TextChapterLayout(
val char = words[index] val char = words[index]
val cw = textWidths[index] val cw = textWidths[index]
val x1 = x + cw 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 x = x1
if (hasIndent && index == indentLength - 1) { if (hasIndent && index == indentLength - 1) {
textLine.indentWidth = x textLine.indentWidth = x
@@ -1064,16 +1092,19 @@ class TextChapterLayout(
xStart: Float, xStart: Float,
xEnd: Float, xEnd: Float,
isLineEnd: Boolean, isLineEnd: Boolean,
srcList: LinkedList<String>? srcList: LinkedList<String>?,
clickList: LinkedList<String?>?
) { ) {
val column = when { val column = when {
!srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> { !srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> {
val src = srcList.removeFirst() val src = srcList.removeFirst()
val click = clickList?.removeFirst()
ImageProvider.cacheImage(book, src, ReadBook.bookSource) ImageProvider.cacheImage(book, src, ReadBook.bookSource)
ImageColumn( ImageColumn(
start = absStartX + xStart, start = absStartX + xStart,
end = absStartX + xEnd, end = absStartX + xEnd,
src = src src = src,
click = click
) )
} }
// isLineEnd && char == ChapterProvider.reviewChar -> { // isLineEnd && char == ChapterProvider.reviewChar -> {
@@ -663,9 +663,10 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
companion object { companion object {
fun start(context: Context, key: String?) { fun start(context: Context, key: String?, searchScope: String? = null) {
context.startActivity<SearchActivity> { context.startActivity<SearchActivity> {
putExtra("key", key) putExtra("key", key)
putExtra("searchScope", searchScope)
} }
} }
@@ -1,10 +1,20 @@
package io.legado.app.ui.login package io.legado.app.ui.login
//import io.legado.app.lib.theme.primaryColor //import io.legado.app.lib.theme.primaryColor
import android.annotation.SuppressLint
import android.content.DialogInterface import android.content.DialogInterface
import android.graphics.Rect
import android.os.Bundle import android.os.Bundle
import android.text.InputType import android.text.InputType
import android.view.Gravity
import android.view.View 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.core.view.setPadding
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -14,18 +24,25 @@ import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.data.entities.BaseSource import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.rule.RowUi 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.DialogLoginBinding
import io.legado.app.databinding.ItemFilletTextBinding import io.legado.app.databinding.ItemFilletTextBinding
import io.legado.app.databinding.ItemSelectorSingleBinding
import io.legado.app.databinding.ItemSourceEditBinding import io.legado.app.databinding.ItemSourceEditBinding
import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.about.AppLogDialog 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.GSON
import io.legado.app.utils.applyTint import io.legado.app.utils.applyTint
import io.legado.app.utils.dpToPx import io.legado.app.utils.dpToPx
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isTrue
import io.legado.app.utils.openUrl import io.legado.app.utils.openUrl
import io.legado.app.utils.printOnDebug import io.legado.app.utils.printOnDebug
import io.legado.app.utils.sendToClip 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.showDialogFragment
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -35,72 +52,543 @@ import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import splitties.init.appCtx import splitties.init.appCtx
import splitties.views.onClick import kotlin.math.abs
class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) { class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
private val binding by viewBinding(DialogLoginBinding::bind) private val binding by viewBinding(DialogLoginBinding::bind)
private val viewModel by activityViewModels<SourceLoginViewModel>() private val viewModel by activityViewModels<SourceLoginViewModel>()
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)
}
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { override fun reUiView() {
activity?.runOnUiThread {
handleReUiView()
}
}
}
)
}
private fun handleReUiView() {
val source = viewModel.source ?: return val source = viewModel.source ?: return
//binding.toolBar.setBackgroundColor(primaryColor) val loginUiStr = source.loginUi ?: return
binding.toolBar.title = getString(R.string.login_source, source.getTag()) val codeStr = loginUiStr.let {
val loginInfo = source.getLoginInfoMap() when {
val loginUi = source.loginUi() it.startsWith("@js:") -> it.substring(4)
try { it.startsWith("<js>") -> it.substring(4, it.lastIndexOf("<"))
loginUi?.forEachIndexed { index, rowUi -> else -> null
}
}
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) { when (rowUi.type) {
RowUi.Type.text -> ItemSourceEditBinding.inflate( Type.button -> {
layoutInflater, rowView.text = rowUi.viewName ?: rowUi.name
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))
} }
RowUi.Type.password -> ItemSourceEditBinding.inflate( 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, layoutInflater,
binding.root, binding.root,
false false
).let { ).let {
val editText = it.editText
binding.flexbox.addView(it.root) 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 it.root.id = index + 1000
it.textInputLayout.hint = rowUi.name if (viewName == null) {
it.editText.inputType = 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 InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
it.editText.setText(loginInfo?.get(rowUi.name)) 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()
}
}
}
} }
RowUi.Type.button -> ItemFilletTextBinding.inflate( 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, layoutInflater,
binding.root, binding.root,
false false
).let { ).let {
binding.flexbox.addView(it.root) binding.flexbox.addView(it.root)
rowUi.style().apply(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.root.id = index + 1000
it.textView.text = rowUi.name it.textView.text = viewName ?: name
it.textView.setPadding(16.dpToPx()) it.textView.setPadding(16.dpToPx())
it.root.onClick {
handleButtonClick(source, rowUi, loginUi) 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
} }
} }
} }
} }
} catch (e: NullPointerException) {
AppLog.put("登录UI JSON 数据错误", e, true)
} }
binding.toolBar.inflateMenu(R.menu.source_login)
binding.toolBar.menu.applyTint(requireContext()) private fun buttonUi(source: BaseSource, rowUis: List<RowUi>?) {
rowUiBuilder(source, rowUis)
binding.toolBar.setOnMenuItemClickListener { item -> binding.toolBar.setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.menu_ok -> { R.id.menu_ok -> {
val loginData = getLoginData(loginUi) oKToClose = true
val loginData = getLoginData(rowUis, true)
login(source, loginData) 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) { lifecycleScope.launch(IO) {
if (rowUi.action.isAbsUrl()) { if (action.isAbsUrl()) {
context?.openUrl(rowUi.action!!) context?.openUrl(action!!)
} else if (rowUi.action != null) { } else if (action != null) {
// JavaScript // JavaScript
val buttonFunctionJS = rowUi.action!! val buttonFunctionJS = action
val loginJS = source.getLoginJs() ?: return@launch val loginJS = source.getLoginJs() ?: return@launch
kotlin.runCatching { kotlin.runCatching {
runScriptWithContext { runScriptWithContext {
source.evalJS("$loginJS\n$buttonFunctionJS") { 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 -> }.onFailure { e ->
ensureActive() 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>() val loginData = hashMapOf<String, String>()
loginUi?.forEachIndexed { index, rowUi -> rowUis?.forEachIndexed { index, rowUi ->
when (rowUi.type) { when (rowUi.type) {
"text", "password" -> { Type.text, Type.password -> {
val rowView = binding.root.findViewById<View>(index + 1000) val rowView = binding.root.findViewById<View>(index + 1000)
ItemSourceEditBinding.bind(rowView).editText.text?.let { ItemSourceEditBinding.bind(rowView).editText.text.let {
loginData[rowUi.name] = it.toString() 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) { lifecycleScope.launch(IO) {
if (loginData.isEmpty()) { if (loginData.isEmpty()) {
source.removeLoginInfo() source.removeLoginInfo()
@@ -184,6 +714,14 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
} }
override fun onDismiss(dialog: DialogInterface) { 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) super.onDismiss(dialog)
activity?.finish() 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 com.script.rhino.runScriptWithContext
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.BaseSource 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.exception.NoStackTraceException
import io.legado.app.model.AudioPlay
import io.legado.app.model.ReadBook
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
class SourceLoginViewModel(application: Application) : BaseViewModel(application) { class SourceLoginViewModel(application: Application) : BaseViewModel(application) {
var source: BaseSource? = null var source: BaseSource? = null
var headerMap: Map<String, String> = emptyMap() 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) { fun initData(intent: Intent, success: (bookSource: BaseSource) -> Unit, error: () -> Unit) {
execute { execute {
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") val sourceKey = intent.getStringExtra("key")
?: throw NoStackTraceException("没有参数") ?: throw NoStackTraceException("没有参数")
when (intent.getStringExtra("type")) { val type = intent.getStringExtra("type")
"bookSource" -> source = appDb.bookSourceDao.getBookSource(sourceKey) source = when (type) {
"rssSource" -> source = appDb.rssSourceDao.getByKey(sourceKey) "bookSource" -> appDb.bookSourceDao.getBookSource(sourceKey)
"httpTts" -> source = appDb.httpTTSDao.get(sourceKey.toLong()) "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 { headerMap = runScriptWithContext {
source?.getHeaderMap(true) ?: emptyMap() source?.getHeaderMap(true) ?: emptyMap()
} }
source?.let { loginInfo = it.getLoginInfoMap() }
source source
}.onSuccess { }.onSuccess {
if (it != null) { if (it != null) {
@@ -121,7 +121,6 @@ fun ReplaceRuleScreen(
val uploadRepository: UploadRepository = koinInject() val uploadRepository: UploadRepository = koinInject()
//TODO: 期望换为Navigation
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -12,7 +12,6 @@ import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.repository.ReplaceRuleRepository import io.legado.app.data.repository.ReplaceRuleRepository
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.ReplaceAnalyzer import io.legado.app.help.ReplaceAnalyzer
import io.legado.app.help.http.decompressed import io.legado.app.help.http.decompressed
import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.newCallResponseBody
@@ -80,9 +80,9 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
return return
} }
val firstArticle = articles.first() 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 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) { if (dbFirstArticle != null && dbLastArticle != null) {
loadFinallyLiveData.postValue(false) loadFinallyLiveData.postValue(false)
} else { } else {
@@ -2,6 +2,7 @@
package io.legado.app.ui.rss.article package io.legado.app.ui.rss.article
import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Bundle 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.read.ReadRssActivity.RedirectPolicy
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
import io.legado.app.ui.widget.dialog.VariableDialog 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.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -245,4 +252,14 @@ class RssSortActivity : VMBaseActivity<ActivityRssArtivlesBinding, RssSortViewMo
return RssArticlesFragment(sort.first, sort.second) 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 package io.legado.app.ui.rss.read
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri import android.net.Uri
@@ -17,7 +18,6 @@ import android.webkit.SslErrorHandler
import android.webkit.URLUtil import android.webkit.URLUtil
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient 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.setOnApplyWindowInsetsListenerCompat
import io.legado.app.utils.share import io.legado.app.utils.share
import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.splitNotBlank
import io.legado.app.utils.startActivity import io.legado.app.utils.startActivity
import io.legado.app.utils.textArray import io.legado.app.utils.textArray
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
@@ -66,9 +65,7 @@ import kotlinx.coroutines.launch
import org.apache.commons.text.StringEscapeUtils import org.apache.commons.text.StringEscapeUtils
import org.jsoup.Jsoup import org.jsoup.Jsoup
import splitties.views.bottomPadding import splitties.views.bottomPadding
import java.io.ByteArrayInputStream
import java.net.URLDecoder import java.net.URLDecoder
import java.util.regex.PatternSyntaxException
/** /**
* rss阅读界面 * rss阅读界面
@@ -102,7 +99,7 @@ class ReadRssActivity : VMBaseActivity<ActivityRssReadBinding, ReadRssViewModel>
private var redirectPolicyMenu: MenuItem? = null private var redirectPolicyMenu: MenuItem? = null
private var isFullScreen = false private var isFullScreen = false
private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null 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? { fun getSource(): RssSource? {
return viewModel.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) { if (link != null) {
rssStar = appDb.rssStarDao.get(origin, link) 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 val rssArticle = rssArticle ?: return@execute
if (!rssArticle.description.isNullOrBlank()) { if (!rssArticle.description.isNullOrBlank()) {
contentLiveData.postValue(rssArticle.description!!) contentLiveData.postValue(rssArticle.description!!)
@@ -1,24 +1,178 @@
package io.legado.app.ui.rss.read 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.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.help.JsExtensions
import io.legado.app.ui.association.AddToBookshelfDialog 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.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.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") @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? { 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) { 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) { 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -44,7 +45,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -148,7 +148,7 @@ fun <T> BatchImportDialog(
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f), .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f),
containerColor = Color.Transparent, containerColor = Color.Transparent,
topBar = { topBar = {
TopAppBar( CenterAlignedTopAppBar(
title = { title = {
AnimatedText( AnimatedText(
if (selectedCount > 0) 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.icu.util.ULocale
import android.net.Uri import android.net.Uri
import android.text.Editable import android.text.Editable
import androidx.core.net.toUri
import cn.hutool.core.net.URLEncodeUtil import cn.hutool.core.net.URLEncodeUtil
import io.legado.app.constant.AppPattern import io.legado.app.constant.AppPattern
import io.legado.app.constant.AppPattern.dataUriRegex import io.legado.app.constant.AppPattern.dataUriRegex
import java.io.File import java.io.File
import java.lang.Character.codePointCount import java.lang.Character.codePointCount
import java.lang.Character.offsetByCodePoints import java.lang.Character.offsetByCodePoints
import java.net.InetAddress
import java.util.Locale import java.util.Locale
import java.util.regex.Pattern import java.util.regex.Pattern
fun String?.safeTrim() = if (this.isNullOrBlank()) null else this.trim() fun String?.safeTrim() = if (this.isNullOrBlank()) null else this.trim()
fun String?.isContentScheme(): Boolean = this?.startsWith("content://") == true 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.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this)
fun String.parseToUri(): Uri { fun String.parseToUri(): Uri {
return if (isUri()) Uri.parse(this) else { return if (isUri()) this.toUri() else {
Uri.fromFile(File(this)) Uri.fromFile(File(this))
} }
} }
@@ -75,7 +78,7 @@ fun String?.isTrue(nullIsTrue: Boolean = false): Boolean {
if (this.isNullOrBlank() || this == "null") { if (this.isNullOrBlank() || this == "null") {
return nullIsTrue 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 { fun String.isHex(): Boolean {
@@ -144,3 +147,41 @@ fun String.encodeURI(): String = URLEncodeUtil.encodeQuery(this)
fun String.normalizeFileName(): String { fun String.normalizeFileName(): String {
return replace(AppPattern.fileNameRegex2, "_") 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.EditText
import android.widget.RadioGroup import android.widget.RadioGroup
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes 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">音频缓存保留时间(分钟)</string>
<string name="audio_cache_clean_time_summary">当前%d, 输入 0 代表退出即刻清理。</string> <string name="audio_cache_clean_time_summary">当前%d, 输入 0 代表退出即刻清理。</string>
<string name="show_swipe_animation">启用滑动时动画</string> <string name="show_swipe_animation">启用滑动时动画</string>
<string name="click_image_way">点击图片行为</string>
</resources> </resources>
+7
View File
@@ -52,6 +52,13 @@
<item>4</item> <item>4</item>
</string-array> </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"> <string-array name="double_page_value">
<item>0</item> <item>0</item>
<item>1</item> <item>1</item>
+7
View File
@@ -116,6 +116,13 @@
<item>@string/screen_portrait_reversed</item> <item>@string/screen_portrait_reversed</item>
</string-array> </string-array>
<string-array name="click_image_way_title">
<item>新包名阅读行为</item>
<item>预览图片</item>
<item>经典阅读行为</item>
<item>不响应</item>
</string-array>
<string-array name="double_page_title"> <string-array name="double_page_title">
<item>Single page</item> <item>Single page</item>
<item>Double page</item> <item>Double page</item>
+1
View File
@@ -1386,4 +1386,5 @@
<string name="audio_cache_clean_time">音频缓存保留时间(分钟)</string> <string name="audio_cache_clean_time">音频缓存保留时间(分钟)</string>
<string name="audio_cache_clean_time_summary">当前%d, 输入 0 代表退出即刻清理。</string> <string name="audio_cache_clean_time_summary">当前%d, 输入 0 代表退出即刻清理。</string>
<string name="show_swipe_animation">启用滑动时动画</string> <string name="show_swipe_animation">启用滑动时动画</string>
<string name="click_image_way">点击图片行为</string>
</resources> </resources>
+7 -4
View File
@@ -196,10 +196,13 @@
android:title="@string/no_anim_scroll_page" android:title="@string/no_anim_scroll_page"
app:isBottomBackground="true" /> app:isBottomBackground="true" />
<io.legado.app.lib.prefs.SwitchPreference <io.legado.app.lib.prefs.NameListPreference
android:defaultValue="false" android:defaultValue="2"
android:key="previewImageByClick" android:entries="@array/click_image_way_title"
android:title="@string/preview_image_by_click" android:entryValues="@array/click_image_way_value"
android:key="clickImgWay"
android:title="@string/click_image_way"
app:iconSpaceReserved="false"
app:isBottomBackground="true" /> app:isBottomBackground="true" />
<io.legado.app.lib.prefs.SwitchPreference <io.legado.app.lib.prefs.SwitchPreference