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