Rename
This commit is contained in:
1 parent
b86d90021c
commit
6c0757c91b
972 files changed
+6885
-7321
No files matched your search
@@ -0,0 +1,80 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.utils.LogUtils
|
||||
|
||||
object AppFreezeMonitor {
|
||||
|
||||
private const val TAG = "AppFreezeMonitor"
|
||||
|
||||
val handler by lazy {
|
||||
Handler(HandlerThread("AppFreezeMonitor").apply { start() }.looper)
|
||||
}
|
||||
|
||||
val screenStatusReceiver by lazy {
|
||||
ScreenStatusReceiver()
|
||||
}
|
||||
|
||||
private var registeredReceiver = false
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
fun init(context: Context) {
|
||||
if (!AppConfig.recordLog) {
|
||||
if (registeredReceiver) {
|
||||
registeredReceiver = false
|
||||
context.unregisterReceiver(screenStatusReceiver)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!registeredReceiver) {
|
||||
registeredReceiver = true
|
||||
context.registerReceiver(screenStatusReceiver, screenStatusReceiver.filter)
|
||||
}
|
||||
|
||||
var previous = SystemClock.uptimeMillis()
|
||||
|
||||
val runnable = object : Runnable {
|
||||
override fun run() {
|
||||
val current = SystemClock.uptimeMillis()
|
||||
val elapsed = current - previous
|
||||
val extra = elapsed - 3000
|
||||
|
||||
if (extra > 300) {
|
||||
LogUtils.d(TAG, "检测到应用被系统冻结,时长:$extra 毫秒")
|
||||
}
|
||||
|
||||
previous = current
|
||||
|
||||
if (AppConfig.recordLog) {
|
||||
handler.postDelayed(this, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
handler.postDelayed(runnable, 3000)
|
||||
}
|
||||
|
||||
class ScreenStatusReceiver : BroadcastReceiver() {
|
||||
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_ON -> LogUtils.d(TAG, "SCREEN_ON")
|
||||
Intent.ACTION_SCREEN_OFF -> LogUtils.d(TAG, "SCREEN_OFF")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.net.Uri
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.PreferKey
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookProgress
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.storage.Backup
|
||||
import io.legato.kazusa.help.storage.Restore
|
||||
import io.legato.kazusa.lib.webdav.Authorization
|
||||
import io.legato.kazusa.lib.webdav.WebDav
|
||||
import io.legato.kazusa.lib.webdav.WebDavException
|
||||
import io.legato.kazusa.lib.webdav.WebDavFile
|
||||
import io.legato.kazusa.model.remote.RemoteBookWebDav
|
||||
import io.legato.kazusa.utils.AlphanumComparator
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.UrlUtil
|
||||
import io.legato.kazusa.utils.compress.ZipUtils
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import io.legato.kazusa.utils.getPrefString
|
||||
import io.legato.kazusa.utils.isJson
|
||||
import io.legato.kazusa.utils.removePref
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* webDav初始化会访问网络,不要放到主线程
|
||||
*/
|
||||
object AppWebDav {
|
||||
private const val defaultWebDavUrl = "https://dav.jianguoyun.com/dav/"
|
||||
private val bookProgressUrl get() = "${rootWebDavUrl}bookProgress/"
|
||||
private val exportsWebDavUrl get() = "${rootWebDavUrl}books/"
|
||||
private val bgWebDavUrl get() = "${rootWebDavUrl}background/"
|
||||
|
||||
var authorization: Authorization? = null
|
||||
private set
|
||||
|
||||
var defaultBookWebDav: RemoteBookWebDav? = null
|
||||
|
||||
val isOk get() = authorization != null
|
||||
|
||||
val isJianGuoYun get() = rootWebDavUrl.startsWith(defaultWebDavUrl, true)
|
||||
|
||||
init {
|
||||
runBlocking {
|
||||
upConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private val rootWebDavUrl: String
|
||||
get() {
|
||||
val configUrl = appCtx.getPrefString(PreferKey.webDavUrl)?.trim()
|
||||
var url = if (configUrl.isNullOrEmpty()) defaultWebDavUrl else configUrl
|
||||
if (!url.endsWith("/")) url = "${url}/"
|
||||
AppConfig.webDavDir?.trim()?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
url = "${url}${it}/"
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
suspend fun upConfig() {
|
||||
kotlin.runCatching {
|
||||
authorization = null
|
||||
defaultBookWebDav = null
|
||||
val account = appCtx.getPrefString(PreferKey.webDavAccount)
|
||||
val password = appCtx.getPrefString(PreferKey.webDavPassword)
|
||||
if (!account.isNullOrEmpty() && !password.isNullOrEmpty()) {
|
||||
val mAuthorization = Authorization(account, password)
|
||||
checkAuthorization(mAuthorization)
|
||||
WebDav(rootWebDavUrl, mAuthorization).makeAsDir()
|
||||
WebDav(bookProgressUrl, mAuthorization).makeAsDir()
|
||||
WebDav(exportsWebDavUrl, mAuthorization).makeAsDir()
|
||||
WebDav(bgWebDavUrl, mAuthorization).makeAsDir()
|
||||
val rootBooksUrl = "${rootWebDavUrl}books/"
|
||||
defaultBookWebDav = RemoteBookWebDav(rootBooksUrl, mAuthorization)
|
||||
authorization = mAuthorization
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(WebDavException::class)
|
||||
private suspend fun checkAuthorization(authorization: Authorization) {
|
||||
if (!WebDav(rootWebDavUrl, authorization).check()) {
|
||||
appCtx.removePref(PreferKey.webDavPassword)
|
||||
appCtx.toastOnUi(R.string.webdav_application_authorization_error)
|
||||
throw WebDavException(appCtx.getString(R.string.webdav_application_authorization_error))
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
suspend fun getBackupNames(): ArrayList<String> {
|
||||
val names = arrayListOf<String>()
|
||||
authorization?.let {
|
||||
var files = WebDav(rootWebDavUrl, it).listFiles()
|
||||
files = files.sortedWith { o1, o2 ->
|
||||
AlphanumComparator.compare(o1.displayName, o2.displayName)
|
||||
}.reversed()
|
||||
files.forEach { webDav ->
|
||||
val name = webDav.displayName
|
||||
if (name.startsWith("backup")) {
|
||||
names.add(name)
|
||||
}
|
||||
}
|
||||
} ?: throw NoStackTraceException("webDav没有配置")
|
||||
return names
|
||||
}
|
||||
|
||||
@Throws(WebDavException::class)
|
||||
suspend fun restoreWebDav(name: String) {
|
||||
authorization?.let {
|
||||
val webDav = WebDav(rootWebDavUrl + name, it)
|
||||
webDav.downloadTo(Backup.zipFilePath, true)
|
||||
FileUtils.delete(Backup.backupPath)
|
||||
ZipUtils.unZipToPath(File(Backup.zipFilePath), Backup.backupPath)
|
||||
Restore.restoreLocked(Backup.backupPath)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun hasBackUp(backUpName: String): Boolean {
|
||||
authorization?.let {
|
||||
val url = "$rootWebDavUrl${backUpName}"
|
||||
return WebDav(url, it).exists()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun lastBackUp(): Result<WebDavFile?> {
|
||||
return kotlin.runCatching {
|
||||
authorization?.let {
|
||||
var lastBackupFile: WebDavFile? = null
|
||||
WebDav(rootWebDavUrl, it).listFiles().reversed().forEach { webDavFile ->
|
||||
if (webDavFile.displayName.startsWith("backup")) {
|
||||
if (lastBackupFile == null
|
||||
|| webDavFile.lastModify > lastBackupFile.lastModify
|
||||
) {
|
||||
lastBackupFile = webDavFile
|
||||
}
|
||||
}
|
||||
}
|
||||
lastBackupFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* webDav备份
|
||||
* @param fileName 备份文件名
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
suspend fun backUpWebDav(fileName: String) {
|
||||
if (!NetworkUtils.isAvailable()) return
|
||||
authorization?.let {
|
||||
val putUrl = "$rootWebDavUrl$fileName"
|
||||
WebDav(putUrl, it).upload(Backup.zipFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取云端所有背景名称
|
||||
*/
|
||||
private suspend fun getAllBgWebDavFiles(): Result<List<WebDavFile>> {
|
||||
return kotlin.runCatching {
|
||||
if (!NetworkUtils.isAvailable())
|
||||
throw NoStackTraceException("网络未连接")
|
||||
authorization.let {
|
||||
it ?: throw NoStackTraceException("webDav未配置")
|
||||
WebDav(bgWebDavUrl, it).listFiles()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传背景图片
|
||||
*/
|
||||
suspend fun upBgs(files: Array<File>) {
|
||||
val authorization = authorization ?: return
|
||||
if (!NetworkUtils.isAvailable()) return
|
||||
val bgWebDavFiles = getAllBgWebDavFiles().getOrThrow()
|
||||
.map { it.displayName }
|
||||
.toSet()
|
||||
files.forEach {
|
||||
if (!bgWebDavFiles.contains(it.name) && it.exists()) {
|
||||
WebDav("$bgWebDavUrl${it.name}", authorization)
|
||||
.upload(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载背景图片
|
||||
*/
|
||||
suspend fun downBgs() {
|
||||
val authorization = authorization ?: return
|
||||
if (!NetworkUtils.isAvailable()) return
|
||||
val bgWebDavFiles = getAllBgWebDavFiles().getOrThrow()
|
||||
.map { it.displayName }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
suspend fun exportWebDav(byteArray: ByteArray, fileName: String) {
|
||||
if (!NetworkUtils.isAvailable()) return
|
||||
try {
|
||||
authorization?.let {
|
||||
// 如果导出的本地文件存在,开始上传
|
||||
val putUrl = exportsWebDavUrl + fileName
|
||||
WebDav(putUrl, it).upload(byteArray, "text/plain")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
AppLog.put("WebDav导出失败\n${e.localizedMessage}", e, true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportWebDav(uri: Uri, fileName: String) {
|
||||
if (!NetworkUtils.isAvailable()) return
|
||||
try {
|
||||
authorization?.let {
|
||||
// 如果导出的本地文件存在,开始上传
|
||||
val putUrl = exportsWebDavUrl + fileName
|
||||
WebDav(putUrl, it).upload(uri, "text/plain")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
AppLog.put("WebDav导出失败\n${e.localizedMessage}", e, true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadBookProgress(book: Book) {
|
||||
val authorization = authorization ?: return
|
||||
if (!AppConfig.syncBookProgress) return
|
||||
if (!NetworkUtils.isAvailable()) return
|
||||
try {
|
||||
val bookProgress = BookProgress(book)
|
||||
val json = GSON.toJson(bookProgress)
|
||||
val url = getProgressUrl(book.name, book.author)
|
||||
WebDav(url, authorization).upload(json.toByteArray(), "application/json")
|
||||
book.syncTime = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
AppLog.put("上传进度失败\n${e.localizedMessage}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadBookProgress(bookProgress: BookProgress, onSuccess: (() -> Unit)? = null) {
|
||||
try {
|
||||
val authorization = authorization ?: return
|
||||
if (!AppConfig.syncBookProgress) return
|
||||
if (!NetworkUtils.isAvailable()) return
|
||||
val json = GSON.toJson(bookProgress)
|
||||
val url = getProgressUrl(bookProgress.name, bookProgress.author)
|
||||
WebDav(url, authorization).upload(json.toByteArray(), "application/json")
|
||||
onSuccess?.invoke()
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
AppLog.put("上传进度失败\n${e.localizedMessage}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getProgressUrl(name: String, author: String): String {
|
||||
return bookProgressUrl + getProgressFileName(name, author)
|
||||
}
|
||||
|
||||
private fun getProgressFileName(name: String, author: String): String {
|
||||
return UrlUtil.replaceReservedChar("${name}_${author}") + ".json"
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取书籍进度
|
||||
*/
|
||||
suspend fun getBookProgress(book: Book): BookProgress? {
|
||||
authorization?.let {
|
||||
val url = getProgressUrl(book.name, book.author)
|
||||
kotlin.runCatching {
|
||||
WebDav(url, it).download().let { byteArray ->
|
||||
val json = String(byteArray)
|
||||
if (json.isJson()) {
|
||||
return GSON.fromJsonObject<BookProgress>(json).getOrNull()
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
coroutineContext.ensureActive()
|
||||
AppLog.put("获取书籍进度失败\n${it.localizedMessage}", it)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun downloadAllBookProgress() {
|
||||
val authorization = authorization ?: return
|
||||
if (!NetworkUtils.isAvailable()) return
|
||||
val bookProgressFiles = WebDav(bookProgressUrl, authorization).listFiles()
|
||||
val map = hashMapOf<String, WebDavFile>()
|
||||
bookProgressFiles.forEach {
|
||||
map[it.displayName] = it
|
||||
}
|
||||
appDb.bookDao.all.forEach { book ->
|
||||
val progressFileName = getProgressFileName(book.name, book.author)
|
||||
val webDavFile = map[progressFileName]
|
||||
webDavFile ?: return
|
||||
if (webDavFile.lastModify <= book.syncTime) {
|
||||
//本地同步时间大于上传时间不用同步
|
||||
return
|
||||
}
|
||||
getBookProgress(book)?.let { bookProgress ->
|
||||
if (bookProgress.durChapterIndex > book.durChapterIndex
|
||||
|| (bookProgress.durChapterIndex == book.durChapterIndex
|
||||
&& bookProgress.durChapterPos > book.durChapterPos)
|
||||
) {
|
||||
book.durChapterIndex = bookProgress.durChapterIndex
|
||||
book.durChapterPos = bookProgress.durChapterPos
|
||||
book.durChapterTitle = bookProgress.durChapterTitle
|
||||
book.durChapterTime = bookProgress.durChapterTime
|
||||
book.syncTime = System.currentTimeMillis()
|
||||
appDb.bookDao.update(book)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import androidx.collection.LruCache
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Cache
|
||||
import io.legato.kazusa.model.analyzeRule.QueryTTF
|
||||
import io.legato.kazusa.utils.ACache
|
||||
import io.legato.kazusa.utils.memorySize
|
||||
|
||||
private val queryTTFMap = LruCache<String, QueryTTF>(4)
|
||||
|
||||
/**
|
||||
* 最多只缓存50M的数据,防止OOM
|
||||
*/
|
||||
private val memoryLruCache = object : LruCache<String, Any>(1024 * 1024 * 50) {
|
||||
|
||||
override fun sizeOf(key: String, value: Any): Int {
|
||||
return value.toString().memorySize()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object AppCacheManager {
|
||||
|
||||
fun put(key: String, queryTTF: QueryTTF) {
|
||||
queryTTFMap.put(key, queryTTF)
|
||||
}
|
||||
|
||||
fun getQueryTTF(key: String): QueryTTF? {
|
||||
return queryTTFMap[key]
|
||||
}
|
||||
|
||||
fun clearSourceVariables() {
|
||||
memoryLruCache.snapshot().keys.forEach {
|
||||
if (it.startsWith("v_")
|
||||
|| it.startsWith("userInfo_")
|
||||
|| it.startsWith("loginHeader_")
|
||||
|| it.startsWith("sourceVariable_")
|
||||
) {
|
||||
memoryLruCache.remove(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Keep
|
||||
@Suppress("unused")
|
||||
object CacheManager {
|
||||
|
||||
/**
|
||||
* saveTime 单位为秒
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun put(key: String, value: Any, saveTime: Int = 0) {
|
||||
val deadline =
|
||||
if (saveTime == 0) 0 else System.currentTimeMillis() + saveTime * 1000
|
||||
when (value) {
|
||||
is ByteArray -> ACache.get().put(key, value, saveTime)
|
||||
else -> {
|
||||
val cache = Cache(key, value.toString(), deadline)
|
||||
putMemory(key, value)
|
||||
appDb.cacheDao.insert(cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun putMemory(key: String, value: Any) {
|
||||
memoryLruCache.put(key, value)
|
||||
}
|
||||
|
||||
//从内存中获取数据 使用lruCache
|
||||
fun getFromMemory(key: String): Any? {
|
||||
return memoryLruCache[key]
|
||||
}
|
||||
|
||||
fun deleteMemory(key: String) {
|
||||
memoryLruCache.remove(key)
|
||||
}
|
||||
|
||||
fun get(key: String): String? {
|
||||
getFromMemory(key)?.let {
|
||||
if (it is String) return it
|
||||
}
|
||||
val cache = appDb.cacheDao.get(key)
|
||||
if (cache != null && (cache.deadline == 0L || cache.deadline > System.currentTimeMillis())) {
|
||||
putMemory(key, cache.value ?: "")
|
||||
return cache.value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getInt(key: String): Int? {
|
||||
return get(key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
fun getLong(key: String): Long? {
|
||||
return get(key)?.toLongOrNull()
|
||||
}
|
||||
|
||||
fun getDouble(key: String): Double? {
|
||||
return get(key)?.toDoubleOrNull()
|
||||
}
|
||||
|
||||
fun getFloat(key: String): Float? {
|
||||
return get(key)?.toFloatOrNull()
|
||||
}
|
||||
|
||||
fun getByteArray(key: String): ByteArray? {
|
||||
return ACache.get().getAsBinary(key)
|
||||
}
|
||||
|
||||
fun putFile(key: String, value: String, saveTime: Int = 0) {
|
||||
ACache.get().put(key, value, saveTime)
|
||||
}
|
||||
|
||||
fun getFile(key: String): String? {
|
||||
return ACache.get().getAsString(key)
|
||||
}
|
||||
|
||||
fun delete(key: String) {
|
||||
appDb.cacheDao.delete(key)
|
||||
deleteMemory(key)
|
||||
ACache.get().remove(key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import io.legato.kazusa.data.entities.BaseSource
|
||||
import io.legato.kazusa.exception.ConcurrentException
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl.ConcurrentRecord
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class ConcurrentRateLimiter(val source: BaseSource?) {
|
||||
|
||||
companion object {
|
||||
private val concurrentRecordMap = hashMapOf<String, ConcurrentRecord>()
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始访问,并发判断
|
||||
*/
|
||||
@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 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) {
|
||||
return@synchronized 0
|
||||
}
|
||||
}
|
||||
if (waitTime > 0) {
|
||||
throw ConcurrentException(
|
||||
"根据并发率还需等待${waitTime}毫秒才可以访问",
|
||||
waitTime = waitTime
|
||||
)
|
||||
}
|
||||
return fetchRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* 访问结束
|
||||
*/
|
||||
fun fetchEnd(concurrentRecord: ConcurrentRecord?) {
|
||||
if (concurrentRecord != null && !concurrentRecord.isConcurrent) {
|
||||
synchronized(concurrentRecord) {
|
||||
concurrentRecord.frequency -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并发记录,若处于并发限制状态下则会等待
|
||||
*/
|
||||
suspend fun getConcurrentRecord(): ConcurrentRecord? {
|
||||
while (true) {
|
||||
try {
|
||||
return fetchStart()
|
||||
} catch (e: ConcurrentException) {
|
||||
delay(e.waitTime.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getConcurrentRecordBlocking(): ConcurrentRecord? {
|
||||
while (true) {
|
||||
try {
|
||||
return fetchStart()
|
||||
} catch (e: ConcurrentException) {
|
||||
Thread.sleep(e.waitTime.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <T> withLimit(block: () -> T): T {
|
||||
val concurrentRecord = getConcurrentRecord()
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
fetchEnd(concurrentRecord)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> withLimitBlocking(block: () -> T): T {
|
||||
val concurrentRecord = getConcurrentRecordBlocking()
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
fetchEnd(concurrentRecord)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Debug
|
||||
import android.os.Looper
|
||||
import android.webkit.WebSettings
|
||||
import io.legato.kazusa.constant.AppConst
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.config.LocalConfig
|
||||
import io.legato.kazusa.model.ReadAloud
|
||||
import io.legato.kazusa.utils.FileDoc
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.createFileIfNotExist
|
||||
import io.legato.kazusa.utils.createFolderReplace
|
||||
import io.legato.kazusa.utils.externalCache
|
||||
import io.legato.kazusa.utils.getFile
|
||||
import io.legato.kazusa.utils.longToastOnUiLegacy
|
||||
import io.legato.kazusa.utils.stackTraceStr
|
||||
import io.legato.kazusa.utils.writeText
|
||||
import splitties.init.appCtx
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 异常管理类
|
||||
*/
|
||||
class CrashHandler(val context: Context) : Thread.UncaughtExceptionHandler {
|
||||
|
||||
/**
|
||||
* 系统默认UncaughtExceptionHandler
|
||||
*/
|
||||
private var mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
|
||||
init {
|
||||
//设置该CrashHandler为系统默认的
|
||||
Thread.setDefaultUncaughtExceptionHandler(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* uncaughtException 回调函数
|
||||
*/
|
||||
override fun uncaughtException(thread: Thread, ex: Throwable) {
|
||||
if (shouldAbsorb(ex)) {
|
||||
AppLog.put("发生未捕获的异常\n${ex.localizedMessage}", ex)
|
||||
Looper.loop()
|
||||
} else {
|
||||
ReadAloud.stop(context)
|
||||
handleException(ex)
|
||||
mDefaultHandler?.uncaughtException(thread, ex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldAbsorb(e: Throwable): Boolean {
|
||||
return when {
|
||||
e::class.simpleName == "CannotDeliverBroadcastException" -> true
|
||||
e is SecurityException && e.message?.contains(
|
||||
"nor current process has android.permission.OBSERVE_GRANT_REVOKE_PERMISSIONS",
|
||||
true
|
||||
) == true -> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理该异常
|
||||
*/
|
||||
private fun handleException(ex: Throwable?) {
|
||||
if (ex == null) return
|
||||
LocalConfig.appCrash = true
|
||||
//保存日志文件
|
||||
saveCrashInfo2File(ex)
|
||||
if ((ex is OutOfMemoryError || ex.cause is OutOfMemoryError) && AppConfig.recordHeapDump) {
|
||||
doHeapDump()
|
||||
}
|
||||
context.longToastOnUiLegacy(ex.stackTraceStr)
|
||||
Thread.sleep(3000)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 存储异常和参数信息
|
||||
*/
|
||||
private val paramsMap by lazy {
|
||||
val map = LinkedHashMap<String, String>()
|
||||
kotlin.runCatching {
|
||||
//获取系统信息
|
||||
map["MANUFACTURER"] = Build.MANUFACTURER
|
||||
map["BRAND"] = Build.BRAND
|
||||
map["MODEL"] = Build.MODEL
|
||||
map["SDK_INT"] = Build.VERSION.SDK_INT.toString()
|
||||
map["RELEASE"] = Build.VERSION.RELEASE
|
||||
map["WebViewUserAgent"] = try {
|
||||
WebSettings.getDefaultUserAgent(appCtx)
|
||||
} catch (e: Throwable) {
|
||||
e.toString()
|
||||
}
|
||||
map["packageName"] = appCtx.packageName
|
||||
map["heapSize"] = Runtime.getRuntime().maxMemory().toString()
|
||||
//获取app版本信息
|
||||
AppConst.appInfo.let {
|
||||
map["versionName"] = it.versionName
|
||||
map["versionCode"] = it.versionCode.toString()
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
private val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss")
|
||||
|
||||
/**
|
||||
* 保存错误信息到文件中
|
||||
*/
|
||||
fun saveCrashInfo2File(ex: Throwable) {
|
||||
val sb = StringBuilder()
|
||||
for ((key, value) in paramsMap) {
|
||||
sb.append(key).append("=").append(value).append("\n")
|
||||
}
|
||||
|
||||
val writer = StringWriter()
|
||||
val printWriter = PrintWriter(writer)
|
||||
ex.printStackTrace(printWriter)
|
||||
var cause: Throwable? = ex.cause
|
||||
while (cause != null) {
|
||||
cause.printStackTrace(printWriter)
|
||||
cause = cause.cause
|
||||
}
|
||||
printWriter.close()
|
||||
val result = writer.toString()
|
||||
sb.append(result)
|
||||
val crashLog = sb.toString()
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val time = format.format(Date())
|
||||
val fileName = "crash-$time-$timestamp.log"
|
||||
try {
|
||||
val backupPath = AppConfig.backupPath
|
||||
?: throw NoStackTraceException("备份路径未配置")
|
||||
val uri = Uri.parse(backupPath)
|
||||
val fileDoc = FileDoc.fromUri(uri, true)
|
||||
fileDoc.createFileIfNotExist(fileName, "crash")
|
||||
.writeText(crashLog)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
kotlin.runCatching {
|
||||
appCtx.externalCacheDir?.let { rootFile ->
|
||||
val exceedTimeMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)
|
||||
rootFile.getFile("crash").listFiles()?.forEach {
|
||||
if (it.lastModified() < exceedTimeMillis) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
FileUtils.createFileIfNotExist(rootFile, "crash", fileName)
|
||||
.writeText(crashLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 进行堆转储
|
||||
*/
|
||||
fun doHeapDump(manually: Boolean = false) {
|
||||
val heapDir = appCtx
|
||||
.externalCache
|
||||
.getFile("heapDump")
|
||||
heapDir.createFolderReplace()
|
||||
val fileName = if (manually) {
|
||||
"heap-dump-manually-${System.currentTimeMillis()}.hprof"
|
||||
} else {
|
||||
"heap-dump-${System.currentTimeMillis()}.hprof"
|
||||
}
|
||||
val heapFile = heapDir.getFile(fileName)
|
||||
val heapDumpName = heapFile.absolutePath
|
||||
Debug.dumpHprofData(heapDumpName)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import io.legato.kazusa.constant.AppConst
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.DictRule
|
||||
import io.legato.kazusa.data.entities.HttpTTS
|
||||
import io.legato.kazusa.data.entities.KeyboardAssist
|
||||
import io.legato.kazusa.data.entities.RssSource
|
||||
import io.legato.kazusa.data.entities.TxtTocRule
|
||||
import io.legato.kazusa.help.config.LocalConfig
|
||||
import io.legato.kazusa.help.config.ReadBookConfig
|
||||
import io.legato.kazusa.help.config.ThemeConfig
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.model.BookCover
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.fromJsonArray
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
|
||||
object DefaultData {
|
||||
|
||||
fun upVersion() {
|
||||
if (LocalConfig.versionCode < AppConst.appInfo.versionCode) {
|
||||
Coroutine.async {
|
||||
if (LocalConfig.needUpHttpTTS) {
|
||||
importDefaultHttpTTS()
|
||||
}
|
||||
if (LocalConfig.needUpTxtTocRule) {
|
||||
importDefaultTocRules()
|
||||
}
|
||||
if (LocalConfig.needUpRssSources) {
|
||||
importDefaultRssSources()
|
||||
}
|
||||
if (LocalConfig.needUpDictRule) {
|
||||
importDefaultDictRules()
|
||||
}
|
||||
}.onError {
|
||||
it.printOnDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val httpTTS: List<HttpTTS> by lazy {
|
||||
val json =
|
||||
String(
|
||||
appCtx.assets.open("defaultData${File.separator}httpTTS.json")
|
||||
.readBytes()
|
||||
)
|
||||
HttpTTS.fromJsonArray(json).getOrElse {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val readConfigs: List<ReadBookConfig.Config> by lazy {
|
||||
val json = String(
|
||||
appCtx.assets.open("defaultData${File.separator}${ReadBookConfig.configFileName}")
|
||||
.readBytes()
|
||||
)
|
||||
GSON.fromJsonArray<ReadBookConfig.Config>(json).getOrNull()
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
val txtTocRules: List<TxtTocRule> by lazy {
|
||||
val json = String(
|
||||
appCtx.assets.open("defaultData${File.separator}txtTocRule.json")
|
||||
.readBytes()
|
||||
)
|
||||
GSON.fromJsonArray<TxtTocRule>(json).getOrNull() ?: emptyList()
|
||||
}
|
||||
|
||||
val themeConfigs: List<ThemeConfig.Config> by lazy {
|
||||
val json = String(
|
||||
appCtx.assets.open("defaultData${File.separator}${ThemeConfig.configFileName}")
|
||||
.readBytes()
|
||||
)
|
||||
GSON.fromJsonArray<ThemeConfig.Config>(json).getOrNull() ?: emptyList()
|
||||
}
|
||||
|
||||
val rssSources: List<RssSource> by lazy {
|
||||
val json = String(
|
||||
appCtx.assets.open("defaultData${File.separator}rssSources.json")
|
||||
.readBytes()
|
||||
)
|
||||
GSON.fromJsonArray<RssSource>(json).getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
val coverRule: BookCover.CoverRule by lazy {
|
||||
val json = String(
|
||||
appCtx.assets.open("defaultData${File.separator}coverRule.json")
|
||||
.readBytes()
|
||||
)
|
||||
GSON.fromJsonObject<BookCover.CoverRule>(json).getOrThrow()
|
||||
}
|
||||
|
||||
val dictRules: List<DictRule> by lazy {
|
||||
val json = String(
|
||||
appCtx.assets.open("defaultData${File.separator}dictRules.json")
|
||||
.readBytes()
|
||||
)
|
||||
GSON.fromJsonArray<DictRule>(json).getOrThrow()
|
||||
}
|
||||
|
||||
val keyboardAssists: List<KeyboardAssist> by lazy {
|
||||
val json = String(
|
||||
appCtx.assets.open("defaultData${File.separator}keyboardAssists.json")
|
||||
.readBytes()
|
||||
)
|
||||
GSON.fromJsonArray<KeyboardAssist>(json).getOrThrow()
|
||||
}
|
||||
|
||||
fun importDefaultHttpTTS() {
|
||||
appDb.httpTTSDao.deleteDefault()
|
||||
appDb.httpTTSDao.insert(*httpTTS.toTypedArray())
|
||||
}
|
||||
|
||||
fun importDefaultTocRules() {
|
||||
appDb.txtTocRuleDao.deleteDefault()
|
||||
appDb.txtTocRuleDao.insert(*txtTocRules.toTypedArray())
|
||||
}
|
||||
|
||||
fun importDefaultRssSources() {
|
||||
appDb.rssSourceDao.deleteDefault()
|
||||
appDb.rssSourceDao.insert(*rssSources.toTypedArray())
|
||||
}
|
||||
|
||||
fun importDefaultDictRules() {
|
||||
appDb.dictRuleDao.insert(*dictRules.toTypedArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.utils.ACache
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.compress.ZipUtils
|
||||
import io.legato.kazusa.utils.createFileReplace
|
||||
import io.legato.kazusa.utils.externalCache
|
||||
import io.legato.kazusa.utils.fromJsonArray
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
object DirectLinkUpload {
|
||||
|
||||
const val ruleFileName = "directLinkUploadRule.json"
|
||||
|
||||
@Throws(NoStackTraceException::class)
|
||||
suspend fun upLoad(
|
||||
fileName: String,
|
||||
file: Any,
|
||||
contentType: String,
|
||||
rule: Rule = getRule()
|
||||
): String {
|
||||
val url = rule.uploadUrl
|
||||
if (url.isBlank()) {
|
||||
throw NoStackTraceException("上传url未配置")
|
||||
}
|
||||
val downloadUrlRule = rule.downloadUrlRule
|
||||
if (downloadUrlRule.isBlank()) {
|
||||
throw NoStackTraceException("下载地址规则未配置")
|
||||
}
|
||||
var mFileName = fileName
|
||||
var mFile = file
|
||||
var mContentType = contentType
|
||||
if (rule.compress && contentType != "application/zip") {
|
||||
mFileName = "$fileName.zip"
|
||||
mContentType = "application/zip"
|
||||
mFile = when (file) {
|
||||
is File -> {
|
||||
val zipFile = File(FileUtils.getPath(appCtx.externalCache, "upload", mFileName))
|
||||
zipFile.createFileReplace()
|
||||
ZipUtils.zipFile(file, zipFile)
|
||||
zipFile
|
||||
}
|
||||
|
||||
is ByteArray -> ZipUtils.zipByteArray(file, fileName)
|
||||
is String -> ZipUtils.zipByteArray(file.toByteArray(), fileName)
|
||||
else -> ZipUtils.zipByteArray(GSON.toJson(file).toByteArray(), fileName)
|
||||
}
|
||||
}
|
||||
val analyzeUrl = AnalyzeUrl(url)
|
||||
val res = analyzeUrl.upload(mFileName, mFile, mContentType)
|
||||
if (mFile is File) {
|
||||
mFile.delete()
|
||||
}
|
||||
val analyzeRule = AnalyzeRule().setContent(res.body, res.url)
|
||||
.setCoroutineContext(coroutineContext)
|
||||
val downloadUrl = analyzeRule.getString(downloadUrlRule)
|
||||
if (downloadUrl.isBlank()) {
|
||||
throw NoStackTraceException("上传失败,${res.body}")
|
||||
}
|
||||
return downloadUrl
|
||||
}
|
||||
|
||||
val defaultRules: List<Rule> by lazy {
|
||||
val json = String(
|
||||
appCtx.assets.open("defaultData${File.separator}directLinkUpload.json")
|
||||
.readBytes()
|
||||
)
|
||||
GSON.fromJsonArray<Rule>(json).getOrThrow()
|
||||
}
|
||||
|
||||
fun getRule(): Rule {
|
||||
return getConfig() ?: defaultRules[0]
|
||||
}
|
||||
|
||||
fun getConfig(): Rule? {
|
||||
val json = ACache.get(cacheDir = false).getAsString(ruleFileName)
|
||||
return GSON.fromJsonObject<Rule>(json).getOrNull()
|
||||
}
|
||||
|
||||
fun putConfig(rule: Rule) {
|
||||
ACache.get(cacheDir = false).put(ruleFileName, GSON.toJson(rule))
|
||||
}
|
||||
|
||||
fun delConfig() {
|
||||
ACache.get(cacheDir = false).remove(ruleFileName)
|
||||
}
|
||||
|
||||
fun getSummary(): String {
|
||||
return getRule().summary
|
||||
}
|
||||
|
||||
@Keep
|
||||
data class Rule(
|
||||
var uploadUrl: String, //上传url
|
||||
var downloadUrlRule: String, //下载链接规则
|
||||
var summary: String, //注释
|
||||
var compress: Boolean = false, //是否压缩
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
return summary
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.text.TextUtils
|
||||
|
||||
@Suppress("unused")
|
||||
class EventMessage {
|
||||
|
||||
var what: Int? = null
|
||||
var tag: String? = null
|
||||
var obj: Any? = null
|
||||
|
||||
fun isFrom(tag: String): Boolean {
|
||||
return TextUtils.equals(this.tag, tag)
|
||||
}
|
||||
|
||||
fun maybeFrom(vararg tags: String): Boolean {
|
||||
return listOf(*tags).contains(tag)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun obtain(tag: String): EventMessage {
|
||||
val message = EventMessage()
|
||||
message.tag = tag
|
||||
return message
|
||||
}
|
||||
|
||||
fun obtain(what: Int): EventMessage {
|
||||
val message = EventMessage()
|
||||
message.what = what
|
||||
return message
|
||||
}
|
||||
|
||||
fun obtain(what: Int, obj: Any): EventMessage {
|
||||
val message = EventMessage()
|
||||
message.what = what
|
||||
message.obj = obj
|
||||
return message
|
||||
}
|
||||
|
||||
fun obtain(tag: String, obj: Any): EventMessage {
|
||||
val message = EventMessage()
|
||||
message.tag = tag
|
||||
message.obj = obj
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
val globalExecutor: ExecutorService by lazy { Executors.newSingleThreadExecutor() }
|
||||
@@ -0,0 +1,32 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
object IntentData {
|
||||
|
||||
private val bigData: MutableMap<String, Any> = mutableMapOf()
|
||||
|
||||
@Synchronized
|
||||
fun put(key: String, data: Any?): String {
|
||||
data?.let {
|
||||
bigData[key] = data
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun put(data: Any?): String {
|
||||
val key = System.currentTimeMillis().toString()
|
||||
data?.let {
|
||||
bigData[key] = data
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@Synchronized
|
||||
fun <T> get(key: String?): T? {
|
||||
if (key == null) return null
|
||||
val data = bigData[key]
|
||||
bigData.remove(key)
|
||||
return data as? T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import splitties.init.appCtx
|
||||
|
||||
@Suppress("unused")
|
||||
object IntentHelp {
|
||||
|
||||
fun getBrowserIntent(url: String): Intent {
|
||||
return getBrowserIntent(Uri.parse(url))
|
||||
}
|
||||
|
||||
fun getBrowserIntent(uri: Uri): Intent {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = uri
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (intent.resolveActivity(appCtx.packageManager) == null) {
|
||||
return Intent.createChooser(intent, "请选择浏览器")
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
fun openTTSSetting() {
|
||||
//跳转到文字转语音设置界面
|
||||
kotlin.runCatching {
|
||||
val intent = Intent()
|
||||
intent.action = "com.android.settings.TTS_SETTINGS"
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
appCtx.startActivity(intent)
|
||||
}.onFailure {
|
||||
appCtx.toastOnUi(R.string.tip_cannot_jump_setting_page)
|
||||
}
|
||||
}
|
||||
|
||||
fun toInstallUnknown(context: Context) {
|
||||
kotlin.runCatching {
|
||||
val intent = Intent()
|
||||
intent.action = "android.settings.MANAGE_UNKNOWN_APP_SOURCES"
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
context.startActivity(intent)
|
||||
}.onFailure {
|
||||
context.toastOnUi("无法打开设置")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.util.Base64
|
||||
import cn.hutool.crypto.digest.DigestUtil
|
||||
import cn.hutool.crypto.digest.HMac
|
||||
import cn.hutool.crypto.symmetric.SymmetricCrypto
|
||||
import io.legato.kazusa.help.crypto.AsymmetricCrypto
|
||||
import io.legato.kazusa.help.crypto.Sign
|
||||
import io.legato.kazusa.help.crypto.SymmetricCryptoAndroid
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
|
||||
|
||||
/**
|
||||
* js加解密扩展类, 在js中通过java变量调用
|
||||
* 添加方法,请更新文档/legado/app/src/main/assets/help/JsHelp.md
|
||||
*/
|
||||
@Suppress("unused")
|
||||
interface JsEncodeUtils {
|
||||
|
||||
fun md5Encode(str: String): String {
|
||||
return MD5Utils.md5Encode(str)
|
||||
}
|
||||
|
||||
fun md5Encode16(str: String): String {
|
||||
return MD5Utils.md5Encode16(str)
|
||||
}
|
||||
|
||||
|
||||
//******************对称加密解密************************//
|
||||
|
||||
/**
|
||||
* 在js中这样使用
|
||||
* java.createSymmetricCrypto(transformation, key, iv).decrypt(data)
|
||||
* java.createSymmetricCrypto(transformation, key, iv).decryptStr(data)
|
||||
|
||||
* java.createSymmetricCrypto(transformation, key, iv).encrypt(data)
|
||||
* java.createSymmetricCrypto(transformation, key, iv).encryptBase64(data)
|
||||
* java.createSymmetricCrypto(transformation, key, iv).encryptHex(data)
|
||||
*/
|
||||
|
||||
/* 调用SymmetricCrypto key为null时使用随机密钥*/
|
||||
fun createSymmetricCrypto(
|
||||
transformation: String,
|
||||
key: ByteArray?,
|
||||
iv: ByteArray?
|
||||
): SymmetricCrypto {
|
||||
val symmetricCrypto = SymmetricCryptoAndroid(transformation, key)
|
||||
return if (iv != null && iv.isNotEmpty()) symmetricCrypto.setIv(iv) else symmetricCrypto
|
||||
}
|
||||
|
||||
fun createSymmetricCrypto(
|
||||
transformation: String,
|
||||
key: ByteArray
|
||||
): SymmetricCrypto {
|
||||
return createSymmetricCrypto(transformation, key, null)
|
||||
}
|
||||
|
||||
fun createSymmetricCrypto(
|
||||
transformation: String,
|
||||
key: String
|
||||
): SymmetricCrypto {
|
||||
return createSymmetricCrypto(transformation, key, null)
|
||||
}
|
||||
|
||||
fun createSymmetricCrypto(
|
||||
transformation: String,
|
||||
key: String,
|
||||
iv: String?
|
||||
): SymmetricCrypto {
|
||||
return createSymmetricCrypto(
|
||||
transformation, key.encodeToByteArray(), iv?.encodeToByteArray()
|
||||
)
|
||||
}
|
||||
//******************非对称加密解密************************//
|
||||
|
||||
/* keys都为null时使用随机密钥 */
|
||||
fun createAsymmetricCrypto(
|
||||
transformation: String
|
||||
): AsymmetricCrypto {
|
||||
return AsymmetricCrypto(transformation)
|
||||
}
|
||||
|
||||
//******************签名************************//
|
||||
fun createSign(
|
||||
algorithm: String
|
||||
): Sign {
|
||||
return Sign(algorithm)
|
||||
}
|
||||
//******************对称加密解密old************************//
|
||||
|
||||
/////AES
|
||||
/**
|
||||
* AES 解码为 ByteArray
|
||||
* @param str 传入的AES加密的数据
|
||||
* @param key AES 解密的key
|
||||
* @param transformation AES加密的方式
|
||||
* @param iv ECB模式的偏移向量
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decrypt(str)")
|
||||
)
|
||||
fun aesDecodeToByteArray(
|
||||
str: String, key: String, transformation: String, iv: String
|
||||
): ByteArray? {
|
||||
return createSymmetricCrypto(transformation, key, iv).decrypt(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* AES 解码为 String
|
||||
* @param str 传入的AES加密的数据
|
||||
* @param key AES 解密的key
|
||||
* @param transformation AES加密的方式
|
||||
* @param iv ECB模式的偏移向量
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(str)")
|
||||
)
|
||||
fun aesDecodeToString(
|
||||
str: String, key: String, transformation: String, iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto(transformation, key, iv).decryptStr(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* AES解码为String,算法参数经过Base64加密
|
||||
*
|
||||
* @param data 加密的字符串
|
||||
* @param key Base64后的密钥
|
||||
* @param mode 模式
|
||||
* @param padding 补码方式
|
||||
* @param iv Base64后的加盐
|
||||
* @return 解密后的字符串
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)")
|
||||
)
|
||||
fun aesDecodeArgsBase64Str(
|
||||
data: String,
|
||||
key: String,
|
||||
mode: String,
|
||||
padding: String,
|
||||
iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto(
|
||||
"AES/${mode}/${padding}",
|
||||
Base64.decode(key, Base64.NO_WRAP),
|
||||
Base64.decode(iv, Base64.NO_WRAP)
|
||||
).decryptStr(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 已经base64的AES 解码为 ByteArray
|
||||
* @param str 传入的AES Base64加密的数据
|
||||
* @param key AES 解密的key
|
||||
* @param transformation AES加密的方式
|
||||
* @param iv ECB模式的偏移向量
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decrypt(str)")
|
||||
)
|
||||
fun aesBase64DecodeToByteArray(
|
||||
str: String, key: String, transformation: String, iv: String
|
||||
): ByteArray? {
|
||||
return createSymmetricCrypto(transformation, key, iv).decrypt(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 已经base64的AES 解码为 String
|
||||
* @param str 传入的AES Base64加密的数据
|
||||
* @param key AES 解密的key
|
||||
* @param transformation AES加密的方式
|
||||
* @param iv ECB模式的偏移向量
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(str)")
|
||||
)
|
||||
fun aesBase64DecodeToString(
|
||||
str: String, key: String, transformation: String, iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto(transformation, key, iv).decryptStr(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密aes为ByteArray
|
||||
* @param data 传入的原始数据
|
||||
* @param key AES加密的key
|
||||
* @param transformation AES加密的方式
|
||||
* @param iv ECB模式的偏移向量
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decrypt(data)")
|
||||
)
|
||||
fun aesEncodeToByteArray(
|
||||
data: String, key: String, transformation: String, iv: String
|
||||
): ByteArray? {
|
||||
return createSymmetricCrypto(transformation, key, iv).encrypt(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密aes为String
|
||||
* @param data 传入的原始数据
|
||||
* @param key AES加密的key
|
||||
* @param transformation AES加密的方式
|
||||
* @param iv ECB模式的偏移向量
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)")
|
||||
)
|
||||
fun aesEncodeToString(
|
||||
data: String, key: String, transformation: String, iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto(transformation, key, iv).decryptStr(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密aes后Base64化的ByteArray
|
||||
* @param data 传入的原始数据
|
||||
* @param key AES加密的key
|
||||
* @param transformation AES加密的方式
|
||||
* @param iv ECB模式的偏移向量
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data).toByteArray()")
|
||||
)
|
||||
fun aesEncodeToBase64ByteArray(
|
||||
data: String, key: String, transformation: String, iv: String
|
||||
): ByteArray? {
|
||||
return createSymmetricCrypto(transformation, key, iv).encryptBase64(data).toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密aes后Base64化的String
|
||||
* @param data 传入的原始数据
|
||||
* @param key AES加密的key
|
||||
* @param transformation AES加密的方式
|
||||
* @param iv ECB模式的偏移向量
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data)")
|
||||
)
|
||||
fun aesEncodeToBase64String(
|
||||
data: String, key: String, transformation: String, iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto(transformation, key, iv).encryptBase64(data)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* AES加密并转为Base64,算法参数经过Base64加密
|
||||
*
|
||||
* @param data 被加密的字符串
|
||||
* @param key Base64后的密钥
|
||||
* @param mode 模式
|
||||
* @param padding 补码方式
|
||||
* @param iv Base64后的加盐
|
||||
* @return 加密后的Base64
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data)")
|
||||
)
|
||||
fun aesEncodeArgsBase64Str(
|
||||
data: String,
|
||||
key: String,
|
||||
mode: String,
|
||||
padding: String,
|
||||
iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto("AES/${mode}/${padding}", key, iv).encryptBase64(data)
|
||||
}
|
||||
|
||||
/////DES
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)")
|
||||
)
|
||||
fun desDecodeToString(
|
||||
data: String, key: String, transformation: String, iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto(transformation, key, iv).decryptStr(data)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)")
|
||||
)
|
||||
fun desBase64DecodeToString(
|
||||
data: String, key: String, transformation: String, iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto(transformation, key, iv).decryptStr(data)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).encrypt(data)")
|
||||
)
|
||||
fun desEncodeToString(
|
||||
data: String, key: String, transformation: String, iv: String
|
||||
): String? {
|
||||
return String(createSymmetricCrypto(transformation, key, iv).encrypt(data))
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data)")
|
||||
)
|
||||
fun desEncodeToBase64String(
|
||||
data: String, key: String, transformation: String, iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto(transformation, key, iv).encryptBase64(data)
|
||||
}
|
||||
|
||||
//////3DES
|
||||
/**
|
||||
* 3DES解密
|
||||
*
|
||||
* @param data 加密的字符串
|
||||
* @param key 密钥
|
||||
* @param mode 模式
|
||||
* @param padding 补码方式
|
||||
* @param iv 加盐
|
||||
* @return 解密后的字符串
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)")
|
||||
)
|
||||
fun tripleDESDecodeStr(
|
||||
data: String,
|
||||
key: String,
|
||||
mode: String,
|
||||
padding: String,
|
||||
iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto("DESede/${mode}/${padding}", key, iv).decryptStr(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 3DES解密,算法参数经过Base64加密
|
||||
*
|
||||
* @param data 加密的字符串
|
||||
* @param key Base64后的密钥
|
||||
* @param mode 模式
|
||||
* @param padding 补码方式
|
||||
* @param iv Base64后的加盐
|
||||
* @return 解密后的字符串
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).decryptStr(data)")
|
||||
)
|
||||
fun tripleDESDecodeArgsBase64Str(
|
||||
data: String,
|
||||
key: String,
|
||||
mode: String,
|
||||
padding: String,
|
||||
iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto(
|
||||
"DESede/${mode}/${padding}",
|
||||
Base64.decode(key, Base64.NO_WRAP),
|
||||
iv.encodeToByteArray()
|
||||
).decryptStr(data)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 3DES加密并转为Base64
|
||||
*
|
||||
* @param data 被加密的字符串
|
||||
* @param key 密钥
|
||||
* @param mode 模式
|
||||
* @param padding 补码方式
|
||||
* @param iv 加盐
|
||||
* @return 加密后的Base64
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data)")
|
||||
)
|
||||
fun tripleDESEncodeBase64Str(
|
||||
data: String,
|
||||
key: String,
|
||||
mode: String,
|
||||
padding: String,
|
||||
iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto("DESede/${mode}/${padding}", key, iv)
|
||||
.encryptBase64(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 3DES加密并转为Base64,算法参数经过Base64加密
|
||||
*
|
||||
* @param data 被加密的字符串
|
||||
* @param key Base64后的密钥
|
||||
* @param mode 模式
|
||||
* @param padding 补码方式
|
||||
* @param iv Base64后的加盐
|
||||
* @return 加密后的Base64
|
||||
*/
|
||||
@Deprecated(
|
||||
"过于繁琐弃用",
|
||||
ReplaceWith("createSymmetricCrypto(transformation, key, iv).encryptBase64(data)")
|
||||
)
|
||||
fun tripleDESEncodeArgsBase64Str(
|
||||
data: String,
|
||||
key: String,
|
||||
mode: String,
|
||||
padding: String,
|
||||
iv: String
|
||||
): String? {
|
||||
return createSymmetricCrypto(
|
||||
"DESede/${mode}/${padding}",
|
||||
Base64.decode(key, Base64.NO_WRAP),
|
||||
iv.encodeToByteArray()
|
||||
).encryptBase64(data)
|
||||
}
|
||||
|
||||
//******************消息摘要/散列消息鉴别码************************//
|
||||
|
||||
/**
|
||||
* 生成摘要,并转为16进制字符串
|
||||
*
|
||||
* @param data 被摘要数据
|
||||
* @param algorithm 签名算法
|
||||
* @return 16进制字符串
|
||||
*/
|
||||
fun digestHex(
|
||||
data: String,
|
||||
algorithm: String,
|
||||
): String {
|
||||
return DigestUtil.digester(algorithm).digestHex(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成摘要,并转为Base64字符串
|
||||
*
|
||||
* @param data 被摘要数据
|
||||
* @param algorithm 签名算法
|
||||
* @return Base64字符串
|
||||
*/
|
||||
fun digestBase64Str(
|
||||
data: String,
|
||||
algorithm: String,
|
||||
): String {
|
||||
return Base64.encodeToString(DigestUtil.digester(algorithm).digest(data), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成散列消息鉴别码,并转为16进制字符串
|
||||
*
|
||||
* @param data 被摘要数据
|
||||
* @param algorithm 签名算法
|
||||
* @param key 密钥
|
||||
* @return 16进制字符串
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
fun HMacHex(
|
||||
data: String,
|
||||
algorithm: String,
|
||||
key: String
|
||||
): String {
|
||||
return HMac(algorithm, key.toByteArray()).digestHex(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成散列消息鉴别码,并转为Base64字符串
|
||||
*
|
||||
* @param data 被摘要数据
|
||||
* @param algorithm 签名算法
|
||||
* @param key 密钥
|
||||
* @return Base64字符串
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
fun HMacBase64(
|
||||
data: String,
|
||||
algorithm: String,
|
||||
key: String
|
||||
): String {
|
||||
return Base64.encodeToString(
|
||||
HMac(algorithm, key.toByteArray()).digest(data),
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,65 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.ui.welcome.*
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import splitties.init.appCtx
|
||||
|
||||
/**
|
||||
* Created by GKF on 2018/2/27.
|
||||
* 更换图标
|
||||
*/
|
||||
object LauncherIconHelp {
|
||||
private val packageManager: PackageManager = appCtx.packageManager
|
||||
private val componentNames = arrayListOf(
|
||||
ComponentName(appCtx, Launcher1::class.java.name),
|
||||
ComponentName(appCtx, Launcher2::class.java.name),
|
||||
ComponentName(appCtx, Launcher3::class.java.name),
|
||||
ComponentName(appCtx, Launcher4::class.java.name),
|
||||
ComponentName(appCtx, Launcher5::class.java.name),
|
||||
ComponentName(appCtx, Launcher6::class.java.name)
|
||||
)
|
||||
|
||||
fun changeIcon(icon: String?) {
|
||||
if (icon.isNullOrEmpty()) return
|
||||
if (Build.VERSION.SDK_INT < 26) {
|
||||
appCtx.toastOnUi(R.string.change_icon_error)
|
||||
return
|
||||
}
|
||||
var hasEnabled = false
|
||||
componentNames.forEach {
|
||||
if (icon.equals(it.className.substringAfterLast("."), true)) {
|
||||
hasEnabled = true
|
||||
//启用
|
||||
packageManager.setComponentEnabledSetting(
|
||||
it,
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
} else {
|
||||
//禁用
|
||||
packageManager.setComponentEnabledSetting(
|
||||
it,
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hasEnabled) {
|
||||
packageManager.setComponentEnabledSetting(
|
||||
ComponentName(appCtx, WelcomeActivity::class.java.name),
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
} else {
|
||||
packageManager.setComponentEnabledSetting(
|
||||
ComponentName(appCtx, WelcomeActivity::class.java.name),
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import androidx.annotation.IntDef
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
|
||||
@Suppress("unused")
|
||||
object LayoutManager {
|
||||
|
||||
interface LayoutManagerFactory {
|
||||
fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager
|
||||
}
|
||||
|
||||
@IntDef(LinearLayoutManager.HORIZONTAL, LinearLayoutManager.VERTICAL)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class Orientation
|
||||
|
||||
fun linear(): LayoutManagerFactory {
|
||||
return object : LayoutManagerFactory {
|
||||
override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager {
|
||||
return LinearLayoutManager(recyclerView.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun linear(@Orientation orientation: Int, reverseLayout: Boolean): LayoutManagerFactory {
|
||||
return object : LayoutManagerFactory {
|
||||
override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager {
|
||||
return LinearLayoutManager(recyclerView.context, orientation, reverseLayout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun grid(spanCount: Int): LayoutManagerFactory {
|
||||
return object : LayoutManagerFactory {
|
||||
override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager {
|
||||
return GridLayoutManager(recyclerView.context, spanCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun grid(
|
||||
spanCount: Int,
|
||||
@Orientation orientation: Int,
|
||||
reverseLayout: Boolean
|
||||
): LayoutManagerFactory {
|
||||
return object : LayoutManagerFactory {
|
||||
override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager {
|
||||
return GridLayoutManager(
|
||||
recyclerView.context,
|
||||
spanCount,
|
||||
orientation,
|
||||
reverseLayout
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun staggeredGrid(spanCount: Int, @Orientation orientation: Int): LayoutManagerFactory {
|
||||
return object : LayoutManagerFactory {
|
||||
override fun create(recyclerView: RecyclerView): RecyclerView.LayoutManager {
|
||||
return StaggeredGridLayoutManager(spanCount, orientation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import io.legato.kazusa.base.BaseService
|
||||
import io.legato.kazusa.utils.LogUtils
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Activity管理器,管理项目中Activity的状态
|
||||
*/
|
||||
@Suppress("unused")
|
||||
object LifecycleHelp : Application.ActivityLifecycleCallbacks {
|
||||
|
||||
private const val TAG = "LifecycleHelp"
|
||||
|
||||
private val activities: MutableList<WeakReference<Activity>> = arrayListOf()
|
||||
private val services: MutableList<WeakReference<BaseService>> = arrayListOf()
|
||||
private var appFinishedListener: (() -> Unit)? = null
|
||||
|
||||
fun activitySize(): Int {
|
||||
return activities.size
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定Activity是否存在
|
||||
*/
|
||||
fun isExistActivity(activityClass: Class<*>): Boolean {
|
||||
activities.forEach { item ->
|
||||
if (item.get()?.javaClass == activityClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定 activity(class)
|
||||
*/
|
||||
fun finishActivity(vararg activityClasses: Class<*>) {
|
||||
val waitFinish = ArrayList<WeakReference<Activity>>()
|
||||
for (temp in activities) {
|
||||
for (activityClass in activityClasses) {
|
||||
if (temp.get()?.javaClass == activityClass) {
|
||||
waitFinish.add(temp)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
waitFinish.forEach {
|
||||
it.get()?.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fun setOnAppFinishedListener(appFinishedListener: (() -> Unit)) {
|
||||
this.appFinishedListener = appFinishedListener
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
LogUtils.d(TAG, "${activity::class.simpleName} onPause")
|
||||
}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
LogUtils.d(TAG, "${activity::class.simpleName} onResume")
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
LogUtils.d(TAG, "${activity::class.simpleName} onStart")
|
||||
}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
LogUtils.d(TAG, "${activity::class.simpleName} onDestroy")
|
||||
for (temp in activities) {
|
||||
if (temp.get() != null && temp.get() === activity) {
|
||||
activities.remove(temp)
|
||||
if (services.size == 0 && activities.size == 0) {
|
||||
onAppFinished()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
|
||||
LogUtils.d(TAG, "${activity::class.simpleName} onSaveInstanceState")
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
LogUtils.d(TAG, "${activity::class.simpleName} onStop")
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
LogUtils.d(TAG, "${activity::class.simpleName} onCreate")
|
||||
activities.add(WeakReference(activity))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onServiceCreate(service: BaseService) {
|
||||
LogUtils.d(TAG, "${service::class.simpleName} onCreate")
|
||||
services.add(WeakReference(service))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onServiceDestroy(service: BaseService) {
|
||||
LogUtils.d(TAG, "${service::class.simpleName} onDestroy")
|
||||
for (temp in services) {
|
||||
if (temp.get() != null && temp.get() === service) {
|
||||
services.remove(temp)
|
||||
if (services.size == 0 && activities.size == 0) {
|
||||
onAppFinished()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAppFinished() {
|
||||
appFinishedListener?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.media.AudioAttributesCompat
|
||||
import androidx.media.AudioFocusRequestCompat
|
||||
import androidx.media.AudioManagerCompat
|
||||
import io.legato.kazusa.R
|
||||
import splitties.systemservices.audioManager
|
||||
|
||||
object MediaHelp {
|
||||
|
||||
const val MEDIA_SESSION_ACTIONS = (PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||
or PlaybackStateCompat.ACTION_REWIND
|
||||
or PlaybackStateCompat.ACTION_PLAY
|
||||
or PlaybackStateCompat.ACTION_PLAY_PAUSE
|
||||
or PlaybackStateCompat.ACTION_PAUSE
|
||||
or PlaybackStateCompat.ACTION_STOP
|
||||
or PlaybackStateCompat.ACTION_FAST_FORWARD
|
||||
or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
||||
or PlaybackStateCompat.ACTION_SEEK_TO
|
||||
or PlaybackStateCompat.ACTION_SET_RATING
|
||||
or PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
|
||||
or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
|
||||
or PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
|
||||
or PlaybackStateCompat.ACTION_PLAY_FROM_URI
|
||||
or PlaybackStateCompat.ACTION_PREPARE
|
||||
or PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID
|
||||
or PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH
|
||||
or PlaybackStateCompat.ACTION_PREPARE_FROM_URI
|
||||
or PlaybackStateCompat.ACTION_SET_REPEAT_MODE
|
||||
or PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE
|
||||
or PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED)
|
||||
|
||||
fun buildAudioFocusRequestCompat(
|
||||
audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener
|
||||
): AudioFocusRequestCompat {
|
||||
val mPlaybackAttributes = AudioAttributesCompat.Builder()
|
||||
.setUsage(AudioAttributesCompat.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
return AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(mPlaybackAttributes)
|
||||
.setOnAudioFocusChangeListener(audioFocusChangeListener)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return 音频焦点
|
||||
*/
|
||||
fun requestFocus(focusRequest: AudioFocusRequestCompat): Boolean {
|
||||
val request = AudioManagerCompat.requestAudioFocus(audioManager, focusRequest)
|
||||
return request == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放静音音频,用来获取音频焦点
|
||||
*/
|
||||
fun playSilentSound(mContext: Context) {
|
||||
kotlin.runCatching {
|
||||
// Stupid Android 8 "Oreo" hack to make media buttons work
|
||||
val mMediaPlayer = MediaPlayer.create(mContext, R.raw.silent_sound)
|
||||
mMediaPlayer.setOnCompletionListener { mMediaPlayer.release() }
|
||||
mMediaPlayer.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.graphics.Paint
|
||||
import io.legato.kazusa.utils.objectpool.BaseSafeObjectPool
|
||||
|
||||
object PaintPool : BaseSafeObjectPool<Paint>(8) {
|
||||
|
||||
private val emptyPaint = Paint()
|
||||
|
||||
override fun create(): Paint = Paint()
|
||||
|
||||
override fun recycle(target: Paint) {
|
||||
target.set(emptyPaint)
|
||||
super.recycle(target)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# 放置一些帮助类
|
||||
@@ -0,0 +1,47 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import io.legato.kazusa.data.entities.ReplaceRule
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.utils.*
|
||||
|
||||
object ReplaceAnalyzer {
|
||||
|
||||
fun jsonToReplaceRules(json: String): Result<MutableList<ReplaceRule>> {
|
||||
return kotlin.runCatching {
|
||||
val replaceRules = mutableListOf<ReplaceRule>()
|
||||
val items: List<Map<String, Any>> = jsonPath.parse(json).read("$")
|
||||
for (item in items) {
|
||||
val jsonItem = jsonPath.parse(item)
|
||||
jsonToReplaceRule(jsonItem.jsonString()).getOrThrow().let {
|
||||
if (it.isValid()) {
|
||||
replaceRules.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
replaceRules
|
||||
}
|
||||
}
|
||||
|
||||
fun jsonToReplaceRule(json: String): Result<ReplaceRule> {
|
||||
return runCatching {
|
||||
val replaceRule: ReplaceRule? =
|
||||
GSON.fromJsonObject<ReplaceRule>(json.trim()).getOrNull()
|
||||
if (replaceRule == null || replaceRule.pattern.isEmpty()) {
|
||||
val jsonItem = jsonPath.parse(json.trim())
|
||||
val rule = ReplaceRule()
|
||||
rule.id = jsonItem.readLong("$.id") ?: System.currentTimeMillis()
|
||||
rule.pattern = jsonItem.readString("$.regex") ?: ""
|
||||
if (rule.pattern.isEmpty()) throw NoStackTraceException("格式不对")
|
||||
rule.name = jsonItem.readString("$.replaceSummary") ?: ""
|
||||
rule.replacement = jsonItem.readString("$.replacement") ?: ""
|
||||
rule.isRegex = jsonItem.readBool("$.isRegex") == true
|
||||
rule.scope = jsonItem.readString("$.useTo")
|
||||
rule.isEnabled = jsonItem.readBool("$.enable") == true
|
||||
rule.order = jsonItem.readInt("$.serialNumber") ?: 0
|
||||
return@runCatching rule
|
||||
}
|
||||
return@runCatching replaceRule
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
import io.legato.kazusa.utils.externalFiles
|
||||
import io.legato.kazusa.utils.getFile
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.withContext
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
|
||||
object RuleBigDataHelp {
|
||||
|
||||
private val ruleDataDir = FileUtils.createFolderIfNotExist(appCtx.externalFiles, "ruleData")
|
||||
private val bookData = FileUtils.createFolderIfNotExist(ruleDataDir, "book")
|
||||
private val rssData = FileUtils.createFolderIfNotExist(ruleDataDir, "rss")
|
||||
|
||||
suspend fun clearInvalid() {
|
||||
withContext(IO) {
|
||||
bookData.listFiles()?.forEach {
|
||||
if (it.isFile) {
|
||||
FileUtils.delete(it)
|
||||
} else {
|
||||
val bookUrlFile = it.getFile("bookUrl.txt")
|
||||
if (!bookUrlFile.exists()) {
|
||||
FileUtils.delete(it)
|
||||
} else {
|
||||
val bookUrl = bookUrlFile.readText()
|
||||
if (!appDb.bookDao.has(bookUrl)) {
|
||||
FileUtils.delete(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rssData.listFiles()?.forEach {
|
||||
if (it.isFile) {
|
||||
FileUtils.delete(it)
|
||||
} else {
|
||||
val originFile = it.getFile("origin.txt")
|
||||
if (!originFile.exists()) {
|
||||
FileUtils.delete(it)
|
||||
} else {
|
||||
val origin = originFile.readText()
|
||||
if (!appDb.rssSourceDao.has(origin)) {
|
||||
FileUtils.delete(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun putBookVariable(bookUrl: String, key: String, value: String?) {
|
||||
val md5BookUrl = MD5Utils.md5Encode(bookUrl)
|
||||
val md5Key = MD5Utils.md5Encode(key)
|
||||
if (value == null) {
|
||||
FileUtils.delete(FileUtils.getPath(bookData, md5BookUrl, "$md5Key.txt"), true)
|
||||
} else {
|
||||
val valueFile = FileUtils.createFileIfNotExist(bookData, md5BookUrl, "$md5Key.txt")
|
||||
valueFile.writeText(value)
|
||||
val bookUrlFile = File(FileUtils.getPath(bookData, md5BookUrl, "bookUrl.txt"))
|
||||
if (!bookUrlFile.exists()) {
|
||||
bookUrlFile.writeText(bookUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getBookVariable(bookUrl: String, key: String?): String? {
|
||||
val md5BookUrl = MD5Utils.md5Encode(bookUrl)
|
||||
val md5Key = MD5Utils.md5Encode(key)
|
||||
val file = File(FileUtils.getPath(bookData, md5BookUrl, "$md5Key.txt"))
|
||||
if (file.exists()) {
|
||||
return file.readText()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun hasBookVariable(bookUrl: String, key: String): Boolean {
|
||||
val md5BookUrl = MD5Utils.md5Encode(bookUrl)
|
||||
val md5Key = MD5Utils.md5Encode(key)
|
||||
val file = File(FileUtils.getPath(bookData, md5BookUrl, "$md5Key.txt"))
|
||||
return file.exists()
|
||||
}
|
||||
|
||||
fun putChapterVariable(bookUrl: String, chapterUrl: String, key: String, value: String?) {
|
||||
val md5BookUrl = MD5Utils.md5Encode(bookUrl)
|
||||
val md5ChapterUrl = MD5Utils.md5Encode(chapterUrl)
|
||||
val md5Key = MD5Utils.md5Encode(key)
|
||||
if (value == null) {
|
||||
FileUtils.delete(FileUtils.getPath(bookData, md5BookUrl, md5ChapterUrl, "$md5Key.txt"))
|
||||
} else {
|
||||
val valueFile =
|
||||
FileUtils.createFileIfNotExist(bookData, md5BookUrl, md5ChapterUrl, "$md5Key.txt")
|
||||
valueFile.writeText(value)
|
||||
val bookUrlFile = File(FileUtils.getPath(bookData, md5BookUrl, "bookUrl.txt"))
|
||||
if (!bookUrlFile.exists()) {
|
||||
bookUrlFile.writeText(bookUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getChapterVariable(bookUrl: String, chapterUrl: String, key: String): String? {
|
||||
val md5BookUrl = MD5Utils.md5Encode(bookUrl)
|
||||
val md5ChapterUrl = MD5Utils.md5Encode(chapterUrl)
|
||||
val md5Key = MD5Utils.md5Encode(key)
|
||||
val file = File(FileUtils.getPath(bookData, md5BookUrl, md5ChapterUrl, "$md5Key.txt"))
|
||||
if (file.exists()) {
|
||||
return file.readText()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun putRssVariable(origin: String, link: String, key: String, value: String?) {
|
||||
val md5Origin = MD5Utils.md5Encode(origin)
|
||||
val md5Link = MD5Utils.md5Encode(link)
|
||||
val md5Key = MD5Utils.md5Encode(key)
|
||||
val filePath = FileUtils.getPath(rssData, md5Origin, md5Link, "$md5Key.txt")
|
||||
if (value == null) {
|
||||
FileUtils.delete(filePath)
|
||||
} else {
|
||||
val valueFile = FileUtils.createFileIfNotExist(filePath)
|
||||
valueFile.writeText(value)
|
||||
val originFile = File(FileUtils.getPath(rssData, md5Origin, "origin.txt"))
|
||||
if (!originFile.exists()) {
|
||||
originFile.writeText(origin)
|
||||
}
|
||||
val linFile = File(FileUtils.getPath(rssData, md5Origin, md5Link, "origin.txt"))
|
||||
if (!linFile.exists()) {
|
||||
linFile.writeText(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getRssVariable(origin: String, link: String, key: String): String? {
|
||||
val md5Origin = MD5Utils.md5Encode(origin)
|
||||
val md5Link = MD5Utils.md5Encode(link)
|
||||
val md5Key = MD5Utils.md5Encode(key)
|
||||
val filePath = FileUtils.getPath(rssData, md5Origin, md5Link, "$md5Key.txt")
|
||||
val file = File(filePath)
|
||||
if (file.exists()) {
|
||||
return file.readText()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
|
||||
@Suppress("RegExpRedundantEscape")
|
||||
object RuleComplete {
|
||||
// 需要补全
|
||||
private val needComplete = Regex(
|
||||
"""(?<!(@|/|^|[|%&]{2})(attr|text|ownText|textNodes|href|content|html|alt|all|value|src)(\(\))?)(?<seq>\&{2}|%%|\|{2}|$)"""
|
||||
)
|
||||
|
||||
// 不能补全 存在js/json/{{xx}}的复杂情况
|
||||
private val notComplete = Regex("""^:|^##|\{\{|@js:|<js>|@Json:|\$\.""")
|
||||
|
||||
// 修正从图片获取信息
|
||||
private val fixImgInfo =
|
||||
Regex("""(?<=(^|tag\.|[\+/@>~| &]))img(?<at>(\[@?.+\]|\.[-\w]+)?)[@/]+text(\(\))?(?<seq>\&{2}|%%|\|{2}|$)""")
|
||||
|
||||
private val isXpath = Regex("^//|^@Xpath:")
|
||||
|
||||
/**
|
||||
* 对简单规则进行补全,简化部分书源规则的编写
|
||||
* 对JSOUP/XPath/CSS规则生效
|
||||
* @author 希弥
|
||||
* @return 补全后的规则 或 原规则
|
||||
* @param rules 需要补全的规则
|
||||
* @param preRule 预处理规则或列表规则
|
||||
* @param type 补全结果的类型,可选的值有:
|
||||
* 1 文字(默认)
|
||||
* 2 链接
|
||||
* 3 图片
|
||||
*/
|
||||
fun autoComplete(
|
||||
rules: String?,
|
||||
preRule: String? = null,
|
||||
type: Int = 1
|
||||
): String? {
|
||||
if (rules.isNullOrEmpty() || rules.contains(notComplete) || preRule?.contains(notComplete) == true) {
|
||||
return rules
|
||||
}
|
||||
|
||||
/** 尾部##分割的正则或由,分割的参数 */
|
||||
val tailStr: String
|
||||
|
||||
/** 分割字符 */
|
||||
val splitStr: String
|
||||
|
||||
/** 用于获取文字时添加的规则 */
|
||||
val textRule: String
|
||||
|
||||
/** 用于获取链接时添加的规则 */
|
||||
val linkRule: String
|
||||
|
||||
/** 用于获取图片时添加的规则 */
|
||||
val imgRule: String
|
||||
|
||||
/** 用于获取图片alt属性时添加的规则 */
|
||||
val imgText: String
|
||||
|
||||
// 分离尾部规则
|
||||
val regexSplit = rules.split("""##|,\{""".toRegex(), 2)
|
||||
val cleanedRule = regexSplit[0]
|
||||
if (regexSplit.size > 1) {
|
||||
splitStr = """##|,\{""".toRegex().find(rules)?.value ?: ""
|
||||
tailStr = splitStr + regexSplit[1]
|
||||
} else {
|
||||
tailStr = ""
|
||||
}
|
||||
if (cleanedRule.contains(isXpath)) {
|
||||
textRule = "//text()\${seq}"
|
||||
linkRule = "//@href\${seq}"
|
||||
imgRule = "//@src\${seq}"
|
||||
imgText = "img\${at}/@alt\${seq}"
|
||||
} else {
|
||||
textRule = "@text\${seq}"
|
||||
linkRule = "@href\${seq}"
|
||||
imgRule = "@src\${seq}"
|
||||
imgText = "img\${at}@alt\${seq}"
|
||||
}
|
||||
return when (type) {
|
||||
1 -> needComplete.replace(cleanedRule, textRule).replace(fixImgInfo, imgText) + tailStr
|
||||
2 -> needComplete.replace(cleanedRule, linkRule) + tailStr
|
||||
3 -> needComplete.replace(cleanedRule, imgRule) + tailStr
|
||||
else -> rules
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package io.legato.kazusa.help
|
||||
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.utils.buildMainHandler
|
||||
import io.legato.kazusa.utils.splitNotBlank
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import splitties.init.appCtx
|
||||
|
||||
class TTS {
|
||||
|
||||
private val handler by lazy { buildMainHandler() }
|
||||
|
||||
private val tag = "legado_tts"
|
||||
|
||||
private val clearTtsRunnable = Runnable { clearTts() }
|
||||
|
||||
private var speakStateListener: SpeakStateListener? = null
|
||||
|
||||
private var textToSpeech: TextToSpeech? = null
|
||||
|
||||
private var text: String? = null
|
||||
|
||||
private var onInit = false
|
||||
|
||||
private val initListener by lazy {
|
||||
InitListener()
|
||||
}
|
||||
|
||||
private val utteranceListener by lazy {
|
||||
TTSUtteranceListener()
|
||||
}
|
||||
|
||||
val isSpeaking: Boolean
|
||||
get() {
|
||||
return textToSpeech?.isSpeaking ?: false
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun setSpeakStateListener(speakStateListener: SpeakStateListener) {
|
||||
this.speakStateListener = speakStateListener
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun removeSpeakStateListener() {
|
||||
speakStateListener = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun speak(text: String) {
|
||||
handler.removeCallbacks(clearTtsRunnable)
|
||||
this.text = text
|
||||
if (onInit) {
|
||||
return
|
||||
}
|
||||
if (textToSpeech == null) {
|
||||
onInit = true
|
||||
textToSpeech = TextToSpeech(appCtx, initListener)
|
||||
} else {
|
||||
addTextToSpeakList()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
textToSpeech?.stop()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearTts() {
|
||||
textToSpeech?.let { tts ->
|
||||
tts.stop()
|
||||
tts.shutdown()
|
||||
}
|
||||
textToSpeech = null
|
||||
}
|
||||
|
||||
private fun addTextToSpeakList() {
|
||||
val tts = textToSpeech ?: return
|
||||
kotlin.runCatching {
|
||||
var result = tts.speak("", TextToSpeech.QUEUE_FLUSH, null, null)
|
||||
if (result == TextToSpeech.ERROR) {
|
||||
clearTts()
|
||||
textToSpeech = TextToSpeech(appCtx, initListener)
|
||||
return
|
||||
}
|
||||
text?.splitNotBlank("\n")?.forEachIndexed { i, s ->
|
||||
result = tts.speak(s, TextToSpeech.QUEUE_ADD, null, tag + i)
|
||||
if (result == TextToSpeech.ERROR) {
|
||||
AppLog.put("tts朗读出错:$text")
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
AppLog.put("tts朗读出错", it)
|
||||
appCtx.toastOnUi(it.localizedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化监听
|
||||
*/
|
||||
private inner class InitListener : TextToSpeech.OnInitListener {
|
||||
|
||||
override fun onInit(status: Int) {
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
textToSpeech?.setOnUtteranceProgressListener(utteranceListener)
|
||||
addTextToSpeakList()
|
||||
} else {
|
||||
appCtx.toastOnUi(R.string.tts_init_failed)
|
||||
}
|
||||
onInit = false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 朗读监听
|
||||
*/
|
||||
private inner class TTSUtteranceListener : UtteranceProgressListener() {
|
||||
|
||||
override fun onStart(utteranceId: String?) {
|
||||
//开始朗读取消释放资源任务
|
||||
handler.removeCallbacks(clearTtsRunnable)
|
||||
speakStateListener?.onStart()
|
||||
}
|
||||
|
||||
override fun onDone(utteranceId: String?) {
|
||||
//一分钟没有朗读释放资源
|
||||
handler.postDelayed(clearTtsRunnable, 60000L)
|
||||
speakStateListener?.onDone()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(utteranceId: String?) {
|
||||
//Deprecated
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface SpeakStateListener {
|
||||
fun onStart()
|
||||
fun onDone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.legato.kazusa.help.book
|
||||
|
||||
import io.legato.kazusa.data.entities.ReplaceRule
|
||||
|
||||
data class BookContent(
|
||||
val sameTitleRemoved: Boolean,
|
||||
val textList: List<String>,
|
||||
//起效的替换规则
|
||||
val effectiveReplaceRules: List<ReplaceRule>?
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
return textList.joinToString("\n")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package io.legato.kazusa.help.book
|
||||
|
||||
import android.net.Uri
|
||||
import com.script.buildScriptBindings
|
||||
import com.script.rhino.RhinoScriptEngine
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.AppPattern
|
||||
import io.legato.kazusa.constant.BookSourceType
|
||||
import io.legato.kazusa.constant.BookType
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.BaseBook
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.RuleBigDataHelp
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.model.localBook.LocalBook
|
||||
import io.legato.kazusa.utils.FileDoc
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
import io.legato.kazusa.utils.exists
|
||||
import io.legato.kazusa.utils.find
|
||||
import io.legato.kazusa.utils.inputStream
|
||||
import io.legato.kazusa.utils.isUri
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
import java.time.Period.between
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
val Book.isAudio: Boolean
|
||||
get() = isType(BookType.audio)
|
||||
|
||||
val Book.isImage: Boolean
|
||||
get() = isType(BookType.image)
|
||||
|
||||
val Book.isLocal: Boolean
|
||||
get() {
|
||||
if (type == 0) {
|
||||
return origin == BookType.localTag || origin.startsWith(BookType.webDavTag)
|
||||
}
|
||||
return isType(BookType.local)
|
||||
}
|
||||
|
||||
val Book.isLocalTxt: Boolean
|
||||
get() = isLocal && originName.endsWith(".txt", true)
|
||||
|
||||
val Book.isEpub: Boolean
|
||||
get() = isLocal && originName.endsWith(".epub", true)
|
||||
|
||||
val Book.isUmd: Boolean
|
||||
get() = isLocal && originName.endsWith(".umd", true)
|
||||
|
||||
val Book.isPdf: Boolean
|
||||
get() = isLocal && originName.endsWith(".pdf", true)
|
||||
|
||||
val Book.isMobi: Boolean
|
||||
get() = isLocal && (originName.endsWith(".mobi", true) ||
|
||||
originName.endsWith(".azw3", true) ||
|
||||
originName.endsWith(".azw", true))
|
||||
|
||||
val Book.isOnLineTxt: Boolean
|
||||
get() = !isLocal && isType(BookType.text)
|
||||
|
||||
val Book.isWebFile: Boolean
|
||||
get() = isType(BookType.webFile)
|
||||
|
||||
val Book.isUpError: Boolean
|
||||
get() = isType(BookType.updateError)
|
||||
|
||||
val Book.isArchive: Boolean
|
||||
get() = isType(BookType.archive)
|
||||
|
||||
val Book.isNotShelf: Boolean
|
||||
get() = isType(BookType.notShelf)
|
||||
|
||||
val Book.archiveName: String
|
||||
get() {
|
||||
if (!isArchive) throw NoStackTraceException("Book is not deCompressed from archive")
|
||||
// local_book::archive.rar
|
||||
// webDav::https://...../archive.rar
|
||||
return origin.substringAfter("::").substringAfterLast("/")
|
||||
}
|
||||
|
||||
fun Book.contains(word: String?): Boolean {
|
||||
if (word.isNullOrEmpty()) {
|
||||
return true
|
||||
}
|
||||
return name.contains(word)
|
||||
|| author.contains(word)
|
||||
|| originName.contains(word)
|
||||
|| origin.contains(word)
|
||||
|| kind?.contains(word) == true
|
||||
|| intro?.contains(word) == true
|
||||
}
|
||||
|
||||
private val localUriCache by lazy {
|
||||
ConcurrentHashMap<String, Uri>()
|
||||
}
|
||||
|
||||
fun Book.getLocalUri(): Uri {
|
||||
if (!isLocal) {
|
||||
throw NoStackTraceException("不是本地书籍")
|
||||
}
|
||||
var uri = localUriCache[bookUrl]
|
||||
if (uri != null) {
|
||||
return uri
|
||||
}
|
||||
uri = if (bookUrl.isUri()) {
|
||||
Uri.parse(bookUrl)
|
||||
} else {
|
||||
Uri.fromFile(File(bookUrl))
|
||||
}
|
||||
//先检测uri是否有效,这个比较快
|
||||
uri.inputStream(appCtx).getOrNull()?.use {
|
||||
localUriCache[bookUrl] = uri
|
||||
}?.let {
|
||||
return uri
|
||||
}
|
||||
//不同的设备书籍保存路径可能不一样, uri无效时尝试寻找当前保存路径下的文件
|
||||
val defaultBookDir = AppConfig.defaultBookTreeUri
|
||||
val importBookDir = AppConfig.importBookPath
|
||||
|
||||
// 查找书籍保存目录
|
||||
if (!defaultBookDir.isNullOrBlank()) {
|
||||
val treeUri = Uri.parse(defaultBookDir)
|
||||
val treeFileDoc = FileDoc.fromUri(treeUri, true)
|
||||
if (!treeFileDoc.exists()) {
|
||||
appCtx.toastOnUi("书籍保存目录失效,请重新设置!")
|
||||
} else {
|
||||
val fileDoc = treeFileDoc.find(originName, 5, 100)
|
||||
if (fileDoc != null) {
|
||||
localUriCache[bookUrl] = fileDoc.uri
|
||||
//更新bookUrl 重启不用再找一遍
|
||||
bookUrl = fileDoc.toString()
|
||||
save()
|
||||
return fileDoc.uri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找添加本地选择的目录
|
||||
if (!importBookDir.isNullOrBlank() && defaultBookDir != importBookDir) {
|
||||
val treeUri = if (importBookDir.isUri()) {
|
||||
Uri.parse(importBookDir)
|
||||
} else {
|
||||
Uri.fromFile(File(importBookDir))
|
||||
}
|
||||
val treeFileDoc = FileDoc.fromUri(treeUri, true)
|
||||
val fileDoc = treeFileDoc.find(originName, 5, 100)
|
||||
if (fileDoc != null) {
|
||||
localUriCache[bookUrl] = fileDoc.uri
|
||||
bookUrl = fileDoc.toString()
|
||||
save()
|
||||
return fileDoc.uri
|
||||
}
|
||||
}
|
||||
|
||||
localUriCache[bookUrl] = uri
|
||||
return uri
|
||||
}
|
||||
|
||||
|
||||
fun Book.getArchiveUri(): Uri? {
|
||||
val defaultBookDir = AppConfig.defaultBookTreeUri
|
||||
return if (isArchive && !defaultBookDir.isNullOrBlank()) {
|
||||
FileDoc.fromUri(Uri.parse(defaultBookDir), true)
|
||||
.find(archiveName)?.uri
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.cacheLocalUri(uri: Uri) {
|
||||
localUriCache[bookUrl] = uri
|
||||
}
|
||||
|
||||
fun Book.removeLocalUriCache() {
|
||||
localUriCache.remove(bookUrl)
|
||||
}
|
||||
|
||||
fun Book.getRemoteUrl(): String? {
|
||||
if (origin.startsWith(BookType.webDavTag)) {
|
||||
return origin.substring(BookType.webDavTag.length)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun Book.setType(@BookType.Type vararg types: Int) {
|
||||
type = 0
|
||||
addType(*types)
|
||||
}
|
||||
|
||||
fun Book.addType(@BookType.Type vararg types: Int) {
|
||||
types.forEach {
|
||||
type = type or it
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.removeType(@BookType.Type vararg types: Int) {
|
||||
types.forEach {
|
||||
type = type and it.inv()
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.removeAllBookType() {
|
||||
removeType(BookType.allBookType)
|
||||
}
|
||||
|
||||
fun Book.clearType() {
|
||||
type = 0
|
||||
}
|
||||
|
||||
fun Book.isType(@BookType.Type bookType: Int): Boolean = type and bookType > 0
|
||||
|
||||
fun Book.upType() {
|
||||
if (type < 8) {
|
||||
type = when (type) {
|
||||
BookSourceType.image -> BookType.image
|
||||
BookSourceType.audio -> BookType.audio
|
||||
BookSourceType.file -> BookType.webFile
|
||||
else -> BookType.text
|
||||
}
|
||||
if (origin == BookType.localTag || origin.startsWith(BookType.webDavTag)) {
|
||||
type = type or BookType.local
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.sync(oldBook: Book) {
|
||||
val curBook = appDb.bookDao.getBook(oldBook.bookUrl)!!
|
||||
durChapterTime = curBook.durChapterTime
|
||||
durChapterPos = curBook.durChapterPos
|
||||
if (durChapterIndex != curBook.durChapterIndex) {
|
||||
durChapterIndex = curBook.durChapterIndex
|
||||
val replaceRules = ContentProcessor.get(this).getTitleReplaceRules()
|
||||
appDb.bookChapterDao.getChapter(bookUrl, durChapterIndex)?.let {
|
||||
durChapterTitle = it.getDisplayTitle(replaceRules, getUseReplaceRule())
|
||||
}
|
||||
}
|
||||
canUpdate = curBook.canUpdate
|
||||
readConfig = curBook.readConfig
|
||||
}
|
||||
|
||||
fun Book.update() {
|
||||
appDb.bookDao.update(this)
|
||||
}
|
||||
|
||||
fun Book.primaryStr(): String {
|
||||
return origin + bookUrl
|
||||
}
|
||||
|
||||
fun Book.updateTo(newBook: Book): Book {
|
||||
newBook.durChapterIndex = durChapterIndex
|
||||
newBook.durChapterTitle = durChapterTitle
|
||||
newBook.durChapterPos = durChapterPos
|
||||
newBook.durChapterTime = durChapterTime
|
||||
newBook.group = group
|
||||
newBook.order = order
|
||||
newBook.customCoverUrl = customCoverUrl
|
||||
newBook.customIntro = customIntro
|
||||
newBook.customTag = customTag
|
||||
newBook.canUpdate = canUpdate
|
||||
newBook.readConfig = readConfig
|
||||
val variableMap = variableMap.toMutableMap()
|
||||
variableMap.keys.removeIf {
|
||||
newBook.hasVariable(it)
|
||||
}
|
||||
newBook.variableMap.putAll(variableMap)
|
||||
newBook.variable = GSON.toJson(variableMap)
|
||||
return newBook
|
||||
}
|
||||
|
||||
fun Book.hasVariable(key: String): Boolean {
|
||||
return variableMap.contains(key) || RuleBigDataHelp.hasBookVariable(bookUrl, key)
|
||||
}
|
||||
|
||||
fun Book.getFolderNameNoCache(): String {
|
||||
return name.replace(AppPattern.fileNameRegex, "").let {
|
||||
it.substring(0, min(9, it.length)) + MD5Utils.md5Encode16(bookUrl)
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.getBookSource(): BookSource? {
|
||||
return appDb.bookSourceDao.getBookSource(origin)
|
||||
}
|
||||
|
||||
fun Book.isLocalModified(): Boolean {
|
||||
return isLocal && LocalBook.getLastModified(this).getOrDefault(0L) > latestChapterTime
|
||||
}
|
||||
|
||||
fun Book.releaseHtmlData() {
|
||||
infoHtml = null
|
||||
tocHtml = null
|
||||
}
|
||||
|
||||
fun Book.isSameNameAuthor(other: Any?): Boolean {
|
||||
if (other is BaseBook) {
|
||||
return name == other.name && author == other.author
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun Book.getExportFileName(suffix: String): String {
|
||||
val jsStr = AppConfig.bookExportFileName
|
||||
if (jsStr.isNullOrBlank()) {
|
||||
return "$name 作者:${getRealAuthor()}.$suffix"
|
||||
}
|
||||
val bindings = buildScriptBindings { bindings ->
|
||||
bindings["epubIndex"] = ""// 兼容老版本,修复可能存在的错误
|
||||
bindings["name"] = name
|
||||
bindings["author"] = getRealAuthor()
|
||||
}
|
||||
return kotlin.runCatching {
|
||||
RhinoScriptEngine.eval(jsStr, bindings).toString() + "." + suffix
|
||||
}.onFailure {
|
||||
AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it)
|
||||
}.getOrDefault("$name 作者:${getRealAuthor()}.$suffix")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分割文件后的文件名
|
||||
*/
|
||||
fun Book.getExportFileName(
|
||||
suffix: String,
|
||||
epubIndex: Int,
|
||||
jsStr: String? = AppConfig.episodeExportFileName
|
||||
): String {
|
||||
// 默认规则
|
||||
val default = "$name 作者:${getRealAuthor()} [${epubIndex}].$suffix"
|
||||
if (jsStr.isNullOrBlank()) {
|
||||
return default
|
||||
}
|
||||
val bindings = buildScriptBindings { bindings ->
|
||||
bindings["name"] = name
|
||||
bindings["author"] = getRealAuthor()
|
||||
bindings["epubIndex"] = epubIndex
|
||||
}
|
||||
return kotlin.runCatching {
|
||||
RhinoScriptEngine.eval(jsStr, bindings).toString() + "." + suffix
|
||||
}.onFailure {
|
||||
AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it)
|
||||
}.getOrDefault(default)
|
||||
}
|
||||
|
||||
// 根据当前日期计算章节总数
|
||||
fun Book.simulatedTotalChapterNum(): Int {
|
||||
return if (readSimulating()) {
|
||||
val currentDate = LocalDate.now()
|
||||
val daysPassed = between(config.startDate, currentDate).days + 1
|
||||
// 计算当前应该解锁到哪一章
|
||||
val chaptersToUnlock =
|
||||
max(0, (config.startChapter ?: 0) + (daysPassed * config.dailyChapters))
|
||||
min(totalChapterNum, chaptersToUnlock)
|
||||
} else {
|
||||
totalChapterNum
|
||||
}
|
||||
}
|
||||
|
||||
fun Book.readSimulating(): Boolean {
|
||||
return config.readSimulating
|
||||
}
|
||||
|
||||
fun tryParesExportFileName(jsStr: String): Boolean {
|
||||
val bindings = buildScriptBindings { bindings ->
|
||||
bindings["name"] = "name"
|
||||
bindings["author"] = "author"
|
||||
bindings["epubIndex"] = "epubIndex"
|
||||
}
|
||||
return runCatching {
|
||||
RhinoScriptEngine.eval(jsStr, bindings)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
package io.legato.kazusa.help.book
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.AppPattern
|
||||
import io.legato.kazusa.constant.EventBus
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.model.localBook.LocalBook
|
||||
import io.legato.kazusa.utils.ArchiveUtils
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.ImageUtils
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.StringUtils
|
||||
import io.legato.kazusa.utils.SvgUtils
|
||||
import io.legato.kazusa.utils.UrlUtil
|
||||
import io.legato.kazusa.utils.createFileIfNotExist
|
||||
import io.legato.kazusa.utils.exists
|
||||
import io.legato.kazusa.utils.externalFiles
|
||||
import io.legato.kazusa.utils.getFile
|
||||
import io.legato.kazusa.utils.isContentScheme
|
||||
import io.legato.kazusa.utils.onEachParallel
|
||||
import io.legato.kazusa.utils.postEvent
|
||||
import com.script.rhino.runScriptWithContext
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.text.similarity.JaccardSimilarity
|
||||
import splitties.init.appCtx
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.regex.Pattern
|
||||
import java.util.zip.ZipFile
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Suppress("unused", "ConstPropertyName")
|
||||
object BookHelp {
|
||||
private val downloadDir: File = appCtx.externalFiles
|
||||
private const val cacheFolderName = "book_cache"
|
||||
private const val cacheImageFolderName = "images"
|
||||
private const val cacheEpubFolderName = "epub"
|
||||
private val downloadImages = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
val cachePath = FileUtils.getPath(downloadDir, cacheFolderName)
|
||||
|
||||
fun clearCache() {
|
||||
FileUtils.delete(
|
||||
FileUtils.getPath(downloadDir, cacheFolderName)
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCache(book: Book) {
|
||||
val filePath = FileUtils.getPath(downloadDir, cacheFolderName, book.getFolderName())
|
||||
FileUtils.delete(filePath)
|
||||
}
|
||||
|
||||
fun updateCacheFolder(oldBook: Book, newBook: Book) {
|
||||
val oldFolderName = oldBook.getFolderNameNoCache()
|
||||
val newFolderName = newBook.getFolderNameNoCache()
|
||||
if (oldFolderName == newFolderName) return
|
||||
val oldFolderPath = FileUtils.getPath(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
oldFolderName
|
||||
)
|
||||
val newFolderPath = FileUtils.getPath(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
newFolderName
|
||||
)
|
||||
FileUtils.move(oldFolderPath, newFolderPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除已删除书的缓存 解压缓存
|
||||
*/
|
||||
suspend fun clearInvalidCache() {
|
||||
withContext(IO) {
|
||||
val bookFolderNames = hashSetOf<String>()
|
||||
val originNames = hashSetOf<String>()
|
||||
appDb.bookDao.all.forEach {
|
||||
clearComicCache(it)
|
||||
bookFolderNames.add(it.getFolderName())
|
||||
if (it.isEpub) originNames.add(it.originName)
|
||||
}
|
||||
downloadDir.getFile(cacheFolderName)
|
||||
.listFiles()?.forEach { bookFile ->
|
||||
if (!bookFolderNames.contains(bookFile.name)) {
|
||||
FileUtils.delete(bookFile.absolutePath)
|
||||
}
|
||||
}
|
||||
downloadDir.getFile(cacheEpubFolderName)
|
||||
.listFiles()?.forEach { epubFile ->
|
||||
if (!originNames.contains(epubFile.name)) {
|
||||
FileUtils.delete(epubFile.absolutePath)
|
||||
}
|
||||
}
|
||||
FileUtils.delete(ArchiveUtils.TEMP_PATH)
|
||||
val filesDir = appCtx.filesDir
|
||||
FileUtils.delete("$filesDir/shareBookSource.json")
|
||||
FileUtils.delete("$filesDir/shareRssSource.json")
|
||||
FileUtils.delete("$filesDir/books.json")
|
||||
}
|
||||
}
|
||||
|
||||
//清除已经看过的漫画数据
|
||||
private fun clearComicCache(book: Book) {
|
||||
//只处理漫画
|
||||
//为0的时候,不清除已缓存数据
|
||||
if (!book.isImage || AppConfig.imageRetainNum == 0) {
|
||||
return
|
||||
}
|
||||
//向前保留设定数量,向后保留预下载数量
|
||||
val startIndex = book.durChapterIndex - AppConfig.imageRetainNum
|
||||
val endIndex = book.durChapterIndex + AppConfig.preDownloadNum
|
||||
val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl, startIndex, endIndex)
|
||||
val imgNames = hashSetOf<String>()
|
||||
//获取需要保留章节的图片信息
|
||||
chapterList.forEach {
|
||||
val content = getContent(book, it)
|
||||
if (content != null) {
|
||||
val matcher = AppPattern.imgPattern.matcher(content)
|
||||
while (matcher.find()) {
|
||||
val src = matcher.group(1) ?: continue
|
||||
val mSrc = NetworkUtils.getAbsoluteURL(it.url, src)
|
||||
imgNames.add("${MD5Utils.md5Encode16(mSrc)}.${getImageSuffix(mSrc)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
downloadDir.getFile(
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
cacheImageFolderName
|
||||
).listFiles()?.forEach { imgFile ->
|
||||
if (!imgNames.contains(imgFile.name)) {
|
||||
imgFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveContent(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
content: String
|
||||
) {
|
||||
try {
|
||||
saveText(book, bookChapter, content)
|
||||
//saveImages(bookSource, book, bookChapter, content)
|
||||
postEvent(EventBus.SAVE_CONTENT, Pair(book, bookChapter))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
AppLog.put("保存正文失败 ${book.name} ${bookChapter.title}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveText(
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
content: String
|
||||
) {
|
||||
if (content.isEmpty()) return
|
||||
//保存文本
|
||||
FileUtils.createFileIfNotExist(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
bookChapter.getFileName(),
|
||||
).writeText(content)
|
||||
if (book.isOnLineTxt && AppConfig.tocCountWords) {
|
||||
val wordCount = StringUtils.wordCountFormat(content.length)
|
||||
bookChapter.wordCount = wordCount
|
||||
appDb.bookChapterDao.upWordCount(bookChapter.bookUrl, bookChapter.url, wordCount)
|
||||
}
|
||||
}
|
||||
|
||||
fun flowImages(bookChapter: BookChapter, content: String): Flow<String> {
|
||||
return flow {
|
||||
val matcher = AppPattern.imgPattern.matcher(content)
|
||||
while (matcher.find()) {
|
||||
val src = matcher.group(1) ?: continue
|
||||
val mSrc = NetworkUtils.getAbsoluteURL(bookChapter.url, src)
|
||||
emit(mSrc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveImages(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
content: String,
|
||||
concurrency: Int = AppConfig.threadCount
|
||||
) = coroutineScope {
|
||||
flowImages(bookChapter, content).onEachParallel(concurrency) { mSrc ->
|
||||
saveImage(bookSource, book, mSrc, bookChapter)
|
||||
}.collect()
|
||||
}
|
||||
|
||||
suspend fun saveImage(
|
||||
bookSource: BookSource?,
|
||||
book: Book,
|
||||
src: String,
|
||||
chapter: BookChapter? = null
|
||||
) {
|
||||
if (isImageExist(book, src)) {
|
||||
return
|
||||
}
|
||||
val mutex = synchronized(this) {
|
||||
downloadImages.getOrPut(src) { Mutex() }
|
||||
}
|
||||
mutex.lock()
|
||||
try {
|
||||
if (isImageExist(book, src)) {
|
||||
return
|
||||
}
|
||||
val analyzeUrl = AnalyzeUrl(
|
||||
src, source = bookSource, coroutineContext = coroutineContext
|
||||
)
|
||||
val bytes = analyzeUrl.getByteArrayAwait()
|
||||
//某些图片被加密,需要进一步解密
|
||||
runScriptWithContext {
|
||||
ImageUtils.decode(
|
||||
src, bytes, isCover = false, bookSource, book
|
||||
)
|
||||
}?.let {
|
||||
if (!checkImage(it)) {
|
||||
// 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据
|
||||
// 所以无论如何都要将数据写入到文件里
|
||||
// throw NoStackTraceException("数据异常")
|
||||
AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常")
|
||||
}
|
||||
writeImage(book, src, it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
coroutineContext.ensureActive()
|
||||
val msg = "${book.name} ${chapter?.title} 图片 $src 下载失败\n${e.localizedMessage}"
|
||||
AppLog.put(msg, e)
|
||||
} finally {
|
||||
downloadImages.remove(src)
|
||||
mutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
fun getImage(book: Book, src: String): File {
|
||||
return downloadDir.getFile(
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
cacheImageFolderName,
|
||||
"${MD5Utils.md5Encode16(src)}.${getImageSuffix(src)}"
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun writeImage(book: Book, src: String, bytes: ByteArray) {
|
||||
getImage(book, src).createFileIfNotExist().writeBytes(bytes)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun isImageExist(book: Book, src: String): Boolean {
|
||||
return getImage(book, src).exists()
|
||||
}
|
||||
|
||||
fun getImageSuffix(src: String): String {
|
||||
return UrlUtil.getSuffix(src, "jpg")
|
||||
}
|
||||
|
||||
@Throws(IOException::class, FileNotFoundException::class)
|
||||
fun getEpubFile(book: Book): ZipFile {
|
||||
val uri = book.getLocalUri()
|
||||
if (uri.isContentScheme()) {
|
||||
FileUtils.createFolderIfNotExist(downloadDir, cacheEpubFolderName)
|
||||
val path = FileUtils.getPath(downloadDir, cacheEpubFolderName, book.originName)
|
||||
val file = File(path)
|
||||
val doc = DocumentFile.fromSingleUri(appCtx, uri)
|
||||
?: throw IOException("文件不存在")
|
||||
if (!file.exists() || doc.lastModified() > book.latestChapterTime) {
|
||||
LocalBook.getBookInputStream(book).use { inputStream ->
|
||||
FileOutputStream(file).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ZipFile(file)
|
||||
}
|
||||
return ZipFile(uri.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地书籍文件的ParcelFileDescriptor
|
||||
*
|
||||
* @param book
|
||||
* @return
|
||||
*/
|
||||
@Throws(IOException::class, FileNotFoundException::class)
|
||||
fun getBookPFD(book: Book): ParcelFileDescriptor? {
|
||||
val uri = book.getLocalUri()
|
||||
return if (uri.isContentScheme()) {
|
||||
appCtx.contentResolver.openFileDescriptor(uri, "r")
|
||||
} else {
|
||||
ParcelFileDescriptor.open(File(uri.path!!), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
}
|
||||
}
|
||||
|
||||
fun getChapterFiles(book: Book): HashSet<String> {
|
||||
val fileNames = hashSetOf<String>()
|
||||
if (book.isLocalTxt) {
|
||||
return fileNames
|
||||
}
|
||||
FileUtils.createFolderIfNotExist(
|
||||
downloadDir,
|
||||
subDirs = arrayOf(cacheFolderName, book.getFolderName())
|
||||
).list()?.let {
|
||||
fileNames.addAll(it)
|
||||
}
|
||||
return fileNames
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测该章节是否下载
|
||||
*/
|
||||
fun hasContent(book: Book, bookChapter: BookChapter): Boolean {
|
||||
return if (book.isLocalTxt ||
|
||||
(bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title))
|
||||
) {
|
||||
true
|
||||
} else {
|
||||
downloadDir.exists(
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
bookChapter.getFileName()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测图片是否下载
|
||||
*/
|
||||
fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean {
|
||||
if (!hasContent(book, bookChapter)) {
|
||||
return false
|
||||
}
|
||||
var ret = true
|
||||
val op = BitmapFactory.Options()
|
||||
op.inJustDecodeBounds = true
|
||||
getContent(book, bookChapter)?.let {
|
||||
val matcher = AppPattern.imgPattern.matcher(it)
|
||||
while (matcher.find()) {
|
||||
val src = matcher.group(1)!!
|
||||
val image = getImage(book, src)
|
||||
if (!image.exists()) {
|
||||
ret = false
|
||||
continue
|
||||
}
|
||||
BitmapFactory.decodeFile(image.absolutePath, op)
|
||||
if (op.outWidth < 1 && op.outHeight < 1) {
|
||||
if (SvgUtils.getSize(image.absolutePath) != null) {
|
||||
continue
|
||||
}
|
||||
ret = false
|
||||
image.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
private fun checkImage(bytes: ByteArray): Boolean {
|
||||
val op = BitmapFactory.Options()
|
||||
op.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, op)
|
||||
if (op.outWidth < 1 && op.outHeight < 1) {
|
||||
return SvgUtils.getSize(ByteArrayInputStream(bytes)) != null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取章节内容
|
||||
*/
|
||||
fun getContent(book: Book, bookChapter: BookChapter): String? {
|
||||
val file = downloadDir.getFile(
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
bookChapter.getFileName()
|
||||
)
|
||||
if (file.exists()) {
|
||||
return file.readText()
|
||||
}
|
||||
if (book.isLocal) {
|
||||
val string = LocalBook.getContent(book, bookChapter)
|
||||
if (string != null && book.isEpub) {
|
||||
saveText(book, bookChapter, string)
|
||||
}
|
||||
return string
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除章节内容
|
||||
*/
|
||||
fun delContent(book: Book, bookChapter: BookChapter) {
|
||||
FileUtils.createFileIfNotExist(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
bookChapter.getFileName()
|
||||
).delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否禁用正文的去除重复标题,针对单个章节
|
||||
*/
|
||||
fun setRemoveSameTitle(book: Book, bookChapter: BookChapter, removeSameTitle: Boolean) {
|
||||
val fileName = bookChapter.getFileName("nr")
|
||||
val contentProcessor = ContentProcessor.get(book)
|
||||
if (removeSameTitle) {
|
||||
val path = FileUtils.getPath(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
fileName
|
||||
)
|
||||
contentProcessor.removeSameTitleCache.remove(fileName)
|
||||
File(path).delete()
|
||||
} else {
|
||||
FileUtils.createFileIfNotExist(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
fileName
|
||||
)
|
||||
contentProcessor.removeSameTitleCache.add(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取是否去除重复标题
|
||||
*/
|
||||
fun removeSameTitle(book: Book, bookChapter: BookChapter): Boolean {
|
||||
val path = FileUtils.getPath(
|
||||
downloadDir,
|
||||
cacheFolderName,
|
||||
book.getFolderName(),
|
||||
bookChapter.getFileName("nr")
|
||||
)
|
||||
return !File(path).exists()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化书名
|
||||
*/
|
||||
fun formatBookName(name: String): String {
|
||||
return name
|
||||
.replace(AppPattern.nameRegex, "")
|
||||
.trim { it <= ' ' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化作者
|
||||
*/
|
||||
fun formatBookAuthor(author: String): String {
|
||||
return author
|
||||
.replace(AppPattern.authorRegex, "")
|
||||
.trim { it <= ' ' }
|
||||
}
|
||||
|
||||
private val jaccardSimilarity by lazy {
|
||||
JaccardSimilarity()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据目录名获取当前章节
|
||||
*/
|
||||
fun getDurChapter(
|
||||
oldDurChapterIndex: Int,
|
||||
oldDurChapterName: String?,
|
||||
newChapterList: List<BookChapter>,
|
||||
oldChapterListSize: Int = 0
|
||||
): Int {
|
||||
if (oldDurChapterIndex <= 0) return 0
|
||||
if (newChapterList.isEmpty()) return oldDurChapterIndex
|
||||
val oldChapterNum = getChapterNum(oldDurChapterName)
|
||||
val oldName = getPureChapterName(oldDurChapterName)
|
||||
val newChapterSize = newChapterList.size
|
||||
val durIndex =
|
||||
if (oldChapterListSize == 0) oldDurChapterIndex
|
||||
else oldDurChapterIndex * oldChapterListSize / newChapterSize
|
||||
val min = max(0, min(oldDurChapterIndex, durIndex) - 10)
|
||||
val max = min(newChapterSize - 1, max(oldDurChapterIndex, durIndex) + 10)
|
||||
var nameSim = 0.0
|
||||
var newIndex = 0
|
||||
var newNum = 0
|
||||
if (oldName.isNotEmpty()) {
|
||||
for (i in min..max) {
|
||||
val newName = getPureChapterName(newChapterList[i].title)
|
||||
val temp = jaccardSimilarity.apply(oldName, newName)
|
||||
if (temp > nameSim) {
|
||||
nameSim = temp
|
||||
newIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nameSim < 0.96 && oldChapterNum > 0) {
|
||||
for (i in min..max) {
|
||||
val temp = getChapterNum(newChapterList[i].title)
|
||||
if (temp == oldChapterNum) {
|
||||
newNum = temp
|
||||
newIndex = i
|
||||
break
|
||||
} else if (abs(temp - oldChapterNum) < abs(newNum - oldChapterNum)) {
|
||||
newNum = temp
|
||||
newIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (nameSim > 0.96 || abs(newNum - oldChapterNum) < 1) {
|
||||
newIndex
|
||||
} else {
|
||||
min(max(0, newChapterList.size - 1), oldDurChapterIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDurChapter(
|
||||
oldBook: Book,
|
||||
newChapterList: List<BookChapter>
|
||||
): Int {
|
||||
return oldBook.run {
|
||||
getDurChapter(durChapterIndex, durChapterTitle, newChapterList, totalChapterNum)
|
||||
}
|
||||
}
|
||||
|
||||
private val chapterNamePattern1 by lazy {
|
||||
Pattern.compile(
|
||||
".*?第([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话]"
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("RegExpSimplifiable")
|
||||
private val chapterNamePattern2 by lazy {
|
||||
Pattern.compile(
|
||||
"^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、]|\\.[^\\d])"
|
||||
)
|
||||
}
|
||||
|
||||
private val regexA by lazy {
|
||||
return@lazy "\\s".toRegex()
|
||||
}
|
||||
|
||||
private fun getChapterNum(chapterName: String?): Int {
|
||||
chapterName ?: return -1
|
||||
val chapterName1 = StringUtils.fullToHalf(chapterName).replace(regexA, "")
|
||||
return StringUtils.stringToInt(
|
||||
(
|
||||
chapterNamePattern1.matcher(chapterName1).takeIf { it.find() }
|
||||
?: chapterNamePattern2.matcher(chapterName1).takeIf { it.find() }
|
||||
)?.group(1)
|
||||
?: "-1"
|
||||
)
|
||||
}
|
||||
|
||||
private val regexOther by lazy {
|
||||
// 所有非字母数字中日韩文字 CJK区+扩展A-F区
|
||||
@Suppress("RegExpDuplicateCharacterInClass")
|
||||
return@lazy "[^\\w\\u4E00-\\u9FEF〇\\u3400-\\u4DBF\\u20000-\\u2A6DF\\u2A700-\\u2EBEF]".toRegex()
|
||||
}
|
||||
|
||||
@Suppress("RegExpUnnecessaryNonCapturingGroup", "RegExpSimplifiable")
|
||||
private val regexB by lazy {
|
||||
//章节序号,排除处于结尾的状况,避免将章节名替换为空字串
|
||||
return@lazy "^.*?第(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话](?!$)|^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、](?!$)|\\.(?=[^\\d]))".toRegex()
|
||||
}
|
||||
|
||||
private val regexC by lazy {
|
||||
//前后附加内容,整个章节名都在括号中时只剔除首尾括号,避免将章节名替换为空字串
|
||||
return@lazy "(?!^)(?:[〖【《〔\\[{(][^〖【《〔\\[{()〕》】〗\\]}]+)?[)〕》】〗\\]}]$|^[〖【《〔\\[{(](?:[^〖【《〔\\[{()〕》】〗\\]}]+[〕》】〗\\]})])?(?!$)".toRegex()
|
||||
}
|
||||
|
||||
private fun getPureChapterName(chapterName: String?): String {
|
||||
return if (chapterName == null) "" else StringUtils.fullToHalf(chapterName)
|
||||
.replace(regexA, "")
|
||||
.replace(regexB, "")
|
||||
.replace(regexC, "")
|
||||
.replace(regexOther, "")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
package io.legato.kazusa.help.book
|
||||
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Suppress("SameParameterValue", "RegExpRedundantEscape")
|
||||
object ContentHelp {
|
||||
|
||||
/**
|
||||
* 段落重排算法入口。把整篇内容输入,连接错误的分段,再把每个段落调用其他方法重新切分
|
||||
*
|
||||
* @param content 正文
|
||||
* @param chapterName 标题
|
||||
* @return
|
||||
*/
|
||||
fun reSegment(content: String, chapterName: String): String {
|
||||
var content1 = content
|
||||
val dict = makeDict(content1)
|
||||
var p = content1
|
||||
.replace(""".toRegex(), "“")
|
||||
.replace("[::]['\"‘”“]+".toRegex(), ":“")
|
||||
.replace("[\"”“]+[\\s]*[\"”“][\\s\"”“]*".toRegex(), "”\n“")
|
||||
.split("\n(\\s*)".toRegex()).toTypedArray()
|
||||
|
||||
//初始化StringBuilder的长度,在原content的长度基础上做冗余
|
||||
var buffer = StringBuilder((content1.length * 1.15).toInt())
|
||||
// 章节的文本格式为章节标题-空行-首段,所以处理段落时需要略过第一行文本。
|
||||
buffer.append(" ")
|
||||
if (chapterName.trim { it <= ' ' } != p[0].trim { it <= ' ' }) {
|
||||
// 去除段落内空格。unicode 3000 象形字间隔(中日韩符号和标点),不包含在\s内
|
||||
buffer.append(p[0].replace("[\u3000\\s]+".toRegex(), ""))
|
||||
}
|
||||
|
||||
//如果原文存在分段错误,需要把段落重新黏合
|
||||
for (i in 1 until p.size) {
|
||||
if (match(MARK_SENTENCES_END, buffer[buffer.length - 1])) buffer.append("\n")
|
||||
// 段落开头以外的地方不应该有空格
|
||||
// 去除段落内空格。unicode 3000 象形字间隔(中日韩符号和标点),不包含在\s内
|
||||
buffer.append(p[i].replace("[\u3000\\s]".toRegex(), ""))
|
||||
}
|
||||
// 预分段预处理
|
||||
// ”“处理为”\n“。
|
||||
// ”。“处理为”。\n“。不考虑“?” “!”的情况。
|
||||
// ”。xxx处理为 ”。\n xxx
|
||||
p = buffer.toString()
|
||||
.replace("[\"”“]+[\\s]*[\"”“]+".toRegex(), "”\n“")
|
||||
.replace("[\"”“]+(?。!?!~)[\"”“]+".toRegex(), "”$1\n“")
|
||||
.replace("[\"”“]+(?。!?!~)([^\"”“])".toRegex(), "”$1\n$2")
|
||||
.replace(
|
||||
"([问说喊唱叫骂道着答])[\\.。]".toRegex(),
|
||||
"$1。\n"
|
||||
)
|
||||
.split("\n".toRegex()).toTypedArray()
|
||||
buffer = StringBuilder((content1.length * 1.15).toInt())
|
||||
for (s in p) {
|
||||
buffer.append("\n")
|
||||
buffer.append(findNewLines(s, dict))
|
||||
}
|
||||
buffer = reduceLength(buffer)
|
||||
content1 = (buffer.toString() // 处理章节头部空格和换行
|
||||
.replaceFirst("^\\s+".toRegex(), "")
|
||||
.replace("\\s*[\"”“]+[\\s]*[\"”“][\\s\"”“]*".toRegex(), "”\n“")
|
||||
.replace("[::][”“\"\\s]+".toRegex(), ":“")
|
||||
.replace("\n[\"“”]([^\n\"“”]+)([,:,:][\"”“])([^\n\"“”]+)".toRegex(), "\n$1:“$3")
|
||||
.replace("\n(\\s*)".toRegex(), "\n"))
|
||||
return content1
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制切分,减少段落内的句子
|
||||
* 如果连续2对引号的段落没有提示语,进入对话模式。最后一对引号后强制切分段落
|
||||
* 如果引号内的内容长于5句,可能引号状态有误,随机分段
|
||||
* 如果引号外的内容长于3句,随机分段
|
||||
*
|
||||
* @param str
|
||||
* @return
|
||||
*/
|
||||
private fun reduceLength(str: StringBuilder): StringBuilder {
|
||||
val p = str.toString().split("\n".toRegex()).toTypedArray()
|
||||
val l = p.size
|
||||
val b = BooleanArray(l)
|
||||
for (i in 0 until l) {
|
||||
b[i] = p[i].matches(PARAGRAPH_DIAGLOG)
|
||||
}
|
||||
var dialogue = 0
|
||||
for (i in 0 until l) {
|
||||
if (b[i]) {
|
||||
if (dialogue < 0) dialogue = 1 else if (dialogue < 2) dialogue++
|
||||
} else {
|
||||
if (dialogue > 1) {
|
||||
p[i] = splitQuote(p[i])
|
||||
dialogue--
|
||||
} else if (dialogue > 0 && i < l - 2) {
|
||||
if (b[i + 1]) p[i] = splitQuote(p[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
val string = StringBuilder()
|
||||
for (i in 0 until l) {
|
||||
string.append('\n')
|
||||
string.append(p[i])
|
||||
//System.out.print(" "+b[i]);
|
||||
}
|
||||
//System.out.println(" " + str);
|
||||
return string
|
||||
}
|
||||
|
||||
// 强制切分进入对话模式后,未构成 “xxx” 形式的段落
|
||||
private fun splitQuote(str: String): String {
|
||||
val length = str.length
|
||||
if (length < 3) return str
|
||||
if (match(MARK_QUOTATION, str[0])) {
|
||||
val i = seekIndex(str, MARK_QUOTATION, 1, length - 2, true) + 1
|
||||
if (i > 1) if (!match(MARK_QUOTATION_BEFORE, str[i - 1])) {
|
||||
return "${str.substring(0, i)}\n${str.substring(i)}"
|
||||
}
|
||||
} else if (match(MARK_QUOTATION, str[length - 1])) {
|
||||
val i = length - 1 - seekIndex(str, MARK_QUOTATION, 1, length - 2, false)
|
||||
if (i > 1) {
|
||||
if (!match(MARK_QUOTATION_BEFORE, str[i - 1])) {
|
||||
return "${str.substring(0, i)}\n${str.substring(i)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算随机插入换行符的位置。
|
||||
* @param str 字符串
|
||||
* @param offset 传回的结果需要叠加的偏移量
|
||||
* @param min 最低几个句子,随机插入换行
|
||||
* @param gain 倍率。每个句子插入换行的数学期望 = 1 / gain , gain越大越不容易插入换行
|
||||
* @return
|
||||
*/
|
||||
private fun forceSplit(
|
||||
str: String,
|
||||
offset: Int,
|
||||
min: Int,
|
||||
gain: Int,
|
||||
tigger: Int
|
||||
): ArrayList<Int> {
|
||||
val result = ArrayList<Int>()
|
||||
val arrayEnd = seekIndexs(str, MARK_SENTENCES_END_P, 0, str.length - 2, true)
|
||||
val arrayMid = seekIndexs(str, MARK_SENTENCES_MID, 0, str.length - 2, true)
|
||||
if (arrayEnd.size < tigger && arrayMid.size < tigger * 3) return result
|
||||
var j = 0
|
||||
var i = min
|
||||
while (i < arrayEnd.size) {
|
||||
var k = 0
|
||||
while (j < arrayMid.size) {
|
||||
if (arrayMid[j] < arrayEnd[i]) k++
|
||||
j++
|
||||
}
|
||||
if (Math.random() * gain < 0.8 + k / 2.5) {
|
||||
result.add(arrayEnd[i] + offset)
|
||||
i = max(i + min, i)
|
||||
}
|
||||
i++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 对内容重新划分段落.输入参数str已经使用换行符预分割
|
||||
private fun findNewLines(str: String, dict: List<String>): String {
|
||||
val string = StringBuilder(str)
|
||||
// 标记string中每个引号的位置.特别的,用引号进行列举时视为只有一对引号。 如:“锅”、“碗”视为“锅、碗”,从而避免误断句。
|
||||
val arrayQuote: MutableList<Int> = ArrayList()
|
||||
// 标记插入换行符的位置,int为插入位置(str的char下标)
|
||||
var insN = ArrayList<Int>()
|
||||
|
||||
//mod[i]标记str的每一段处于引号内还是引号外。范围: str.substring( array_quote.get(i), array_quote.get(i+1) )的状态。
|
||||
//长度:array_quote.size(),但是初始化时未预估占用的长度,用空间换时间
|
||||
//0未知,正数引号内,负数引号外。
|
||||
//如果相邻的两个标记都为+1,那么需要增加1个引号。
|
||||
//引号内不进行断句
|
||||
val mod = IntArray(str.length)
|
||||
var waitClose = false
|
||||
for (i in str.indices) {
|
||||
val c = str[i]
|
||||
if (match(MARK_QUOTATION, c)) {
|
||||
val size = arrayQuote.size
|
||||
|
||||
// 把“xxx”、“yy”合并为“xxx_yy”进行处理
|
||||
if (size > 0) {
|
||||
val quotePre = arrayQuote[size - 1]
|
||||
if (i - quotePre == 2) {
|
||||
var remove = false
|
||||
if (waitClose) {
|
||||
if (match(",,、/", str[i - 1])) {
|
||||
// 考虑出现“和”这种特殊情况
|
||||
remove = true
|
||||
}
|
||||
} else if (match(",,、/和与或", str[i - 1])) {
|
||||
remove = true
|
||||
}
|
||||
if (remove) {
|
||||
string.setCharAt(i, '“')
|
||||
string.setCharAt(i - 2, '”')
|
||||
arrayQuote.removeAt(size - 1)
|
||||
mod[size - 1] = 1
|
||||
mod[size] = -1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
arrayQuote.add(i)
|
||||
|
||||
// 为xxx:“xxx”做标记
|
||||
if (i > 1) {
|
||||
// 当前发言的正引号的前一个字符
|
||||
val charB1 = str[i - 1]
|
||||
// 上次发言的正引号的前一个字符
|
||||
var charB2 = 0.toChar()
|
||||
if (match(MARK_QUOTATION_BEFORE, charB1)) {
|
||||
// 如果不是第一处引号,寻找上一处断句,进行分段
|
||||
if (arrayQuote.size > 1) {
|
||||
val lastQuote = arrayQuote[arrayQuote.size - 2]
|
||||
var p = 0
|
||||
if (charB1 == ',' || charB1 == ',') {
|
||||
if (arrayQuote.size > 2) {
|
||||
p = arrayQuote[arrayQuote.size - 3]
|
||||
if (p > 0) {
|
||||
charB2 = str[p - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
//if(char_b2=='.' || char_b2=='。')
|
||||
if (match(MARK_SENTENCES_END_P, charB2)) {
|
||||
insN.add(p - 1)
|
||||
} else if (!match("的", charB2)) {
|
||||
val lastEnd = seekLast(str, MARK_SENTENCES_END, i, lastQuote)
|
||||
if (lastEnd > 0) insN.add(lastEnd) else insN.add(lastQuote)
|
||||
}
|
||||
}
|
||||
waitClose = true
|
||||
mod[size] = 1
|
||||
if (size > 0) {
|
||||
mod[size - 1] = -1
|
||||
if (size > 1) {
|
||||
mod[size - 2] = 1
|
||||
}
|
||||
}
|
||||
} else if (waitClose) {
|
||||
run {
|
||||
waitClose = false
|
||||
insN.add(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val size = arrayQuote.size
|
||||
|
||||
|
||||
//标记循环状态,此位置前的引号是否已经配对
|
||||
var opend = false
|
||||
if (size > 0) {
|
||||
//第1次遍历array_quote,令其元素的值不为0
|
||||
for (i in 0 until size) {
|
||||
if (mod[i] > 0) {
|
||||
opend = true
|
||||
} else if (mod[i] < 0) {
|
||||
//连续2个反引号表明存在冲突,强制把前一个设为正引号
|
||||
if (!opend) {
|
||||
if (i > 0) mod[i] = 3
|
||||
}
|
||||
opend = false
|
||||
} else {
|
||||
opend = !opend
|
||||
if (opend) mod[i] = 2 else mod[i] = -2
|
||||
}
|
||||
}
|
||||
// 修正,断尾必须封闭引号
|
||||
if (opend) {
|
||||
if (arrayQuote[size - 1] - string.length > -3) {
|
||||
//if((match(MARK_QUOTATION,string.charAt(string.length()-1)) || match(MARK_QUOTATION,string.charAt(string.length()-2)))){
|
||||
if (size > 1) mod[size - 2] = 4
|
||||
// 0<=i<size,故无需判断size>=1
|
||||
mod[size - 1] = -4
|
||||
} else if (!match(MARK_SENTENCES_SAY, string[string.length - 2])) string.append(
|
||||
"”"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
//第2次循环,mod[i]由负变正时,前1字符如果是句末,需要插入换行
|
||||
var loop2Mod1 = -1 //上一个引号跟随内容的状态
|
||||
var loop2Mod2: Int //当前引号跟随内容的状态
|
||||
var i = 0
|
||||
var j = arrayQuote[0] - 1 //当前引号前一字符的序号
|
||||
if (j < 0) {
|
||||
i = 1
|
||||
loop2Mod1 = 0
|
||||
}
|
||||
while (i < size) {
|
||||
j = arrayQuote[i] - 1
|
||||
loop2Mod2 = mod[i]
|
||||
if (loop2Mod1 < 0 && loop2Mod2 > 0) {
|
||||
if (match(MARK_SENTENCES_END, string[j])) insN.add(j)
|
||||
}
|
||||
loop2Mod1 = loop2Mod2
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
//第3次循环,匹配并插入换行。
|
||||
//"xxxx" xxxx。\n xxx“xxxx”
|
||||
//未实现
|
||||
|
||||
// 使用字典验证ins_n , 避免插入不必要的换行。
|
||||
// 由于目前没有插入、的列表,无法解决 “xx”、“xx”“xx” 被插入换行的问题
|
||||
val insN1 = ArrayList<Int>()
|
||||
for (i in insN) {
|
||||
if (match("\"'”“", string[i])) {
|
||||
val start: Int = seekLast(
|
||||
str,
|
||||
"\"'”“",
|
||||
i - 1,
|
||||
i - WORD_MAX_LENGTH
|
||||
)
|
||||
if (start > 0) {
|
||||
val word = str.substring(start + 1, i)
|
||||
if (dict.contains(word)) {
|
||||
//System.out.println("使用字典验证 跳过\tins_n=" + i + " word=" + word);
|
||||
//引号内如果是字典词条,后方不插入换行符(前方不需要优化)
|
||||
continue
|
||||
} else {
|
||||
//System.out.println("使用字典验证 插入\tins_n=" + i + " word=" + word);
|
||||
if (match("的地得", str[start])) {
|
||||
//xx的“xx”,后方不插入换行符(前方不需要优化)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
insN1.add(i)
|
||||
}
|
||||
insN = insN1
|
||||
|
||||
// 随机在句末插入换行符
|
||||
insN = ArrayList(HashSet(insN))
|
||||
insN.sort()
|
||||
run {
|
||||
var subs: String
|
||||
var j = 0
|
||||
var progress = 0
|
||||
var nextLine = -1
|
||||
if (insN.size > 0) nextLine = insN[j]
|
||||
var gain = 3
|
||||
var min = 0
|
||||
var trigger = 2
|
||||
for (i in arrayQuote.indices) {
|
||||
val qutoe = arrayQuote[i]
|
||||
if (qutoe > 0) {
|
||||
gain = 4
|
||||
min = 2
|
||||
trigger = 4
|
||||
} else {
|
||||
gain = 3
|
||||
min = 0
|
||||
trigger = 2
|
||||
}
|
||||
|
||||
// 把引号前的换行符与内容相间插入
|
||||
while (j < insN.size) {
|
||||
|
||||
// 如果下一个换行符在当前引号前,那么需要此次处理.如果紧挨当前引号,需要考虑插入引号的情况
|
||||
if (nextLine >= qutoe) break
|
||||
nextLine = insN[j]
|
||||
if (progress < nextLine) {
|
||||
subs = string.substring(progress, nextLine)
|
||||
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
|
||||
progress = nextLine + 1
|
||||
}
|
||||
j++
|
||||
}
|
||||
if (progress < qutoe) {
|
||||
subs = string.substring(progress, qutoe + 1)
|
||||
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
|
||||
progress = qutoe + 1
|
||||
}
|
||||
}
|
||||
while (j < insN.size) {
|
||||
nextLine = insN[j]
|
||||
if (progress < nextLine) {
|
||||
subs = string.substring(progress, nextLine)
|
||||
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
|
||||
progress = nextLine + 1
|
||||
}
|
||||
j++
|
||||
}
|
||||
if (progress < string.length) {
|
||||
subs = string.substring(progress, string.length)
|
||||
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
|
||||
}
|
||||
}
|
||||
|
||||
// 根据段落状态修正引号方向、计算需要插入引号的位置
|
||||
// ins_quote跟随array_quote ins_quote[i]!=0,则array_quote.get(i)的引号前需要前插入'”'
|
||||
val insQuote = BooleanArray(size)
|
||||
opend = false
|
||||
for (i in 0 until size) {
|
||||
val p = arrayQuote[i]
|
||||
if (mod[i] > 0) {
|
||||
string.setCharAt(p, '“')
|
||||
if (opend) insQuote[i] = true
|
||||
opend = true
|
||||
} else if (mod[i] < 0) {
|
||||
string.setCharAt(p, '”')
|
||||
opend = false
|
||||
} else {
|
||||
opend = !opend
|
||||
if (opend) string.setCharAt(p, '“') else string.setCharAt(p, '”')
|
||||
}
|
||||
}
|
||||
insN = ArrayList(HashSet(insN))
|
||||
insN.sort()
|
||||
|
||||
// 完成字符串拼接(从string复制、插入引号和换行
|
||||
// ins_quote 在引号前插入一个引号。 ins_quote[i]!=0,则array_quote.get(i)的引号前需要前插入'”'
|
||||
// ins_n 插入换行。数组的值表示插入换行符的位置
|
||||
val buffer = StringBuilder((str.length * 1.15).toInt())
|
||||
var j = 0
|
||||
var progress = 0
|
||||
var nextLine = -1
|
||||
if (insN.size > 0) nextLine = insN[j]
|
||||
for (i in arrayQuote.indices) {
|
||||
val qutoe = arrayQuote[i]
|
||||
|
||||
// 把引号前的换行符与内容相间插入
|
||||
while (j < insN.size) {
|
||||
|
||||
// 如果下一个换行符在当前引号前,那么需要此次处理.如果紧挨当前引号,需要考虑插入引号的情况
|
||||
if (nextLine >= qutoe) break
|
||||
nextLine = insN[j]
|
||||
buffer.append(string, progress, nextLine + 1)
|
||||
buffer.append('\n')
|
||||
progress = nextLine + 1
|
||||
j++
|
||||
}
|
||||
if (progress < qutoe) {
|
||||
buffer.append(string, progress, qutoe + 1)
|
||||
progress = qutoe + 1
|
||||
}
|
||||
if (insQuote[i] && buffer.length > 2) {
|
||||
if (buffer[buffer.length - 1] == '\n') buffer.append('“') else buffer.insert(
|
||||
buffer.length - 1,
|
||||
"”\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
while (j < insN.size) {
|
||||
nextLine = insN[j]
|
||||
if (progress <= nextLine) {
|
||||
buffer.append(string, progress, nextLine + 1)
|
||||
buffer.append('\n')
|
||||
progress = nextLine + 1
|
||||
}
|
||||
j++
|
||||
}
|
||||
if (progress < string.length) {
|
||||
buffer.append(string, progress, string.length)
|
||||
}
|
||||
return buffer.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串提取引号包围,且不止出现一次的内容为字典
|
||||
*
|
||||
* @param str
|
||||
* @return 词条列表
|
||||
*/
|
||||
private fun makeDict(str: String): List<String> {
|
||||
|
||||
// 引号中间不包含任何标点
|
||||
val patten = Pattern.compile(
|
||||
"""
|
||||
(?<=["'”“])([^
|
||||
\p{P}]{1,$WORD_MAX_LENGTH})(?=["'”“])
|
||||
""".trimIndent()
|
||||
)
|
||||
//Pattern patten = Pattern.compile("(?<=[\"'”“])([^\n\"'”“]{1,16})(?=[\"'”“])");
|
||||
val matcher = patten.matcher(str)
|
||||
val cache: MutableList<String> = ArrayList()
|
||||
val dict: MutableList<String> = ArrayList()
|
||||
while (matcher.find()) {
|
||||
val word = matcher.group()
|
||||
if (cache.contains(word)) {
|
||||
if (!dict.contains(word)) dict.add(word)
|
||||
} else cache.add(word)
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算匹配到字典的每个字符的位置
|
||||
*
|
||||
* @param str 待匹配的字符串
|
||||
* @param key 字典
|
||||
* @param from 从字符串的第几个字符开始匹配
|
||||
* @param to 匹配到第几个字符结束
|
||||
* @param inOrder 是否按照从前向后的顺序匹配
|
||||
* @return 返回距离构成的ArrayList<Int>
|
||||
*/
|
||||
private fun seekIndexs(
|
||||
str: String,
|
||||
key: String,
|
||||
from: Int,
|
||||
to: Int,
|
||||
inOrder: Boolean
|
||||
): ArrayList<Int> {
|
||||
val list = ArrayList<Int>()
|
||||
if (str.length - from < 1) return list
|
||||
var i = 0
|
||||
if (from > i) i = from
|
||||
var t = str.length
|
||||
if (to > 0) t = min(t, to)
|
||||
var c: Char
|
||||
while (i < t) {
|
||||
c = if (inOrder) str[i] else str[str.length - i - 1]
|
||||
if (key.indexOf(c) != -1) {
|
||||
list.add(i)
|
||||
}
|
||||
i++
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字符串最后出现与字典中字符匹配的位置
|
||||
*
|
||||
* @param str 数据字符串
|
||||
* @param key 字典字符串
|
||||
* @param from 从哪个字符开始匹配,默认最末位
|
||||
* @param to 匹配到哪个字符(不包含此字符)默认0
|
||||
* @return 位置(正向计算)
|
||||
*/
|
||||
private fun seekLast(str: String, key: String, from: Int, to: Int): Int {
|
||||
if (str.length - from < 1) return -1
|
||||
var i = str.lastIndex
|
||||
if (from < i && i > 0) i = from
|
||||
var t = 0
|
||||
if (to > 0) t = to
|
||||
var c: Char
|
||||
while (i > t) {
|
||||
c = str[i]
|
||||
if (key.indexOf(c) != -1) {
|
||||
return i
|
||||
}
|
||||
i--
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字符串与字典中字符的最短距离
|
||||
*
|
||||
* @param str 数据字符串
|
||||
* @param key 字典字符串
|
||||
* @param from 从哪个字符开始匹配,默认0
|
||||
* @param to 匹配到哪个字符(不包含此字符)默认匹配到最末位
|
||||
* @param inOrder 是否从正向开始匹配
|
||||
* @return 返回最短距离, 注意不是str的char的下标
|
||||
*/
|
||||
private fun seekIndex(str: String, key: String, from: Int, to: Int, inOrder: Boolean): Int {
|
||||
if (str.length - from < 1) return -1
|
||||
var i = 0
|
||||
if (from > i) i = from
|
||||
var t = str.length
|
||||
if (to > 0) t = min(t, to)
|
||||
var c: Char
|
||||
while (i < t) {
|
||||
c = if (inOrder) str[i] else str[str.length - i - 1]
|
||||
if (key.indexOf(c) != -1) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/* 搜寻引号并进行分段。处理了一、二、五三类常见情况
|
||||
参照百科词条[引号#应用示例](https://baike.baidu.com/item/%E5%BC%95%E5%8F%B7/998963?#5)对引号内容进行矫正并分句。
|
||||
一、完整引用说话内容,在反引号内侧有断句标点。例如:
|
||||
1) 丫姑折断几枝扔下来,边叫我的小名儿边说:“先喂饱你!”
|
||||
2)“哎呀,真是美极了!”皇帝说,“我十分满意!”
|
||||
3)“怕什么!海的美就在这里!”我说道。
|
||||
二、部分引用,在反引号外侧有断句标点:
|
||||
4)适当地改善自己的生活,岂但“你管得着吗”,而且是顺乎天理,合乎人情的。
|
||||
5)现代画家徐悲鸿笔下的马,正如有的评论家所说的那样,“形神兼备,充满生机”。
|
||||
6)唐朝的张嘉贞说它“制造奇特,人不知其所为”。
|
||||
三、一段接着一段地直接引用时,中间段落只在段首用起引号,该段段尾却不用引回号。但是正统文学不在考虑范围内。
|
||||
四、引号里面又要用引号时,外面一层用双引号,里面一层用单引号。暂时不需要考虑
|
||||
五、反语和强调,周围没有断句符号。
|
||||
*/
|
||||
|
||||
// 句子结尾的标点。因为引号可能存在误判,不包含引号。
|
||||
private const val MARK_SENTENCES_END = "?。!?!~"
|
||||
private const val MARK_SENTENCES_END_P = ".?。!?!~"
|
||||
|
||||
// 句中标点,由于某些网站常把“,”写为".",故英文句点按照句中标点判断
|
||||
private const val MARK_SENTENCES_MID = ".,、,—…"
|
||||
private const val MARK_SENTENCES_SAY = "问说喊唱叫骂道着答"
|
||||
|
||||
// XXX说:“”的冒号
|
||||
private const val MARK_QUOTATION_BEFORE = ",:,:"
|
||||
|
||||
// 引号
|
||||
private const val MARK_QUOTATION = "\"“”"
|
||||
private val PARAGRAPH_DIAGLOG = "^[\"”“][^\"”“]+[\"”“]$".toRegex()
|
||||
|
||||
// 限制字典的长度
|
||||
private const val WORD_MAX_LENGTH = 16
|
||||
|
||||
private fun match(rule: String, chr: Char): Boolean {
|
||||
return rule.indexOf(chr) != -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package io.legato.kazusa.help.book
|
||||
|
||||
import android.os.Build
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.AppPattern.spaceRegex
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookChapter
|
||||
import io.legato.kazusa.data.entities.ReplaceRule
|
||||
import io.legato.kazusa.exception.RegexTimeoutException
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.config.ReadBookConfig
|
||||
import io.legato.kazusa.utils.ChineseUtils
|
||||
import io.legato.kazusa.utils.escapeRegex
|
||||
import io.legato.kazusa.utils.replace
|
||||
import io.legato.kazusa.utils.stackTraceStr
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import splitties.init.appCtx
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class ContentProcessor private constructor(
|
||||
private val bookName: String,
|
||||
private val bookOrigin: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val processors = hashMapOf<String, WeakReference<ContentProcessor>>()
|
||||
private val isAndroid8 = Build.VERSION.SDK_INT in 26..27
|
||||
|
||||
fun get(book: Book) = get(book.name, book.origin)
|
||||
|
||||
fun get(bookName: String, bookOrigin: String): ContentProcessor {
|
||||
val processorWr = processors[bookName + bookOrigin]
|
||||
var processor: ContentProcessor? = processorWr?.get()
|
||||
if (processor == null) {
|
||||
processor = ContentProcessor(bookName, bookOrigin)
|
||||
processors[bookName + bookOrigin] = WeakReference(processor)
|
||||
}
|
||||
return processor
|
||||
}
|
||||
|
||||
fun upReplaceRules() {
|
||||
processors.forEach {
|
||||
it.value.get()?.upReplaceRules()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val titleReplaceRules = CopyOnWriteArrayList<ReplaceRule>()
|
||||
private val contentReplaceRules = CopyOnWriteArrayList<ReplaceRule>()
|
||||
val removeSameTitleCache = hashSetOf<String>()
|
||||
|
||||
init {
|
||||
upReplaceRules()
|
||||
upRemoveSameTitle()
|
||||
}
|
||||
|
||||
fun upReplaceRules() {
|
||||
titleReplaceRules.run {
|
||||
clear()
|
||||
addAll(appDb.replaceRuleDao.findEnabledByTitleScope(bookName, bookOrigin))
|
||||
}
|
||||
contentReplaceRules.run {
|
||||
clear()
|
||||
addAll(appDb.replaceRuleDao.findEnabledByContentScope(bookName, bookOrigin))
|
||||
}
|
||||
}
|
||||
|
||||
private fun upRemoveSameTitle() {
|
||||
val book = appDb.bookDao.getBookByOrigin(bookName, bookOrigin) ?: return
|
||||
removeSameTitleCache.clear()
|
||||
val files = BookHelp.getChapterFiles(book).filter {
|
||||
it.endsWith("nr")
|
||||
}
|
||||
removeSameTitleCache.addAll(files)
|
||||
}
|
||||
|
||||
fun getTitleReplaceRules(): List<ReplaceRule> {
|
||||
return titleReplaceRules
|
||||
}
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
fun getContentReplaceRules(): List<ReplaceRule> {
|
||||
return contentReplaceRules
|
||||
}
|
||||
|
||||
fun getContent(
|
||||
book: Book,
|
||||
chapter: BookChapter,
|
||||
content: String,
|
||||
includeTitle: Boolean = true,
|
||||
useReplace: Boolean = true,
|
||||
chineseConvert: Boolean = true,
|
||||
reSegment: Boolean = true
|
||||
): BookContent {
|
||||
var mContent = content
|
||||
var sameTitleRemoved = false
|
||||
var effectiveReplaceRules: ArrayList<ReplaceRule>? = null
|
||||
if (content != "null") {
|
||||
//去除重复标题
|
||||
val fileName = chapter.getFileName("nr")
|
||||
if (!removeSameTitleCache.contains(fileName)) try {
|
||||
val name = Pattern.quote(book.name)
|
||||
var title = chapter.title.escapeRegex().replace(spaceRegex, "\\\\s*")
|
||||
var matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*")
|
||||
.matcher(mContent)
|
||||
if (matcher.find()) {
|
||||
mContent = mContent.substring(matcher.end())
|
||||
sameTitleRemoved = true
|
||||
} else if (useReplace && book.getUseReplaceRule()) {
|
||||
title = Pattern.quote(
|
||||
chapter.getDisplayTitle(
|
||||
contentReplaceRules,
|
||||
chineseConvert = false
|
||||
)
|
||||
)
|
||||
matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*")
|
||||
.matcher(mContent)
|
||||
if (matcher.find()) {
|
||||
mContent = mContent.substring(matcher.end())
|
||||
sameTitleRemoved = true
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("去除重复标题出错\n${e.localizedMessage}", e)
|
||||
}
|
||||
if (reSegment && book.getReSegment()) {
|
||||
//重新分段
|
||||
mContent = ContentHelp.reSegment(mContent, chapter.title)
|
||||
}
|
||||
if (chineseConvert) {
|
||||
//简繁转换
|
||||
try {
|
||||
when (AppConfig.chineseConverterType) {
|
||||
1 -> mContent = ChineseUtils.t2s(mContent)
|
||||
2 -> mContent = ChineseUtils.s2t(mContent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
appCtx.toastOnUi("简繁转换出错")
|
||||
}
|
||||
}
|
||||
if (useReplace && book.getUseReplaceRule()) {
|
||||
//替换
|
||||
effectiveReplaceRules = arrayListOf()
|
||||
mContent = mContent.lines().joinToString("\n") { it.trim() }
|
||||
getContentReplaceRules().forEach { item ->
|
||||
if (item.pattern.isEmpty()) {
|
||||
return@forEach
|
||||
}
|
||||
try {
|
||||
val tmp = if (item.isRegex) {
|
||||
mContent.replace(
|
||||
item.regex,
|
||||
item.replacement,
|
||||
item.getValidTimeoutMillisecond()
|
||||
)
|
||||
} else {
|
||||
mContent.replace(item.pattern, item.replacement)
|
||||
}
|
||||
if (mContent != tmp) {
|
||||
effectiveReplaceRules.add(item)
|
||||
mContent = tmp
|
||||
}
|
||||
} catch (e: RegexTimeoutException) {
|
||||
item.isEnabled = false
|
||||
appDb.replaceRuleDao.update(item)
|
||||
mContent = item.name + e.stackTraceStr
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("替换净化: 规则 ${item.name}替换出错.\n${mContent}", e)
|
||||
appCtx.toastOnUi("替换净化: 规则 ${item.name}替换出错")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (includeTitle) {
|
||||
//重新添加标题
|
||||
mContent = chapter.getDisplayTitle(
|
||||
getTitleReplaceRules(),
|
||||
useReplace = useReplace && book.getUseReplaceRule()
|
||||
) + "\n" + mContent
|
||||
}
|
||||
if (isAndroid8) {
|
||||
mContent = mContent.replace('\u00A0', ' ')
|
||||
}
|
||||
val contents = arrayListOf<String>()
|
||||
mContent.split("\n").forEach { str ->
|
||||
val paragraph = str.trim {
|
||||
it.code <= 0x20 || it == ' '
|
||||
}
|
||||
if (paragraph.isNotEmpty()) {
|
||||
if (contents.isEmpty() && includeTitle) {
|
||||
contents.add(paragraph)
|
||||
} else {
|
||||
contents.add("${ReadBookConfig.paragraphIndent}$paragraph")
|
||||
}
|
||||
}
|
||||
}
|
||||
return BookContent(sameTitleRemoved, contents, effectiveReplaceRules)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
package io.legato.kazusa.help.config
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import io.legato.kazusa.BuildConfig
|
||||
import io.legato.kazusa.constant.PreferKey
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.utils.canvasrecorder.CanvasRecorderFactory
|
||||
import io.legato.kazusa.utils.getPrefBoolean
|
||||
import io.legato.kazusa.utils.getPrefInt
|
||||
import io.legato.kazusa.utils.getPrefLong
|
||||
import io.legato.kazusa.utils.getPrefString
|
||||
import io.legato.kazusa.utils.isNightMode
|
||||
import io.legato.kazusa.utils.putPrefBoolean
|
||||
import io.legato.kazusa.utils.putPrefInt
|
||||
import io.legato.kazusa.utils.putPrefLong
|
||||
import io.legato.kazusa.utils.putPrefString
|
||||
import io.legato.kazusa.utils.removePref
|
||||
import io.legato.kazusa.utils.sysConfiguration
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import splitties.init.appCtx
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate", "ConstPropertyName")
|
||||
object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
val isCronet = appCtx.getPrefBoolean(PreferKey.cronet)
|
||||
var useAntiAlias = appCtx.getPrefBoolean(PreferKey.antiAlias)
|
||||
var userAgent: String = getPrefUserAgent()
|
||||
var isEInkMode = appCtx.getPrefString(PreferKey.themeMode) == "3"
|
||||
var clickActionTL = appCtx.getPrefInt(PreferKey.clickActionTL, 2)
|
||||
var clickActionTC = appCtx.getPrefInt(PreferKey.clickActionTC, 2)
|
||||
var clickActionTR = appCtx.getPrefInt(PreferKey.clickActionTR, 1)
|
||||
var clickActionML = appCtx.getPrefInt(PreferKey.clickActionML, 2)
|
||||
var clickActionMC = appCtx.getPrefInt(PreferKey.clickActionMC, 0)
|
||||
var clickActionMR = appCtx.getPrefInt(PreferKey.clickActionMR, 1)
|
||||
var clickActionBL = appCtx.getPrefInt(PreferKey.clickActionBL, 2)
|
||||
var clickActionBC = appCtx.getPrefInt(PreferKey.clickActionBC, 1)
|
||||
var clickActionBR = appCtx.getPrefInt(PreferKey.clickActionBR, 1)
|
||||
var themeMode = appCtx.getPrefString(PreferKey.themeMode, "0")
|
||||
var useDefaultCover = appCtx.getPrefBoolean(PreferKey.useDefaultCover, false)
|
||||
var optimizeRender = CanvasRecorderFactory.isSupport
|
||||
&& appCtx.getPrefBoolean(PreferKey.optimizeRender, false)
|
||||
var recordLog = appCtx.getPrefBoolean(PreferKey.recordLog)
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
PreferKey.themeMode -> {
|
||||
themeMode = appCtx.getPrefString(PreferKey.themeMode, "0")
|
||||
isEInkMode = themeMode == "3"
|
||||
}
|
||||
|
||||
PreferKey.clickActionTL -> clickActionTL =
|
||||
appCtx.getPrefInt(PreferKey.clickActionTL, 2)
|
||||
|
||||
PreferKey.clickActionTC -> clickActionTC =
|
||||
appCtx.getPrefInt(PreferKey.clickActionTC, 2)
|
||||
|
||||
PreferKey.clickActionTR -> clickActionTR =
|
||||
appCtx.getPrefInt(PreferKey.clickActionTR, 1)
|
||||
|
||||
PreferKey.clickActionML -> clickActionML =
|
||||
appCtx.getPrefInt(PreferKey.clickActionML, 2)
|
||||
|
||||
PreferKey.clickActionMC -> clickActionMC =
|
||||
appCtx.getPrefInt(PreferKey.clickActionMC, 0)
|
||||
|
||||
PreferKey.clickActionMR -> clickActionMR =
|
||||
appCtx.getPrefInt(PreferKey.clickActionMR, 1)
|
||||
|
||||
PreferKey.clickActionBL -> clickActionBL =
|
||||
appCtx.getPrefInt(PreferKey.clickActionBL, 2)
|
||||
|
||||
PreferKey.clickActionBC -> clickActionBC =
|
||||
appCtx.getPrefInt(PreferKey.clickActionBC, 1)
|
||||
|
||||
PreferKey.clickActionBR -> clickActionBR =
|
||||
appCtx.getPrefInt(PreferKey.clickActionBR, 1)
|
||||
|
||||
PreferKey.readBodyToLh -> ReadBookConfig.readBodyToLh =
|
||||
appCtx.getPrefBoolean(PreferKey.readBodyToLh, true)
|
||||
|
||||
PreferKey.useZhLayout -> ReadBookConfig.useZhLayout =
|
||||
appCtx.getPrefBoolean(PreferKey.useZhLayout)
|
||||
|
||||
PreferKey.userAgent -> userAgent = getPrefUserAgent()
|
||||
|
||||
PreferKey.antiAlias -> useAntiAlias = appCtx.getPrefBoolean(PreferKey.antiAlias)
|
||||
|
||||
PreferKey.useDefaultCover -> useDefaultCover =
|
||||
appCtx.getPrefBoolean(PreferKey.useDefaultCover, false)
|
||||
|
||||
PreferKey.optimizeRender -> optimizeRender = CanvasRecorderFactory.isSupport
|
||||
&& appCtx.getPrefBoolean(PreferKey.optimizeRender, false)
|
||||
|
||||
PreferKey.recordLog -> recordLog = appCtx.getPrefBoolean(PreferKey.recordLog)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
var isNightTheme: Boolean
|
||||
get() = when (themeMode) {
|
||||
"1" -> false
|
||||
"2" -> true
|
||||
"3" -> false
|
||||
else -> sysConfiguration.isNightMode
|
||||
}
|
||||
set(value) {
|
||||
if (isNightTheme != value) {
|
||||
if (value) {
|
||||
appCtx.putPrefString(PreferKey.themeMode, "2")
|
||||
} else {
|
||||
appCtx.putPrefString(PreferKey.themeMode, "1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var showUnread: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.showUnread, true)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.showUnread, value)
|
||||
}
|
||||
|
||||
var showLastUpdateTime: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.showLastUpdateTime, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.showLastUpdateTime, value)
|
||||
}
|
||||
|
||||
var showWaitUpCount: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.showWaitUpCount, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.showWaitUpCount, value)
|
||||
}
|
||||
|
||||
var readBrightness: Int
|
||||
get() = if (isNightTheme) {
|
||||
appCtx.getPrefInt(PreferKey.nightBrightness, 100)
|
||||
} else {
|
||||
appCtx.getPrefInt(PreferKey.brightness, 100)
|
||||
}
|
||||
set(value) {
|
||||
if (isNightTheme) {
|
||||
appCtx.putPrefInt(PreferKey.nightBrightness, value)
|
||||
} else {
|
||||
appCtx.putPrefInt(PreferKey.brightness, value)
|
||||
}
|
||||
}
|
||||
|
||||
val textSelectAble: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.textSelectAble, true)
|
||||
|
||||
// val isTransparentStatusBar: Boolean
|
||||
// get() = appCtx.getPrefBoolean(PreferKey.transparentStatusBar, true)
|
||||
//
|
||||
// val immNavigationBar: Boolean
|
||||
// get() = appCtx.getPrefBoolean(PreferKey.immNavigationBar, true)
|
||||
|
||||
val screenOrientation: String?
|
||||
get() = appCtx.getPrefString(PreferKey.screenOrientation)
|
||||
|
||||
var bookGroupStyle: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.bookGroupStyle, 0)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.bookGroupStyle, value)
|
||||
}
|
||||
|
||||
var bookshelfLayout: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.bookshelfLayout, 0)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.bookshelfLayout, value)
|
||||
}
|
||||
|
||||
var saveTabPosition: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.saveTabPosition, 0)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.saveTabPosition, value)
|
||||
}
|
||||
|
||||
var bookExportFileName: String?
|
||||
get() = appCtx.getPrefString(PreferKey.bookExportFileName)
|
||||
set(value) {
|
||||
appCtx.putPrefString(PreferKey.bookExportFileName, value)
|
||||
}
|
||||
|
||||
// 保存 自定义导出章节模式 文件名js表达式
|
||||
var episodeExportFileName: String?
|
||||
get() = appCtx.getPrefString(PreferKey.episodeExportFileName, "")
|
||||
set(value) {
|
||||
appCtx.putPrefString(PreferKey.episodeExportFileName, value)
|
||||
}
|
||||
|
||||
var bookImportFileName: String?
|
||||
get() = appCtx.getPrefString(PreferKey.bookImportFileName)
|
||||
set(value) {
|
||||
appCtx.putPrefString(PreferKey.bookImportFileName, value)
|
||||
}
|
||||
|
||||
var backupPath: String?
|
||||
get() = appCtx.getPrefString(PreferKey.backupPath)
|
||||
set(value) {
|
||||
if (value.isNullOrEmpty()) {
|
||||
appCtx.removePref(PreferKey.backupPath)
|
||||
} else {
|
||||
appCtx.putPrefString(PreferKey.backupPath, value)
|
||||
}
|
||||
}
|
||||
|
||||
// 书籍保存位置
|
||||
var defaultBookTreeUri: String?
|
||||
get() = appCtx.getPrefString(PreferKey.defaultBookTreeUri)
|
||||
set(value) {
|
||||
if (value.isNullOrEmpty()) {
|
||||
appCtx.removePref(PreferKey.defaultBookTreeUri)
|
||||
} else {
|
||||
appCtx.putPrefString(PreferKey.defaultBookTreeUri, value)
|
||||
}
|
||||
}
|
||||
|
||||
val showDiscovery: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.showDiscovery, true)
|
||||
|
||||
val showRSS: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.showRss, true)
|
||||
|
||||
val autoRefreshBook: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.autoRefresh)
|
||||
|
||||
var enableReview: Boolean
|
||||
get() = BuildConfig.DEBUG && appCtx.getPrefBoolean(PreferKey.enableReview, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.enableReview, value)
|
||||
}
|
||||
|
||||
var threadCount: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.threadCount, 16)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.threadCount, value)
|
||||
}
|
||||
|
||||
var remoteServerId: Long
|
||||
get() = appCtx.getPrefLong(PreferKey.remoteServerId)
|
||||
set(value) {
|
||||
appCtx.putPrefLong(PreferKey.remoteServerId, value)
|
||||
}
|
||||
|
||||
// 添加本地选择的目录
|
||||
var importBookPath: String?
|
||||
get() = appCtx.getPrefString("importBookPath")
|
||||
set(value) {
|
||||
if (value == null) {
|
||||
appCtx.removePref("importBookPath")
|
||||
} else {
|
||||
appCtx.putPrefString("importBookPath", value)
|
||||
}
|
||||
}
|
||||
|
||||
var ttsFlowSys: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.ttsFollowSys, true)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.ttsFollowSys, value)
|
||||
}
|
||||
|
||||
val noAnimScrollPage: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.noAnimScrollPage, false)
|
||||
|
||||
const val defaultSpeechRate = 5
|
||||
|
||||
var ttsSpeechRate: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.ttsSpeechRate, defaultSpeechRate)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.ttsSpeechRate, value)
|
||||
}
|
||||
|
||||
var ttsTimer: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.ttsTimer, 0)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.ttsTimer, value)
|
||||
}
|
||||
|
||||
val speechRatePlay: Int get() = if (ttsFlowSys) defaultSpeechRate else ttsSpeechRate
|
||||
|
||||
var chineseConverterType: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.chineseConverterType)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.chineseConverterType, value)
|
||||
}
|
||||
|
||||
var systemTypefaces: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.systemTypefaces)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.systemTypefaces, value)
|
||||
}
|
||||
|
||||
// var elevation: Int
|
||||
// get() = if (isEInkMode) 0 else appCtx.getPrefInt(
|
||||
// PreferKey.barElevation,
|
||||
// AppConst.sysElevation
|
||||
// )
|
||||
// set(value) {
|
||||
// appCtx.putPrefInt(PreferKey.barElevation, value)
|
||||
// }
|
||||
|
||||
var readUrlInBrowser: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.readUrlOpenInBrowser)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.readUrlOpenInBrowser, value)
|
||||
}
|
||||
|
||||
var exportCharset: String
|
||||
get() {
|
||||
val c = appCtx.getPrefString(PreferKey.exportCharset)
|
||||
if (c.isNullOrBlank()) {
|
||||
return "UTF-8"
|
||||
}
|
||||
return c
|
||||
}
|
||||
set(value) {
|
||||
appCtx.putPrefString(PreferKey.exportCharset, value)
|
||||
}
|
||||
|
||||
var exportUseReplace: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.exportUseReplace, true)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.exportUseReplace, value)
|
||||
}
|
||||
|
||||
var exportToWebDav: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.exportToWebDav)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.exportToWebDav, value)
|
||||
}
|
||||
var exportNoChapterName: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.exportNoChapterName)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.exportNoChapterName, value)
|
||||
}
|
||||
|
||||
// 是否启用自定义导出 default->false
|
||||
var enableCustomExport: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.enableCustomExport, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.enableCustomExport, value)
|
||||
}
|
||||
|
||||
var exportType: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.exportType)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.exportType, value)
|
||||
}
|
||||
var exportPictureFile: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.exportPictureFile, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.exportPictureFile, value)
|
||||
}
|
||||
|
||||
var parallelExportBook: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.parallelExportBook, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.parallelExportBook, value)
|
||||
}
|
||||
|
||||
var changeSourceCheckAuthor: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.changeSourceCheckAuthor)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.changeSourceCheckAuthor, value)
|
||||
}
|
||||
|
||||
var ttsEngine: String?
|
||||
get() = appCtx.getPrefString(PreferKey.ttsEngine)
|
||||
set(value) {
|
||||
appCtx.putPrefString(PreferKey.ttsEngine, value)
|
||||
}
|
||||
|
||||
var webPort: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.webPort, 1122)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.webPort, value)
|
||||
}
|
||||
|
||||
var tocUiUseReplace: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.tocUiUseReplace)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.tocUiUseReplace, value)
|
||||
}
|
||||
|
||||
var tocCountWords: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.tocCountWords, true)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.tocCountWords, value)
|
||||
}
|
||||
|
||||
var enableReadRecord: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.enableReadRecord, true)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.enableReadRecord, value)
|
||||
}
|
||||
|
||||
val autoChangeSource: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.autoChangeSource, true)
|
||||
|
||||
var changeSourceLoadInfo: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadInfo)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.changeSourceLoadInfo, value)
|
||||
}
|
||||
|
||||
var changeSourceLoadToc: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadToc)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.changeSourceLoadToc, value)
|
||||
}
|
||||
|
||||
var changeSourceLoadWordCount: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadWordCount)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.changeSourceLoadWordCount, value)
|
||||
}
|
||||
|
||||
var openBookInfoByClickTitle: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.openBookInfoByClickTitle, true)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.openBookInfoByClickTitle, value)
|
||||
}
|
||||
|
||||
var showBookshelfFastScroller: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.showBookshelfFastScroller, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.showBookshelfFastScroller, value)
|
||||
}
|
||||
|
||||
var contentSelectSpeakMod: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.contentSelectSpeakMod)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.contentSelectSpeakMod, value)
|
||||
}
|
||||
|
||||
var batchChangeSourceDelay: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.batchChangeSourceDelay)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.batchChangeSourceDelay, value)
|
||||
}
|
||||
|
||||
val importKeepName get() = appCtx.getPrefBoolean(PreferKey.importKeepName)
|
||||
val importKeepGroup get() = appCtx.getPrefBoolean(PreferKey.importKeepGroup)
|
||||
var importKeepEnable: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.importKeepEnable, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.importKeepEnable, value)
|
||||
}
|
||||
|
||||
var previewImageByClick: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.previewImageByClick, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.previewImageByClick, value)
|
||||
}
|
||||
|
||||
var preDownloadNum
|
||||
get() = appCtx.getPrefInt(PreferKey.preDownloadNum, 10)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.preDownloadNum, value)
|
||||
}
|
||||
|
||||
val syncBookProgress get() = appCtx.getPrefBoolean(PreferKey.syncBookProgress, true)
|
||||
|
||||
val syncBookProgressPlus get() = appCtx.getPrefBoolean(PreferKey.syncBookProgressPlus, false)
|
||||
|
||||
val mediaButtonOnExit get() = appCtx.getPrefBoolean("mediaButtonOnExit", true)
|
||||
|
||||
val readAloudByMediaButton
|
||||
get() = appCtx.getPrefBoolean(PreferKey.readAloudByMediaButton, false)
|
||||
|
||||
val replaceEnableDefault get() = appCtx.getPrefBoolean(PreferKey.replaceEnableDefault, true)
|
||||
|
||||
val webDavDir get() = appCtx.getPrefString(PreferKey.webDavDir, "legado")
|
||||
|
||||
val webDavDeviceName get() = appCtx.getPrefString(PreferKey.webDavDeviceName, Build.MODEL)
|
||||
|
||||
val recordHeapDump get() = appCtx.getPrefBoolean(PreferKey.recordHeapDump, false)
|
||||
|
||||
val loadCoverOnlyWifi get() = appCtx.getPrefBoolean(PreferKey.loadCoverOnlyWifi, false)
|
||||
|
||||
val showAddToShelfAlert get() = appCtx.getPrefBoolean(PreferKey.showAddToShelfAlert, true)
|
||||
|
||||
val ignoreAudioFocus get() = appCtx.getPrefBoolean(PreferKey.ignoreAudioFocus, false)
|
||||
|
||||
var pauseReadAloudWhilePhoneCalls
|
||||
get() = appCtx.getPrefBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, false)
|
||||
set(value) = appCtx.putPrefBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, value)
|
||||
|
||||
val onlyLatestBackup get() = appCtx.getPrefBoolean(PreferKey.onlyLatestBackup, true)
|
||||
|
||||
val defaultHomePage get() = appCtx.getPrefString(PreferKey.defaultHomePage, "bookshelf")
|
||||
|
||||
val updateToVariant get() = appCtx.getPrefString(PreferKey.updateToVariant, "default_version")
|
||||
|
||||
val streamReadAloudAudio get() = appCtx.getPrefBoolean(PreferKey.streamReadAloudAudio, false)
|
||||
|
||||
val doublePageHorizontal: String?
|
||||
get() = appCtx.getPrefString(PreferKey.doublePageHorizontal)
|
||||
|
||||
val progressBarBehavior: String?
|
||||
get() = appCtx.getPrefString(PreferKey.progressBarBehavior, "page")
|
||||
|
||||
val keyPageOnLongPress
|
||||
get() = appCtx.getPrefBoolean(PreferKey.keyPageOnLongPress, false)
|
||||
|
||||
val volumeKeyPage
|
||||
get() = appCtx.getPrefBoolean(PreferKey.volumeKeyPage, true)
|
||||
|
||||
val volumeKeyPageOnPlay
|
||||
get() = appCtx.getPrefBoolean(PreferKey.volumeKeyPageOnPlay, true)
|
||||
|
||||
val mouseWheelPage
|
||||
get() = appCtx.getPrefBoolean(PreferKey.mouseWheelPage, true)
|
||||
|
||||
var searchScope: String
|
||||
get() = appCtx.getPrefString("searchScope") ?: ""
|
||||
set(value) {
|
||||
appCtx.putPrefString("searchScope", value)
|
||||
}
|
||||
|
||||
var searchGroup: String
|
||||
get() = appCtx.getPrefString("searchGroup") ?: ""
|
||||
set(value) {
|
||||
appCtx.putPrefString("searchGroup", value)
|
||||
}
|
||||
|
||||
var pageTouchSlop: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.pageTouchSlop, 0)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.pageTouchSlop, value)
|
||||
}
|
||||
|
||||
var bookshelfSort: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.bookshelfSort, 0)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.bookshelfSort, value)
|
||||
}
|
||||
|
||||
fun getBookSortByGroupId(groupId: Long): Int {
|
||||
return appDb.bookGroupDao.getByID(groupId)?.getRealBookSort()
|
||||
?: bookshelfSort
|
||||
}
|
||||
|
||||
private fun getPrefUserAgent(): String {
|
||||
val ua = appCtx.getPrefString(PreferKey.userAgent)
|
||||
if (ua.isNullOrBlank()) {
|
||||
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + BuildConfig.Cronet_Main_Version + " Safari/537.36"
|
||||
}
|
||||
return ua
|
||||
}
|
||||
|
||||
var bitmapCacheSize: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.bitmapCacheSize, 50)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.bitmapCacheSize, value)
|
||||
}
|
||||
|
||||
var imageRetainNum: Int
|
||||
get() = appCtx.getPrefInt(PreferKey.imageRetainNum, 0)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.imageRetainNum, value)
|
||||
}
|
||||
|
||||
var showReadTitleBarAddition: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.showReadTitleAddition, true)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.showReadTitleAddition, value)
|
||||
}
|
||||
var readBarStyleFollowPage: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.readBarStyleFollowPage, value)
|
||||
}
|
||||
|
||||
var sourceEditMaxLine: Int
|
||||
get() {
|
||||
val maxLine = appCtx.getPrefInt(PreferKey.sourceEditMaxLine, Int.MAX_VALUE)
|
||||
if (maxLine < 10) {
|
||||
return Int.MAX_VALUE
|
||||
}
|
||||
return maxLine
|
||||
}
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.sourceEditMaxLine, value)
|
||||
}
|
||||
|
||||
var audioPlayUseWakeLock: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.audioPlayWakeLock)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.audioPlayWakeLock, value)
|
||||
}
|
||||
|
||||
var brightnessVwPos: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.brightnessVwPos)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.brightnessVwPos, value)
|
||||
}
|
||||
|
||||
fun detectClickArea() {
|
||||
if (clickActionTL * clickActionTC * clickActionTR
|
||||
* clickActionML * clickActionMC * clickActionMR
|
||||
* clickActionBL * clickActionBC * clickActionBR != 0
|
||||
) {
|
||||
appCtx.putPrefInt(PreferKey.clickActionMC, 0)
|
||||
appCtx.toastOnUi("当前没有配置菜单区域,自动恢复中间区域为菜单.")
|
||||
}
|
||||
}
|
||||
|
||||
//跳转到漫画界面不使用富文本模式
|
||||
val showMangaUi: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.showMangaUi, true)
|
||||
|
||||
//禁用漫画缩放
|
||||
var disableMangaScale: Boolean
|
||||
get() = appCtx.getPrefBoolean(PreferKey.disableMangaScale, true)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.disableMangaScale, value)
|
||||
}
|
||||
|
||||
//漫画预加载数量
|
||||
var mangaPreDownloadNum
|
||||
get() = appCtx.getPrefInt(PreferKey.mangaPreDownloadNum, 10)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.mangaPreDownloadNum, value)
|
||||
}
|
||||
|
||||
//点击翻页
|
||||
var disableClickScroll
|
||||
get() = appCtx.getPrefBoolean(PreferKey.disableClickScroll, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.disableClickScroll, value)
|
||||
}
|
||||
|
||||
//漫画滚动速度
|
||||
var mangaAutoPageSpeed
|
||||
get() = appCtx.getPrefInt(PreferKey.mangaAutoPageSpeed, 3)
|
||||
set(value) {
|
||||
appCtx.putPrefInt(PreferKey.mangaAutoPageSpeed, value)
|
||||
}
|
||||
|
||||
//漫画页脚配置
|
||||
var mangaFooterConfig
|
||||
get() = appCtx.getPrefString(PreferKey.mangaFooterConfig, "")
|
||||
set(value) {
|
||||
appCtx.putPrefString(PreferKey.mangaFooterConfig, value)
|
||||
}
|
||||
|
||||
//漫画水平滚动
|
||||
var enableMangaHorizontalScroll
|
||||
get() = appCtx.getPrefBoolean(PreferKey.enableMangaHorizontalScroll, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.enableMangaHorizontalScroll, value)
|
||||
}
|
||||
|
||||
var mangaColorFilter
|
||||
get() = appCtx.getPrefString(PreferKey.mangaColorFilter, "")
|
||||
set(value) {
|
||||
appCtx.putPrefString(PreferKey.mangaColorFilter, value)
|
||||
}
|
||||
|
||||
//禁用漫画内标题
|
||||
var hideMangaTitle
|
||||
get() = appCtx.getPrefBoolean(PreferKey.hideMangaTitle, false)
|
||||
set(value) {
|
||||
appCtx.putPrefBoolean(PreferKey.hideMangaTitle, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package io.legato.kazusa.help.config
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import io.legato.kazusa.utils.getBoolean
|
||||
import io.legato.kazusa.utils.putBoolean
|
||||
import io.legato.kazusa.utils.putLong
|
||||
import io.legato.kazusa.utils.putString
|
||||
import io.legato.kazusa.utils.remove
|
||||
import splitties.init.appCtx
|
||||
|
||||
@Suppress("ConstPropertyName")
|
||||
object LocalConfig : SharedPreferences
|
||||
by appCtx.getSharedPreferences("local", Context.MODE_PRIVATE) {
|
||||
|
||||
private const val versionCodeKey = "appVersionCode"
|
||||
|
||||
/**
|
||||
* 本地密码,用来对需要备份的敏感信息加密,如 webdav 配置等
|
||||
*/
|
||||
var password: String?
|
||||
get() = getString("password", null)
|
||||
set(value) {
|
||||
if (value != null) {
|
||||
putString("password", value)
|
||||
} else {
|
||||
remove("password")
|
||||
}
|
||||
}
|
||||
|
||||
var lastBackup: Long
|
||||
get() = getLong("lastBackup", 0)
|
||||
set(value) {
|
||||
putLong("lastBackup", value)
|
||||
}
|
||||
|
||||
var privacyPolicyOk: Boolean
|
||||
get() = getBoolean("privacyPolicyOk")
|
||||
set(value) {
|
||||
putBoolean("privacyPolicyOk", value)
|
||||
}
|
||||
|
||||
val readHelpVersionIsLast: Boolean
|
||||
get() = isLastVersion(1, "readHelpVersion", "firstRead")
|
||||
|
||||
val backupHelpVersionIsLast: Boolean
|
||||
get() = isLastVersion(1, "backupHelpVersion", "firstBackup")
|
||||
|
||||
val readMenuHelpVersionIsLast: Boolean
|
||||
get() = isLastVersion(1, "readMenuHelpVersion", "firstReadMenu")
|
||||
|
||||
val bookSourcesHelpVersionIsLast: Boolean
|
||||
get() = isLastVersion(1, "bookSourceHelpVersion", "firstOpenBookSources")
|
||||
|
||||
val webDavBookHelpVersionIsLast: Boolean
|
||||
get() = isLastVersion(1, "webDavBookHelpVersion", "firstOpenWebDavBook")
|
||||
|
||||
val ruleHelpVersionIsLast: Boolean
|
||||
get() = isLastVersion(1, "ruleHelpVersion")
|
||||
|
||||
val needUpHttpTTS: Boolean
|
||||
get() = !isLastVersion(6, "httpTtsVersion")
|
||||
|
||||
val needUpTxtTocRule: Boolean
|
||||
get() = !isLastVersion(3, "txtTocRuleVersion")
|
||||
|
||||
val needUpRssSources: Boolean
|
||||
get() = !isLastVersion(6, "rssSourceVersion")
|
||||
|
||||
val needUpDictRule: Boolean
|
||||
get() = !isLastVersion(2, "needUpDictRule")
|
||||
|
||||
var versionCode
|
||||
get() = getLong(versionCodeKey, 0)
|
||||
set(value) {
|
||||
edit { putLong(versionCodeKey, value) }
|
||||
}
|
||||
|
||||
val isFirstOpenApp: Boolean
|
||||
get() {
|
||||
val value = getBoolean("firstOpen", true)
|
||||
if (value) {
|
||||
edit { putBoolean("firstOpen", false) }
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun isLastVersion(
|
||||
lastVersion: Int,
|
||||
versionKey: String,
|
||||
firstOpenKey: String? = null
|
||||
): Boolean {
|
||||
var version = getInt(versionKey, 0)
|
||||
if (version == 0 && firstOpenKey != null) {
|
||||
if (!getBoolean(firstOpenKey, true)) {
|
||||
version = 1
|
||||
}
|
||||
}
|
||||
if (version < lastVersion) {
|
||||
edit { putInt(versionKey, lastVersion) }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var bookInfoDeleteAlert: Boolean
|
||||
get() = getBoolean("bookInfoDeleteAlert", true)
|
||||
set(value) {
|
||||
putBoolean("bookInfoDeleteAlert", value)
|
||||
}
|
||||
|
||||
var deleteBookOriginal: Boolean
|
||||
get() = getBoolean("deleteBookOriginal")
|
||||
set(value) {
|
||||
putBoolean("deleteBookOriginal", value)
|
||||
}
|
||||
|
||||
var appCrash: Boolean
|
||||
get() = getBoolean("appCrash")
|
||||
set(value) {
|
||||
putBoolean("appCrash", value)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
package io.legato.kazusa.help.config
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.PageAnim
|
||||
import io.legato.kazusa.constant.PreferKey
|
||||
import io.legato.kazusa.help.DefaultData
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.utils.BitmapUtils
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.compress.ZipUtils
|
||||
import io.legato.kazusa.utils.createFolderReplace
|
||||
import io.legato.kazusa.utils.externalCache
|
||||
import io.legato.kazusa.utils.externalFiles
|
||||
import io.legato.kazusa.utils.fromJsonArray
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import io.legato.kazusa.utils.getFile
|
||||
import io.legato.kazusa.utils.getMeanColor
|
||||
import io.legato.kazusa.utils.getPrefBoolean
|
||||
import io.legato.kazusa.utils.getPrefInt
|
||||
import io.legato.kazusa.utils.hexString
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import io.legato.kazusa.utils.putPrefBoolean
|
||||
import io.legato.kazusa.utils.putPrefInt
|
||||
import io.legato.kazusa.utils.resizeAndRecycle
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.withContext
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 阅读界面配置
|
||||
*/
|
||||
@Suppress("ConstPropertyName")
|
||||
@Keep
|
||||
object ReadBookConfig {
|
||||
const val configFileName = "readConfig.json"
|
||||
const val shareConfigFileName = "shareReadConfig.json"
|
||||
val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName)
|
||||
val shareConfigFilePath = FileUtils.getPath(appCtx.filesDir, shareConfigFileName)
|
||||
val configList: ArrayList<Config> = arrayListOf()
|
||||
lateinit var shareConfig: Config
|
||||
var durConfig
|
||||
get() = getConfig(styleSelect)
|
||||
set(value) {
|
||||
configList[styleSelect] = value
|
||||
if (shareLayout) {
|
||||
shareConfig = value
|
||||
}
|
||||
}
|
||||
|
||||
var isComic: Boolean = false
|
||||
var bg: Drawable? = null
|
||||
var bgMeanColor: Int = 0
|
||||
val textColor: Int get() = durConfig.curTextColor()
|
||||
|
||||
init {
|
||||
initConfigs()
|
||||
initShareConfig()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getConfig(index: Int): Config {
|
||||
if (configList.size < 5) {
|
||||
resetAll()
|
||||
}
|
||||
return configList.getOrNull(index) ?: configList[0]
|
||||
}
|
||||
|
||||
fun initConfigs() {
|
||||
val configFile = File(configFilePath)
|
||||
var configs: List<Config>? = null
|
||||
if (configFile.exists()) {
|
||||
try {
|
||||
val json = configFile.readText()
|
||||
configs = GSON.fromJsonArray<Config>(json).getOrThrow()
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("读取排版配置文件出错", e)
|
||||
}
|
||||
}
|
||||
(configs ?: DefaultData.readConfigs).let {
|
||||
configList.clear()
|
||||
configList.addAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun initShareConfig() {
|
||||
val configFile = File(shareConfigFilePath)
|
||||
var c: Config? = null
|
||||
if (configFile.exists()) {
|
||||
try {
|
||||
val json = configFile.readText()
|
||||
c = GSON.fromJsonObject<Config>(json).getOrThrow()
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
}
|
||||
}
|
||||
shareConfig = c ?: configList.getOrNull(5) ?: Config()
|
||||
}
|
||||
|
||||
fun upBg(width: Int, height: Int) {
|
||||
val drawable = durConfig.curBgDrawable(width, height)
|
||||
if (drawable is BitmapDrawable && drawable.bitmap != null) {
|
||||
bgMeanColor = drawable.bitmap.getMeanColor()
|
||||
} else if (drawable is ColorDrawable) {
|
||||
bgMeanColor = drawable.color
|
||||
}
|
||||
val tmp = bg
|
||||
bg = drawable
|
||||
(tmp as? BitmapDrawable)?.bitmap?.recycle()
|
||||
}
|
||||
|
||||
fun save() {
|
||||
Coroutine.async {
|
||||
synchronized(this) {
|
||||
GSON.toJson(configList).let {
|
||||
FileUtils.delete(configFilePath)
|
||||
FileUtils.createFileIfNotExist(configFilePath).writeText(it)
|
||||
}
|
||||
GSON.toJson(shareConfig).let {
|
||||
FileUtils.delete(shareConfigFilePath)
|
||||
FileUtils.createFileIfNotExist(shareConfigFilePath).writeText(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllPicBgStr(): ArrayList<String> {
|
||||
val list = arrayListOf<String>()
|
||||
configList.forEach {
|
||||
if (it.bgType == 2) {
|
||||
list.add(it.bgStr)
|
||||
}
|
||||
if (it.bgTypeNight == 2) {
|
||||
list.add(it.bgStrNight)
|
||||
}
|
||||
if (it.bgTypeEInk == 2) {
|
||||
list.add(it.bgStrEInk)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun deleteDur(): Boolean {
|
||||
if (configList.size > 5) {
|
||||
val removeIndex = styleSelect
|
||||
configList.removeAt(removeIndex)
|
||||
if (removeIndex <= readStyleSelect) {
|
||||
readStyleSelect -= 1
|
||||
}
|
||||
if (removeIndex <= comicStyleSelect) {
|
||||
comicStyleSelect -= 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun resetAll() {
|
||||
DefaultData.readConfigs.let {
|
||||
configList.clear()
|
||||
configList.addAll(it)
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
//配置写入读取
|
||||
var readBodyToLh = appCtx.getPrefBoolean(PreferKey.readBodyToLh, true)
|
||||
var autoReadSpeed = appCtx.getPrefInt(PreferKey.autoReadSpeed, 10)
|
||||
set(value) {
|
||||
field = value
|
||||
appCtx.putPrefInt(PreferKey.autoReadSpeed, value)
|
||||
}
|
||||
var styleSelect: Int
|
||||
get() = if (isComic) comicStyleSelect else readStyleSelect
|
||||
set(value) {
|
||||
if (isComic) {
|
||||
comicStyleSelect = value
|
||||
} else {
|
||||
readStyleSelect = value
|
||||
}
|
||||
}
|
||||
var readStyleSelect = appCtx.getPrefInt(PreferKey.readStyleSelect)
|
||||
set(value) {
|
||||
field = value
|
||||
if (appCtx.getPrefInt(PreferKey.readStyleSelect) != value) {
|
||||
appCtx.putPrefInt(PreferKey.readStyleSelect, value)
|
||||
}
|
||||
}
|
||||
var comicStyleSelect = appCtx.getPrefInt(PreferKey.comicStyleSelect, readStyleSelect)
|
||||
set(value) {
|
||||
field = value
|
||||
if (appCtx.getPrefInt(PreferKey.comicStyleSelect) != value) {
|
||||
appCtx.putPrefInt(PreferKey.comicStyleSelect, value)
|
||||
}
|
||||
}
|
||||
var shareLayout = appCtx.getPrefBoolean(PreferKey.shareLayout)
|
||||
set(value) {
|
||||
field = value
|
||||
if (appCtx.getPrefBoolean(PreferKey.shareLayout) != value) {
|
||||
appCtx.putPrefBoolean(PreferKey.shareLayout, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 两端对齐
|
||||
*/
|
||||
val textFullJustify get() = appCtx.getPrefBoolean(PreferKey.textFullJustify, true)
|
||||
|
||||
/**
|
||||
* 底部对齐
|
||||
*/
|
||||
val textBottomJustify get() = appCtx.getPrefBoolean(PreferKey.textBottomJustify, true)
|
||||
var hideStatusBar = appCtx.getPrefBoolean(PreferKey.hideStatusBar)
|
||||
var hideNavigationBar = appCtx.getPrefBoolean(PreferKey.hideNavigationBar)
|
||||
var useZhLayout = appCtx.getPrefBoolean(PreferKey.useZhLayout)
|
||||
|
||||
val config get() = if (shareLayout) shareConfig else durConfig
|
||||
|
||||
var bgAlpha: Int
|
||||
get() = config.bgAlpha
|
||||
set(value) {
|
||||
config.bgAlpha = value
|
||||
}
|
||||
|
||||
var pageAnim: Int
|
||||
get() = config.curPageAnim()
|
||||
set(@PageAnim.Anim value) {
|
||||
config.setCurPageAnim(value)
|
||||
}
|
||||
|
||||
var textFont: String
|
||||
get() = config.textFont
|
||||
set(value) {
|
||||
config.textFont = value
|
||||
}
|
||||
|
||||
var textBold: Int
|
||||
get() = config.textBold
|
||||
set(value) {
|
||||
config.textBold = value
|
||||
}
|
||||
|
||||
var textSize: Int
|
||||
get() = config.textSize
|
||||
set(value) {
|
||||
config.textSize = value
|
||||
}
|
||||
|
||||
var letterSpacing: Float
|
||||
get() = config.letterSpacing
|
||||
set(value) {
|
||||
config.letterSpacing = value
|
||||
}
|
||||
|
||||
var lineSpacingExtra: Int
|
||||
get() = config.lineSpacingExtra
|
||||
set(value) {
|
||||
config.lineSpacingExtra = value
|
||||
}
|
||||
|
||||
var paragraphSpacing: Int
|
||||
get() = config.paragraphSpacing
|
||||
set(value) {
|
||||
config.paragraphSpacing = value
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题位置 0:居左 1:居中 2:隐藏
|
||||
*/
|
||||
var titleMode: Int
|
||||
get() = config.titleMode
|
||||
set(value) {
|
||||
config.titleMode = value
|
||||
}
|
||||
var titleSize: Int
|
||||
get() = config.titleSize
|
||||
set(value) {
|
||||
config.titleSize = value
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否标题居中
|
||||
*/
|
||||
val isMiddleTitle get() = titleMode == 1
|
||||
|
||||
var titleTopSpacing: Int
|
||||
get() = config.titleTopSpacing
|
||||
set(value) {
|
||||
config.titleTopSpacing = value
|
||||
}
|
||||
|
||||
var titleBottomSpacing: Int
|
||||
get() = config.titleBottomSpacing
|
||||
set(value) {
|
||||
config.titleBottomSpacing = value
|
||||
}
|
||||
|
||||
var paragraphIndent: String
|
||||
get() = config.paragraphIndent
|
||||
set(value) {
|
||||
config.paragraphIndent = value
|
||||
}
|
||||
|
||||
var underline: Boolean
|
||||
get() = config.underline
|
||||
set(value) {
|
||||
config.underline = value
|
||||
}
|
||||
|
||||
var paddingBottom: Int
|
||||
get() = config.paddingBottom
|
||||
set(value) {
|
||||
config.paddingBottom = value
|
||||
}
|
||||
|
||||
var paddingLeft: Int
|
||||
get() = config.paddingLeft
|
||||
set(value) {
|
||||
config.paddingLeft = value
|
||||
}
|
||||
|
||||
var paddingRight: Int
|
||||
get() = config.paddingRight
|
||||
set(value) {
|
||||
config.paddingRight = value
|
||||
}
|
||||
|
||||
var paddingTop: Int
|
||||
get() = config.paddingTop
|
||||
set(value) {
|
||||
config.paddingTop = value
|
||||
}
|
||||
|
||||
var headerPaddingBottom: Int
|
||||
get() = config.headerPaddingBottom
|
||||
set(value) {
|
||||
config.headerPaddingBottom = value
|
||||
}
|
||||
|
||||
var headerPaddingLeft: Int
|
||||
get() = config.headerPaddingLeft
|
||||
set(value) {
|
||||
config.headerPaddingLeft = value
|
||||
}
|
||||
|
||||
var headerPaddingRight: Int
|
||||
get() = config.headerPaddingRight
|
||||
set(value) {
|
||||
config.headerPaddingRight = value
|
||||
}
|
||||
|
||||
var headerPaddingTop: Int
|
||||
get() = config.headerPaddingTop
|
||||
set(value) {
|
||||
config.headerPaddingTop = value
|
||||
}
|
||||
|
||||
var footerPaddingBottom: Int
|
||||
get() = config.footerPaddingBottom
|
||||
set(value) {
|
||||
config.footerPaddingBottom = value
|
||||
}
|
||||
|
||||
var footerPaddingLeft: Int
|
||||
get() = config.footerPaddingLeft
|
||||
set(value) {
|
||||
config.footerPaddingLeft = value
|
||||
}
|
||||
|
||||
var footerPaddingRight: Int
|
||||
get() = config.footerPaddingRight
|
||||
set(value) {
|
||||
config.footerPaddingRight = value
|
||||
}
|
||||
|
||||
var footerPaddingTop: Int
|
||||
get() = config.footerPaddingTop
|
||||
set(value) {
|
||||
config.footerPaddingTop = value
|
||||
}
|
||||
|
||||
var showHeaderLine: Boolean
|
||||
get() = config.showHeaderLine
|
||||
set(value) {
|
||||
config.showHeaderLine = value
|
||||
}
|
||||
|
||||
var showFooterLine: Boolean
|
||||
get() = config.showFooterLine
|
||||
set(value) {
|
||||
config.showFooterLine = value
|
||||
}
|
||||
|
||||
fun getExportConfig(): Config {
|
||||
val exportConfig = durConfig.copy()
|
||||
if (shareLayout) {
|
||||
exportConfig.textFont = shareConfig.textFont
|
||||
exportConfig.textBold = shareConfig.textBold
|
||||
exportConfig.textSize = shareConfig.textSize
|
||||
exportConfig.letterSpacing = shareConfig.letterSpacing
|
||||
exportConfig.lineSpacingExtra = shareConfig.lineSpacingExtra
|
||||
exportConfig.paragraphSpacing = shareConfig.paragraphSpacing
|
||||
exportConfig.titleMode = shareConfig.titleMode
|
||||
exportConfig.titleSize = shareConfig.titleSize
|
||||
exportConfig.titleTopSpacing = shareConfig.titleTopSpacing
|
||||
exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing
|
||||
exportConfig.paddingBottom = shareConfig.paddingBottom
|
||||
exportConfig.paddingLeft = shareConfig.paddingLeft
|
||||
exportConfig.paddingRight = shareConfig.paddingRight
|
||||
exportConfig.paddingTop = shareConfig.paddingTop
|
||||
exportConfig.headerPaddingBottom = shareConfig.headerPaddingBottom
|
||||
exportConfig.headerPaddingLeft = shareConfig.headerPaddingLeft
|
||||
exportConfig.headerPaddingRight = shareConfig.headerPaddingRight
|
||||
exportConfig.headerPaddingTop = shareConfig.headerPaddingTop
|
||||
exportConfig.footerPaddingBottom = shareConfig.footerPaddingBottom
|
||||
exportConfig.footerPaddingLeft = shareConfig.footerPaddingLeft
|
||||
exportConfig.footerPaddingRight = shareConfig.footerPaddingRight
|
||||
exportConfig.footerPaddingTop = shareConfig.footerPaddingTop
|
||||
exportConfig.showHeaderLine = shareConfig.showHeaderLine
|
||||
exportConfig.showFooterLine = shareConfig.showFooterLine
|
||||
exportConfig.tipHeaderLeft = shareConfig.tipHeaderLeft
|
||||
exportConfig.tipHeaderMiddle = shareConfig.tipHeaderMiddle
|
||||
exportConfig.tipHeaderRight = shareConfig.tipHeaderRight
|
||||
exportConfig.tipFooterLeft = shareConfig.tipFooterLeft
|
||||
exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle
|
||||
exportConfig.tipFooterRight = shareConfig.tipFooterRight
|
||||
exportConfig.tipColor = shareConfig.tipColor
|
||||
exportConfig.headerMode = shareConfig.headerMode
|
||||
exportConfig.footerMode = shareConfig.footerMode
|
||||
}
|
||||
return exportConfig
|
||||
}
|
||||
|
||||
suspend fun import(byteArray: ByteArray): Result<Config> {
|
||||
return kotlin.runCatching {
|
||||
withContext(IO) {
|
||||
val configZipPath = FileUtils.getPath(appCtx.externalCache, "readConfig.zip")
|
||||
FileUtils.delete(configZipPath)
|
||||
val zipFile = FileUtils.createFileIfNotExist(configZipPath)
|
||||
zipFile.writeBytes(byteArray)
|
||||
val configDir = appCtx.externalCache.getFile("readConfig")
|
||||
configDir.createFolderReplace()
|
||||
ZipUtils.unZipToPath(zipFile, configDir)
|
||||
val configFile = configDir.getFile(configFileName)
|
||||
val config: Config = GSON.fromJsonObject<Config>(configFile.readText()).getOrThrow()
|
||||
if (config.textFont.isNotEmpty()) {
|
||||
val fontName = FileUtils.getName(config.textFont)
|
||||
val fontPath =
|
||||
FileUtils.getPath(appCtx.externalFiles, "font", fontName)
|
||||
if (!FileUtils.exist(fontPath)) {
|
||||
configDir.getFile(fontName).copyTo(File(fontPath))
|
||||
}
|
||||
config.textFont = fontPath
|
||||
}
|
||||
if (config.bgType == 2) {
|
||||
val bgName = FileUtils.getName(config.bgStr)
|
||||
config.bgStr = bgName
|
||||
val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
|
||||
if (!FileUtils.exist(bgPath)) {
|
||||
val bgFile = configDir.getFile(bgName)
|
||||
if (bgFile.exists()) {
|
||||
bgFile.copyTo(File(bgPath))
|
||||
}
|
||||
}
|
||||
config.bgStr = bgPath
|
||||
}
|
||||
if (config.bgTypeNight == 2) {
|
||||
val bgName = FileUtils.getName(config.bgStrNight)
|
||||
config.bgStrNight = bgName
|
||||
val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
|
||||
if (!FileUtils.exist(bgPath)) {
|
||||
val bgFile = configDir.getFile(bgName)
|
||||
if (bgFile.exists()) {
|
||||
bgFile.copyTo(File(bgPath))
|
||||
}
|
||||
}
|
||||
config.bgStrNight = bgPath
|
||||
}
|
||||
if (config.bgTypeEInk == 2) {
|
||||
val bgName = FileUtils.getName(config.bgStrEInk)
|
||||
config.bgStrEInk = bgName
|
||||
val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
|
||||
if (!FileUtils.exist(bgPath)) {
|
||||
val bgFile = configDir.getFile(bgName)
|
||||
if (bgFile.exists()) {
|
||||
bgFile.copyTo(File(bgPath))
|
||||
}
|
||||
}
|
||||
config.bgStrEInk = bgPath
|
||||
}
|
||||
return@withContext config
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
data class Config(
|
||||
var name: String = "",
|
||||
var bgStr: String = "#EEEEEE",//白天背景
|
||||
var bgStrNight: String = "#000000",//夜间背景
|
||||
var bgStrEInk: String = "#FFFFFF",//EInk背景
|
||||
var bgAlpha: Int = 100,//背景透明度
|
||||
var bgType: Int = 0,//白天背景类型 0:颜色, 1:assets图片, 2其它图片
|
||||
var bgTypeNight: Int = 0,//夜间背景类型
|
||||
var bgTypeEInk: Int = 0,//EInk背景类型
|
||||
private var darkStatusIcon: Boolean = true,//白天是否暗色状态栏
|
||||
private var darkStatusIconNight: Boolean = false,//晚上是否暗色状态栏
|
||||
private var darkStatusIconEInk: Boolean = true,
|
||||
private var textColor: String = "#3E3D3B",//白天文字颜色
|
||||
private var textColorNight: String = "#ADADAD",//夜间文字颜色
|
||||
private var textColorEInk: String = "#000000",
|
||||
private var pageAnim: Int = 0,//翻页动画
|
||||
private var pageAnimEInk: Int = 4,
|
||||
var textFont: String = "",//字体
|
||||
var textBold: Int = 0,//是否粗体字 0:正常, 1:粗体, 2:细体
|
||||
var textSize: Int = 20,//文字大小
|
||||
var letterSpacing: Float = 0.1f,//字间距
|
||||
var lineSpacingExtra: Int = 12,//行间距
|
||||
var paragraphSpacing: Int = 2,//段距
|
||||
var titleMode: Int = 0,//标题位置 0:居左 1:居中 2:隐藏
|
||||
var titleSize: Int = 0,
|
||||
var titleTopSpacing: Int = 0,
|
||||
var titleBottomSpacing: Int = 0,
|
||||
var paragraphIndent: String = " ",//段落缩进
|
||||
var underline: Boolean = false, //下划线
|
||||
var paddingBottom: Int = 6,
|
||||
var paddingLeft: Int = 16,
|
||||
var paddingRight: Int = 16,
|
||||
var paddingTop: Int = 6,
|
||||
var headerPaddingBottom: Int = 0,
|
||||
var headerPaddingLeft: Int = 16,
|
||||
var headerPaddingRight: Int = 16,
|
||||
var headerPaddingTop: Int = 0,
|
||||
var footerPaddingBottom: Int = 6,
|
||||
var footerPaddingLeft: Int = 16,
|
||||
var footerPaddingRight: Int = 16,
|
||||
var footerPaddingTop: Int = 6,
|
||||
var showHeaderLine: Boolean = false,
|
||||
var showFooterLine: Boolean = true,
|
||||
var tipHeaderLeft: Int = ReadTipConfig.time,
|
||||
var tipHeaderMiddle: Int = ReadTipConfig.none,
|
||||
var tipHeaderRight: Int = ReadTipConfig.battery,
|
||||
var tipFooterLeft: Int = ReadTipConfig.chapterTitle,
|
||||
var tipFooterMiddle: Int = ReadTipConfig.none,
|
||||
var tipFooterRight: Int = ReadTipConfig.pageAndTotal,
|
||||
var tipColor: Int = 0,
|
||||
var tipDividerColor: Int = -1,
|
||||
var headerMode: Int = 0,
|
||||
var footerMode: Int = 0
|
||||
) {
|
||||
|
||||
@Transient
|
||||
private var textColorIntEInk = -1
|
||||
|
||||
@Transient
|
||||
private var textColorIntNight = -1
|
||||
|
||||
@Transient
|
||||
private var textColorInt = -1
|
||||
|
||||
@Transient
|
||||
private var initColorInt = false
|
||||
|
||||
private fun initColorInt() {
|
||||
textColorIntEInk = Color.parseColor(textColorEInk)
|
||||
textColorIntNight = Color.parseColor(textColorNight)
|
||||
textColorInt = Color.parseColor(textColor)
|
||||
initColorInt = true
|
||||
}
|
||||
|
||||
fun setCurTextColor(color: Int) {
|
||||
when {
|
||||
AppConfig.isEInkMode -> {
|
||||
textColorEInk = "#${color.hexString}"
|
||||
textColorIntEInk = color
|
||||
}
|
||||
|
||||
AppConfig.isNightTheme -> {
|
||||
textColorNight = "#${color.hexString}"
|
||||
textColorIntNight = color
|
||||
}
|
||||
|
||||
else -> {
|
||||
textColor = "#${color.hexString}"
|
||||
textColorInt = color
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun curTextColor(): Int {
|
||||
if (!initColorInt) {
|
||||
initColorInt()
|
||||
}
|
||||
return when {
|
||||
AppConfig.isEInkMode -> textColorIntEInk
|
||||
AppConfig.isNightTheme -> textColorIntNight
|
||||
else -> textColorInt
|
||||
}
|
||||
}
|
||||
|
||||
fun setCurStatusIconDark(isDark: Boolean) {
|
||||
when {
|
||||
AppConfig.isEInkMode -> darkStatusIconEInk = isDark
|
||||
AppConfig.isNightTheme -> darkStatusIconNight = isDark
|
||||
else -> darkStatusIcon = isDark
|
||||
}
|
||||
}
|
||||
|
||||
fun curStatusIconDark(): Boolean {
|
||||
return when {
|
||||
AppConfig.isEInkMode -> darkStatusIconEInk
|
||||
AppConfig.isNightTheme -> darkStatusIconNight
|
||||
else -> darkStatusIcon
|
||||
}
|
||||
}
|
||||
|
||||
fun setCurPageAnim(@PageAnim.Anim anim: Int) {
|
||||
when {
|
||||
AppConfig.isEInkMode -> pageAnimEInk = anim
|
||||
else -> pageAnim = anim
|
||||
}
|
||||
}
|
||||
|
||||
fun curPageAnim(): Int {
|
||||
return when {
|
||||
AppConfig.isEInkMode -> pageAnimEInk
|
||||
else -> pageAnim
|
||||
}
|
||||
}
|
||||
|
||||
fun setCurBg(bgType: Int, bg: String) {
|
||||
when {
|
||||
AppConfig.isEInkMode -> {
|
||||
bgTypeEInk = bgType
|
||||
bgStrEInk = bg
|
||||
}
|
||||
|
||||
AppConfig.isNightTheme -> {
|
||||
bgTypeNight = bgType
|
||||
bgStrNight = bg
|
||||
}
|
||||
|
||||
else -> {
|
||||
this.bgType = bgType
|
||||
bgStr = bg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun curBgStr(): String {
|
||||
return when {
|
||||
AppConfig.isEInkMode -> bgStrEInk
|
||||
AppConfig.isNightTheme -> bgStrNight
|
||||
else -> bgStr
|
||||
}
|
||||
}
|
||||
|
||||
fun curBgType(): Int {
|
||||
return when {
|
||||
AppConfig.isEInkMode -> bgTypeEInk
|
||||
AppConfig.isNightTheme -> bgTypeNight
|
||||
else -> bgType
|
||||
}
|
||||
}
|
||||
|
||||
fun curBgDrawable(width: Int, height: Int): Drawable {
|
||||
if (width == 0 || height == 0) {
|
||||
val backgroundColor = MaterialColors.getColor(appCtx, com.google.android.material.R.attr.colorSurface, Color.WHITE)
|
||||
return ColorDrawable(backgroundColor)
|
||||
}
|
||||
|
||||
var bgDrawable: Drawable? = null
|
||||
val resources = appCtx.resources
|
||||
try {
|
||||
bgDrawable = when (curBgType()) {
|
||||
0 -> ColorDrawable(Color.parseColor(curBgStr()))
|
||||
1 -> {
|
||||
val path = "bg" + File.separator + curBgStr()
|
||||
val bitmap = BitmapUtils.decodeAssetsBitmap(appCtx, path, width, height)
|
||||
BitmapDrawable(resources, bitmap?.resizeAndRecycle(width, height))
|
||||
}
|
||||
else -> {
|
||||
val path = curBgStr().let {
|
||||
if (it.contains(File.separator)) it
|
||||
else FileUtils.getPath(appCtx.externalFiles, "bg", curBgStr())
|
||||
}
|
||||
val bitmap = BitmapUtils.decodeBitmap(path, width, height)
|
||||
BitmapDrawable(resources, bitmap?.resizeAndRecycle(width, height))
|
||||
}
|
||||
}
|
||||
} catch (e: OutOfMemoryError) {
|
||||
e.printOnDebug()
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
}
|
||||
|
||||
// fallback 使用 MD3 的 colorSurface 作为背景色
|
||||
val fallbackColor = MaterialColors.getColor(appCtx, com.google.android.material.R.attr.colorSurface, Color.WHITE)
|
||||
return bgDrawable ?: ColorDrawable(fallbackColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package io.legato.kazusa.help.config
|
||||
|
||||
import android.content.Context
|
||||
import io.legato.kazusa.R
|
||||
import splitties.init.appCtx
|
||||
|
||||
@Suppress("ConstPropertyName")
|
||||
object ReadTipConfig {
|
||||
|
||||
const val none = 0
|
||||
const val chapterTitle = 1
|
||||
const val time = 2
|
||||
const val battery = 3
|
||||
const val batteryPercentage = 10
|
||||
const val page = 4
|
||||
const val totalProgress = 5
|
||||
const val pageAndTotal = 6
|
||||
const val bookName = 7
|
||||
const val timeBattery = 8
|
||||
const val timeBatteryPercentage = 9
|
||||
const val totalProgress1 = 11
|
||||
|
||||
val tipValues = arrayOf(
|
||||
none, bookName, chapterTitle, time, battery, batteryPercentage, page,
|
||||
totalProgress, totalProgress1, pageAndTotal, timeBattery, timeBatteryPercentage
|
||||
)
|
||||
val tipNames get() = appCtx.resources.getStringArray(R.array.read_tip).toList()
|
||||
|
||||
val tipColorNames get() = appCtx.resources.getStringArray(R.array.tip_color).toList()
|
||||
val tipDividerColorNames
|
||||
get() = appCtx.resources.getStringArray(R.array.tip_divider_color).toList()
|
||||
|
||||
var tipHeaderLeft: Int
|
||||
get() = ReadBookConfig.config.tipHeaderLeft
|
||||
set(value) {
|
||||
ReadBookConfig.config.tipHeaderLeft = value
|
||||
}
|
||||
|
||||
var tipHeaderMiddle: Int
|
||||
get() = ReadBookConfig.config.tipHeaderMiddle
|
||||
set(value) {
|
||||
ReadBookConfig.config.tipHeaderMiddle = value
|
||||
}
|
||||
|
||||
var tipHeaderRight: Int
|
||||
get() = ReadBookConfig.config.tipHeaderRight
|
||||
set(value) {
|
||||
ReadBookConfig.config.tipHeaderRight = value
|
||||
}
|
||||
|
||||
var tipFooterLeft: Int
|
||||
get() = ReadBookConfig.config.tipFooterLeft
|
||||
set(value) {
|
||||
ReadBookConfig.config.tipFooterLeft = value
|
||||
}
|
||||
|
||||
var tipFooterMiddle: Int
|
||||
get() = ReadBookConfig.config.tipFooterMiddle
|
||||
set(value) {
|
||||
ReadBookConfig.config.tipFooterMiddle = value
|
||||
}
|
||||
|
||||
var tipFooterRight: Int
|
||||
get() = ReadBookConfig.config.tipFooterRight
|
||||
set(value) {
|
||||
ReadBookConfig.config.tipFooterRight = value
|
||||
}
|
||||
|
||||
var headerMode: Int
|
||||
get() = ReadBookConfig.config.headerMode
|
||||
set(value) {
|
||||
ReadBookConfig.config.headerMode = value
|
||||
}
|
||||
|
||||
var footerMode: Int
|
||||
get() = ReadBookConfig.config.footerMode
|
||||
set(value) {
|
||||
ReadBookConfig.config.footerMode = value
|
||||
}
|
||||
|
||||
var tipColor: Int
|
||||
get() = ReadBookConfig.config.tipColor
|
||||
set(value) {
|
||||
ReadBookConfig.config.tipColor = value
|
||||
}
|
||||
|
||||
var tipDividerColor: Int
|
||||
get() = ReadBookConfig.config.tipDividerColor
|
||||
set(value) {
|
||||
ReadBookConfig.config.tipDividerColor = value
|
||||
}
|
||||
|
||||
fun getHeaderModes(context: Context): LinkedHashMap<Int, String> {
|
||||
return linkedMapOf(
|
||||
Pair(0, context.getString(R.string.hide_when_status_bar_show)),
|
||||
Pair(1, context.getString(R.string.show)),
|
||||
Pair(2, context.getString(R.string.hide))
|
||||
)
|
||||
}
|
||||
|
||||
fun getFooterModes(context: Context): LinkedHashMap<Int, String> {
|
||||
return linkedMapOf(
|
||||
Pair(0, context.getString(R.string.show)),
|
||||
Pair(1, context.getString(R.string.hide))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package io.legato.kazusa.help.config
|
||||
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import androidx.core.content.edit
|
||||
import splitties.init.appCtx
|
||||
|
||||
object SourceConfig {
|
||||
private val sp = appCtx.getSharedPreferences("SourceConfig", MODE_PRIVATE)
|
||||
fun setBookScore(origin: String, name: String, author: String, score: Int) {
|
||||
sp.edit {
|
||||
val preScore = getBookScore(origin, name, author)
|
||||
var newScore = score
|
||||
if (preScore != 0) {
|
||||
newScore = score - preScore
|
||||
}
|
||||
|
||||
putInt(origin, getSourceScore(origin) + newScore)
|
||||
|
||||
putInt("${origin}_${name}_${author}", score)
|
||||
}
|
||||
}
|
||||
|
||||
fun getBookScore(origin: String, name: String, author: String): Int {
|
||||
return sp.getInt("${origin}_${name}_${author}", 0)
|
||||
}
|
||||
|
||||
fun getSourceScore(origin: String): Int {
|
||||
return sp.getInt(origin, 0)
|
||||
}
|
||||
|
||||
|
||||
fun removeSource(origin: String) {
|
||||
sp.all.keys.filter {
|
||||
it.startsWith(origin)
|
||||
}.let {
|
||||
sp.edit {
|
||||
it.forEach {
|
||||
remove(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package io.legato.kazusa.help.config
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.annotation.Keep
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.EventBus
|
||||
import io.legato.kazusa.constant.PreferKey
|
||||
import io.legato.kazusa.constant.Theme
|
||||
import io.legato.kazusa.help.DefaultData
|
||||
import io.legato.kazusa.lib.theme.ThemeStore
|
||||
import io.legato.kazusa.model.BookCover
|
||||
import io.legato.kazusa.utils.BitmapUtils
|
||||
import io.legato.kazusa.utils.ColorUtils
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.fromJsonArray
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import io.legato.kazusa.utils.getCompatColor
|
||||
import io.legato.kazusa.utils.getPrefInt
|
||||
import io.legato.kazusa.utils.getPrefString
|
||||
import io.legato.kazusa.utils.hexString
|
||||
import io.legato.kazusa.utils.postEvent
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import io.legato.kazusa.utils.putPrefInt
|
||||
import io.legato.kazusa.utils.stackBlur
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import androidx.core.graphics.toColorInt
|
||||
|
||||
@Keep
|
||||
object ThemeConfig {
|
||||
const val configFileName = "themeConfig.json"
|
||||
val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName)
|
||||
|
||||
val configList: ArrayList<Config> by lazy {
|
||||
val cList = getConfigs() ?: DefaultData.themeConfigs
|
||||
ArrayList(cList)
|
||||
}
|
||||
|
||||
fun getTheme() = when {
|
||||
AppConfig.isEInkMode -> Theme.EInk
|
||||
AppConfig.isNightTheme -> Theme.Dark
|
||||
else -> Theme.Light
|
||||
}
|
||||
|
||||
fun isDarkTheme(): Boolean {
|
||||
return getTheme() == Theme.Dark
|
||||
}
|
||||
|
||||
fun applyDayNight(context: Context) {
|
||||
applyTheme(context)
|
||||
initNightMode()
|
||||
BookCover.upDefaultCover()
|
||||
postEvent(EventBus.RECREATE, "")
|
||||
}
|
||||
|
||||
fun applyDayNightInit(context: Context) {
|
||||
applyTheme(context)
|
||||
initNightMode()
|
||||
}
|
||||
|
||||
private fun initNightMode() {
|
||||
val targetMode =
|
||||
if (AppConfig.isNightTheme) {
|
||||
AppCompatDelegate.MODE_NIGHT_YES
|
||||
} else {
|
||||
AppCompatDelegate.MODE_NIGHT_NO
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(targetMode)
|
||||
}
|
||||
|
||||
fun getBgImage(context: Context, metrics: DisplayMetrics): Bitmap? {
|
||||
val bgCfg = when (getTheme()) {
|
||||
Theme.Light -> Pair(
|
||||
context.getPrefString(PreferKey.bgImage),
|
||||
context.getPrefInt(PreferKey.bgImageBlurring, 0)
|
||||
)
|
||||
|
||||
Theme.Dark -> Pair(
|
||||
context.getPrefString(PreferKey.bgImageN),
|
||||
context.getPrefInt(PreferKey.bgImageNBlurring, 0)
|
||||
)
|
||||
|
||||
else -> null
|
||||
} ?: return null
|
||||
if (bgCfg.first.isNullOrBlank()) return null
|
||||
val bgImage = BitmapUtils
|
||||
.decodeBitmap(bgCfg.first!!, metrics.widthPixels, metrics.heightPixels)
|
||||
if (bgCfg.second == 0) {
|
||||
return bgImage
|
||||
}
|
||||
return bgImage?.stackBlur(bgCfg.second)
|
||||
}
|
||||
|
||||
fun upConfig() {
|
||||
getConfigs()?.forEach { config ->
|
||||
addConfig(config)
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val json = GSON.toJson(configList)
|
||||
FileUtils.delete(configFilePath)
|
||||
FileUtils.createFileIfNotExist(configFilePath).writeText(json)
|
||||
}
|
||||
|
||||
fun delConfig(index: Int) {
|
||||
configList.removeAt(index)
|
||||
save()
|
||||
}
|
||||
|
||||
fun addConfig(json: String): Boolean {
|
||||
GSON.fromJsonObject<Config>(json.trim { it < ' ' }).getOrNull()
|
||||
?.let {
|
||||
addConfig(it)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun addConfig(newConfig: Config) {
|
||||
configList.forEachIndexed { index, config ->
|
||||
if (newConfig.themeName == config.themeName) {
|
||||
configList[index] = newConfig
|
||||
return
|
||||
}
|
||||
}
|
||||
configList.add(newConfig)
|
||||
save()
|
||||
}
|
||||
|
||||
private fun getConfigs(): List<Config>? {
|
||||
val configFile = File(configFilePath)
|
||||
if (configFile.exists()) {
|
||||
kotlin.runCatching {
|
||||
val json = configFile.readText()
|
||||
return GSON.fromJsonArray<Config>(json).getOrThrow()
|
||||
}.onFailure {
|
||||
it.printOnDebug()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun applyConfig(context: Context, config: Config) {
|
||||
val primary = config.primaryColor.toColorInt()
|
||||
val accent = config.accentColor.toColorInt()
|
||||
val background = config.backgroundColor.toColorInt()
|
||||
val bBackground = config.bottomBackground.toColorInt()
|
||||
if (config.isNightTheme) {
|
||||
context.putPrefInt(PreferKey.cNPrimary, primary)
|
||||
context.putPrefInt(PreferKey.cNAccent, accent)
|
||||
context.putPrefInt(PreferKey.cNBackground, background)
|
||||
context.putPrefInt(PreferKey.cNBBackground, bBackground)
|
||||
} else {
|
||||
context.putPrefInt(PreferKey.cPrimary, primary)
|
||||
context.putPrefInt(PreferKey.cAccent, accent)
|
||||
context.putPrefInt(PreferKey.cBackground, background)
|
||||
context.putPrefInt(PreferKey.cBBackground, bBackground)
|
||||
}
|
||||
AppConfig.isNightTheme = config.isNightTheme
|
||||
applyDayNight(context)
|
||||
}
|
||||
|
||||
fun saveDayTheme(context: Context, name: String) {
|
||||
val primary =
|
||||
context.getPrefInt(PreferKey.cPrimary, context.getCompatColor(R.color.md_brown_500))
|
||||
val accent =
|
||||
context.getPrefInt(PreferKey.cAccent, context.getCompatColor(R.color.md_red_600))
|
||||
val background =
|
||||
context.getPrefInt(PreferKey.cBackground, context.getCompatColor(R.color.md_grey_100))
|
||||
val bBackground =
|
||||
context.getPrefInt(PreferKey.cBBackground, context.getCompatColor(R.color.md_grey_200))
|
||||
val config = Config(
|
||||
themeName = name,
|
||||
isNightTheme = false,
|
||||
primaryColor = "#${primary.hexString}",
|
||||
accentColor = "#${accent.hexString}",
|
||||
backgroundColor = "#${background.hexString}",
|
||||
bottomBackground = "#${bBackground.hexString}"
|
||||
)
|
||||
addConfig(config)
|
||||
}
|
||||
|
||||
fun saveNightTheme(context: Context, name: String) {
|
||||
val primary =
|
||||
context.getPrefInt(
|
||||
PreferKey.cNPrimary,
|
||||
context.getCompatColor(R.color.md_blue_grey_600)
|
||||
)
|
||||
val accent =
|
||||
context.getPrefInt(
|
||||
PreferKey.cNAccent,
|
||||
context.getCompatColor(R.color.md_deep_orange_800)
|
||||
)
|
||||
val background =
|
||||
context.getPrefInt(PreferKey.cNBackground, context.getCompatColor(R.color.md_grey_900))
|
||||
val bBackground =
|
||||
context.getPrefInt(PreferKey.cNBBackground, context.getCompatColor(R.color.md_grey_850))
|
||||
val config = Config(
|
||||
themeName = name,
|
||||
isNightTheme = true,
|
||||
primaryColor = "#${primary.hexString}",
|
||||
accentColor = "#${accent.hexString}",
|
||||
backgroundColor = "#${background.hexString}",
|
||||
bottomBackground = "#${bBackground.hexString}"
|
||||
)
|
||||
addConfig(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新主题
|
||||
*/
|
||||
fun applyTheme(context: Context) = with(context) {
|
||||
when {
|
||||
AppConfig.isEInkMode -> {
|
||||
ThemeStore.editTheme(this)
|
||||
.primaryColor(Color.WHITE)
|
||||
.accentColor(Color.BLACK)
|
||||
.backgroundColor(Color.WHITE)
|
||||
.bottomBackground(Color.WHITE)
|
||||
.apply()
|
||||
}
|
||||
|
||||
AppConfig.isNightTheme -> {
|
||||
val primary =
|
||||
getPrefInt(PreferKey.cNPrimary, getCompatColor(R.color.md_blue_grey_600))
|
||||
val accent =
|
||||
getPrefInt(PreferKey.cNAccent, getCompatColor(R.color.md_deep_orange_800))
|
||||
var background =
|
||||
getPrefInt(PreferKey.cNBackground, getCompatColor(R.color.md_grey_900))
|
||||
if (ColorUtils.isColorLight(background)) {
|
||||
background = getCompatColor(R.color.md_grey_900)
|
||||
putPrefInt(PreferKey.cNBackground, background)
|
||||
}
|
||||
val bBackground =
|
||||
getPrefInt(PreferKey.cNBBackground, getCompatColor(R.color.md_grey_850))
|
||||
ThemeStore.editTheme(this)
|
||||
.primaryColor(ColorUtils.withAlpha(primary, 1f))
|
||||
.accentColor(ColorUtils.withAlpha(accent, 1f))
|
||||
.backgroundColor(ColorUtils.withAlpha(background, 1f))
|
||||
.bottomBackground(ColorUtils.withAlpha(bBackground, 1f))
|
||||
.apply()
|
||||
}
|
||||
|
||||
else -> {
|
||||
val primary =
|
||||
getPrefInt(PreferKey.cPrimary, getCompatColor(R.color.md_brown_500))
|
||||
val accent =
|
||||
getPrefInt(PreferKey.cAccent, getCompatColor(R.color.md_red_600))
|
||||
var background =
|
||||
getPrefInt(PreferKey.cBackground, getCompatColor(R.color.md_grey_100))
|
||||
if (!ColorUtils.isColorLight(background)) {
|
||||
background = getCompatColor(R.color.md_grey_100)
|
||||
putPrefInt(PreferKey.cBackground, background)
|
||||
}
|
||||
val bBackground =
|
||||
getPrefInt(PreferKey.cBBackground, getCompatColor(R.color.md_grey_200))
|
||||
ThemeStore.editTheme(this)
|
||||
.primaryColor(ColorUtils.withAlpha(primary, 1f))
|
||||
.accentColor(ColorUtils.withAlpha(accent, 1f))
|
||||
.backgroundColor(ColorUtils.withAlpha(background, 1f))
|
||||
.bottomBackground(ColorUtils.withAlpha(bBackground, 1f))
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
data class Config(
|
||||
var themeName: String,
|
||||
var isNightTheme: Boolean,
|
||||
var primaryColor: String,
|
||||
var accentColor: String,
|
||||
var backgroundColor: String,
|
||||
var bottomBackground: String
|
||||
) {
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return GSON.toJson(this).hashCode()
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
other ?: return false
|
||||
if (other is Config) {
|
||||
return other.themeName == themeName
|
||||
&& other.isNightTheme == isNightTheme
|
||||
&& other.primaryColor == primaryColor
|
||||
&& other.accentColor == accentColor
|
||||
&& other.backgroundColor == backgroundColor
|
||||
&& other.bottomBackground == bottomBackground
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package io.legato.kazusa.help.coroutine
|
||||
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class ActivelyCancelException : CancellationException() {
|
||||
|
||||
override fun fillInStackTrace(): Throwable {
|
||||
stackTrace = emptyArray()
|
||||
return this
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.legato.kazusa.help.coroutine
|
||||
|
||||
@Suppress("unused")
|
||||
class CompositeCoroutine : CoroutineContainer {
|
||||
|
||||
private var resources: HashSet<Coroutine<*>>? = null
|
||||
|
||||
val size: Int
|
||||
get() = resources?.size ?: 0
|
||||
|
||||
val isEmpty: Boolean
|
||||
get() = size == 0
|
||||
|
||||
constructor()
|
||||
|
||||
constructor(vararg coroutines: Coroutine<*>) {
|
||||
this.resources = hashSetOf(*coroutines)
|
||||
}
|
||||
|
||||
constructor(coroutines: Iterable<Coroutine<*>>) {
|
||||
this.resources = hashSetOf()
|
||||
for (d in coroutines) {
|
||||
this.resources?.add(d)
|
||||
}
|
||||
}
|
||||
|
||||
override fun add(coroutine: Coroutine<*>): Boolean {
|
||||
synchronized(this) {
|
||||
var set: HashSet<Coroutine<*>>? = resources
|
||||
if (resources == null) {
|
||||
set = hashSetOf()
|
||||
resources = set
|
||||
}
|
||||
return set!!.add(coroutine)
|
||||
}
|
||||
}
|
||||
|
||||
override fun addAll(vararg coroutines: Coroutine<*>): Boolean {
|
||||
synchronized(this) {
|
||||
var set: HashSet<Coroutine<*>>? = resources
|
||||
if (resources == null) {
|
||||
set = hashSetOf()
|
||||
resources = set
|
||||
}
|
||||
for (coroutine in coroutines) {
|
||||
val add = set!!.add(coroutine)
|
||||
if (!add) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun remove(coroutine: Coroutine<*>): Boolean {
|
||||
if (delete(coroutine)) {
|
||||
coroutine.cancel()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun delete(coroutine: Coroutine<*>): Boolean {
|
||||
synchronized(this) {
|
||||
val set = resources
|
||||
if (set == null || !set.remove(coroutine)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
val set: HashSet<Coroutine<*>>?
|
||||
synchronized(this) {
|
||||
set = resources
|
||||
resources = null
|
||||
}
|
||||
|
||||
set?.forEachIndexed { _, coroutine ->
|
||||
coroutine.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package io.legato.kazusa.help.coroutine
|
||||
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.DisposableHandle
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* 链式协程
|
||||
* 注意:如果协程太快完成,回调会不执行
|
||||
*/
|
||||
@Suppress("unused", "MemberVisibilityCanBePrivate")
|
||||
class Coroutine<T>(
|
||||
private val scope: CoroutineScope,
|
||||
context: CoroutineContext = Dispatchers.IO,
|
||||
private val startOption: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
private val executeContext: CoroutineContext = Dispatchers.Main,
|
||||
private val semaphore: Semaphore? = null,
|
||||
block: suspend CoroutineScope.() -> T
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
private val DEFAULT = MainScope()
|
||||
|
||||
fun <T> async(
|
||||
scope: CoroutineScope = DEFAULT,
|
||||
context: CoroutineContext = Dispatchers.IO,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
executeContext: CoroutineContext = Dispatchers.Main,
|
||||
semaphore: Semaphore? = null,
|
||||
block: suspend CoroutineScope.() -> T
|
||||
): Coroutine<T> {
|
||||
return Coroutine(scope, context, start, executeContext, semaphore, block)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val job: Job
|
||||
|
||||
private var start: VoidCallback? = null
|
||||
private var success: Callback<T>? = null
|
||||
private var error: Callback<Throwable>? = null
|
||||
private var finally: VoidCallback? = null
|
||||
private var cancel: VoidCallback? = null
|
||||
|
||||
private var timeMillis: Long? = null
|
||||
private var errorReturn: Result<T>? = null
|
||||
|
||||
val isCancelled: Boolean
|
||||
get() = job.isCancelled
|
||||
|
||||
val isActive: Boolean
|
||||
get() = job.isActive
|
||||
|
||||
val isCompleted: Boolean
|
||||
get() = job.isCompleted
|
||||
|
||||
init {
|
||||
this.job = executeInternal(context, block)
|
||||
}
|
||||
|
||||
fun timeout(timeMillis: () -> Long): Coroutine<T> {
|
||||
this.timeMillis = timeMillis()
|
||||
return this@Coroutine
|
||||
}
|
||||
|
||||
fun timeout(timeMillis: Long): Coroutine<T> {
|
||||
this.timeMillis = timeMillis
|
||||
return this@Coroutine
|
||||
}
|
||||
|
||||
fun onErrorReturn(value: () -> T?): Coroutine<T> {
|
||||
this.errorReturn = Result(value())
|
||||
return this@Coroutine
|
||||
}
|
||||
|
||||
fun onErrorReturn(value: T?): Coroutine<T> {
|
||||
this.errorReturn = Result(value)
|
||||
return this@Coroutine
|
||||
}
|
||||
|
||||
fun onStart(
|
||||
context: CoroutineContext? = null,
|
||||
block: (suspend CoroutineScope.() -> Unit)
|
||||
): Coroutine<T> {
|
||||
this.start = VoidCallback(context, block)
|
||||
return this@Coroutine
|
||||
}
|
||||
|
||||
fun onSuccess(
|
||||
context: CoroutineContext? = null,
|
||||
block: suspend CoroutineScope.(T) -> Unit
|
||||
): Coroutine<T> {
|
||||
this.success = Callback(context, block)
|
||||
return this@Coroutine
|
||||
}
|
||||
|
||||
fun onError(
|
||||
context: CoroutineContext? = null,
|
||||
block: suspend CoroutineScope.(Throwable) -> Unit
|
||||
): Coroutine<T> {
|
||||
this.error = Callback(context, block)
|
||||
return this@Coroutine
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果协程被取消,不执行
|
||||
*/
|
||||
fun onFinally(
|
||||
context: CoroutineContext? = null,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Coroutine<T> {
|
||||
this.finally = VoidCallback(context, block)
|
||||
return this@Coroutine
|
||||
}
|
||||
|
||||
fun onCancel(
|
||||
context: CoroutineContext? = null,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Coroutine<T> {
|
||||
this.cancel = VoidCallback(context, block)
|
||||
job.invokeOnCompletion {
|
||||
if (it is CancellationException && it !is ActivelyCancelException) {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
return this@Coroutine
|
||||
}
|
||||
|
||||
//取消当前任务
|
||||
fun cancel(cause: ActivelyCancelException = ActivelyCancelException()) {
|
||||
if (!job.isCancelled) {
|
||||
job.cancel(cause)
|
||||
}
|
||||
cancel?.let {
|
||||
DEFAULT.launch(executeContext) {
|
||||
if (null == it.context) {
|
||||
it.block.invoke(this)
|
||||
} else {
|
||||
withContext(it.context) {
|
||||
it.block.invoke(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle {
|
||||
return job.invokeOnCompletion(handler)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
job.start()
|
||||
}
|
||||
|
||||
private fun executeInternal(
|
||||
context: CoroutineContext,
|
||||
block: suspend CoroutineScope.() -> T
|
||||
): Job {
|
||||
return (scope.plus(executeContext)).launch(start = startOption) {
|
||||
semaphore?.acquire()
|
||||
try {
|
||||
start?.let { dispatchVoidCallback(this, it) }
|
||||
ensureActive()
|
||||
val value = executeBlock(this, context, timeMillis ?: 0L, block)
|
||||
ensureActive()
|
||||
success?.let { dispatchCallback(this, value, it) }
|
||||
} catch (e: Throwable) {
|
||||
e.printOnDebug()
|
||||
val consume: Boolean = errorReturn?.value?.let { value ->
|
||||
success?.let { dispatchCallback(this, value, it) }
|
||||
true
|
||||
} ?: false
|
||||
if (!consume) {
|
||||
error?.let { dispatchCallback(this, e, it) }
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
finally?.let { dispatchVoidCallback(this, it) }
|
||||
} finally {
|
||||
semaphore?.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun dispatchVoidCallback(scope: CoroutineScope, callback: VoidCallback) {
|
||||
if (null == callback.context) {
|
||||
withContext(scope.coroutineContext) {
|
||||
callback.block.invoke(scope)
|
||||
}
|
||||
} else {
|
||||
withContext(scope.coroutineContext + callback.context) {
|
||||
callback.block.invoke(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <R> dispatchCallback(
|
||||
scope: CoroutineScope,
|
||||
value: R,
|
||||
callback: Callback<R>
|
||||
) {
|
||||
if (!scope.isActive) return
|
||||
if (null == callback.context) {
|
||||
callback.block.invoke(scope, value)
|
||||
} else {
|
||||
withContext(scope.coroutineContext + callback.context) {
|
||||
callback.block.invoke(this, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun executeBlock(
|
||||
scope: CoroutineScope,
|
||||
context: CoroutineContext,
|
||||
timeMillis: Long,
|
||||
noinline block: suspend CoroutineScope.() -> T
|
||||
): T {
|
||||
return withContext(scope.coroutineContext + context) {
|
||||
if (timeMillis > 0L) withTimeout(timeMillis) {
|
||||
block()
|
||||
} else {
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Result<out T>(val value: T?)
|
||||
|
||||
private inner class VoidCallback(
|
||||
val context: CoroutineContext?,
|
||||
val block: suspend CoroutineScope.() -> Unit
|
||||
)
|
||||
|
||||
private inner class Callback<VALUE>(
|
||||
val context: CoroutineContext?,
|
||||
val block: suspend CoroutineScope.(VALUE) -> Unit
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package io.legato.kazusa.help.coroutine
|
||||
|
||||
internal interface CoroutineContainer {
|
||||
|
||||
fun add(coroutine: Coroutine<*>): Boolean
|
||||
|
||||
fun addAll(vararg coroutines: Coroutine<*>): Boolean
|
||||
|
||||
fun remove(coroutine: Coroutine<*>): Boolean
|
||||
|
||||
fun delete(coroutine: Coroutine<*>): Boolean
|
||||
|
||||
fun clear()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package io.legato.kazusa.help.crypto
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import cn.hutool.crypto.KeyUtil
|
||||
import cn.hutool.crypto.asymmetric.KeyType
|
||||
import io.legato.kazusa.utils.EncoderUtils
|
||||
import java.io.InputStream
|
||||
import cn.hutool.crypto.asymmetric.AsymmetricCrypto as HutoolAsymmetricCrypto
|
||||
|
||||
@Keep
|
||||
@Suppress("unused")
|
||||
class AsymmetricCrypto(algorithm: String) : HutoolAsymmetricCrypto(algorithm) {
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
fun setPrivateKey(key: ByteArray): AsymmetricCrypto {
|
||||
setPrivateKey(
|
||||
KeyUtil.generatePrivateKey(this.algorithm, key)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setPrivateKey(key: String): AsymmetricCrypto = setPrivateKey(key.encodeToByteArray())
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
fun setPublicKey(key: ByteArray): AsymmetricCrypto {
|
||||
setPublicKey(
|
||||
KeyUtil.generatePublicKey(this.algorithm, key)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setPublicKey(key: String): AsymmetricCrypto = setPublicKey(key.encodeToByteArray())
|
||||
|
||||
private fun getKeyType(usePublicKey: Boolean? = true): KeyType {
|
||||
return when (usePublicKey) {
|
||||
true -> KeyType.PublicKey
|
||||
else -> KeyType.PrivateKey
|
||||
}
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun decrypt(data: Any, usePublicKey: Boolean? = true): ByteArray {
|
||||
return when (data) {
|
||||
is ByteArray -> decrypt(data, getKeyType(usePublicKey))
|
||||
is String -> decrypt(data, getKeyType(usePublicKey))
|
||||
is InputStream -> decrypt(data, getKeyType(usePublicKey))
|
||||
else -> throw IllegalArgumentException("Unexpected input type")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun decryptStr(data: Any, usePublicKey: Boolean? = true): String {
|
||||
return when (data) {
|
||||
is ByteArray -> String(decrypt(data, getKeyType(usePublicKey)))
|
||||
is String -> decryptStr(data, getKeyType(usePublicKey))
|
||||
is InputStream -> String(decrypt(data, getKeyType(usePublicKey)))
|
||||
else -> throw IllegalArgumentException("Unexpected input type")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun encrypt(data: Any, usePublicKey: Boolean? = true): ByteArray {
|
||||
return when (data) {
|
||||
is ByteArray -> encrypt(data, getKeyType(usePublicKey))
|
||||
is String -> encrypt(data, getKeyType(usePublicKey))
|
||||
is InputStream -> encrypt(data, getKeyType(usePublicKey))
|
||||
else -> throw IllegalArgumentException("Unexpected input type")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun encryptHex(data: Any, usePublicKey: Boolean? = true): String {
|
||||
return when (data) {
|
||||
is ByteArray -> encryptHex(data, getKeyType(usePublicKey))
|
||||
is String -> encryptHex(data, getKeyType(usePublicKey))
|
||||
is InputStream -> encryptHex(data, getKeyType(usePublicKey))
|
||||
else -> throw IllegalArgumentException("Unexpected input type")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun encryptBase64(data: Any, usePublicKey: Boolean? = true): String {
|
||||
return EncoderUtils.base64Encode(encrypt(data, usePublicKey))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
https://github.com/gedoor/legado/pull/2880
|
||||
|
||||
非对称加密一般只能知道其中一个密钥,而RhinoJs调用java方法不能传入null和KeyType, 因此提供以下重载函数
|
||||
```kotlin
|
||||
fun setPublicKey(key: ByteArray): T
|
||||
fun setPublicKey(key: String): T
|
||||
fun setPrivateKey(key: ByteArray): T
|
||||
fun setPrivateKey(key: String): T
|
||||
|
||||
fun decrypt(data: Any, usePublicKey: Boolean? = true): ByteArray?
|
||||
fun decryptStr(data: Any, usePublicKey: Boolean? = true): String?
|
||||
|
||||
fun encrypt(data: Any, usePublicKey: Boolean? = true): ByteArray?
|
||||
fun encryptHex(data: Any, usePublicKey: Boolean? = true): String?
|
||||
fun encryptBase64(data: Any, usePublicKey: Boolean? = true): String?
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
package io.legato.kazusa.help.crypto
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import cn.hutool.crypto.KeyUtil
|
||||
import cn.hutool.crypto.asymmetric.Sign as HutoolSign
|
||||
|
||||
@Keep
|
||||
@Suppress("unused")
|
||||
class Sign(algorithm: String): HutoolSign(algorithm) {
|
||||
|
||||
fun setPrivateKey(key: ByteArray): Sign {
|
||||
setPrivateKey(KeyUtil.generatePrivateKey(algorithm, key))
|
||||
return this
|
||||
}
|
||||
|
||||
fun setPrivateKey(key: String): Sign = setPrivateKey(key.encodeToByteArray())
|
||||
|
||||
fun setPublicKey(key: ByteArray): Sign {
|
||||
setPublicKey(KeyUtil.generatePublicKey(algorithm, key))
|
||||
return this
|
||||
}
|
||||
|
||||
fun setPublicKey(key: String): Sign = setPublicKey(key.encodeToByteArray())
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package io.legato.kazusa.help.crypto
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import cn.hutool.core.codec.Base64
|
||||
import cn.hutool.core.util.HexUtil
|
||||
import cn.hutool.crypto.symmetric.SymmetricCrypto
|
||||
import io.legato.kazusa.utils.EncoderUtils
|
||||
import io.legato.kazusa.utils.isHex
|
||||
import java.io.InputStream
|
||||
import java.nio.charset.Charset
|
||||
|
||||
@Keep
|
||||
class SymmetricCryptoAndroid(
|
||||
algorithm: String,
|
||||
key: ByteArray?,
|
||||
) : SymmetricCrypto(algorithm, key) {
|
||||
|
||||
override fun encryptBase64(data: ByteArray): String {
|
||||
return EncoderUtils.base64Encode(encrypt(data))
|
||||
}
|
||||
|
||||
override fun encryptBase64(data: String, charset: String?): String {
|
||||
return EncoderUtils.base64Encode(encrypt(data, charset))
|
||||
}
|
||||
|
||||
override fun encryptBase64(data: String, charset: Charset?): String {
|
||||
return EncoderUtils.base64Encode(encrypt(data, charset))
|
||||
}
|
||||
|
||||
override fun encryptBase64(data: String): String {
|
||||
return EncoderUtils.base64Encode(encrypt(data))
|
||||
}
|
||||
|
||||
override fun encryptBase64(data: InputStream): String {
|
||||
return EncoderUtils.base64Encode(encrypt(data))
|
||||
}
|
||||
|
||||
override fun decrypt(data: String): ByteArray {
|
||||
val bytes = if (data.isHex()) {
|
||||
HexUtil.decodeHex(data)
|
||||
} else {
|
||||
Base64.decode(data)
|
||||
}
|
||||
return decrypt(bytes)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package io.legato.kazusa.help.exoplayer
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.FileDataSource
|
||||
import androidx.media3.datasource.ResolvingDataSource
|
||||
import androidx.media3.datasource.cache.Cache
|
||||
import androidx.media3.datasource.cache.CacheDataSink
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import io.legato.kazusa.help.http.okHttpClient
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.externalCache
|
||||
import okhttp3.CacheControl
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
object ExoPlayerHelper {
|
||||
|
||||
private const val SPLIT_TAG = "\uD83D\uDEA7"
|
||||
|
||||
private val mapType by lazy {
|
||||
object : TypeToken<Map<String, String>>() {}.type
|
||||
}
|
||||
|
||||
fun createMediaItem(url: String, headers: Map<String, String>): MediaItem {
|
||||
val formatUrl = url + SPLIT_TAG + GSON.toJson(headers, mapType)
|
||||
return MediaItem.Builder().setUri(formatUrl).build()
|
||||
}
|
||||
|
||||
fun createHttpExoPlayer(context: Context): ExoPlayer {
|
||||
return ExoPlayer.Builder(context).setLoadControl(
|
||||
DefaultLoadControl.Builder().setBufferDurationsMs(
|
||||
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS,
|
||||
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS / 10,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS / 10
|
||||
).build()
|
||||
|
||||
).setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(context)
|
||||
.setDataSourceFactory(resolvingDataSource)
|
||||
.setLiveTargetOffsetMs(5000)
|
||||
).build()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private val resolvingDataSource: ResolvingDataSource.Factory by lazy {
|
||||
ResolvingDataSource.Factory(cacheDataSourceFactory) {
|
||||
var res = it
|
||||
|
||||
if (it.uri.toString().contains(SPLIT_TAG)) {
|
||||
val urls = it.uri.toString().split(SPLIT_TAG)
|
||||
val url = urls[0]
|
||||
res = res.withUri(Uri.parse(url))
|
||||
try {
|
||||
val headers: Map<String, String> = GSON.fromJson(urls[1], mapType)
|
||||
okhttpDataFactory.setDefaultRequestProperties(headers)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
res
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 支持缓存的DataSource.Factory
|
||||
*/
|
||||
private val cacheDataSourceFactory by lazy {
|
||||
//使用自定义的CacheDataSource以支持设置UA
|
||||
return@lazy CacheDataSource.Factory()
|
||||
.setCache(cache)
|
||||
.setUpstreamDataSourceFactory(okhttpDataFactory)
|
||||
.setCacheReadDataSourceFactory(FileDataSource.Factory())
|
||||
.setCacheWriteDataSinkFactory(
|
||||
CacheDataSink.Factory()
|
||||
.setCache(cache)
|
||||
.setFragmentSize(CacheDataSink.DEFAULT_FRAGMENT_SIZE)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Okhttp DataSource.Factory
|
||||
*/
|
||||
private val okhttpDataFactory by lazy {
|
||||
val client = okHttpClient.newBuilder()
|
||||
.callTimeout(0, TimeUnit.SECONDS)
|
||||
.build()
|
||||
OkHttpDataSource.Factory(client)
|
||||
.setCacheControl(CacheControl.Builder().maxAge(1, TimeUnit.DAYS).build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Exoplayer 内置的缓存
|
||||
*/
|
||||
private val cache: Cache by lazy {
|
||||
val databaseProvider = StandaloneDatabaseProvider(appCtx)
|
||||
return@lazy SimpleCache(
|
||||
//Exoplayer的缓存路径
|
||||
File(appCtx.externalCache, "exoplayer"),
|
||||
//100M的缓存
|
||||
LeastRecentlyUsedCacheEvictor((100 * 1024 * 1024).toLong()),
|
||||
//记录缓存的数据库
|
||||
databaseProvider
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过kotlin扩展函数+反射实现CacheDataSource.Factory设置默认请求头
|
||||
* 需要添加混淆规则 -keepclassmembers class com.google.android.exoplayer2.upstream.cache.CacheDataSource$Factory{upstreamDataSourceFactory;}
|
||||
* @param headers
|
||||
* @return
|
||||
*/
|
||||
// private fun CacheDataSource.Factory.setDefaultRequestProperties(headers: Map<String, String> = mapOf()): CacheDataSource.Factory {
|
||||
// val declaredField = this.javaClass.getDeclaredField("upstreamDataSourceFactory")
|
||||
// declaredField.isAccessible = true
|
||||
// val df = declaredField[this] as DataSource.Factory
|
||||
// if (df is OkHttpDataSource.Factory) {
|
||||
// df.setDefaultRequestProperties(headers)
|
||||
// }
|
||||
// return this
|
||||
// }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package io.legato.kazusa.help.exoplayer
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.datasource.BaseDataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class InputStreamDataSource(private val supplier: () -> InputStream) : BaseDataSource(false) {
|
||||
private var dataSpec: DataSpec? = null
|
||||
private var bytesRemaining: Long = 0
|
||||
private var opened = false
|
||||
private val inputStream by lazy {
|
||||
supplier.invoke()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
this.dataSpec = dataSpec
|
||||
transferInitializing(dataSpec)
|
||||
|
||||
inputStream.skip(dataSpec.position)
|
||||
|
||||
bytesRemaining = dataSpec.length
|
||||
|
||||
opened = true
|
||||
transferStarted(dataSpec)
|
||||
return bytesRemaining
|
||||
}
|
||||
|
||||
override fun getUri(): Uri? = dataSpec?.uri
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int {
|
||||
if (readLength == 0) {
|
||||
return 0
|
||||
} else if (bytesRemaining == 0L) {
|
||||
return C.RESULT_END_OF_INPUT
|
||||
}
|
||||
|
||||
val bytesToRead =
|
||||
if (bytesRemaining == C.LENGTH_UNSET.toLong()) readLength
|
||||
else min(bytesRemaining, readLength.toLong()).toInt()
|
||||
|
||||
val bytesRead = inputStream.read(buffer, offset, bytesToRead)
|
||||
|
||||
if (bytesRead == -1) {
|
||||
if (bytesRemaining != C.LENGTH_UNSET.toLong()) {
|
||||
// End of stream reached having not read sufficient data.
|
||||
throw EOFException()
|
||||
}
|
||||
return C.RESULT_END_OF_INPUT
|
||||
}
|
||||
|
||||
if (bytesRemaining != C.LENGTH_UNSET.toLong()) {
|
||||
bytesRemaining -= bytesRead.toLong()
|
||||
bytesTransferred(bytesRead)
|
||||
}
|
||||
|
||||
return bytesRead
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
if (!opened) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
inputStream.close()
|
||||
} finally {
|
||||
opened = false
|
||||
transferEnded()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.legato.kazusa.help.glide
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool
|
||||
import io.legato.kazusa.help.globalExecutor
|
||||
|
||||
class AsyncRecycleBitmapPool(private val delegate: BitmapPool) : BitmapPool by delegate {
|
||||
|
||||
constructor(maxSize: Int) : this(
|
||||
if (maxSize > 0) {
|
||||
LruBitmapPool(maxSize.toLong())
|
||||
} else {
|
||||
BitmapPoolAdapter()
|
||||
}
|
||||
)
|
||||
|
||||
override fun put(bitmap: Bitmap) {
|
||||
globalExecutor.execute {
|
||||
delegate.put(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package io.legato.kazusa.help.glide
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.annotation.IntRange
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
|
||||
import io.legato.kazusa.utils.stackBlur
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* 模糊
|
||||
* @radius: 0..25
|
||||
*/
|
||||
class BlurTransformation(
|
||||
@IntRange(from = 0, to = 25) private val radius: Int
|
||||
) : BitmapTransformation() {
|
||||
|
||||
override fun transform(
|
||||
pool: BitmapPool,
|
||||
toTransform: Bitmap,
|
||||
outWidth: Int,
|
||||
outHeight: Int
|
||||
): Bitmap {
|
||||
return toTransform.stackBlur(radius)
|
||||
}
|
||||
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
messageDigest.update("blur transformation".toByteArray())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package io.legato.kazusa.help.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import java.io.File
|
||||
|
||||
class FilePathLoader : ModelLoader<String, File> {
|
||||
override fun buildLoadData(
|
||||
model: String,
|
||||
width: Int,
|
||||
height: Int,
|
||||
options: com.bumptech.glide.load.Options
|
||||
): ModelLoader.LoadData<File>? {
|
||||
return ModelLoader.LoadData(ObjectKey(model), FilePathFetcher(model))
|
||||
}
|
||||
|
||||
override fun handles(model: String): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
class FilePathFetcher(private val filePath: String) : DataFetcher<File> {
|
||||
override fun loadData(
|
||||
priority: Priority,
|
||||
callback: DataFetcher.DataCallback<in File>
|
||||
) {
|
||||
val file = File(filePath)
|
||||
if (file.exists() && file.isFile) {
|
||||
callback.onDataReady(file)
|
||||
} else {
|
||||
callback.onLoadFailed(Exception("File not found: $filePath"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {}
|
||||
|
||||
override fun cancel() {}
|
||||
|
||||
override fun getDataClass(): Class<File> = File::class.java
|
||||
|
||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||
}
|
||||
|
||||
class Factory : ModelLoaderFactory<String, File> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<String, File> {
|
||||
return FilePathLoader()
|
||||
}
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.legato.kazusa.help.glide
|
||||
|
||||
import com.bumptech.glide.load.model.Headers
|
||||
|
||||
class GlideHeaders(private val headers: MutableMap<String, String>) : Headers {
|
||||
|
||||
override fun getHeaders(): MutableMap<String, String> {
|
||||
return headers
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package io.legato.kazusa.help.glide
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import io.legato.kazusa.utils.isAbsUrl
|
||||
import io.legato.kazusa.utils.isContentScheme
|
||||
import io.legato.kazusa.utils.isDataUrl
|
||||
import io.legato.kazusa.utils.lifecycle
|
||||
import java.io.File
|
||||
|
||||
//https://bumptech.github.io/glide/doc/generatedapi.html
|
||||
//Instead of GlideApp, use com.bumptech.Glide
|
||||
@Suppress("unused")
|
||||
object ImageLoader {
|
||||
|
||||
/**
|
||||
* 自动判断path类型
|
||||
*/
|
||||
fun load(context: Context, path: String?): RequestBuilder<Drawable> {
|
||||
return when {
|
||||
path.isNullOrEmpty() -> Glide.with(context).load(path)
|
||||
path.isDataUrl() -> Glide.with(context).load(path)
|
||||
path.isAbsUrl() -> Glide.with(context).load(path)
|
||||
path.isContentScheme() -> Glide.with(context).load(Uri.parse(path))
|
||||
else -> kotlin.runCatching {
|
||||
Glide.with(context).load(File(path))
|
||||
}.getOrElse {
|
||||
Glide.with(context).load(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(fragment: Fragment, lifecycle: Lifecycle, path: String?): RequestBuilder<Drawable> {
|
||||
val requestManager = Glide.with(fragment).lifecycle(lifecycle)
|
||||
return when {
|
||||
path.isNullOrEmpty() -> requestManager.load(path)
|
||||
path.isDataUrl() -> requestManager.load(path)
|
||||
path.isAbsUrl() -> requestManager.load(path)
|
||||
path.isContentScheme() -> requestManager.load(Uri.parse(path))
|
||||
|
||||
else -> kotlin.runCatching {
|
||||
requestManager.load(File(path))
|
||||
}.getOrElse {
|
||||
requestManager.load(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadBitmap(context: Context, path: String?): RequestBuilder<Bitmap> {
|
||||
val requestManager = Glide.with(context).`as`(Bitmap::class.java)
|
||||
return when {
|
||||
path.isNullOrEmpty() -> requestManager.load(path)
|
||||
path.isDataUrl() -> requestManager.load(path)
|
||||
path.isAbsUrl() -> requestManager.load(path)
|
||||
path.isContentScheme() -> requestManager.load(Uri.parse(path))
|
||||
else -> kotlin.runCatching {
|
||||
requestManager.load(File(path))
|
||||
}.getOrElse {
|
||||
requestManager.load(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFile(context: Context, path: String?): RequestBuilder<File> {
|
||||
return when {
|
||||
path.isNullOrEmpty() -> Glide.with(context).asFile().load(path)
|
||||
path.isAbsUrl() -> Glide.with(context).asFile().load(path)
|
||||
path.isContentScheme() -> Glide.with(context).asFile().load(Uri.parse(path))
|
||||
else -> kotlin.runCatching {
|
||||
Glide.with(context).asFile().load(File(path))
|
||||
}.getOrElse {
|
||||
Glide.with(context).asFile().load(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(context: Context, @DrawableRes resId: Int?): RequestBuilder<Drawable> {
|
||||
return Glide.with(context).load(resId)
|
||||
}
|
||||
|
||||
fun load(context: Context, file: File?): RequestBuilder<Drawable> {
|
||||
return Glide.with(context).load(file)
|
||||
}
|
||||
|
||||
fun load(context: Context, uri: Uri?): RequestBuilder<Drawable> {
|
||||
return Glide.with(context).load(uri)
|
||||
}
|
||||
|
||||
fun load(context: Context, drawable: Drawable?): RequestBuilder<Drawable> {
|
||||
return Glide.with(context).load(drawable)
|
||||
}
|
||||
|
||||
fun load(context: Context, bitmap: Bitmap?): RequestBuilder<Drawable> {
|
||||
return Glide.with(context).load(bitmap)
|
||||
}
|
||||
|
||||
fun load(context: Context, bytes: ByteArray?): RequestBuilder<Drawable> {
|
||||
return Glide.with(context).load(bytes)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package io.legato.kazusa.help.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.model.ReadManga
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.utils.ImageUtils
|
||||
import com.script.rhino.runScriptWithContext
|
||||
import kotlinx.coroutines.Job
|
||||
import java.io.InputStream
|
||||
|
||||
class LegadoDataUrlLoader : ModelLoader<String, InputStream> {
|
||||
|
||||
override fun buildLoadData(
|
||||
model: String,
|
||||
width: Int,
|
||||
height: Int,
|
||||
options: Options
|
||||
): ModelLoader.LoadData<InputStream>? {
|
||||
if (options.get(OkHttpModelLoader.mangaOption) == false) {
|
||||
return null
|
||||
}
|
||||
return ModelLoader.LoadData(ObjectKey(model), LegadoDataUrlFetcher(model))
|
||||
}
|
||||
|
||||
override fun handles(model: String): Boolean {
|
||||
return model.startsWith("data:")
|
||||
}
|
||||
|
||||
class LegadoDataUrlFetcher(private val model: String) : DataFetcher<InputStream> {
|
||||
|
||||
private val coroutineContext = Job()
|
||||
|
||||
override fun loadData(
|
||||
priority: Priority,
|
||||
callback: DataFetcher.DataCallback<in InputStream>
|
||||
) {
|
||||
try {
|
||||
val bytes = AnalyzeUrl(
|
||||
model, source = ReadManga.bookSource,
|
||||
coroutineContext = coroutineContext
|
||||
).getByteArray()
|
||||
val decoded = runScriptWithContext(coroutineContext) {
|
||||
ImageUtils.decode(
|
||||
model, bytes, isCover = false, ReadManga.bookSource, ReadManga.book
|
||||
)?.inputStream()
|
||||
}
|
||||
if (decoded == null) {
|
||||
throw NoStackTraceException("漫画图片解密失败")
|
||||
}
|
||||
callback.onDataReady(decoded)
|
||||
} catch (e: Exception) {
|
||||
callback.onLoadFailed(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
coroutineContext.cancel()
|
||||
}
|
||||
|
||||
override fun getDataClass(): Class<InputStream> {
|
||||
return InputStream::class.java
|
||||
}
|
||||
|
||||
override fun getDataSource(): DataSource {
|
||||
return DataSource.LOCAL
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Factory : ModelLoaderFactory<String, InputStream> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<String, InputStream> {
|
||||
return LegadoDataUrlLoader()
|
||||
}
|
||||
|
||||
override fun teardown() {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package io.legato.kazusa.help.glide
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.GlideBuilder
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
|
||||
import com.bumptech.glide.load.engine.cache.MemorySizeCalculator
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
import io.legato.kazusa.BuildConfig
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
|
||||
@Suppress("unused")
|
||||
@GlideModule
|
||||
class LegadoGlideModule : AppGlideModule() {
|
||||
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
registry.replace(
|
||||
GlideUrl::class.java,
|
||||
InputStream::class.java,
|
||||
OkHttpModeLoaderFactory
|
||||
)
|
||||
registry.prepend(
|
||||
String::class.java,
|
||||
InputStream::class.java,
|
||||
LegadoDataUrlLoader.Factory()
|
||||
)
|
||||
registry.prepend(
|
||||
String::class.java,
|
||||
File::class.java,
|
||||
FilePathLoader.Factory()
|
||||
)
|
||||
}
|
||||
|
||||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||
super.applyOptions(context, builder)
|
||||
val calculator = MemorySizeCalculator.Builder(context).build()
|
||||
val bitmapPool = AsyncRecycleBitmapPool(calculator.bitmapPoolSize)
|
||||
builder.setMemorySizeCalculator(calculator)
|
||||
builder.setBitmapPool(bitmapPool)
|
||||
builder.setDiskCache(InternalCacheDiskCacheFactory(context, 1024 * 1024 * 1000))
|
||||
if (!BuildConfig.DEBUG && !AppConfig.recordLog) {
|
||||
builder.setLogLevel(Log.ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package io.legato.kazusa.help.glide
|
||||
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import java.io.InputStream
|
||||
|
||||
|
||||
object OkHttpModeLoaderFactory: ModelLoaderFactory<GlideUrl?, InputStream?> {
|
||||
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<GlideUrl?, InputStream?> {
|
||||
return OkHttpModelLoader
|
||||
}
|
||||
|
||||
override fun teardown() {
|
||||
// Do nothing, this instance doesn't own the client.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.legato.kazusa.help.glide
|
||||
|
||||
import com.bumptech.glide.load.Option
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import java.io.InputStream
|
||||
|
||||
object OkHttpModelLoader : ModelLoader<GlideUrl?, InputStream?> {
|
||||
|
||||
val loadOnlyWifiOption = Option.memory("loadOnlyWifi", false)
|
||||
val sourceOriginOption = Option.memory<String>("sourceOrigin")
|
||||
val mangaOption = Option.memory<Boolean>("manga",false)
|
||||
|
||||
override fun buildLoadData(
|
||||
model: GlideUrl,
|
||||
width: Int,
|
||||
height: Int,
|
||||
options: Options,
|
||||
): ModelLoader.LoadData<InputStream?> {
|
||||
return ModelLoader.LoadData(model, OkHttpStreamFetcher(model, options))
|
||||
}
|
||||
|
||||
override fun handles(model: GlideUrl): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package io.legato.kazusa.help.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.HttpException
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.util.ContentLengthInputStream
|
||||
import com.script.rhino.runScriptWithContext
|
||||
import io.legato.kazusa.data.entities.BaseSource
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.help.http.addHeaders
|
||||
import io.legato.kazusa.help.http.okHttpClient
|
||||
import io.legato.kazusa.help.http.okHttpClientManga
|
||||
import io.legato.kazusa.help.source.SourceHelp
|
||||
import io.legato.kazusa.model.ReadManga
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import io.legato.kazusa.utils.ImageUtils
|
||||
import io.legato.kazusa.utils.isWifiConnect
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import okhttp3.Call
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import splitties.init.appCtx
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
|
||||
class OkHttpStreamFetcher(
|
||||
private val url: GlideUrl,
|
||||
private val options: Options,
|
||||
) :
|
||||
DataFetcher<InputStream>, okhttp3.Callback {
|
||||
private var stream: InputStream? = null
|
||||
private var responseBody: ResponseBody? = null
|
||||
private var callback: DataFetcher.DataCallback<in InputStream>? = null
|
||||
private var source: BaseSource? = null
|
||||
private val manga = options.get(OkHttpModelLoader.mangaOption) == true
|
||||
private val coroutineContext = SupervisorJob()
|
||||
private val coroutineScope = CoroutineScope(coroutineContext)
|
||||
private lateinit var analyzedUrl: GlideUrl
|
||||
|
||||
@Volatile
|
||||
private var call: Call? = null
|
||||
|
||||
companion object {
|
||||
private val failUrl = hashSetOf<String>()
|
||||
}
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) {
|
||||
if (failUrl.contains(url.toStringUrl())) {
|
||||
callback.onLoadFailed(NoStackTraceException("跳过加载失败的图片"))
|
||||
return
|
||||
}
|
||||
val loadOnlyWifi = options.get(OkHttpModelLoader.loadOnlyWifiOption) ?: false
|
||||
if (loadOnlyWifi && !appCtx.isWifiConnect) {
|
||||
callback.onLoadFailed(NoStackTraceException("只在wifi加载图片"))
|
||||
return
|
||||
}
|
||||
|
||||
options.get(OkHttpModelLoader.sourceOriginOption)?.let { sourceUrl ->
|
||||
source = SourceHelp.getSource(sourceUrl)
|
||||
}
|
||||
|
||||
analyzedUrl = AnalyzeUrl(
|
||||
url.toString(),
|
||||
source = source,
|
||||
coroutineContext = coroutineContext
|
||||
).getGlideUrl()
|
||||
|
||||
val requestBuilder = Request.Builder().url(analyzedUrl.toStringUrl())
|
||||
requestBuilder.addHeaders(analyzedUrl.headers)
|
||||
val request: Request = requestBuilder.build()
|
||||
this.callback = callback
|
||||
call = if (manga) {
|
||||
okHttpClientManga.newCall(request)
|
||||
} else {
|
||||
okHttpClient.newCall(request)
|
||||
}
|
||||
call?.enqueue(this)
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
kotlin.runCatching {
|
||||
stream?.close()
|
||||
}
|
||||
responseBody?.close()
|
||||
coroutineContext.cancel()
|
||||
callback = null
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
call?.cancel()
|
||||
coroutineContext.cancel()
|
||||
}
|
||||
|
||||
override fun getDataClass(): Class<InputStream> {
|
||||
return InputStream::class.java
|
||||
}
|
||||
|
||||
override fun getDataSource(): DataSource {
|
||||
return DataSource.REMOTE
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
callback?.onLoadFailed(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
responseBody = response.body
|
||||
if (!response.isSuccessful) {
|
||||
if (!manga) {
|
||||
failUrl.add(url.toStringUrl())
|
||||
}
|
||||
callback?.onLoadFailed(HttpException(response.message, response.code))
|
||||
return
|
||||
}
|
||||
if (ImageUtils.skipDecode(source, !manga)) {
|
||||
onStreamReady(responseBody!!.byteStream())
|
||||
return
|
||||
}
|
||||
Coroutine.async(coroutineScope, executeContext = IO) {
|
||||
val decodeResult = runScriptWithContext(coroutineContext) {
|
||||
if (manga) {
|
||||
ImageUtils.decode(
|
||||
url.toString(),
|
||||
responseBody!!.bytes(),
|
||||
isCover = false,
|
||||
source,
|
||||
ReadManga.book
|
||||
)?.inputStream()
|
||||
} else {
|
||||
ImageUtils.decode(
|
||||
analyzedUrl.toStringUrl(), responseBody!!.byteStream(),
|
||||
isCover = true, source
|
||||
)
|
||||
}
|
||||
}
|
||||
onStreamReady(decodeResult)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStreamReady(inputStream: InputStream?) {
|
||||
if (inputStream == null) {
|
||||
if (!manga) {
|
||||
failUrl.add(url.toStringUrl())
|
||||
}
|
||||
callback?.onLoadFailed(NoStackTraceException("封面二次解密失败"))
|
||||
} else {
|
||||
val contentLength: Long =
|
||||
if (inputStream is ByteArrayInputStream) inputStream.available().toLong()
|
||||
else responseBody!!.contentLength()
|
||||
stream = ContentLengthInputStream.obtain(inputStream, contentLength)
|
||||
callback?.onDataReady(stream)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package io.legato.kazusa.help.glide.progress
|
||||
|
||||
typealias OnProgressListener = ((isComplete: Boolean, percentage: Int, bytesRead: Long, totalBytes: Long) -> Unit)?
|
||||
@@ -0,0 +1,62 @@
|
||||
package io.legato.kazusa.help.glide.progress
|
||||
|
||||
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 进度监听器管理类
|
||||
* 加入图片加载进度监听,加入Https支持
|
||||
*/
|
||||
object ProgressManager {
|
||||
private val listenersMap = ConcurrentHashMap<String, OnProgressListener>()
|
||||
|
||||
val LISTENER = object : ProgressResponseBody.InternalProgressListener {
|
||||
override fun onProgress(url: String, bytesRead: Long, totalBytes: Long) {
|
||||
getProgressListener(url)?.let {
|
||||
var percentage = (bytesRead * 1f / totalBytes * 100f).toInt()
|
||||
var isComplete = percentage >= 100
|
||||
if (percentage <= -100) {
|
||||
percentage = 0
|
||||
isComplete = true
|
||||
}
|
||||
it.invoke(isComplete, percentage, bytesRead, totalBytes)
|
||||
if (isComplete) {
|
||||
removeListener(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addListener(url: String, listener: OnProgressListener) {
|
||||
if (url.isNotEmpty() && listener != null) {
|
||||
val url = getUrlNoOption(url)
|
||||
listenersMap[url] = listener
|
||||
listener.invoke(false, 1, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeListener(url: String) {
|
||||
if (url.isNotEmpty()) {
|
||||
val url = getUrlNoOption(url)
|
||||
listenersMap.remove(url)
|
||||
}
|
||||
}
|
||||
|
||||
fun getProgressListener(url: String): OnProgressListener {
|
||||
return if (url.isEmpty() || listenersMap.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
listenersMap[url]
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUrlNoOption(url: String): String {
|
||||
val urlMatcher = AnalyzeUrl.paramPattern.matcher(url)
|
||||
return if (urlMatcher.find()) {
|
||||
url.substring(0, urlMatcher.start())
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package io.legato.kazusa.help.glide.progress
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.ResponseBody
|
||||
import okio.*
|
||||
import java.io.IOException
|
||||
import kotlin.jvm.Throws
|
||||
|
||||
class ProgressResponseBody internal constructor(private val url: String, private val internalProgressListener: InternalProgressListener?, private val responseBody: ResponseBody) : ResponseBody() {
|
||||
private var bufferedSource: BufferedSource? = null
|
||||
override fun contentType(): MediaType? {
|
||||
return responseBody.contentType()
|
||||
}
|
||||
|
||||
override fun contentLength(): Long {
|
||||
return responseBody.contentLength()
|
||||
}
|
||||
|
||||
override fun source(): BufferedSource {
|
||||
if (bufferedSource == null) {
|
||||
bufferedSource = source(responseBody.source()).buffer()
|
||||
}
|
||||
return bufferedSource!!
|
||||
}
|
||||
|
||||
private fun source(source: Source): Source {
|
||||
return object : ForwardingSource(source) {
|
||||
var totalBytesRead: Long = 0
|
||||
var lastTotalBytesRead: Long = 0
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val bytesRead = super.read(sink, byteCount)
|
||||
totalBytesRead += if (bytesRead == -1L) 0 else bytesRead
|
||||
if (internalProgressListener != null && lastTotalBytesRead != totalBytesRead) {
|
||||
lastTotalBytesRead = totalBytesRead
|
||||
mainThreadHandler.post { internalProgressListener.onProgress(url, totalBytesRead, contentLength()) }
|
||||
}
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface InternalProgressListener {
|
||||
fun onProgress(url: String, bytesRead: Long, totalBytes: Long)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val mainThreadHandler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.http.SslError
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.AndroidRuntimeException
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import io.legato.kazusa.constant.AppConst
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.utils.runOnUI
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Runnable
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.apache.commons.text.StringEscapeUtils
|
||||
import splitties.init.appCtx
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/**
|
||||
* 后台webView
|
||||
*/
|
||||
class BackstageWebView(
|
||||
private val url: String? = null,
|
||||
private val html: String? = null,
|
||||
private val encode: String? = null,
|
||||
private val tag: String? = null,
|
||||
private val headerMap: Map<String, String>? = null,
|
||||
private val sourceRegex: String? = null,
|
||||
private val overrideUrlRegex: String? = null,
|
||||
private val javaScript: String? = null,
|
||||
private val delayTime: Long = 0,
|
||||
) {
|
||||
|
||||
private val mHandler = Handler(Looper.getMainLooper())
|
||||
private var callback: Callback? = null
|
||||
private var mWebView: WebView? = null
|
||||
|
||||
suspend fun getStrResponse(): StrResponse = withTimeout(60000L) {
|
||||
suspendCancellableCoroutine { block ->
|
||||
block.invokeOnCancellation {
|
||||
runOnUI {
|
||||
destroy()
|
||||
}
|
||||
}
|
||||
callback = object : Callback() {
|
||||
override fun onResult(response: StrResponse) {
|
||||
if (!block.isCompleted) {
|
||||
block.resume(response)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
if (!block.isCompleted)
|
||||
block.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
runOnUI {
|
||||
try {
|
||||
load()
|
||||
} catch (error: Throwable) {
|
||||
block.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEncoding(): String {
|
||||
return encode ?: "utf-8"
|
||||
}
|
||||
|
||||
@Throws(AndroidRuntimeException::class)
|
||||
private fun load() {
|
||||
val webView = createWebView()
|
||||
mWebView = webView
|
||||
try {
|
||||
when {
|
||||
!html.isNullOrEmpty() -> if (url.isNullOrEmpty()) {
|
||||
webView.loadData(html, "text/html", getEncoding())
|
||||
} else {
|
||||
webView.loadDataWithBaseURL(url, html, "text/html", getEncoding(), url)
|
||||
}
|
||||
|
||||
else -> if (headerMap == null) {
|
||||
webView.loadUrl(url!!)
|
||||
} else {
|
||||
webView.loadUrl(url!!, headerMap)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback?.onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
|
||||
private fun createWebView(): WebView {
|
||||
val webView = WebView(appCtx)
|
||||
val settings = webView.settings
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.blockNetworkImage = true
|
||||
settings.userAgentString = headerMap?.get(AppConst.UA_NAME) ?: AppConfig.userAgent
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
if (sourceRegex.isNullOrBlank() && overrideUrlRegex.isNullOrBlank()) {
|
||||
webView.webViewClient = HtmlWebViewClient()
|
||||
} else {
|
||||
webView.webViewClient = SnifferWebClient()
|
||||
}
|
||||
return webView
|
||||
}
|
||||
|
||||
private fun destroy() {
|
||||
mWebView?.destroy()
|
||||
mWebView = null
|
||||
}
|
||||
|
||||
private fun getJs(): String {
|
||||
javaScript?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
return it
|
||||
}
|
||||
}
|
||||
return JS
|
||||
}
|
||||
|
||||
private fun setCookie(url: String) {
|
||||
tag?.let {
|
||||
Coroutine.async(executeContext = IO) {
|
||||
val cookie = CookieManager.getInstance().getCookie(url)
|
||||
CookieStore.setCookie(it, cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class HtmlWebViewClient : WebViewClient() {
|
||||
|
||||
private var runnable: EvalJsRunnable? = null
|
||||
private var isRedirect = false
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): Boolean {
|
||||
isRedirect = isRedirect || if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
request.isRedirect
|
||||
} else {
|
||||
request.url.toString() != view.url
|
||||
}
|
||||
return super.shouldOverrideUrlLoading(view, request)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
setCookie(url)
|
||||
if (runnable == null) {
|
||||
runnable = EvalJsRunnable(view, url, getJs())
|
||||
}
|
||||
mHandler.removeCallbacks(runnable!!)
|
||||
mHandler.postDelayed(runnable!!, 1000 + delayTime)
|
||||
}
|
||||
|
||||
@SuppressLint("WebViewClientOnReceivedSslError")
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: SslErrorHandler?,
|
||||
error: SslError?
|
||||
) {
|
||||
handler?.proceed()
|
||||
}
|
||||
|
||||
private inner class EvalJsRunnable(
|
||||
webView: WebView,
|
||||
private val url: String,
|
||||
private val mJavaScript: String
|
||||
) : Runnable {
|
||||
var retry = 0
|
||||
private val mWebView: WeakReference<WebView> = WeakReference(webView)
|
||||
override fun run() {
|
||||
mWebView.get()?.evaluateJavascript(mJavaScript) {
|
||||
handleResult(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleResult(result: String) = Coroutine.async {
|
||||
if (result.isNotEmpty() && result != "null") {
|
||||
val content = StringEscapeUtils.unescapeJson(result)
|
||||
.replace(quoteRegex, "")
|
||||
try {
|
||||
val response = buildStrResponse(content)
|
||||
callback?.onResult(response)
|
||||
} catch (e: Exception) {
|
||||
callback?.onError(e)
|
||||
}
|
||||
mHandler.post {
|
||||
destroy()
|
||||
}
|
||||
return@async
|
||||
}
|
||||
if (retry > 30) {
|
||||
callback?.onError(NoStackTraceException("js执行超时"))
|
||||
mHandler.post {
|
||||
destroy()
|
||||
}
|
||||
return@async
|
||||
}
|
||||
retry++
|
||||
mHandler.postDelayed(this@EvalJsRunnable, 1000)
|
||||
}
|
||||
|
||||
private fun buildStrResponse(content: String): StrResponse {
|
||||
if (!isRedirect) {
|
||||
return StrResponse(url, content)
|
||||
}
|
||||
val originUrl = this@BackstageWebView.url ?: url
|
||||
val originResponse = Response.Builder()
|
||||
.code(302)
|
||||
.request(Request.Builder().url(originUrl).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.message("Found")
|
||||
.build()
|
||||
val response = Response.Builder()
|
||||
.code(200)
|
||||
.request(Request.Builder().url(url).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.message("OK")
|
||||
.priorResponse(originResponse)
|
||||
.build()
|
||||
return StrResponse(response, content)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private inner class SnifferWebClient : WebViewClient() {
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): Boolean {
|
||||
if (shouldOverrideUrlLoading(request.url.toString())) {
|
||||
return true
|
||||
}
|
||||
return super.shouldOverrideUrlLoading(view, request)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION", "KotlinRedundantDiagnosticSuppress")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
if (shouldOverrideUrlLoading(url)) {
|
||||
return true
|
||||
}
|
||||
return super.shouldOverrideUrlLoading(view, url)
|
||||
}
|
||||
|
||||
private fun shouldOverrideUrlLoading(requestUrl: String): Boolean {
|
||||
overrideUrlRegex?.let {
|
||||
if (requestUrl.matches(it.toRegex())) {
|
||||
try {
|
||||
val response = StrResponse(url!!, requestUrl)
|
||||
callback?.onResult(response)
|
||||
} catch (e: Exception) {
|
||||
callback?.onError(e)
|
||||
}
|
||||
destroy()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onLoadResource(view: WebView, resUrl: String) {
|
||||
sourceRegex?.let {
|
||||
if (resUrl.matches(it.toRegex())) {
|
||||
try {
|
||||
val response = StrResponse(url!!, resUrl)
|
||||
callback?.onResult(response)
|
||||
} catch (e: Exception) {
|
||||
callback?.onError(e)
|
||||
}
|
||||
destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageFinished(webView: WebView, url: String) {
|
||||
setCookie(url)
|
||||
if (!javaScript.isNullOrEmpty()) {
|
||||
val runnable = LoadJsRunnable(webView, javaScript)
|
||||
mHandler.postDelayed(runnable, 1000L + delayTime)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("WebViewClientOnReceivedSslError")
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: SslErrorHandler?,
|
||||
error: SslError?
|
||||
) {
|
||||
handler?.proceed()
|
||||
}
|
||||
|
||||
private inner class LoadJsRunnable(
|
||||
webView: WebView,
|
||||
private val mJavaScript: String?
|
||||
) : Runnable {
|
||||
private val mWebView: WeakReference<WebView> = WeakReference(webView)
|
||||
override fun run() {
|
||||
mWebView.get()?.loadUrl("javascript:${mJavaScript}")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val JS = "document.documentElement.outerHTML"
|
||||
private val quoteRegex = "^\"|\"$".toRegex()
|
||||
}
|
||||
|
||||
abstract class Callback {
|
||||
abstract fun onResult(response: StrResponse)
|
||||
abstract fun onError(error: Throwable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.help.CacheManager
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.splitNotBlank
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Connection
|
||||
|
||||
@Suppress("ConstPropertyName")
|
||||
object CookieManager {
|
||||
/**
|
||||
* <domain>_session_cookie 会话期 cookie,应用重启后失效
|
||||
* <domain>_cookie cookies 缓存
|
||||
*/
|
||||
|
||||
const val cookieJarHeader = "CookieJar"
|
||||
|
||||
/**
|
||||
* 从响应中保存Cookies
|
||||
*/
|
||||
fun saveResponse(response: Response) {
|
||||
val url = response.request.url
|
||||
val headers = response.headers
|
||||
saveCookiesFromHeaders(url, headers)
|
||||
}
|
||||
|
||||
fun saveResponse(response: Connection.Response) {
|
||||
val url = response.url().toHttpUrlOrNull() ?: return
|
||||
val headers = response.multiHeaders().toHeaders()
|
||||
saveCookiesFromHeaders(url, headers)
|
||||
}
|
||||
|
||||
private fun saveCookiesFromHeaders(url: HttpUrl, headers: Headers) {
|
||||
val domain = NetworkUtils.getSubDomain(url.toString())
|
||||
val cookies = Cookie.parseAll(url, headers)
|
||||
|
||||
val sessionCookie = cookies.filter { !it.persistent }.getString()
|
||||
updateSessionCookie(domain, sessionCookie)
|
||||
|
||||
val cookieString = cookies.filter { it.persistent }.getString()
|
||||
CookieStore.replaceCookie(domain, cookieString)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载Cookies到请求中
|
||||
*/
|
||||
fun loadRequest(request: Request): Request {
|
||||
val url = request.url.toString()
|
||||
val domain = NetworkUtils.getSubDomain(url)
|
||||
|
||||
val cookie = CookieStore.getCookie(domain)
|
||||
val requestCookie = request.header("Cookie")
|
||||
|
||||
val newCookie = mergeCookies(requestCookie, cookie) ?: return request
|
||||
|
||||
kotlin.runCatching {
|
||||
return request.newBuilder()
|
||||
.header("Cookie", newCookie)
|
||||
.build()
|
||||
}.onFailure {
|
||||
CookieStore.removeCookie(url)
|
||||
val msg = "设置cookie出错,已清除cookie $domain cookie:$newCookie\n$it"
|
||||
AppLog.put(msg, it)
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
private fun getSessionCookieMap(domain: String): MutableMap<String, String>? {
|
||||
return getSessionCookie(domain)?.let { CookieStore.cookieToMap(it) }
|
||||
}
|
||||
|
||||
fun getSessionCookie(domain: String): String? {
|
||||
return CacheManager.getFromMemory("${domain}_session_cookie") as? String
|
||||
}
|
||||
|
||||
private fun updateSessionCookie(domain: String, cookies: String) {
|
||||
val sessionCookie = getSessionCookie(domain)
|
||||
if (sessionCookie.isNullOrEmpty()) {
|
||||
CacheManager.putMemory("${domain}_session_cookie", cookies)
|
||||
return
|
||||
}
|
||||
|
||||
val ck = mergeCookies(sessionCookie, cookies) ?: return
|
||||
CacheManager.putMemory("${domain}_session_cookie", ck)
|
||||
}
|
||||
|
||||
fun mergeCookies(vararg cookies: String?): String? {
|
||||
val cookieMap = mergeCookiesToMap(*cookies)
|
||||
return CookieStore.mapToCookie(cookieMap)
|
||||
}
|
||||
|
||||
fun mergeCookiesToMap(vararg cookies: String?): MutableMap<String, String> {
|
||||
return cookies.filterNotNull().map {
|
||||
CookieStore.cookieToMap(it)
|
||||
}.reduce { acc, cookieMap ->
|
||||
acc.apply { putAll(cookieMap) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个Cookie
|
||||
*/
|
||||
fun removeCookie(url: String, key: String) {
|
||||
val domain = NetworkUtils.getSubDomain(url)
|
||||
|
||||
getSessionCookieMap(domain)?.let {
|
||||
it.remove(key)
|
||||
CookieStore.mapToCookie(it)?.let { cookie ->
|
||||
CacheManager.putMemory("${domain}_session_cookie", cookie)
|
||||
}
|
||||
}
|
||||
|
||||
val cookie = getCookieNoSession(url)
|
||||
if (cookie.isNotEmpty()) {
|
||||
val cookieMap = CookieStore.cookieToMap(cookie).apply { remove(key) }
|
||||
CookieStore.mapToCookie(cookieMap)?.let {
|
||||
CookieStore.setCookie(url, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCookieNoSession(url: String): String {
|
||||
val domain = NetworkUtils.getSubDomain(url)
|
||||
val cacheCookie = CacheManager.getFromMemory("${domain}_cookie") as? String
|
||||
|
||||
return if (cacheCookie != null) {
|
||||
cacheCookie
|
||||
} else {
|
||||
val cookieBean = appDb.cookieDao.get(domain)
|
||||
cookieBean?.cookie ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
fun applyToWebView(url: String) {
|
||||
val baseUrl = NetworkUtils.getBaseUrl(url) ?: return
|
||||
val cookies = CookieStore.getCookie(url).splitNotBlank(";")
|
||||
val cookieManager = CookieManager.getInstance()
|
||||
cookieManager.removeSessionCookies(null)
|
||||
cookies.forEach {
|
||||
cookieManager.setCookie(baseUrl, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun List<Cookie>.getString() = buildString {
|
||||
this@getString.forEachIndexed { index, cookie ->
|
||||
if (index > 0) append("; ")
|
||||
append(cookie.name).append('=').append(cookie.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, List<String>>.toHeaders(): Headers {
|
||||
return Headers.Builder().apply {
|
||||
this@toHeaders.forEach { (k, v) ->
|
||||
v.forEach {
|
||||
add(k, it)
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import android.text.TextUtils
|
||||
import androidx.annotation.Keep
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.AppPattern.equalsRegex
|
||||
import io.legato.kazusa.constant.AppPattern.semicolonRegex
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Cookie
|
||||
import io.legato.kazusa.help.CacheManager
|
||||
import io.legato.kazusa.help.http.CookieManager.getCookieNoSession
|
||||
import io.legato.kazusa.help.http.CookieManager.mergeCookiesToMap
|
||||
import io.legato.kazusa.help.http.api.CookieManagerInterface
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.removeCookie
|
||||
|
||||
@Keep
|
||||
object CookieStore : CookieManagerInterface {
|
||||
|
||||
/**
|
||||
*保存cookie到数据库,会自动识别url的二级域名
|
||||
*/
|
||||
override fun setCookie(url: String, cookie: String?) {
|
||||
try {
|
||||
val domain = NetworkUtils.getSubDomain(url)
|
||||
CacheManager.putMemory("${domain}_cookie", cookie ?: "")
|
||||
val cookieBean = Cookie(domain, cookie ?: "")
|
||||
appDb.cookieDao.insert(cookieBean)
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("保存Cookie失败\n$e", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun replaceCookie(url: String, cookie: String) {
|
||||
if (TextUtils.isEmpty(url) || TextUtils.isEmpty(cookie)) {
|
||||
return
|
||||
}
|
||||
val oldCookie = getCookieNoSession(url)
|
||||
if (TextUtils.isEmpty(oldCookie)) {
|
||||
setCookie(url, cookie)
|
||||
} else {
|
||||
val cookieMap = cookieToMap(oldCookie)
|
||||
cookieMap.putAll(cookieToMap(cookie))
|
||||
val newCookie = mapToCookie(cookieMap)
|
||||
setCookie(url, newCookie)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*获取url所属的二级域名的cookie
|
||||
*/
|
||||
override fun getCookie(url: String): String {
|
||||
val domain = NetworkUtils.getSubDomain(url)
|
||||
|
||||
val cookie = getCookieNoSession(url)
|
||||
val sessionCookie = CookieManager.getSessionCookie(domain)
|
||||
|
||||
val cookieMap = mergeCookiesToMap(cookie, sessionCookie)
|
||||
|
||||
var ck = mapToCookie(cookieMap) ?: ""
|
||||
while (ck.length > 4096) {
|
||||
val removeKey = cookieMap.keys.random()
|
||||
CookieManager.removeCookie(url, removeKey)
|
||||
cookieMap.remove(removeKey)
|
||||
ck = mapToCookie(cookieMap) ?: ""
|
||||
}
|
||||
return ck
|
||||
}
|
||||
|
||||
fun getKey(url: String, key: String): String {
|
||||
val cookie = getCookie(url)
|
||||
val sessionCookie = CookieManager.getSessionCookie(url)
|
||||
val cookieMap = mergeCookiesToMap(cookie, sessionCookie)
|
||||
return cookieMap[key] ?: ""
|
||||
}
|
||||
|
||||
override fun removeCookie(url: String) {
|
||||
val domain = NetworkUtils.getSubDomain(url)
|
||||
appDb.cookieDao.delete(domain)
|
||||
CacheManager.deleteMemory("${domain}_cookie")
|
||||
CacheManager.deleteMemory("${domain}_session_cookie")
|
||||
android.webkit.CookieManager.getInstance().removeCookie(url)
|
||||
}
|
||||
|
||||
override fun cookieToMap(cookie: String): MutableMap<String, String> {
|
||||
val cookieMap = mutableMapOf<String, String>()
|
||||
if (cookie.isBlank()) {
|
||||
return cookieMap
|
||||
}
|
||||
val pairArray = cookie.split(semicolonRegex).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
for (pair in pairArray) {
|
||||
val pairs = pair.split(equalsRegex, 2).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
if (pairs.size <= 1) {
|
||||
continue
|
||||
}
|
||||
val key = pairs[0].trim { it <= ' ' }
|
||||
val value = pairs[1]
|
||||
if (value.isNotBlank() || value.trim { it <= ' ' } == "null") {
|
||||
cookieMap[key] = value.trim { it <= ' ' }
|
||||
}
|
||||
}
|
||||
return cookieMap
|
||||
}
|
||||
|
||||
override fun mapToCookie(cookieMap: Map<String, String>?): String? {
|
||||
if (cookieMap.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val builder = StringBuilder()
|
||||
cookieMap.keys.forEachIndexed { index, key ->
|
||||
if (index > 0) builder.append("; ")
|
||||
builder.append(key).append("=").append(cookieMap[key])
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
appDb.cookieDao.deleteOkHttp()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import io.legato.kazusa.lib.cronet.CronetInterceptor
|
||||
import io.legato.kazusa.lib.cronet.CronetLoader
|
||||
import okhttp3.Interceptor
|
||||
|
||||
object Cronet {
|
||||
|
||||
val loader: LoaderInterface? by lazy {
|
||||
CronetLoader
|
||||
}
|
||||
|
||||
fun preDownload() {
|
||||
loader?.preDownload()
|
||||
}
|
||||
|
||||
val interceptor: Interceptor? by lazy {
|
||||
CronetInterceptor(cookieJar)
|
||||
}
|
||||
|
||||
interface LoaderInterface {
|
||||
|
||||
fun install(): Boolean
|
||||
|
||||
fun preDownload()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.asResponseBody
|
||||
import okhttp3.internal.http.promisesBody
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.Inflater
|
||||
import java.util.zip.InflaterInputStream
|
||||
|
||||
object DecompressInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val requestBuilder = request.newBuilder()
|
||||
|
||||
var transparentDecompress = false
|
||||
if (request.header("Accept-Encoding") == null && request.header("Range") == null) {
|
||||
transparentDecompress = true
|
||||
requestBuilder.header("Accept-Encoding", "gzip, deflate")
|
||||
}
|
||||
|
||||
val response = chain.proceed(requestBuilder.build())
|
||||
val body = response.body
|
||||
|
||||
if (!transparentDecompress || !response.promisesBody() || body == null) {
|
||||
return response
|
||||
}
|
||||
|
||||
val encoding = response.header("Content-Encoding")?.lowercase()
|
||||
val source = when (encoding) {
|
||||
"gzip" -> GZIPInputStream(body.byteStream()).source().buffer()
|
||||
"deflate" -> InflaterInputStream(body.byteStream(), Inflater(true)).source().buffer()
|
||||
else -> return response
|
||||
}
|
||||
|
||||
return response.newBuilder()
|
||||
.removeHeader("Content-Encoding")
|
||||
.removeHeader("Content-Length")
|
||||
.body(source.asResponseBody(body.contentType(), -1))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import io.legato.kazusa.constant.AppConst
|
||||
import io.legato.kazusa.help.CacheManager
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.glide.progress.ProgressManager.LISTENER
|
||||
import io.legato.kazusa.help.glide.progress.ProgressResponseBody
|
||||
import io.legato.kazusa.help.http.CookieManager.cookieJarHeader
|
||||
import io.legato.kazusa.model.ReadManga
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ThreadFactory
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val proxyClientCache: ConcurrentHashMap<String, OkHttpClient> by lazy {
|
||||
ConcurrentHashMap()
|
||||
}
|
||||
|
||||
val cookieJar by lazy {
|
||||
object : CookieJar {
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
if (cookies.isEmpty()) return
|
||||
//临时保存 书源启用cookie选项再添加到数据库
|
||||
val cookieBuilder = StringBuilder()
|
||||
cookies.forEachIndexed { index, cookie ->
|
||||
if (index > 0) cookieBuilder.append(";")
|
||||
cookieBuilder.append(cookie.name).append('=').append(cookie.value)
|
||||
}
|
||||
val domain = NetworkUtils.getSubDomain(url.toString())
|
||||
CacheManager.putMemory("${domain}_cookieJar", cookieBuilder.toString())
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
val okHttpClient: OkHttpClient by lazy {
|
||||
val specs = arrayListOf(
|
||||
ConnectionSpec.MODERN_TLS,
|
||||
ConnectionSpec.COMPATIBLE_TLS,
|
||||
ConnectionSpec.CLEARTEXT
|
||||
)
|
||||
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.callTimeout(60, TimeUnit.SECONDS)
|
||||
//.cookieJar(cookieJar = cookieJar)
|
||||
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory, SSLHelper.unsafeTrustManager)
|
||||
.retryOnConnectionFailure(true)
|
||||
.hostnameVerifier(SSLHelper.unsafeHostnameVerifier)
|
||||
.connectionSpecs(specs)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(OkHttpExceptionInterceptor)
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
val builder = request.newBuilder()
|
||||
if (request.header(AppConst.UA_NAME) == null) {
|
||||
builder.addHeader(AppConst.UA_NAME, AppConfig.userAgent)
|
||||
} else if (request.header(AppConst.UA_NAME) == "null") {
|
||||
builder.removeHeader(AppConst.UA_NAME)
|
||||
}
|
||||
builder.addHeader("Keep-Alive", "300")
|
||||
builder.addHeader("Connection", "Keep-Alive")
|
||||
builder.addHeader("Cache-Control", "no-cache")
|
||||
chain.proceed(builder.build())
|
||||
}
|
||||
.addNetworkInterceptor { chain ->
|
||||
var request = chain.request()
|
||||
val enableCookieJar = request.header(cookieJarHeader) != null
|
||||
|
||||
if (enableCookieJar) {
|
||||
val requestBuilder = request.newBuilder()
|
||||
requestBuilder.removeHeader(cookieJarHeader)
|
||||
request = CookieManager.loadRequest(requestBuilder.build())
|
||||
}
|
||||
|
||||
val networkResponse = chain.proceed(request)
|
||||
|
||||
if (enableCookieJar) {
|
||||
CookieManager.saveResponse(networkResponse)
|
||||
}
|
||||
networkResponse
|
||||
}
|
||||
if (AppConfig.isCronet) {
|
||||
if (Cronet.loader?.install() == true) {
|
||||
Cronet.interceptor?.let {
|
||||
builder.addInterceptor(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
builder.addInterceptor(DecompressInterceptor)
|
||||
builder.build().apply {
|
||||
val okHttpName =
|
||||
OkHttpClient::class.java.name.removePrefix("okhttp3.").removeSuffix("Client")
|
||||
val executor = dispatcher.executorService as ThreadPoolExecutor
|
||||
val threadName = "$okHttpName Dispatcher"
|
||||
executor.threadFactory = ThreadFactory { runnable ->
|
||||
Thread(runnable, threadName).apply {
|
||||
isDaemon = false
|
||||
uncaughtExceptionHandler = OkhttpUncaughtExceptionHandler
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val okHttpClientManga by lazy {
|
||||
okHttpClient.newBuilder().run {
|
||||
val interceptors = interceptors()
|
||||
interceptors.add(1) { chain ->
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
val url = request.url.toString()
|
||||
response.newBuilder()
|
||||
.body(ProgressResponseBody(url, LISTENER, response.body!!))
|
||||
.build()
|
||||
}
|
||||
interceptors.add(1) { chain ->
|
||||
ReadManga.rateLimiter.withLimitBlocking {
|
||||
chain.proceed(chain.request())
|
||||
}
|
||||
}
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存代理okHttp
|
||||
*/
|
||||
fun getProxyClient(proxy: String? = null): OkHttpClient {
|
||||
if (proxy.isNullOrBlank()) {
|
||||
return okHttpClient
|
||||
}
|
||||
proxyClientCache[proxy]?.let {
|
||||
return it
|
||||
}
|
||||
val r = Regex("(http|socks4|socks5)://(.*):(\\d{2,5})(@.*@.*)?")
|
||||
val ms = r.findAll(proxy)
|
||||
val group = ms.first()
|
||||
var username = "" //代理服务器验证用户名
|
||||
var password = "" //代理服务器验证密码
|
||||
val type = if (group.groupValues[1] == "http") "http" else "socks"
|
||||
val host = group.groupValues[2]
|
||||
val port = group.groupValues[3].toInt()
|
||||
if (group.groupValues[4] != "") {
|
||||
username = group.groupValues[4].split("@")[1]
|
||||
password = group.groupValues[4].split("@")[2]
|
||||
}
|
||||
if (host != "") {
|
||||
val builder = okHttpClient.newBuilder()
|
||||
if (type == "http") {
|
||||
builder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port)))
|
||||
} else {
|
||||
builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress(host, port)))
|
||||
}
|
||||
if (username != "" && password != "") {
|
||||
builder.proxyAuthenticator { _, response -> //设置代理服务器账号密码
|
||||
val credential: String = Credentials.basic(username, password)
|
||||
response.request.newBuilder()
|
||||
.header("Proxy-Authorization", credential)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
val proxyClient = builder.build()
|
||||
proxyClientCache[proxy] = proxyClient
|
||||
return proxyClient
|
||||
}
|
||||
return okHttpClient
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
object OkHttpExceptionInterceptor : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
try {
|
||||
return chain.proceed(chain.request())
|
||||
} catch (e: IOException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import io.legato.kazusa.utils.EncodingDetect
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.Utf8BomUtils
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.internal.http.RealResponseBody
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.charset.Charset
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
suspend fun OkHttpClient.newCallResponse(
|
||||
retry: Int = 0,
|
||||
builder: Request.Builder.() -> Unit
|
||||
): Response {
|
||||
val requestBuilder = Request.Builder()
|
||||
requestBuilder.apply(builder)
|
||||
var response: Response? = null
|
||||
for (i in 0..retry) {
|
||||
response = newCall(requestBuilder.build()).await()
|
||||
if (response.isSuccessful) {
|
||||
return response
|
||||
}
|
||||
}
|
||||
return response!!
|
||||
}
|
||||
|
||||
suspend fun OkHttpClient.newCallResponseBody(
|
||||
retry: Int = 0,
|
||||
builder: Request.Builder.() -> Unit
|
||||
): ResponseBody {
|
||||
return newCallResponse(retry, builder).let {
|
||||
it.body ?: throw IOException(it.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun OkHttpClient.newCallStrResponse(
|
||||
retry: Int = 0,
|
||||
builder: Request.Builder.() -> Unit
|
||||
): StrResponse {
|
||||
return newCallResponse(retry, builder).let {
|
||||
StrResponse(it, it.body?.text() ?: it.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Call.await(): Response = suspendCancellableCoroutine { block ->
|
||||
|
||||
block.invokeOnCancellation {
|
||||
cancel()
|
||||
}
|
||||
|
||||
enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
block.resumeWithException(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
block.resume(response)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
fun ResponseBody.text(encode: String? = null): String {
|
||||
val responseBytes = Utf8BomUtils.removeUTF8BOM(bytes())
|
||||
var charsetName: String? = encode
|
||||
|
||||
charsetName?.let {
|
||||
return String(responseBytes, Charset.forName(charsetName))
|
||||
}
|
||||
|
||||
//根据http头判断
|
||||
contentType()?.charset()?.let { charset ->
|
||||
return String(responseBytes, charset)
|
||||
}
|
||||
|
||||
//根据内容判断
|
||||
charsetName = EncodingDetect.getHtmlEncode(responseBytes)
|
||||
return String(responseBytes, Charset.forName(charsetName))
|
||||
}
|
||||
|
||||
fun ResponseBody.decompressed(): ResponseBody {
|
||||
val contentType = contentType()?.toString()
|
||||
if (contentType != "application/zip") {
|
||||
return this
|
||||
}
|
||||
val source = ZipInputStream(byteStream()).apply {
|
||||
try {
|
||||
nextEntry
|
||||
} catch (e: Exception) {
|
||||
close()
|
||||
throw e
|
||||
}
|
||||
}.source().buffer()
|
||||
return RealResponseBody(null, -1, source)
|
||||
}
|
||||
|
||||
fun Request.Builder.addHeaders(headers: Map<String, String>) {
|
||||
headers.forEach {
|
||||
addHeader(it.key, it.value)
|
||||
}
|
||||
}
|
||||
|
||||
fun Request.Builder.get(url: String, queryMap: Map<String, String>, encoded: Boolean = false) {
|
||||
val httpBuilder = url.toHttpUrl().newBuilder()
|
||||
queryMap.forEach {
|
||||
if (encoded) {
|
||||
httpBuilder.addEncodedQueryParameter(it.key, it.value)
|
||||
} else {
|
||||
httpBuilder.addQueryParameter(it.key, it.value)
|
||||
}
|
||||
}
|
||||
url(httpBuilder.build())
|
||||
}
|
||||
|
||||
fun Request.Builder.get(url: String, encodedQuery: String?) {
|
||||
val httpBuilder = url.toHttpUrl().newBuilder()
|
||||
httpBuilder.encodedQuery(encodedQuery)
|
||||
url(httpBuilder.build())
|
||||
}
|
||||
|
||||
private val formContentType = "application/x-www-form-urlencoded".toMediaType()
|
||||
|
||||
fun Request.Builder.postForm(encodedForm: String) {
|
||||
post(encodedForm.toRequestBody(formContentType))
|
||||
}
|
||||
|
||||
fun Request.Builder.postForm(form: Map<String, String>, encoded: Boolean = false) {
|
||||
val formBody = FormBody.Builder()
|
||||
form.forEach {
|
||||
if (encoded) {
|
||||
formBody.addEncoded(it.key, it.value)
|
||||
} else {
|
||||
formBody.add(it.key, it.value)
|
||||
}
|
||||
}
|
||||
post(formBody.build())
|
||||
}
|
||||
|
||||
fun Request.Builder.postMultipart(type: String?, form: Map<String, Any>) {
|
||||
val multipartBody = MultipartBody.Builder()
|
||||
type?.let {
|
||||
multipartBody.setType(type.toMediaType())
|
||||
}
|
||||
form.forEach {
|
||||
when (val value = it.value) {
|
||||
is Map<*, *> -> {
|
||||
val fileName = value["fileName"] as String
|
||||
val file = value["file"]
|
||||
val mediaType = (value["contentType"] as? String)?.toMediaType()
|
||||
val requestBody = when (file) {
|
||||
is File -> {
|
||||
file.asRequestBody(mediaType)
|
||||
}
|
||||
|
||||
is ByteArray -> {
|
||||
file.toRequestBody(mediaType)
|
||||
}
|
||||
|
||||
is String -> {
|
||||
file.toRequestBody(mediaType)
|
||||
}
|
||||
|
||||
else -> {
|
||||
GSON.toJson(file).toRequestBody(mediaType)
|
||||
}
|
||||
}
|
||||
multipartBody.addFormDataPart(it.key, fileName, requestBody)
|
||||
}
|
||||
|
||||
else -> multipartBody.addFormDataPart(it.key, it.value.toString())
|
||||
}
|
||||
}
|
||||
post(multipartBody.build())
|
||||
}
|
||||
|
||||
fun Request.Builder.postJson(json: String?) {
|
||||
json?.let {
|
||||
val requestBody = json.toRequestBody("application/json; charset=UTF-8".toMediaType())
|
||||
post(requestBody)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
|
||||
object OkhttpUncaughtExceptionHandler : Thread.UncaughtExceptionHandler {
|
||||
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
AppLog.put("Okhttp Dispatcher中的线程执行出错\n${e.localizedMessage}", e)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
enum class RequestMethod {
|
||||
GET, POST
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.http.X509TrustManagerExtensions
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.security.KeyManagementException
|
||||
import java.security.KeyStore
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.*
|
||||
|
||||
@Suppress("unused")
|
||||
object SSLHelper {
|
||||
|
||||
/**
|
||||
* 为了解决客户端不信任服务器数字证书的问题,
|
||||
* 网络上大部分的解决方案都是让客户端不对证书做任何检查,
|
||||
* 这是一种有很大安全漏洞的办法
|
||||
*/
|
||||
val unsafeTrustManager: X509TrustManager =
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
object : X509TrustManager {
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
//do nothing,接受任意客户端证书
|
||||
}
|
||||
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
//do nothing,接受任意客户端证书
|
||||
}
|
||||
|
||||
fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, host: String): List<X509Certificate> {
|
||||
return chain.toList()
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> {
|
||||
return arrayOf()
|
||||
}
|
||||
}
|
||||
|
||||
val unsafeTrustManagerExtensions by lazy {
|
||||
X509TrustManagerExtensions(unsafeTrustManager)
|
||||
}
|
||||
|
||||
val unsafeSSLSocketFactory: SSLSocketFactory by lazy {
|
||||
try {
|
||||
val sslContext = SSLContext.getInstance("SSL")
|
||||
sslContext.init(null, arrayOf(unsafeTrustManager), SecureRandom())
|
||||
sslContext.socketFactory
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 此类是用于主机名验证的基接口。 在握手期间,如果 URL 的主机名和服务器的标识主机名不匹配,
|
||||
* 则验证机制可以回调此接口的实现程序来确定是否应该允许此连接。策略可以是基于证书的或依赖于其他验证方案。
|
||||
* 当验证 URL 主机名使用的默认规则失败时使用这些回调。如果主机名是可接受的,则返回 true
|
||||
*/
|
||||
val unsafeHostnameVerifier: HostnameVerifier = HostnameVerifier { _, _ -> true }
|
||||
|
||||
class SSLParams {
|
||||
lateinit var sSLSocketFactory: SSLSocketFactory
|
||||
lateinit var trustManager: X509TrustManager
|
||||
}
|
||||
|
||||
/**
|
||||
* https单向认证
|
||||
* 可以额外配置信任服务端的证书策略,否则默认是按CA证书去验证的,若不是CA可信任的证书,则无法通过验证
|
||||
*/
|
||||
fun getSslSocketFactory(trustManager: X509TrustManager): SSLParams? {
|
||||
return getSslSocketFactoryBase(trustManager, null, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* https单向认证
|
||||
* 用含有服务端公钥的证书校验服务端证书
|
||||
*/
|
||||
fun getSslSocketFactory(vararg certificates: InputStream): SSLParams? {
|
||||
return getSslSocketFactoryBase(null, null, null, *certificates)
|
||||
}
|
||||
|
||||
/**
|
||||
* https双向认证
|
||||
* bksFile 和 password -> 客户端使用bks证书校验服务端证书
|
||||
* certificates -> 用含有服务端公钥的证书校验服务端证书
|
||||
*/
|
||||
fun getSslSocketFactory(
|
||||
bksFile: InputStream,
|
||||
password: String,
|
||||
vararg certificates: InputStream
|
||||
): SSLParams? {
|
||||
return getSslSocketFactoryBase(null, bksFile, password, *certificates)
|
||||
}
|
||||
|
||||
/**
|
||||
* https双向认证
|
||||
* bksFile 和 password -> 客户端使用bks证书校验服务端证书
|
||||
* X509TrustManager -> 如果需要自己校验,那么可以自己实现相关校验,如果不需要自己校验,那么传null即可
|
||||
*/
|
||||
fun getSslSocketFactory(
|
||||
bksFile: InputStream,
|
||||
password: String,
|
||||
trustManager: X509TrustManager
|
||||
): SSLParams? {
|
||||
return getSslSocketFactoryBase(trustManager, bksFile, password)
|
||||
}
|
||||
|
||||
private fun getSslSocketFactoryBase(
|
||||
trustManager: X509TrustManager?,
|
||||
bksFile: InputStream?,
|
||||
password: String?,
|
||||
vararg certificates: InputStream
|
||||
): SSLParams? {
|
||||
val sslParams = SSLParams()
|
||||
try {
|
||||
val keyManagers = prepareKeyManager(bksFile, password)
|
||||
val trustManagers = prepareTrustManager(*certificates)
|
||||
val manager: X509TrustManager = trustManager ?: chooseTrustManager(trustManagers)
|
||||
// 创建TLS类型的SSLContext对象, that uses our TrustManager
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
// 用上面得到的trustManagers初始化SSLContext,这样sslContext就会信任keyStore中的证书
|
||||
// 第一个参数是授权的密钥管理器,用来授权验证,比如授权自签名的证书验证。第二个是被授权的证书管理器,用来验证服务器端的证书
|
||||
sslContext.init(keyManagers, arrayOf<TrustManager>(manager), null)
|
||||
// 通过sslContext获取SSLSocketFactory对象
|
||||
sslParams.sSLSocketFactory = sslContext.socketFactory
|
||||
sslParams.trustManager = manager
|
||||
return sslParams
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
e.printOnDebug()
|
||||
} catch (e: KeyManagementException) {
|
||||
e.printOnDebug()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun prepareKeyManager(bksFile: InputStream?, password: String?): Array<KeyManager>? {
|
||||
try {
|
||||
if (bksFile == null || password == null) return null
|
||||
val clientKeyStore = KeyStore.getInstance("BKS")
|
||||
clientKeyStore.load(bksFile, password.toCharArray())
|
||||
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||
kmf.init(clientKeyStore, password.toCharArray())
|
||||
return kmf.keyManagers
|
||||
} catch (e: Exception) {
|
||||
e.printOnDebug()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun prepareTrustManager(vararg certificates: InputStream): Array<TrustManager> {
|
||||
val certificateFactory = CertificateFactory.getInstance("X.509")
|
||||
// 创建一个默认类型的KeyStore,存储我们信任的证书
|
||||
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
keyStore.load(null)
|
||||
for ((index, certStream) in certificates.withIndex()) {
|
||||
val certificateAlias = index.toString()
|
||||
// 证书工厂根据证书文件的流生成证书 cert
|
||||
val cert = certificateFactory.generateCertificate(certStream)
|
||||
// 将 cert 作为可信证书放入到keyStore中
|
||||
keyStore.setCertificateEntry(certificateAlias, cert)
|
||||
try {
|
||||
certStream.close()
|
||||
} catch (e: IOException) {
|
||||
e.printOnDebug()
|
||||
}
|
||||
}
|
||||
//我们创建一个默认类型的TrustManagerFactory
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
//用我们之前的keyStore实例初始化TrustManagerFactory,这样tmf就会信任keyStore中的证书
|
||||
tmf.init(keyStore)
|
||||
//通过tmf获取TrustManager数组,TrustManager也会信任keyStore中的证书
|
||||
return tmf.trustManagers
|
||||
}
|
||||
|
||||
private fun chooseTrustManager(trustManagers: Array<TrustManager>): X509TrustManager {
|
||||
for (trustManager in trustManagers) {
|
||||
if (trustManager is X509TrustManager) {
|
||||
return trustManager
|
||||
}
|
||||
}
|
||||
throw NullPointerException()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.legato.kazusa.help.http
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Response.Builder
|
||||
import okhttp3.ResponseBody
|
||||
|
||||
/**
|
||||
* An HTTP response.
|
||||
*/
|
||||
@Keep
|
||||
@Suppress("unused", "MemberVisibilityCanBePrivate")
|
||||
class StrResponse {
|
||||
var raw: Response
|
||||
private set
|
||||
var body: String? = null
|
||||
private set
|
||||
var errorBody: ResponseBody? = null
|
||||
private set
|
||||
|
||||
constructor(rawResponse: Response, body: String?) {
|
||||
this.raw = rawResponse
|
||||
this.body = body
|
||||
}
|
||||
|
||||
constructor(url: String, body: String?) {
|
||||
val request = try {
|
||||
Request.Builder().url(url).build()
|
||||
} catch (e: Exception) {
|
||||
Request.Builder().url("http://localhost/").build()
|
||||
}
|
||||
raw = Builder()
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.request(request)
|
||||
.build()
|
||||
this.body = body
|
||||
}
|
||||
|
||||
constructor(rawResponse: Response, errorBody: ResponseBody?) {
|
||||
this.raw = rawResponse
|
||||
this.errorBody = errorBody
|
||||
}
|
||||
|
||||
fun raw() = raw
|
||||
|
||||
fun url(): String {
|
||||
raw.networkResponse?.let {
|
||||
return it.request.url.toString()
|
||||
}
|
||||
return raw.request.url.toString()
|
||||
}
|
||||
|
||||
val url: String get() = url()
|
||||
|
||||
fun body() = body
|
||||
|
||||
fun code(): Int {
|
||||
return raw.code
|
||||
}
|
||||
|
||||
fun message(): String {
|
||||
return raw.message
|
||||
}
|
||||
|
||||
fun headers(): Headers {
|
||||
return raw.headers
|
||||
}
|
||||
|
||||
fun isSuccessful(): Boolean = raw.isSuccessful
|
||||
|
||||
fun errorBody(): ResponseBody? {
|
||||
return errorBody
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return raw.toString()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.legato.kazusa.help.http.api
|
||||
|
||||
interface CookieManagerInterface {
|
||||
|
||||
/**
|
||||
* 保存cookie
|
||||
*/
|
||||
fun setCookie(url: String, cookie: String?)
|
||||
|
||||
/**
|
||||
* 替换cookie
|
||||
*/
|
||||
fun replaceCookie(url: String, cookie: String)
|
||||
|
||||
/**
|
||||
* 获取cookie
|
||||
*/
|
||||
fun getCookie(url: String): String
|
||||
|
||||
/**
|
||||
* 移除cookie
|
||||
*/
|
||||
fun removeCookie(url: String)
|
||||
|
||||
fun cookieToMap(cookie: String): MutableMap<String, String>
|
||||
|
||||
fun mapToCookie(cookieMap: Map<String, String>?): String?
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package io.legato.kazusa.help.rhino
|
||||
|
||||
import com.script.rhino.JavaObjectWrapFactory
|
||||
import org.mozilla.javascript.NativeJavaObject
|
||||
import org.mozilla.javascript.Scriptable
|
||||
|
||||
class NativeBaseSource(scope: Scriptable?, javaObject: Any, staticType: Class<*>?) :
|
||||
NativeJavaObject(scope, javaObject, staticType) {
|
||||
|
||||
override fun has(name: String, start: Scriptable): Boolean {
|
||||
if (name != "setVariable" && name.length > 3 && name.startsWith("set")) {
|
||||
val name = name.substring(3).replaceFirstChar { it.lowercase() }
|
||||
if (super.has(name, start)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return super.has(name, start)
|
||||
}
|
||||
|
||||
override fun get(name: String, start: Scriptable): Any? {
|
||||
if (name != "setVariable" && name.length > 3 && name.startsWith("set")) {
|
||||
val name = name.substring(3).replaceFirstChar { it.lowercase() }
|
||||
if (super.has(name, start)) {
|
||||
return NOT_FOUND
|
||||
}
|
||||
}
|
||||
return super.get(name, start)
|
||||
}
|
||||
|
||||
override fun put(
|
||||
name: String,
|
||||
start: Scriptable,
|
||||
value: Any?
|
||||
) {
|
||||
if (name == "variable") {
|
||||
super.put(name, start, value)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val factory = JavaObjectWrapFactory { scope, javaObject, staticType ->
|
||||
NativeBaseSource(scope, javaObject, staticType)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package io.legato.kazusa.help.source
|
||||
|
||||
import io.legato.kazusa.constant.SourceType
|
||||
import io.legato.kazusa.data.entities.BaseSource
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.RssSource
|
||||
import io.legato.kazusa.model.SharedJsScope
|
||||
import org.mozilla.javascript.Scriptable
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
fun BaseSource.getShareScope(coroutineContext: CoroutineContext? = null): Scriptable? {
|
||||
return SharedJsScope.getScope(jsLib, coroutineContext)
|
||||
}
|
||||
|
||||
fun BaseSource.getSourceType(): Int {
|
||||
return when (this) {
|
||||
is BookSource -> SourceType.book
|
||||
is RssSource -> SourceType.rss
|
||||
else -> error("unknown source type: ${this::class.simpleName}.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package io.legato.kazusa.help.source
|
||||
|
||||
import com.script.rhino.runScriptWithContext
|
||||
import io.legato.kazusa.constant.BookSourceType
|
||||
import io.legato.kazusa.constant.BookType
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.BookSourcePart
|
||||
import io.legato.kazusa.data.entities.rule.ExploreKind
|
||||
import io.legato.kazusa.utils.ACache
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
import io.legato.kazusa.utils.fromJsonArray
|
||||
import io.legato.kazusa.utils.isJsonArray
|
||||
import io.legato.kazusa.utils.printOnDebug
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 采用md5作为key可以在分类修改后自动重新计算,不需要手动刷新
|
||||
*/
|
||||
|
||||
private val mutexMap by lazy { hashMapOf<String, Mutex>() }
|
||||
private val exploreKindsMap by lazy { ConcurrentHashMap<String, List<ExploreKind>>() }
|
||||
private val aCache by lazy { ACache.get("explore") }
|
||||
|
||||
private fun BookSource.getExploreKindsKey(): String {
|
||||
return MD5Utils.md5Encode(bookSourceUrl + exploreUrl)
|
||||
}
|
||||
|
||||
private fun BookSourcePart.getExploreKindsKey(): String {
|
||||
return getBookSource()!!.getExploreKindsKey()
|
||||
}
|
||||
|
||||
suspend fun BookSourcePart.exploreKinds(): List<ExploreKind> {
|
||||
return getBookSource()!!.exploreKinds()
|
||||
}
|
||||
|
||||
suspend fun BookSource.exploreKinds(): List<ExploreKind> {
|
||||
val exploreKindsKey = getExploreKindsKey()
|
||||
exploreKindsMap[exploreKindsKey]?.let { return it }
|
||||
val exploreUrl = exploreUrl
|
||||
if (exploreUrl.isNullOrBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
val mutex = mutexMap[bookSourceUrl] ?: Mutex().apply { mutexMap[bookSourceUrl] = this }
|
||||
mutex.withLock {
|
||||
exploreKindsMap[exploreKindsKey]?.let { return it }
|
||||
val kinds = arrayListOf<ExploreKind>()
|
||||
withContext(Dispatchers.IO) {
|
||||
kotlin.runCatching {
|
||||
var ruleStr = exploreUrl
|
||||
if (exploreUrl.startsWith("<js>", true)
|
||||
|| exploreUrl.startsWith("@js:", true)
|
||||
) {
|
||||
ruleStr = aCache.getAsString(exploreKindsKey)
|
||||
if (ruleStr.isNullOrBlank()) {
|
||||
val jsStr = if (exploreUrl.startsWith("@")) {
|
||||
exploreUrl.substring(4)
|
||||
} else {
|
||||
exploreUrl.substring(4, exploreUrl.lastIndexOf("<"))
|
||||
}
|
||||
ruleStr = runScriptWithContext {
|
||||
evalJS(jsStr).toString().trim()
|
||||
}
|
||||
aCache.put(exploreKindsKey, ruleStr)
|
||||
}
|
||||
}
|
||||
if (ruleStr.isJsonArray()) {
|
||||
GSON.fromJsonArray<ExploreKind>(ruleStr).getOrThrow().let {
|
||||
kinds.addAll(it)
|
||||
}
|
||||
} else {
|
||||
ruleStr.split("(&&|\n)+".toRegex()).forEach { kindStr ->
|
||||
val kindCfg = kindStr.split("::")
|
||||
kinds.add(ExploreKind(kindCfg.first(), kindCfg.getOrNull(1)))
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
kinds.add(ExploreKind("ERROR:${it.localizedMessage}", it.stackTraceToString()))
|
||||
it.printOnDebug()
|
||||
}
|
||||
}
|
||||
exploreKindsMap[exploreKindsKey] = kinds
|
||||
return kinds
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun BookSourcePart.clearExploreKindsCache() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val exploreKindsKey = getExploreKindsKey()
|
||||
aCache.remove(exploreKindsKey)
|
||||
exploreKindsMap.remove(exploreKindsKey)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun BookSource.clearExploreKindsCache() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val exploreKindsKey = getExploreKindsKey()
|
||||
aCache.remove(exploreKindsKey)
|
||||
exploreKindsMap.remove(exploreKindsKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun BookSource.exploreKindsJson(): String {
|
||||
val exploreKindsKey = getExploreKindsKey()
|
||||
return aCache.getAsString(exploreKindsKey)?.takeIf { it.isJsonArray() }
|
||||
?: exploreUrl.takeIf { it.isJsonArray() }
|
||||
?: ""
|
||||
}
|
||||
|
||||
fun BookSource.getBookType(): Int {
|
||||
return when (bookSourceType) {
|
||||
BookSourceType.file -> BookType.text or BookType.webFile
|
||||
BookSourceType.image -> BookType.image
|
||||
BookSourceType.audio -> BookType.audio
|
||||
else -> BookType.text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package io.legato.kazusa.help.source
|
||||
|
||||
import io.legato.kazusa.data.entities.RssSource
|
||||
import io.legato.kazusa.utils.ACache
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import com.script.rhino.runScriptWithContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val aCache by lazy { ACache.get("rssSortUrl") }
|
||||
|
||||
private fun RssSource.getSortUrlsKey(): String {
|
||||
return MD5Utils.md5Encode(sourceUrl + sortUrl)
|
||||
}
|
||||
|
||||
suspend fun RssSource.sortUrls(): List<Pair<String, String>> {
|
||||
return arrayListOf<Pair<String, String>>().apply {
|
||||
val sortUrlsKey = getSortUrlsKey()
|
||||
withContext(Dispatchers.IO) {
|
||||
kotlin.runCatching {
|
||||
var str = sortUrl
|
||||
if (sortUrl?.startsWith("<js>", false) == true
|
||||
|| sortUrl?.startsWith("@js:", false) == true
|
||||
) {
|
||||
str = aCache.getAsString(sortUrlsKey)
|
||||
if (str.isNullOrBlank()) {
|
||||
val jsStr = if (sortUrl!!.startsWith("@")) {
|
||||
sortUrl!!.substring(4)
|
||||
} else {
|
||||
sortUrl!!.substring(4, sortUrl!!.lastIndexOf("<"))
|
||||
}
|
||||
str = runScriptWithContext {
|
||||
evalJS(jsStr).toString()
|
||||
}
|
||||
aCache.put(sortUrlsKey, str)
|
||||
}
|
||||
}
|
||||
str?.split("(&&|\n)+".toRegex())?.forEach { sort ->
|
||||
val name = sort.substringBefore("::")
|
||||
val url = sort.substringAfter("::", "")
|
||||
if (url.isNotEmpty()) {
|
||||
if (url.startsWith("{{")) {
|
||||
add(Pair(name, url))
|
||||
} else {
|
||||
add(Pair(name, NetworkUtils.getAbsoluteURL(sourceUrl, url)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isEmpty()) {
|
||||
add(Pair("", sourceUrl))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun RssSource.removeSortCache() {
|
||||
withContext(Dispatchers.IO) {
|
||||
aCache.remove(getSortUrlsKey())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package io.legato.kazusa.help.source
|
||||
|
||||
import io.legato.kazusa.constant.SourceType
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.BaseSource
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.BookSourcePart
|
||||
import io.legato.kazusa.data.entities.RssSource
|
||||
import io.legato.kazusa.help.AppCacheManager
|
||||
import io.legato.kazusa.help.config.SourceConfig
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.model.AudioPlay
|
||||
import io.legato.kazusa.model.ReadBook
|
||||
import io.legato.kazusa.model.ReadManga
|
||||
import io.legato.kazusa.utils.EncoderUtils
|
||||
import io.legato.kazusa.utils.NetworkUtils
|
||||
import io.legato.kazusa.utils.splitNotBlank
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import splitties.init.appCtx
|
||||
|
||||
object SourceHelp {
|
||||
|
||||
private val list18Plus by lazy {
|
||||
try {
|
||||
return@lazy String(appCtx.assets.open("18PlusList.txt").readBytes())
|
||||
.splitNotBlank("\n").map {
|
||||
EncoderUtils.base64Decode(it)
|
||||
}.toHashSet()
|
||||
} catch (_: Exception) {
|
||||
return@lazy emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
fun getSource(key: String?): BaseSource? {
|
||||
key ?: return null
|
||||
if (ReadBook.bookSource?.bookSourceUrl == key) {
|
||||
return ReadBook.bookSource
|
||||
} else if (AudioPlay.bookSource?.bookSourceUrl == key) {
|
||||
return AudioPlay.bookSource
|
||||
} else if (ReadManga.bookSource?.bookSourceUrl == key) {
|
||||
return ReadManga.bookSource
|
||||
}
|
||||
return appDb.bookSourceDao.getBookSource(key)
|
||||
?: appDb.rssSourceDao.getByKey(key)
|
||||
}
|
||||
|
||||
fun getSource(key: String?, @SourceType.Type type: Int): BaseSource? {
|
||||
key ?: return null
|
||||
return when (type) {
|
||||
SourceType.book -> appDb.bookSourceDao.getBookSource(key)
|
||||
SourceType.rss -> appDb.rssSourceDao.getByKey(key)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSource(key: String, @SourceType.Type type: Int) {
|
||||
when (type) {
|
||||
SourceType.book -> deleteBookSource(key)
|
||||
SourceType.rss -> deleteRssSource(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteBookSourceParts(sources: List<BookSourcePart>) {
|
||||
appDb.runInTransaction {
|
||||
sources.forEach {
|
||||
deleteBookSourceInternal(it.bookSourceUrl)
|
||||
}
|
||||
}
|
||||
AppCacheManager.clearSourceVariables()
|
||||
}
|
||||
|
||||
fun deleteBookSources(sources: List<BookSource>) {
|
||||
appDb.runInTransaction {
|
||||
sources.forEach {
|
||||
deleteBookSourceInternal(it.bookSourceUrl)
|
||||
}
|
||||
}
|
||||
AppCacheManager.clearSourceVariables()
|
||||
}
|
||||
|
||||
private fun deleteBookSourceInternal(key: String) {
|
||||
appDb.bookSourceDao.delete(key)
|
||||
appDb.cacheDao.deleteSourceVariables(key)
|
||||
SourceConfig.removeSource(key)
|
||||
}
|
||||
|
||||
fun deleteBookSource(key: String) {
|
||||
deleteBookSourceInternal(key)
|
||||
AppCacheManager.clearSourceVariables()
|
||||
}
|
||||
|
||||
fun deleteRssSources(sources: List<RssSource>) {
|
||||
appDb.runInTransaction {
|
||||
sources.forEach {
|
||||
deleteRssSourceInternal(it.sourceUrl)
|
||||
}
|
||||
}
|
||||
AppCacheManager.clearSourceVariables()
|
||||
}
|
||||
|
||||
private fun deleteRssSourceInternal(key: String) {
|
||||
appDb.rssSourceDao.delete(key)
|
||||
appDb.rssArticleDao.delete(key)
|
||||
appDb.cacheDao.deleteSourceVariables(key)
|
||||
}
|
||||
|
||||
fun deleteRssSource(key: String) {
|
||||
deleteRssSourceInternal(key)
|
||||
AppCacheManager.clearSourceVariables()
|
||||
}
|
||||
|
||||
fun enableSource(key: String, @SourceType.Type type: Int, enable: Boolean) {
|
||||
when (type) {
|
||||
SourceType.book -> appDb.bookSourceDao.enable(key, enable)
|
||||
SourceType.rss -> appDb.rssSourceDao.enable(key, enable)
|
||||
}
|
||||
}
|
||||
|
||||
fun insertRssSource(vararg rssSources: RssSource) {
|
||||
val rssSourcesGroup = rssSources.groupBy {
|
||||
is18Plus(it.sourceUrl)
|
||||
}
|
||||
rssSourcesGroup[true]?.forEach {
|
||||
appCtx.toastOnUi("${it.sourceName}是18+网址,禁止导入.")
|
||||
}
|
||||
rssSourcesGroup[false]?.let {
|
||||
appDb.rssSourceDao.insert(*it.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun insertBookSource(vararg bookSources: BookSource) {
|
||||
val bookSourcesGroup = bookSources.groupBy {
|
||||
is18Plus(it.bookSourceUrl)
|
||||
}
|
||||
bookSourcesGroup[true]?.forEach {
|
||||
appCtx.toastOnUi("${it.bookSourceName}是18+网址,禁止导入.")
|
||||
}
|
||||
bookSourcesGroup[false]?.let {
|
||||
appDb.bookSourceDao.insert(*it.toTypedArray())
|
||||
}
|
||||
Coroutine.async {
|
||||
adjustSortNumber()
|
||||
}
|
||||
}
|
||||
|
||||
private fun is18Plus(url: String?): Boolean {
|
||||
if (list18Plus.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
url ?: return false
|
||||
val baseUrl = NetworkUtils.getBaseUrl(url) ?: return false
|
||||
kotlin.runCatching {
|
||||
val host = baseUrl.split("//", ".").let {
|
||||
if (it.size > 2) "${it[it.lastIndex - 1]}.${it.last()}" else return false
|
||||
}
|
||||
return list18Plus.contains(host)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整排序序号
|
||||
*/
|
||||
fun adjustSortNumber() {
|
||||
if (
|
||||
appDb.bookSourceDao.maxOrder > 99999
|
||||
|| appDb.bookSourceDao.minOrder < -99999
|
||||
|| appDb.bookSourceDao.hasDuplicateOrder
|
||||
) {
|
||||
val sources = appDb.bookSourceDao.allPart
|
||||
sources.forEachIndexed { index, bookSource ->
|
||||
bookSource.customOrder = index
|
||||
}
|
||||
appDb.bookSourceDao.upOrder(sources)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package io.legato.kazusa.help.source
|
||||
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.data.entities.BaseSource
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.CacheManager
|
||||
import io.legato.kazusa.help.IntentData
|
||||
import io.legato.kazusa.ui.association.VerificationCodeActivity
|
||||
import io.legato.kazusa.ui.browser.WebViewActivity
|
||||
import io.legato.kazusa.utils.isMainThread
|
||||
import io.legato.kazusa.utils.startActivity
|
||||
import splitties.init.appCtx
|
||||
import java.util.concurrent.locks.LockSupport
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* 源验证
|
||||
*/
|
||||
object SourceVerificationHelp {
|
||||
|
||||
private val waitTime = 1.minutes.inWholeNanoseconds
|
||||
|
||||
private fun getVerificationResultKey(source: BaseSource) =
|
||||
getVerificationResultKey(source.getKey())
|
||||
|
||||
private fun getVerificationResultKey(sourceKey: String) = "${sourceKey}_verificationResult"
|
||||
|
||||
/**
|
||||
* 获取书源验证结果
|
||||
* 图片验证码 防爬 滑动验证码 点击字符 等等
|
||||
*/
|
||||
fun getVerificationResult(
|
||||
source: BaseSource?,
|
||||
url: String,
|
||||
title: String,
|
||||
useBrowser: Boolean,
|
||||
refetchAfterSuccess: Boolean = true
|
||||
): String {
|
||||
source
|
||||
?: throw NoStackTraceException("getVerificationResult parameter source cannot be null")
|
||||
if (isMainThread) {
|
||||
error("getVerificationResult must be called on a background thread")
|
||||
}
|
||||
|
||||
clearResult(source.getKey())
|
||||
|
||||
if (!useBrowser) {
|
||||
appCtx.startActivity<VerificationCodeActivity> {
|
||||
putExtra("imageUrl", url)
|
||||
putExtra("sourceOrigin", source.getKey())
|
||||
putExtra("sourceName", source.getTag())
|
||||
putExtra("sourceType", source.getSourceType())
|
||||
IntentData.put(getVerificationResultKey(source), Thread.currentThread())
|
||||
}
|
||||
} else {
|
||||
startBrowser(source, url, title, true, refetchAfterSuccess)
|
||||
}
|
||||
|
||||
var waitUserInput = false
|
||||
while (getResult(source.getKey()) == null) {
|
||||
if (!waitUserInput) {
|
||||
AppLog.putDebug("等待返回验证结果...")
|
||||
waitUserInput = true
|
||||
}
|
||||
LockSupport.parkNanos(this, waitTime)
|
||||
}
|
||||
|
||||
return getResult(source.getKey())!!.let {
|
||||
it.ifBlank {
|
||||
throw NoStackTraceException("验证结果为空")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动内置浏览器
|
||||
* @param saveResult 保存网页源代码到数据库
|
||||
*/
|
||||
fun startBrowser(
|
||||
source: BaseSource?,
|
||||
url: String,
|
||||
title: String,
|
||||
saveResult: Boolean? = false,
|
||||
refetchAfterSuccess: Boolean? = true
|
||||
) {
|
||||
source ?: throw NoStackTraceException("startBrowser parameter source cannot be null")
|
||||
appCtx.startActivity<WebViewActivity> {
|
||||
putExtra("title", title)
|
||||
putExtra("url", url)
|
||||
putExtra("sourceOrigin", source.getKey())
|
||||
putExtra("sourceName", source.getTag())
|
||||
putExtra("sourceType", source.getSourceType())
|
||||
putExtra("sourceVerificationEnable", saveResult)
|
||||
putExtra("refetchAfterSuccess", refetchAfterSuccess)
|
||||
IntentData.put(getVerificationResultKey(source), Thread.currentThread())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun checkResult(sourceKey: String) {
|
||||
getResult(sourceKey) ?: setResult(sourceKey, "")
|
||||
val thread = IntentData.get<Thread>(getVerificationResultKey(sourceKey))
|
||||
LockSupport.unpark(thread)
|
||||
}
|
||||
|
||||
fun setResult(sourceKey: String, result: String?) {
|
||||
CacheManager.putMemory(getVerificationResultKey(sourceKey), result ?: "")
|
||||
}
|
||||
|
||||
fun getResult(sourceKey: String): String? {
|
||||
return CacheManager.get(getVerificationResultKey(sourceKey))
|
||||
}
|
||||
|
||||
fun clearResult(sourceKey: String) {
|
||||
CacheManager.delete(getVerificationResultKey(sourceKey))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package io.legato.kazusa.help.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.PreferKey
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.AppWebDav
|
||||
import io.legato.kazusa.help.DirectLinkUpload
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.config.LocalConfig
|
||||
import io.legato.kazusa.help.config.ReadBookConfig
|
||||
import io.legato.kazusa.help.config.ThemeConfig
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.utils.*
|
||||
import io.legato.kazusa.utils.compress.ZipUtils
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* 备份
|
||||
*/
|
||||
object Backup {
|
||||
|
||||
val backupPath: String by lazy {
|
||||
appCtx.filesDir.getFile("backup").createFolderIfNotExist().absolutePath
|
||||
}
|
||||
val zipFilePath = "${appCtx.externalFiles.absolutePath}${File.separator}tmp_backup.zip"
|
||||
|
||||
private const val TAG = "Backup"
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val backupFileNames by lazy {
|
||||
arrayOf(
|
||||
"bookshelf.json",
|
||||
"bookmark.json",
|
||||
"bookGroup.json",
|
||||
"bookSource.json",
|
||||
"rssSources.json",
|
||||
"rssStar.json",
|
||||
"replaceRule.json",
|
||||
"readRecord.json",
|
||||
"searchHistory.json",
|
||||
"sourceSub.json",
|
||||
"txtTocRule.json",
|
||||
"httpTTS.json",
|
||||
"keyboardAssists.json",
|
||||
"dictRule.json",
|
||||
"servers.json",
|
||||
DirectLinkUpload.ruleFileName,
|
||||
ReadBookConfig.configFileName,
|
||||
ReadBookConfig.shareConfigFileName,
|
||||
ThemeConfig.configFileName,
|
||||
"config.xml"
|
||||
)
|
||||
}
|
||||
|
||||
private fun getNowZipFileName(): String {
|
||||
val backupDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||
.format(Date(System.currentTimeMillis()))
|
||||
val deviceName = AppConfig.webDavDeviceName
|
||||
return if (deviceName?.isNotBlank() == true) {
|
||||
"backup${backupDate}-${deviceName}.zip"
|
||||
} else {
|
||||
"backup${backupDate}.zip"
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldBackup(): Boolean {
|
||||
val lastBackup = LocalConfig.lastBackup
|
||||
return lastBackup + TimeUnit.DAYS.toMillis(1) < System.currentTimeMillis()
|
||||
}
|
||||
|
||||
fun autoBack(context: Context) {
|
||||
if (shouldBackup()) {
|
||||
Coroutine.async {
|
||||
mutex.withLock {
|
||||
if (shouldBackup()) {
|
||||
val backupZipFileName = getNowZipFileName()
|
||||
if (!AppWebDav.hasBackUp(backupZipFileName)) {
|
||||
backup(context, AppConfig.backupPath)
|
||||
} else {
|
||||
LocalConfig.lastBackup = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onError {
|
||||
AppLog.put("自动备份失败\n${it.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun backupLocked(context: Context, path: String?) {
|
||||
mutex.withLock {
|
||||
withContext(IO) {
|
||||
backup(context, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun backup(context: Context, path: String?) {
|
||||
LogUtils.d(TAG, "开始备份 path:$path")
|
||||
LocalConfig.lastBackup = System.currentTimeMillis()
|
||||
val aes = BackupAES()
|
||||
FileUtils.delete(backupPath)
|
||||
writeListToJson(appDb.bookDao.all, "bookshelf.json", backupPath)
|
||||
writeListToJson(appDb.bookmarkDao.all, "bookmark.json", backupPath)
|
||||
writeListToJson(appDb.bookGroupDao.all, "bookGroup.json", backupPath)
|
||||
writeListToJson(appDb.bookSourceDao.all, "bookSource.json", backupPath)
|
||||
writeListToJson(appDb.rssSourceDao.all, "rssSources.json", backupPath)
|
||||
writeListToJson(appDb.rssStarDao.all, "rssStar.json", backupPath)
|
||||
writeListToJson(appDb.replaceRuleDao.all, "replaceRule.json", backupPath)
|
||||
writeListToJson(appDb.readRecordDao.all, "readRecord.json", backupPath)
|
||||
writeListToJson(appDb.searchKeywordDao.all, "searchHistory.json", backupPath)
|
||||
writeListToJson(appDb.ruleSubDao.all, "sourceSub.json", backupPath)
|
||||
writeListToJson(appDb.txtTocRuleDao.all, "txtTocRule.json", backupPath)
|
||||
writeListToJson(appDb.httpTTSDao.all, "httpTTS.json", backupPath)
|
||||
writeListToJson(appDb.keyboardAssistsDao.all, "keyboardAssists.json", backupPath)
|
||||
writeListToJson(appDb.dictRuleDao.all, "dictRule.json", backupPath)
|
||||
GSON.toJson(appDb.serverDao.all).let { json ->
|
||||
aes.runCatching {
|
||||
encryptBase64(json)
|
||||
}.getOrDefault(json).let {
|
||||
FileUtils.createFileIfNotExist(backupPath + File.separator + "servers.json")
|
||||
.writeText(it)
|
||||
}
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
GSON.toJson(ReadBookConfig.configList).let {
|
||||
FileUtils.createFileIfNotExist(backupPath + File.separator + ReadBookConfig.configFileName)
|
||||
.writeText(it)
|
||||
}
|
||||
GSON.toJson(ReadBookConfig.shareConfig).let {
|
||||
FileUtils.createFileIfNotExist(backupPath + File.separator + ReadBookConfig.shareConfigFileName)
|
||||
.writeText(it)
|
||||
}
|
||||
GSON.toJson(ThemeConfig.configList).let {
|
||||
FileUtils.createFileIfNotExist(backupPath + File.separator + ThemeConfig.configFileName)
|
||||
.writeText(it)
|
||||
}
|
||||
DirectLinkUpload.getConfig()?.let {
|
||||
FileUtils.createFileIfNotExist(backupPath + File.separator + DirectLinkUpload.ruleFileName)
|
||||
.writeText(GSON.toJson(it))
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
appCtx.getSharedPreferences(backupPath, "config")?.let { sp ->
|
||||
val edit = sp.edit()
|
||||
appCtx.defaultSharedPreferences.all.forEach { (key, value) ->
|
||||
if (BackupConfig.keyIsNotIgnore(key)) {
|
||||
when (key) {
|
||||
PreferKey.webDavPassword -> {
|
||||
edit.putString(key, aes.runCatching {
|
||||
encryptBase64(value.toString())
|
||||
}.getOrDefault(value.toString()))
|
||||
}
|
||||
|
||||
else -> when (value) {
|
||||
is Int -> edit.putInt(key, value)
|
||||
is Boolean -> edit.putBoolean(key, value)
|
||||
is Long -> edit.putLong(key, value)
|
||||
is Float -> edit.putFloat(key, value)
|
||||
is String -> edit.putString(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edit.commit()
|
||||
}
|
||||
coroutineContext.ensureActive()
|
||||
val zipFileName = getNowZipFileName()
|
||||
val paths = arrayListOf(*backupFileNames)
|
||||
for (i in 0 until paths.size) {
|
||||
paths[i] = backupPath + File.separator + paths[i]
|
||||
}
|
||||
FileUtils.delete(zipFilePath)
|
||||
FileUtils.delete(zipFilePath.replace("tmp_", ""))
|
||||
val backupFileName = if (AppConfig.onlyLatestBackup) {
|
||||
"backup.zip"
|
||||
} else {
|
||||
zipFileName
|
||||
}
|
||||
if (ZipUtils.zipFiles(paths, zipFilePath)) {
|
||||
when {
|
||||
path.isNullOrBlank() -> {
|
||||
copyBackup(context.getExternalFilesDir(null)!!, backupFileName)
|
||||
}
|
||||
|
||||
path.isContentScheme() -> {
|
||||
copyBackup(context, Uri.parse(path), backupFileName)
|
||||
}
|
||||
|
||||
else -> {
|
||||
copyBackup(File(path), backupFileName)
|
||||
}
|
||||
}
|
||||
try {
|
||||
AppWebDav.backUpWebDav(zipFileName)
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("上传备份至webdav失败\n$e", e)
|
||||
}
|
||||
}
|
||||
FileUtils.delete(backupPath)
|
||||
FileUtils.delete(zipFilePath)
|
||||
coroutineContext.ensureActive()
|
||||
ReadBookConfig.getAllPicBgStr().map {
|
||||
if (it.contains(File.separator)) {
|
||||
File(it)
|
||||
} else {
|
||||
appCtx.externalFiles.getFile("bg", it)
|
||||
}
|
||||
}.let {
|
||||
AppWebDav.upBgs(it.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writeListToJson(list: List<Any>, fileName: String, path: String) {
|
||||
coroutineContext.ensureActive()
|
||||
withContext(IO) {
|
||||
if (list.isNotEmpty()) {
|
||||
LogUtils.d(TAG, "阅读备份 $fileName 列表大小 ${list.size}")
|
||||
val file = FileUtils.createFileIfNotExist(path + File.separator + fileName)
|
||||
file.outputStream().buffered().use {
|
||||
GSON.writeToOutputStream(it, list)
|
||||
}
|
||||
LogUtils.d(TAG, "阅读备份 $fileName 写入大小 ${file.length()}")
|
||||
} else {
|
||||
LogUtils.d(TAG, "阅读备份 $fileName 列表为空")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
@Suppress("SameParameterValue")
|
||||
private fun copyBackup(context: Context, uri: Uri, fileName: String) {
|
||||
val treeDoc = DocumentFile.fromTreeUri(context, uri)!!
|
||||
treeDoc.findFile(fileName)?.delete()
|
||||
val fileDoc = treeDoc.createFile("", fileName)
|
||||
?: throw NoStackTraceException("创建文件失败")
|
||||
val outputS = fileDoc.openOutputStream()
|
||||
?: throw NoStackTraceException("打开OutputStream失败")
|
||||
outputS.use {
|
||||
FileInputStream(zipFilePath).use { inputS ->
|
||||
inputS.copyTo(outputS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
@Suppress("SameParameterValue")
|
||||
private fun copyBackup(rootFile: File, fileName: String) {
|
||||
FileInputStream(File(zipFilePath)).use { inputS ->
|
||||
val file = FileUtils.createFileIfNotExist(rootFile, fileName)
|
||||
FileOutputStream(file).use { outputS ->
|
||||
inputS.copyTo(outputS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
FileUtils.delete(backupPath)
|
||||
FileUtils.delete(zipFilePath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package io.legato.kazusa.help.storage
|
||||
|
||||
import cn.hutool.crypto.symmetric.AES
|
||||
import io.legato.kazusa.help.config.LocalConfig
|
||||
import io.legato.kazusa.utils.MD5Utils
|
||||
|
||||
class BackupAES : AES(
|
||||
MD5Utils.md5Encode(LocalConfig.password ?: "").encodeToByteArray(0, 16)
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
package io.legato.kazusa.help.storage
|
||||
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.PreferKey
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import splitties.init.appCtx
|
||||
|
||||
/**
|
||||
* 备份配置
|
||||
*/
|
||||
@Suppress("ConstPropertyName")
|
||||
object BackupConfig {
|
||||
|
||||
private val ignoreConfigPath = FileUtils.getPath(appCtx.filesDir, "restoreIgnore.json")
|
||||
val ignoreConfig: HashMap<String, Boolean> by lazy {
|
||||
val file = FileUtils.createFileIfNotExist(ignoreConfigPath)
|
||||
val json = file.readText()
|
||||
GSON.fromJsonObject<HashMap<String, Boolean>>(json).getOrNull() ?: hashMapOf()
|
||||
}
|
||||
|
||||
private const val readConfigKey = "readConfig"
|
||||
private const val themeConfigKey = "themeConfig"
|
||||
private const val coverConfigKey = "coverConfig"
|
||||
private const val localBookKey = "localBook"
|
||||
|
||||
//配置忽略key
|
||||
val ignoreKeys = arrayOf(
|
||||
readConfigKey,
|
||||
PreferKey.themeMode,
|
||||
themeConfigKey,
|
||||
coverConfigKey,
|
||||
PreferKey.bookshelfLayout,
|
||||
PreferKey.showRss,
|
||||
PreferKey.threadCount,
|
||||
localBookKey
|
||||
)
|
||||
|
||||
//配置忽略标题
|
||||
val ignoreTitle = arrayOf(
|
||||
appCtx.getString(R.string.read_config),
|
||||
appCtx.getString(R.string.theme_mode),
|
||||
appCtx.getString(R.string.theme_config),
|
||||
appCtx.getString(R.string.cover_config),
|
||||
appCtx.getString(R.string.bookshelf_layout),
|
||||
appCtx.getString(R.string.show_rss),
|
||||
appCtx.getString(R.string.thread_count),
|
||||
appCtx.getString(R.string.local_book)
|
||||
)
|
||||
|
||||
//自动忽略keys
|
||||
private val ignorePrefKeys = arrayOf(
|
||||
PreferKey.defaultCover,
|
||||
PreferKey.defaultCoverDark,
|
||||
PreferKey.backupPath,
|
||||
PreferKey.defaultBookTreeUri,
|
||||
PreferKey.webDavDeviceName,
|
||||
PreferKey.launcherIcon,
|
||||
PreferKey.bitmapCacheSize,
|
||||
PreferKey.webServiceWakeLock,
|
||||
PreferKey.readAloudWakeLock,
|
||||
PreferKey.audioPlayWakeLock
|
||||
)
|
||||
|
||||
//阅读配置
|
||||
private val readPrefKeys = arrayOf(
|
||||
PreferKey.readStyleSelect,
|
||||
PreferKey.comicStyleSelect,
|
||||
PreferKey.shareLayout,
|
||||
PreferKey.hideStatusBar,
|
||||
PreferKey.hideNavigationBar,
|
||||
PreferKey.autoReadSpeed,
|
||||
PreferKey.clickActionTL,
|
||||
PreferKey.clickActionTC,
|
||||
PreferKey.clickActionTR,
|
||||
PreferKey.clickActionML,
|
||||
PreferKey.clickActionMC,
|
||||
PreferKey.clickActionMR,
|
||||
PreferKey.clickActionBL,
|
||||
PreferKey.clickActionBC,
|
||||
PreferKey.clickActionBR
|
||||
)
|
||||
|
||||
private val themePrefKeys = arrayOf(
|
||||
PreferKey.cPrimary,
|
||||
PreferKey.cAccent,
|
||||
PreferKey.cBackground,
|
||||
PreferKey.cBBackground,
|
||||
PreferKey.bgImage,
|
||||
PreferKey.bgImageBlurring,
|
||||
PreferKey.cNPrimary,
|
||||
PreferKey.cNAccent,
|
||||
PreferKey.cNBackground,
|
||||
PreferKey.cNBBackground,
|
||||
PreferKey.bgImageN,
|
||||
PreferKey.bgImageNBlurring
|
||||
)
|
||||
|
||||
private val coverPrefKeys = arrayOf(
|
||||
PreferKey.useDefaultCover,
|
||||
PreferKey.loadCoverOnlyWifi,
|
||||
PreferKey.coverShowName,
|
||||
PreferKey.coverShowAuthor,
|
||||
PreferKey.coverShowNameN,
|
||||
PreferKey.coverShowAuthorN
|
||||
)
|
||||
|
||||
fun keyIsNotIgnore(key: String): Boolean {
|
||||
return when {
|
||||
ignorePrefKeys.contains(key) -> false
|
||||
ignoreReadConfig && readPrefKeys.contains(key) -> false
|
||||
ignoreThemeConfig && themePrefKeys.contains(key) -> false
|
||||
ignoreCoverConfig && coverPrefKeys.contains(key) -> false
|
||||
PreferKey.themeMode == key && ignoreThemeMode -> false
|
||||
PreferKey.bookshelfLayout == key && ignoreBookshelfLayout -> false
|
||||
PreferKey.showRss == key && ignoreShowRss -> false
|
||||
PreferKey.threadCount == key && ignoreThreadCount -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
val ignoreReadConfig: Boolean
|
||||
get() = ignoreConfig[readConfigKey] == true
|
||||
private val ignoreThemeMode: Boolean
|
||||
get() = ignoreConfig[PreferKey.themeMode] == true
|
||||
private val ignoreThemeConfig: Boolean
|
||||
get() = ignoreConfig[themeConfigKey] == true
|
||||
private val ignoreCoverConfig: Boolean
|
||||
get() = ignoreConfig[coverConfigKey] == true
|
||||
private val ignoreBookshelfLayout: Boolean
|
||||
get() = ignoreConfig[PreferKey.bookshelfLayout] == true
|
||||
private val ignoreShowRss: Boolean
|
||||
get() = ignoreConfig[PreferKey.showRss] == true
|
||||
private val ignoreThreadCount: Boolean
|
||||
get() = ignoreConfig[PreferKey.threadCount] == true
|
||||
val ignoreLocalBook: Boolean
|
||||
get() = ignoreConfig[localBookKey] == true
|
||||
|
||||
fun saveIgnoreConfig() {
|
||||
val json = GSON.toJson(ignoreConfig)
|
||||
FileUtils.createFileIfNotExist(ignoreConfigPath).writeText(json)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package io.legato.kazusa.help.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.jayway.jsonpath.DocumentContext
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.AppConst
|
||||
import io.legato.kazusa.constant.BookSourceType
|
||||
import io.legato.kazusa.constant.BookType
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.rule.*
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.ReplaceAnalyzer
|
||||
import io.legato.kazusa.utils.*
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object ImportOldData {
|
||||
|
||||
@Suppress("RegExpRedundantEscape")
|
||||
private val headerPattern = Pattern.compile("@Header:\\{.+?\\}", Pattern.CASE_INSENSITIVE)
|
||||
@Suppress("RegExpRedundantEscape")
|
||||
private val jsPattern = Pattern.compile("\\{\\{.+?\\}\\}", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
fun importUri(context: Context, uri: Uri) {
|
||||
if (uri.isContentScheme()) {
|
||||
DocumentFile.fromTreeUri(context, uri)?.listFiles()?.forEach { doc ->
|
||||
when (doc.name) {
|
||||
"myBookShelf.json" ->
|
||||
kotlin.runCatching {
|
||||
doc.uri.readText(context).let { json ->
|
||||
val importCount = importOldBookshelf(json)
|
||||
context.toastOnUi("成功导入书架${importCount}")
|
||||
}
|
||||
}.onFailure {
|
||||
context.toastOnUi("导入书架失败\n${it.localizedMessage}")
|
||||
}
|
||||
"myBookSource.json" ->
|
||||
kotlin.runCatching {
|
||||
doc.uri.readText(context).let { json ->
|
||||
val importCount = importOldSource(json)
|
||||
context.toastOnUi("成功导入书源${importCount}")
|
||||
}
|
||||
}.onFailure {
|
||||
context.toastOnUi("导入源失败\n${it.localizedMessage}")
|
||||
}
|
||||
"myBookReplaceRule.json" ->
|
||||
kotlin.runCatching {
|
||||
doc.uri.readText(context).let { json ->
|
||||
val importCount = importOldReplaceRule(json)
|
||||
context.toastOnUi("成功导入替换规则${importCount}")
|
||||
}
|
||||
}.onFailure {
|
||||
context.toastOnUi("导入替换规则失败\n${it.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uri.path?.let { path ->
|
||||
val file = File(path)
|
||||
kotlin.runCatching {// 导入书架
|
||||
val shelfFile =
|
||||
FileUtils.createFileIfNotExist(file, "myBookShelf.json")
|
||||
val json = shelfFile.readText()
|
||||
val importCount = importOldBookshelf(json)
|
||||
context.toastOnUi("成功导入书架${importCount}")
|
||||
}.onFailure {
|
||||
context.toastOnUi("导入书架失败\n${it.localizedMessage}")
|
||||
}
|
||||
|
||||
kotlin.runCatching {// Book source
|
||||
val sourceFile =
|
||||
file.getFile("myBookSource.json")
|
||||
val json = sourceFile.readText()
|
||||
val importCount = importOldSource(json)
|
||||
context.toastOnUi("成功导入书源${importCount}")
|
||||
}.onFailure {
|
||||
context.toastOnUi("导入源失败\n${it.localizedMessage}")
|
||||
}
|
||||
|
||||
kotlin.runCatching {// Replace rules
|
||||
val ruleFile = file.getFile("myBookReplaceRule.json")
|
||||
if (ruleFile.exists()) {
|
||||
val json = ruleFile.readText()
|
||||
val importCount = importOldReplaceRule(json)
|
||||
context.toastOnUi("成功导入替换规则${importCount}")
|
||||
} else {
|
||||
context.toastOnUi("未找到替换规则")
|
||||
}
|
||||
}.onFailure {
|
||||
context.toastOnUi("导入替换规则失败\n${it.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun importOldBookshelf(json: String): Int {
|
||||
val books = fromOldBooks(json)
|
||||
appDb.bookDao.insert(*books.toTypedArray())
|
||||
return books.size
|
||||
}
|
||||
|
||||
fun importOldSource(json: String): Int {
|
||||
val sources = fromOldBookSources(json)
|
||||
appDb.bookSourceDao.insert(*sources.toTypedArray())
|
||||
return sources.size
|
||||
}
|
||||
|
||||
private fun importOldReplaceRule(json: String): Int {
|
||||
val rules = ReplaceAnalyzer.jsonToReplaceRules(json).getOrNull()
|
||||
rules?.let {
|
||||
appDb.replaceRuleDao.insert(*rules.toTypedArray())
|
||||
return rules.size
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun fromOldBooks(json: String): List<Book> {
|
||||
val books = mutableListOf<Book>()
|
||||
val items: List<Map<String, Any>> = jsonPath.parse(json).read("$")
|
||||
val existingBooks = appDb.bookDao.allBookUrls.toSet()
|
||||
for (item in items) {
|
||||
val jsonItem = jsonPath.parse(item)
|
||||
val book = Book()
|
||||
book.bookUrl = jsonItem.readString("$.noteUrl") ?: ""
|
||||
if (book.bookUrl.isBlank()) continue
|
||||
book.name = jsonItem.readString("$.bookInfoBean.name") ?: ""
|
||||
if (book.bookUrl in existingBooks) {
|
||||
DebugLog.d(javaClass.name, "Found existing book: " + book.name)
|
||||
continue
|
||||
}
|
||||
book.origin = jsonItem.readString("$.tag") ?: ""
|
||||
book.originName = jsonItem.readString("$.bookInfoBean.origin") ?: ""
|
||||
book.author = jsonItem.readString("$.bookInfoBean.author") ?: ""
|
||||
val local = if (book.origin == "loc_book") BookType.local else 0
|
||||
val isAudio = jsonItem.readString("$.bookInfoBean.bookSourceType") == "AUDIO"
|
||||
book.type = local or if (isAudio) BookType.audio else BookType.text
|
||||
book.tocUrl = jsonItem.readString("$.bookInfoBean.chapterUrl") ?: book.bookUrl
|
||||
book.coverUrl = jsonItem.readString("$.bookInfoBean.coverUrl")
|
||||
book.customCoverUrl = jsonItem.readString("$.customCoverPath")
|
||||
book.lastCheckTime = jsonItem.readLong("$.bookInfoBean.finalRefreshData") ?: 0
|
||||
book.canUpdate = jsonItem.readBool("$.allowUpdate") == true
|
||||
book.totalChapterNum = jsonItem.readInt("$.chapterListSize") ?: 0
|
||||
book.durChapterIndex = jsonItem.readInt("$.durChapter") ?: 0
|
||||
book.durChapterTitle = jsonItem.readString("$.durChapterName")
|
||||
book.durChapterPos = jsonItem.readInt("$.durChapterPage") ?: 0
|
||||
book.durChapterTime = jsonItem.readLong("$.finalDate") ?: 0
|
||||
book.intro = jsonItem.readString("$.bookInfoBean.introduce")
|
||||
book.latestChapterTitle = jsonItem.readString("$.lastChapterName")
|
||||
book.lastCheckCount = jsonItem.readInt("$.newChapters") ?: 0
|
||||
book.order = jsonItem.readInt("$.serialNumber") ?: 0
|
||||
book.variable = jsonItem.readString("$.variable")
|
||||
book.setUseReplaceRule(jsonItem.readBool("$.useReplaceRule") == true)
|
||||
books.add(book)
|
||||
}
|
||||
return books
|
||||
}
|
||||
|
||||
private fun fromOldBookSources(json: String): MutableList<BookSource> {
|
||||
val sources = mutableListOf<BookSource>()
|
||||
val items: List<Map<String, Any>> = jsonPath.parse(json).read("$")
|
||||
for (item in items) {
|
||||
val jsonItem = jsonPath.parse(item)
|
||||
val source = fromOldBookSource(jsonItem)
|
||||
sources.add(source)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
fun fromOldBookSource(jsonItem: DocumentContext): BookSource {
|
||||
val source = BookSource()
|
||||
return source.apply {
|
||||
bookSourceUrl = jsonItem.readString("bookSourceUrl")
|
||||
?: throw NoStackTraceException(appCtx.getString(R.string.wrong_format))
|
||||
bookSourceName = jsonItem.readString("bookSourceName") ?: ""
|
||||
bookSourceGroup = jsonItem.readString("bookSourceGroup")
|
||||
loginUrl = jsonItem.readString("loginUrl")
|
||||
loginUi = jsonItem.readString("loginUi")
|
||||
loginCheckJs = jsonItem.readString("loginCheckJs")
|
||||
coverDecodeJs = jsonItem.readString("coverDecodeJs")
|
||||
bookSourceComment = jsonItem.readString("bookSourceComment") ?: ""
|
||||
bookUrlPattern = jsonItem.readString("ruleBookUrlPattern")
|
||||
customOrder = jsonItem.readInt("serialNumber") ?: 0
|
||||
header = uaToHeader(jsonItem.readString("httpUserAgent"))
|
||||
searchUrl = toNewUrl(jsonItem.readString("ruleSearchUrl"))
|
||||
exploreUrl = toNewUrls(jsonItem.readString("ruleFindUrl"))
|
||||
bookSourceType =
|
||||
if (jsonItem.readString("bookSourceType") == "AUDIO") BookSourceType.audio else BookSourceType.default
|
||||
enabled = jsonItem.readBool("enable") ?: true
|
||||
if (exploreUrl.isNullOrBlank()) {
|
||||
enabledExplore = false
|
||||
}
|
||||
ruleSearch = SearchRule(
|
||||
bookList = toNewRule(jsonItem.readString("ruleSearchList")),
|
||||
name = toNewRule(jsonItem.readString("ruleSearchName")),
|
||||
author = toNewRule(jsonItem.readString("ruleSearchAuthor")),
|
||||
intro = toNewRule(jsonItem.readString("ruleSearchIntroduce")),
|
||||
kind = toNewRule(jsonItem.readString("ruleSearchKind")),
|
||||
bookUrl = toNewRule(jsonItem.readString("ruleSearchNoteUrl")),
|
||||
coverUrl = toNewRule(jsonItem.readString("ruleSearchCoverUrl")),
|
||||
lastChapter = toNewRule(jsonItem.readString("ruleSearchLastChapter"))
|
||||
)
|
||||
ruleExplore = ExploreRule(
|
||||
bookList = toNewRule(jsonItem.readString("ruleFindList")),
|
||||
name = toNewRule(jsonItem.readString("ruleFindName")),
|
||||
author = toNewRule(jsonItem.readString("ruleFindAuthor")),
|
||||
intro = toNewRule(jsonItem.readString("ruleFindIntroduce")),
|
||||
kind = toNewRule(jsonItem.readString("ruleFindKind")),
|
||||
bookUrl = toNewRule(jsonItem.readString("ruleFindNoteUrl")),
|
||||
coverUrl = toNewRule(jsonItem.readString("ruleFindCoverUrl")),
|
||||
lastChapter = toNewRule(jsonItem.readString("ruleFindLastChapter"))
|
||||
)
|
||||
ruleBookInfo = BookInfoRule(
|
||||
init = toNewRule(jsonItem.readString("ruleBookInfoInit")),
|
||||
name = toNewRule(jsonItem.readString("ruleBookName")),
|
||||
author = toNewRule(jsonItem.readString("ruleBookAuthor")),
|
||||
intro = toNewRule(jsonItem.readString("ruleIntroduce")),
|
||||
kind = toNewRule(jsonItem.readString("ruleBookKind")),
|
||||
coverUrl = toNewRule(jsonItem.readString("ruleCoverUrl")),
|
||||
lastChapter = toNewRule(jsonItem.readString("ruleBookLastChapter")),
|
||||
tocUrl = toNewRule(jsonItem.readString("ruleChapterUrl"))
|
||||
)
|
||||
ruleToc = TocRule(
|
||||
chapterList = toNewRule(jsonItem.readString("ruleChapterList")),
|
||||
chapterName = toNewRule(jsonItem.readString("ruleChapterName")),
|
||||
chapterUrl = toNewRule(jsonItem.readString("ruleContentUrl")),
|
||||
nextTocUrl = toNewRule(jsonItem.readString("ruleChapterUrlNext"))
|
||||
)
|
||||
var content = toNewRule(jsonItem.readString("ruleBookContent")) ?: ""
|
||||
if (content.startsWith("$") && !content.startsWith("$.")) {
|
||||
content = content.substring(1)
|
||||
}
|
||||
ruleContent = ContentRule(
|
||||
content = content,
|
||||
replaceRegex = toNewRule(jsonItem.readString("ruleBookContentReplace")),
|
||||
nextContentUrl = toNewRule(jsonItem.readString("ruleContentUrlNext"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// default规则适配
|
||||
// #正则#替换内容 替换成 ##正则##替换内容
|
||||
// | 替换成 ||
|
||||
// & 替换成 &&
|
||||
private fun toNewRule(oldRule: String?): String? {
|
||||
if (oldRule.isNullOrBlank()) return null
|
||||
var newRule = oldRule
|
||||
var reverse = false
|
||||
var allinone = false
|
||||
if (oldRule.startsWith("-")) {
|
||||
reverse = true
|
||||
newRule = oldRule.substring(1)
|
||||
}
|
||||
if (newRule.startsWith("+")) {
|
||||
allinone = true
|
||||
newRule = newRule.substring(1)
|
||||
}
|
||||
if (!newRule.startsWith("@CSS:", true) &&
|
||||
!newRule.startsWith("@XPath:", true) &&
|
||||
!newRule.startsWith("//") &&
|
||||
!newRule.startsWith("##") &&
|
||||
!newRule.startsWith(":") &&
|
||||
!newRule.contains("@js:", true) &&
|
||||
!newRule.contains("<js>", true)
|
||||
) {
|
||||
if (newRule.contains("#") && !newRule.contains("##")) {
|
||||
newRule = oldRule.replace("#", "##")
|
||||
}
|
||||
if (newRule.contains("|") && !newRule.contains("||")) {
|
||||
if (newRule.contains("##")) {
|
||||
val list = newRule.split("##")
|
||||
if (list[0].contains("|")) {
|
||||
newRule = list[0].replace("|", "||")
|
||||
for (i in 1 until list.size) {
|
||||
newRule += "##" + list[i]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newRule = newRule.replace("|", "||")
|
||||
}
|
||||
}
|
||||
if (newRule.contains("&")
|
||||
&& !newRule.contains("&&")
|
||||
&& !newRule.contains("http")
|
||||
&& !newRule.startsWith("/")
|
||||
) {
|
||||
newRule = newRule.replace("&", "&&")
|
||||
}
|
||||
}
|
||||
if (allinone) {
|
||||
newRule = "+$newRule"
|
||||
}
|
||||
if (reverse) {
|
||||
newRule = "-$newRule"
|
||||
}
|
||||
return newRule
|
||||
}
|
||||
|
||||
private fun toNewUrls(oldUrls: String?): String? {
|
||||
if (oldUrls.isNullOrBlank()) return null
|
||||
if (oldUrls.startsWith("@js:") || oldUrls.startsWith("<js>")) {
|
||||
return oldUrls
|
||||
}
|
||||
if (!oldUrls.contains("\n") && !oldUrls.contains("&&")) {
|
||||
return toNewUrl(oldUrls)
|
||||
}
|
||||
val urls = oldUrls.split("(&&|\r?\n)+".toRegex())
|
||||
return urls.map {
|
||||
toNewUrl(it)?.replace("\n\\s*".toRegex(), "")
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun toNewUrl(oldUrl: String?): String? {
|
||||
if (oldUrl.isNullOrBlank()) return null
|
||||
var url: String = oldUrl
|
||||
if (oldUrl.startsWith("<js>", true)) {
|
||||
url = url.replace("=searchKey", "={{key}}")
|
||||
.replace("=searchPage", "={{page}}")
|
||||
return url
|
||||
}
|
||||
val map = HashMap<String, String>()
|
||||
var mather = headerPattern.matcher(url)
|
||||
if (mather.find()) {
|
||||
val header = mather.group()
|
||||
url = url.replace(header, "")
|
||||
map["headers"] = header.substring(8)
|
||||
}
|
||||
var urlList = url.split("|")
|
||||
url = urlList[0]
|
||||
if (urlList.size > 1) {
|
||||
map["charset"] = urlList[1].split("=")[1]
|
||||
}
|
||||
mather = jsPattern.matcher(url)
|
||||
val jsList = arrayListOf<String>()
|
||||
while (mather.find()) {
|
||||
jsList.add(mather.group())
|
||||
url = url.replace(jsList.last(), "$${jsList.size - 1}")
|
||||
}
|
||||
url = url.replace("{", "<").replace("}", ">")
|
||||
url = url.replace("searchKey", "{{key}}")
|
||||
url = url.replace("<searchPage([-+]1)>".toRegex(), "{{page$1}}")
|
||||
.replace("searchPage([-+]1)".toRegex(), "{{page$1}}")
|
||||
.replace("searchPage", "{{page}}")
|
||||
for ((index, item) in jsList.withIndex()) {
|
||||
url = url.replace(
|
||||
"$$index",
|
||||
item.replace("searchKey", "key").replace("searchPage", "page")
|
||||
)
|
||||
}
|
||||
urlList = url.split("@")
|
||||
url = urlList[0]
|
||||
if (urlList.size > 1) {
|
||||
map["method"] = "POST"
|
||||
map["body"] = urlList[1]
|
||||
}
|
||||
if (map.size > 0) {
|
||||
url += "," + GSON.toJson(map)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private fun uaToHeader(ua: String?): String? {
|
||||
if (ua.isNullOrEmpty()) return null
|
||||
val map = mapOf(Pair(AppConst.UA_NAME, ua))
|
||||
return GSON.toJson(map)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package io.legato.kazusa.help.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.legato.kazusa.BuildConfig
|
||||
import io.legato.kazusa.R
|
||||
import io.legato.kazusa.constant.AppConst.androidId
|
||||
import io.legato.kazusa.constant.AppLog
|
||||
import io.legato.kazusa.constant.PreferKey
|
||||
import io.legato.kazusa.data.appDb
|
||||
import io.legato.kazusa.data.entities.Book
|
||||
import io.legato.kazusa.data.entities.BookGroup
|
||||
import io.legato.kazusa.data.entities.BookSource
|
||||
import io.legato.kazusa.data.entities.Bookmark
|
||||
import io.legato.kazusa.data.entities.DictRule
|
||||
import io.legato.kazusa.data.entities.HttpTTS
|
||||
import io.legato.kazusa.data.entities.KeyboardAssist
|
||||
import io.legato.kazusa.data.entities.ReadRecord
|
||||
import io.legato.kazusa.data.entities.ReplaceRule
|
||||
import io.legato.kazusa.data.entities.RssSource
|
||||
import io.legato.kazusa.data.entities.RssStar
|
||||
import io.legato.kazusa.data.entities.RuleSub
|
||||
import io.legato.kazusa.data.entities.SearchKeyword
|
||||
import io.legato.kazusa.data.entities.Server
|
||||
import io.legato.kazusa.data.entities.TxtTocRule
|
||||
import io.legato.kazusa.help.DirectLinkUpload
|
||||
import io.legato.kazusa.help.LauncherIconHelp
|
||||
import io.legato.kazusa.help.book.isLocal
|
||||
import io.legato.kazusa.help.book.upType
|
||||
import io.legato.kazusa.help.config.LocalConfig
|
||||
import io.legato.kazusa.help.config.ReadBookConfig
|
||||
import io.legato.kazusa.help.config.ThemeConfig
|
||||
import io.legato.kazusa.model.localBook.LocalBook
|
||||
import io.legato.kazusa.utils.ACache
|
||||
import io.legato.kazusa.utils.FileUtils
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.LogUtils
|
||||
import io.legato.kazusa.utils.compress.ZipUtils
|
||||
import io.legato.kazusa.utils.defaultSharedPreferences
|
||||
import io.legato.kazusa.utils.fromJsonArray
|
||||
import io.legato.kazusa.utils.getPrefBoolean
|
||||
import io.legato.kazusa.utils.getPrefInt
|
||||
import io.legato.kazusa.utils.getPrefString
|
||||
import io.legato.kazusa.utils.getSharedPreferences
|
||||
import io.legato.kazusa.utils.isContentScheme
|
||||
import io.legato.kazusa.utils.isJsonArray
|
||||
import io.legato.kazusa.utils.openInputStream
|
||||
import io.legato.kazusa.utils.toastOnUi
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
||||
/**
|
||||
* 恢复
|
||||
*/
|
||||
object Restore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private const val TAG = "Restore"
|
||||
|
||||
suspend fun restore(context: Context, uri: Uri) {
|
||||
LogUtils.d(TAG, "开始恢复备份 uri:$uri")
|
||||
kotlin.runCatching {
|
||||
FileUtils.delete(Backup.backupPath)
|
||||
if (uri.isContentScheme()) {
|
||||
DocumentFile.fromSingleUri(context, uri)!!.openInputStream()!!.use {
|
||||
ZipUtils.unZipToPath(it, Backup.backupPath)
|
||||
}
|
||||
} else {
|
||||
ZipUtils.unZipToPath(File(uri.path!!), Backup.backupPath)
|
||||
}
|
||||
}.onFailure {
|
||||
AppLog.put("复制解压文件出错\n${it.localizedMessage}", it)
|
||||
return
|
||||
}
|
||||
kotlin.runCatching {
|
||||
restoreLocked(Backup.backupPath)
|
||||
LocalConfig.lastBackup = System.currentTimeMillis()
|
||||
}.onFailure {
|
||||
appCtx.toastOnUi("恢复备份出错\n${it.localizedMessage}")
|
||||
AppLog.put("恢复备份出错\n${it.localizedMessage}", it)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restoreLocked(path: String) {
|
||||
mutex.withLock {
|
||||
restore(path)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restore(path: String) {
|
||||
val aes = BackupAES()
|
||||
fileToListT<Book>(path, "bookshelf.json")?.let {
|
||||
it.forEach { book ->
|
||||
book.upType()
|
||||
}
|
||||
it.filter { book -> book.isLocal }
|
||||
.forEach { book ->
|
||||
book.coverUrl = LocalBook.getCoverPath(book)
|
||||
}
|
||||
val updateBooks = arrayListOf<Book>()
|
||||
val newBooks = arrayListOf<Book>()
|
||||
val ignoreLocalBook = BackupConfig.ignoreLocalBook
|
||||
it.forEach { book ->
|
||||
if (ignoreLocalBook && book.isLocal) {
|
||||
return@forEach
|
||||
}
|
||||
if (appDb.bookDao.has(book.bookUrl)) {
|
||||
updateBooks.add(book)
|
||||
} else {
|
||||
newBooks.add(book)
|
||||
}
|
||||
}
|
||||
appDb.bookDao.update(*updateBooks.toTypedArray())
|
||||
appDb.bookDao.insert(*newBooks.toTypedArray())
|
||||
}
|
||||
fileToListT<Bookmark>(path, "bookmark.json")?.let {
|
||||
appDb.bookmarkDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<BookGroup>(path, "bookGroup.json")?.let {
|
||||
appDb.bookGroupDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<BookSource>(path, "bookSource.json")?.let {
|
||||
appDb.bookSourceDao.insert(*it.toTypedArray())
|
||||
} ?: run {
|
||||
val bookSourceFile = File(path, "bookSource.json")
|
||||
if (bookSourceFile.exists()) {
|
||||
val json = bookSourceFile.readText()
|
||||
ImportOldData.importOldSource(json)
|
||||
}
|
||||
}
|
||||
fileToListT<RssSource>(path, "rssSources.json")?.let {
|
||||
appDb.rssSourceDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<RssStar>(path, "rssStar.json")?.let {
|
||||
appDb.rssStarDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<ReplaceRule>(path, "replaceRule.json")?.let {
|
||||
appDb.replaceRuleDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<SearchKeyword>(path, "searchHistory.json")?.let {
|
||||
appDb.searchKeywordDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<RuleSub>(path, "sourceSub.json")?.let {
|
||||
appDb.ruleSubDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<TxtTocRule>(path, "txtTocRule.json")?.let {
|
||||
appDb.txtTocRuleDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<HttpTTS>(path, "httpTTS.json")?.let {
|
||||
appDb.httpTTSDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<DictRule>(path, "dictRule.json")?.let {
|
||||
appDb.dictRuleDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<KeyboardAssist>(path, "keyboardAssists.json")?.let {
|
||||
appDb.keyboardAssistsDao.insert(*it.toTypedArray())
|
||||
}
|
||||
fileToListT<ReadRecord>(path, "readRecord.json")?.let {
|
||||
it.forEach { readRecord ->
|
||||
//判断是不是本机记录
|
||||
if (readRecord.deviceId != androidId) {
|
||||
appDb.readRecordDao.insert(readRecord)
|
||||
} else {
|
||||
val time = appDb.readRecordDao
|
||||
.getReadTime(readRecord.deviceId, readRecord.bookName)
|
||||
if (time == null || time < readRecord.readTime) {
|
||||
appDb.readRecordDao.insert(readRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File(path, "servers.json").takeIf {
|
||||
it.exists()
|
||||
}?.runCatching {
|
||||
var json = readText()
|
||||
if (!json.isJsonArray()) {
|
||||
json = aes.decryptStr(json)
|
||||
}
|
||||
GSON.fromJsonArray<Server>(json).getOrNull()?.let {
|
||||
appDb.serverDao.insert(*it.toTypedArray())
|
||||
}
|
||||
}?.onFailure {
|
||||
AppLog.put("恢复服务器配置出错\n${it.localizedMessage}", it)
|
||||
}
|
||||
File(path, DirectLinkUpload.ruleFileName).takeIf {
|
||||
it.exists()
|
||||
}?.runCatching {
|
||||
val json = readText()
|
||||
ACache.get(cacheDir = false).put(DirectLinkUpload.ruleFileName, json)
|
||||
}?.onFailure {
|
||||
AppLog.put("恢复直链上传出错\n${it.localizedMessage}", it)
|
||||
}
|
||||
//恢复主题配置
|
||||
File(path, ThemeConfig.configFileName).takeIf {
|
||||
it.exists()
|
||||
}?.runCatching {
|
||||
FileUtils.delete(ThemeConfig.configFilePath)
|
||||
copyTo(File(ThemeConfig.configFilePath))
|
||||
ThemeConfig.upConfig()
|
||||
}?.onFailure {
|
||||
AppLog.put("恢复主题出错\n${it.localizedMessage}", it)
|
||||
}
|
||||
if (!BackupConfig.ignoreReadConfig) {
|
||||
//恢复阅读界面配置
|
||||
File(path, ReadBookConfig.configFileName).takeIf {
|
||||
it.exists()
|
||||
}?.runCatching {
|
||||
FileUtils.delete(ReadBookConfig.configFilePath)
|
||||
copyTo(File(ReadBookConfig.configFilePath))
|
||||
ReadBookConfig.initConfigs()
|
||||
}?.onFailure {
|
||||
AppLog.put("恢复阅读界面出错\n${it.localizedMessage}", it)
|
||||
}
|
||||
File(path, ReadBookConfig.shareConfigFileName).takeIf {
|
||||
it.exists()
|
||||
}?.runCatching {
|
||||
FileUtils.delete(ReadBookConfig.shareConfigFilePath)
|
||||
copyTo(File(ReadBookConfig.shareConfigFilePath))
|
||||
ReadBookConfig.initShareConfig()
|
||||
}?.onFailure {
|
||||
AppLog.put("恢复阅读界面出错\n${it.localizedMessage}", it)
|
||||
}
|
||||
}
|
||||
//AppWebDav.downBgs()
|
||||
appCtx.getSharedPreferences(path, "config")?.all?.let { map ->
|
||||
val edit = appCtx.defaultSharedPreferences.edit()
|
||||
|
||||
map.forEach { (key, value) ->
|
||||
if (BackupConfig.keyIsNotIgnore(key)) {
|
||||
when (key) {
|
||||
PreferKey.webDavPassword -> {
|
||||
kotlin.runCatching {
|
||||
aes.decryptStr(value.toString())
|
||||
}.getOrNull()?.let {
|
||||
edit.putString(key, it)
|
||||
} ?: let {
|
||||
if (appCtx.getPrefString(PreferKey.webDavPassword)
|
||||
.isNullOrBlank()
|
||||
) {
|
||||
edit.putString(key, value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> when (value) {
|
||||
is Int -> edit.putInt(key, value)
|
||||
is Boolean -> edit.putBoolean(key, value)
|
||||
is Long -> edit.putLong(key, value)
|
||||
is Float -> edit.putFloat(key, value)
|
||||
is String -> edit.putString(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edit.apply()
|
||||
}
|
||||
ReadBookConfig.apply {
|
||||
comicStyleSelect = appCtx.getPrefInt(PreferKey.comicStyleSelect)
|
||||
readStyleSelect = appCtx.getPrefInt(PreferKey.readStyleSelect)
|
||||
shareLayout = appCtx.getPrefBoolean(PreferKey.shareLayout)
|
||||
hideStatusBar = appCtx.getPrefBoolean(PreferKey.hideStatusBar)
|
||||
hideNavigationBar = appCtx.getPrefBoolean(PreferKey.hideNavigationBar)
|
||||
autoReadSpeed = appCtx.getPrefInt(PreferKey.autoReadSpeed, 46)
|
||||
}
|
||||
appCtx.toastOnUi(R.string.restore_success)
|
||||
withContext(Main) {
|
||||
delay(100)
|
||||
if (!BuildConfig.DEBUG) {
|
||||
LauncherIconHelp.changeIcon(appCtx.getPrefString(PreferKey.launcherIcon))
|
||||
}
|
||||
ThemeConfig.applyDayNight(appCtx)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> fileToListT(path: String, fileName: String): List<T>? {
|
||||
try {
|
||||
val file = File(path, fileName)
|
||||
if (file.exists()) {
|
||||
LogUtils.d(TAG, "阅读恢复备份 $fileName 文件大小 ${file.length()}")
|
||||
FileInputStream(file).use {
|
||||
return GSON.fromJsonArray<T>(it).getOrThrow().also { list ->
|
||||
LogUtils.d(TAG, "阅读恢复备份 $fileName 列表大小 ${list.size}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LogUtils.d(TAG, "阅读恢复备份 $fileName 文件不存在")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLog.put("$fileName\n读取解析出错\n${e.localizedMessage}", e)
|
||||
appCtx.toastOnUi("$fileName\n读取文件出错\n${e.localizedMessage}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package io.legato.kazusa.help.update
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import java.time.Instant
|
||||
|
||||
data class AppReleaseInfo(
|
||||
val appVariant: AppVariant,
|
||||
val createdAt: Long,
|
||||
val note: String,
|
||||
val name: String,
|
||||
val downloadUrl: String,
|
||||
val assetUrl: String
|
||||
) {
|
||||
val versionName: String = name.split("_").getOrNull(2)?.dropLast(2) ?: ""
|
||||
}
|
||||
|
||||
enum class AppVariant {
|
||||
OFFICIAL,
|
||||
BETA_RELEASEA,
|
||||
BETA_RELEASE,
|
||||
UNKNOWN;
|
||||
|
||||
fun isBeta(): Boolean {
|
||||
return this == BETA_RELEASE || this == BETA_RELEASEA
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Keep
|
||||
data class GithubRelease(
|
||||
val assets: List<Asset>?,
|
||||
val body: String,
|
||||
@SerializedName("prerelease")
|
||||
val isPreRelease: Boolean,
|
||||
) {
|
||||
fun gitReleaseToAppReleaseInfo(): List<AppReleaseInfo> {
|
||||
assets ?: throw NoStackTraceException("获取新版本出错")
|
||||
return assets
|
||||
.filter { it.isValid }
|
||||
.map { it.assetToAppReleaseInfo(isPreRelease, body) }
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
data class Asset(
|
||||
@SerializedName("browser_download_url")
|
||||
val apkUrl: String,
|
||||
@SerializedName("content_type")
|
||||
val contentType: String,
|
||||
@SerializedName("created_at")
|
||||
val createdAt: String,
|
||||
@SerializedName("download_count")
|
||||
val downloadCount: Int,
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val state: String,
|
||||
val url: String
|
||||
) {
|
||||
val isValid: Boolean
|
||||
get() = (contentType == "application/vnd.android.package-archive") && (state == "uploaded")
|
||||
|
||||
fun assetToAppReleaseInfo(preRelease: Boolean, note: String): AppReleaseInfo {
|
||||
val instant = Instant.parse(createdAt)
|
||||
val timestamp: Long = instant.toEpochMilli()
|
||||
|
||||
val appVariant = when {
|
||||
preRelease && name.contains("releaseA") -> AppVariant.BETA_RELEASEA
|
||||
preRelease && name.contains("release") -> AppVariant.BETA_RELEASE
|
||||
else -> AppVariant.OFFICIAL
|
||||
}
|
||||
|
||||
return AppReleaseInfo(appVariant, timestamp, note, name, apkUrl, url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.legato.kazusa.help.update
|
||||
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
object AppUpdate {
|
||||
|
||||
val gitHubUpdate: AppUpdateInterface? by lazy {
|
||||
AppUpdateGitHub
|
||||
}
|
||||
|
||||
data class UpdateInfo(
|
||||
val tagName: String,
|
||||
val updateLog: String,
|
||||
val downloadUrl: String,
|
||||
val fileName: String
|
||||
)
|
||||
|
||||
interface AppUpdateInterface {
|
||||
|
||||
fun check(scope: CoroutineScope): Coroutine<UpdateInfo>
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.legato.kazusa.help.update
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import io.legato.kazusa.constant.AppConst
|
||||
import io.legato.kazusa.exception.NoStackTraceException
|
||||
import io.legato.kazusa.help.config.AppConfig
|
||||
import io.legato.kazusa.help.coroutine.Coroutine
|
||||
import io.legato.kazusa.help.http.newCallResponse
|
||||
import io.legato.kazusa.help.http.okHttpClient
|
||||
import io.legato.kazusa.help.http.text
|
||||
import io.legato.kazusa.utils.GSON
|
||||
import io.legato.kazusa.utils.fromJsonObject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@Keep
|
||||
@Suppress("unused")
|
||||
object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
|
||||
|
||||
private val checkVariant: AppVariant
|
||||
get() = when (AppConfig.updateToVariant) {
|
||||
"official_version" -> AppVariant.OFFICIAL
|
||||
"beta_release_version" -> AppVariant.BETA_RELEASE
|
||||
"beta_releaseA_version" -> AppVariant.BETA_RELEASEA
|
||||
else -> AppConst.appInfo.appVariant
|
||||
}
|
||||
|
||||
private suspend fun getLatestRelease(): List<AppReleaseInfo> {
|
||||
val lastReleaseUrl = if (checkVariant.isBeta()) {
|
||||
"https://api.github.com/repos/gedoor/legado/releases/tags/beta"
|
||||
} else {
|
||||
"https://api.github.com/repos/gedoor/legado/releases/latest"
|
||||
}
|
||||
val res = okHttpClient.newCallResponse {
|
||||
url(lastReleaseUrl)
|
||||
}
|
||||
if (!res.isSuccessful) {
|
||||
throw NoStackTraceException("获取新版本出错(${res.code})")
|
||||
}
|
||||
val body = res.body?.text()
|
||||
if (body.isNullOrBlank()) {
|
||||
throw NoStackTraceException("获取新版本出错")
|
||||
}
|
||||
return GSON.fromJsonObject<GithubRelease>(body)
|
||||
.getOrElse {
|
||||
throw NoStackTraceException("获取新版本出错 " + it.localizedMessage)
|
||||
}
|
||||
.gitReleaseToAppReleaseInfo()
|
||||
.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
override fun check(
|
||||
scope: CoroutineScope,
|
||||
): Coroutine<AppUpdate.UpdateInfo> {
|
||||
return Coroutine.async(scope) {
|
||||
getLatestRelease()
|
||||
.filter { it.appVariant == checkVariant }
|
||||
.firstOrNull { it.versionName > AppConst.appInfo.versionName }
|
||||
?.let {
|
||||
return@async AppUpdate.UpdateInfo(
|
||||
it.versionName,
|
||||
it.note,
|
||||
it.downloadUrl,
|
||||
it.name
|
||||
)
|
||||
}
|
||||
?: throw NoStackTraceException("已是最新版本")
|
||||
}.timeout(10000)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user