This commit is contained in:
HapeLee committed 2025-06-04 13:33:51 +08:00
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("&quot;".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)
}
}