fetch太慢去除历史提交记录

This commit is contained in:
gedoor
2022-01-03 23:59:44 +08:00
commit 51c16a1efc
1294 changed files with 194322 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
package io.legado.app
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import androidx.multidex.MultiDexApplication
import com.jeremyliao.liveeventbus.LiveEventBus
import io.legado.app.base.AppContextWrapper
import io.legado.app.constant.AppConst.channelIdDownload
import io.legado.app.constant.AppConst.channelIdReadAloud
import io.legado.app.constant.AppConst.channelIdWeb
import io.legado.app.help.AppConfig
import io.legado.app.help.CrashHandler
import io.legado.app.help.LifecycleHelp
import io.legado.app.help.ThemeConfig.applyDayNight
import io.legado.app.help.http.cronet.CronetLoader
import io.legado.app.utils.defaultSharedPreferences
import timber.log.Timber
class App : MultiDexApplication() {
override fun onCreate() {
super.onCreate()
CrashHandler(this)
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
//预下载Cronet so
CronetLoader.preDownload()
createNotificationChannels()
applyDayNight(this)
LiveEventBus.config()
.lifecycleObserverAlwaysActive(true)
.autoClear(false)
registerActivityLifecycleCallbacks(LifecycleHelp)
defaultSharedPreferences.registerOnSharedPreferenceChangeListener(AppConfig)
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(AppContextWrapper.wrap(base))
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
when (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
Configuration.UI_MODE_NIGHT_YES,
Configuration.UI_MODE_NIGHT_NO -> applyDayNight(this)
}
}
/**
* 创建通知ID
*/
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager)?.let {
val downloadChannel = NotificationChannel(
channelIdDownload,
getString(R.string.action_download),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
enableLights(false)
enableVibration(false)
setSound(null, null)
}
val readAloudChannel = NotificationChannel(
channelIdReadAloud,
getString(R.string.read_aloud),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
enableLights(false)
enableVibration(false)
setSound(null, null)
}
val webChannel = NotificationChannel(
channelIdWeb,
getString(R.string.web_service),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
enableLights(false)
enableVibration(false)
setSound(null, null)
}
//向notification manager 提交channel
it.createNotificationChannels(listOf(downloadChannel, readAloudChannel, webChannel))
}
}
}
+12
View File
@@ -0,0 +1,12 @@
# 文件结构介绍
* base 基类
* constant 常量
* data 数据
* help 帮助
* lib 库
* model 解析
* receiver 广播侦听
* service 服务
* ui 界面
* web web服务
@@ -0,0 +1,147 @@
/*
* Copyright (C) 2020 w568w
*/
package io.legado.app.api
import android.content.ContentProvider
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import com.google.gson.Gson
import io.legado.app.api.controller.BookController
import io.legado.app.api.controller.BookSourceController
import io.legado.app.api.controller.RssSourceController
import java.util.*
/**
* Export book data to other app.
*/
class ReaderProvider : ContentProvider() {
private enum class RequestCode {
SaveBookSource, SaveBookSources, DeleteBookSources, GetBookSource, GetBookSources,
SaveRssSource, SaveRssSources, DeleteRssSources, GetRssSource, GetRssSources,
SaveBook, GetBookshelf, RefreshToc, GetChapterList, GetBookContent, GetBookCover
}
private val postBodyKey = "json"
private val sMatcher by lazy {
UriMatcher(UriMatcher.NO_MATCH).apply {
"${context?.applicationInfo?.packageName}.readerProvider".also { authority ->
addURI(authority, "bookSource/insert", RequestCode.SaveBookSource.ordinal)
addURI(authority, "bookSources/insert", RequestCode.SaveBookSources.ordinal)
addURI(authority, "bookSources/delete", RequestCode.DeleteBookSources.ordinal)
addURI(authority, "bookSource/query", RequestCode.GetBookSource.ordinal)
addURI(authority, "bookSources/query", RequestCode.GetBookSources.ordinal)
addURI(authority, "rssSource/insert", RequestCode.SaveBookSource.ordinal)
addURI(authority, "rssSources/insert", RequestCode.SaveBookSources.ordinal)
addURI(authority, "rssSources/delete", RequestCode.DeleteBookSources.ordinal)
addURI(authority, "rssSource/query", RequestCode.GetBookSource.ordinal)
addURI(authority, "rssSources/query", RequestCode.GetBookSources.ordinal)
addURI(authority, "book/insert", RequestCode.SaveBook.ordinal)
addURI(authority, "books/query", RequestCode.GetBookshelf.ordinal)
addURI(authority, "book/refreshToc/query", RequestCode.RefreshToc.ordinal)
addURI(authority, "book/chapter/query", RequestCode.GetChapterList.ordinal)
addURI(authority, "book/content/query", RequestCode.GetBookContent.ordinal)
addURI(authority, "book/cover/query", RequestCode.GetBookCover.ordinal)
}
}
}
override fun onCreate() = false
override fun delete(
uri: Uri,
selection: String?,
selectionArgs: Array<String>?
): Int {
if (sMatcher.match(uri) < 0) return -1
when (RequestCode.values()[sMatcher.match(uri)]) {
RequestCode.DeleteBookSources -> BookSourceController.deleteSources(selection)
RequestCode.DeleteRssSources -> BookSourceController.deleteSources(selection)
else -> throw IllegalStateException(
"Unexpected value: " + RequestCode.values()[sMatcher.match(uri)].name
)
}
return 0
}
override fun getType(uri: Uri) = throw UnsupportedOperationException("Not yet implemented")
override fun insert(uri: Uri, values: ContentValues?): Uri? {
if (sMatcher.match(uri) < 0) return null
when (RequestCode.values()[sMatcher.match(uri)]) {
RequestCode.SaveBookSource -> values?.let {
BookSourceController.saveSource(values.getAsString(postBodyKey))
}
RequestCode.SaveBookSources -> values?.let {
BookSourceController.saveSources(values.getAsString(postBodyKey))
}
RequestCode.SaveRssSource -> values?.let {
RssSourceController.saveSource(values.getAsString(postBodyKey))
}
RequestCode.SaveRssSources -> values?.let {
RssSourceController.saveSources(values.getAsString(postBodyKey))
}
RequestCode.SaveBook -> values?.let {
BookController.saveBook(values.getAsString(postBodyKey))
}
else -> throw IllegalStateException(
"Unexpected value: " + RequestCode.values()[sMatcher.match(uri)].name
)
}
return null
}
override fun query(
uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?
): Cursor? {
val map: MutableMap<String, ArrayList<String>> = HashMap()
uri.getQueryParameter("url")?.let {
map["url"] = arrayListOf(it)
}
uri.getQueryParameter("index")?.let {
map["index"] = arrayListOf(it)
}
uri.getQueryParameter("path")?.let {
map["path"] = arrayListOf(it)
}
return if (sMatcher.match(uri) < 0) null else when (RequestCode.values()[sMatcher.match(uri)]) {
RequestCode.GetBookSource -> SimpleCursor(BookSourceController.getSource(map))
RequestCode.GetBookSources -> SimpleCursor(BookSourceController.sources)
RequestCode.GetRssSource -> SimpleCursor(RssSourceController.getSource(map))
RequestCode.GetRssSources -> SimpleCursor(RssSourceController.sources)
RequestCode.GetBookshelf -> SimpleCursor(BookController.bookshelf)
RequestCode.GetBookContent -> SimpleCursor(BookController.getBookContent(map))
RequestCode.RefreshToc -> SimpleCursor(BookController.refreshToc(map))
RequestCode.GetChapterList -> SimpleCursor(BookController.getChapterList(map))
RequestCode.GetBookCover -> SimpleCursor(BookController.getCover(map))
else -> throw IllegalStateException(
"Unexpected value: " + RequestCode.values()[sMatcher.match(uri)].name
)
}
}
override fun update(
uri: Uri, values: ContentValues?, selection: String?,
selectionArgs: Array<String>?
) = throw UnsupportedOperationException("Not yet implemented")
/**
* Simple inner class to deliver json callback data.
*
* Only getString() makes sense.
*/
private class SimpleCursor(data: ReturnData?) : MatrixCursor(arrayOf("result"), 1) {
private val mData: String = Gson().toJson(data)
init {
addRow(arrayOf(mData))
}
}
}
@@ -0,0 +1,27 @@
package io.legado.app.api
class ReturnData {
var isSuccess: Boolean = false
private set
var errorMsg: String = "未知错误,请联系开发者!"
private set
var data: Any? = null
private set
fun setErrorMsg(errorMsg: String): ReturnData {
this.isSuccess = false
this.errorMsg = errorMsg
return this
}
fun setData(data: Any): ReturnData {
this.isSuccess = true
this.errorMsg = ""
this.data = data
return this
}
}
@@ -0,0 +1,222 @@
package io.legado.app.api.controller
import android.util.Base64
import androidx.core.graphics.drawable.toBitmap
import io.legado.app.R
import io.legado.app.api.ReturnData
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.help.BookHelp
import io.legado.app.help.ContentProcessor
import io.legado.app.help.glide.ImageLoader
import io.legado.app.help.storage.AppWebDav
import io.legado.app.model.BookCover
import io.legado.app.model.ReadBook
import io.legado.app.model.localBook.EpubFile
import io.legado.app.model.localBook.LocalBook
import io.legado.app.model.localBook.UmdFile
import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.*
import kotlinx.coroutines.runBlocking
import splitties.init.appCtx
import timber.log.Timber
object BookController {
/**
* 书架所有书籍
*/
val bookshelf: ReturnData
get() {
val books = appDb.bookDao.all
val returnData = ReturnData()
return if (books.isEmpty()) {
returnData.setErrorMsg("还没有添加小说")
} else {
val data = when (appCtx.getPrefInt(PreferKey.bookshelfSort)) {
1 -> books.sortedByDescending { it.latestChapterTime }
2 -> books.sortedWith { o1, o2 ->
o1.name.cnCompare(o2.name)
}
3 -> books.sortedBy { it.order }
else -> books.sortedByDescending { it.durChapterTime }
}
returnData.setData(data)
}
}
fun getCover(parameters: Map<String, List<String>>): ReturnData {
val returnData = ReturnData()
val coverPath = parameters["path"]?.firstOrNull()
val ftBitmap = ImageLoader.loadBitmap(appCtx, coverPath).submit()
return try {
returnData.setData(ftBitmap.get())
} catch (e: Exception) {
returnData.setData(BookCover.defaultDrawable.toBitmap())
}
}
/**
* 更新目录
*/
fun refreshToc(parameters: Map<String, List<String>>): ReturnData {
val returnData = ReturnData()
try {
val bookUrl = parameters["url"]?.firstOrNull()
if (bookUrl.isNullOrEmpty()) {
return returnData.setErrorMsg("参数url不能为空,请指定书籍地址")
}
val book = appDb.bookDao.getBook(bookUrl)
?: return returnData.setErrorMsg("bookUrl不对")
if (book.isLocalBook()) {
val toc = LocalBook.getChapterList(book)
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*toc.toTypedArray())
appDb.bookDao.update(book)
return returnData.setData(toc)
} else {
val bookSource = appDb.bookSourceDao.getBookSource(book.origin)
?: return returnData.setErrorMsg("未找到对应书源,请换源")
val toc = runBlocking {
if (book.tocUrl.isBlank()) {
WebBook.getBookInfoAwait(this, bookSource, book)
}
WebBook.getChapterListAwait(this, bookSource, book)
}
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*toc.toTypedArray())
appDb.bookDao.update(book)
return returnData.setData(toc)
}
} catch (e: Exception) {
return returnData.setErrorMsg(e.localizedMessage ?: "refresh toc error")
}
}
/**
* 获取目录
*/
fun getChapterList(parameters: Map<String, List<String>>): ReturnData {
val bookUrl = parameters["url"]?.firstOrNull()
val returnData = ReturnData()
if (bookUrl.isNullOrEmpty()) {
return returnData.setErrorMsg("参数url不能为空,请指定书籍地址")
}
val chapterList = appDb.bookChapterDao.getChapterList(bookUrl)
if (chapterList.isEmpty()) {
return refreshToc(parameters)
}
return returnData.setData(chapterList)
}
/**
* 获取正文
*/
fun getBookContent(parameters: Map<String, List<String>>): ReturnData {
val bookUrl = parameters["url"]?.firstOrNull()
val index = parameters["index"]?.firstOrNull()?.toInt()
val returnData = ReturnData()
if (bookUrl.isNullOrEmpty()) {
return returnData.setErrorMsg("参数url不能为空,请指定书籍地址")
}
if (index == null) {
return returnData.setErrorMsg("参数index不能为空, 请指定目录序号")
}
val book = appDb.bookDao.getBook(bookUrl)
val chapter = appDb.bookChapterDao.getChapter(bookUrl, index)
if (book == null || chapter == null) {
return returnData.setErrorMsg("未找到")
}
var content: String? = BookHelp.getContent(book, chapter)
if (content != null) {
val contentProcessor = ContentProcessor.get(book.name, book.origin)
saveBookReadIndex(book, index)
return returnData.setData(
contentProcessor.getContent(book, chapter, content, includeTitle = false)
.joinToString("\n")
)
}
val bookSource = appDb.bookSourceDao.getBookSource(book.origin)
?: return returnData.setErrorMsg("未找到书源")
try {
content = runBlocking {
WebBook.getContentAwait(this, bookSource, book, chapter)
}
val contentProcessor = ContentProcessor.get(book.name, book.origin)
saveBookReadIndex(book, index)
returnData.setData(
contentProcessor.getContent(book, chapter, content, includeTitle = false)
.joinToString("\n")
)
} catch (e: Exception) {
returnData.setErrorMsg(e.msg)
}
return returnData
}
fun saveBook(postData: String?): ReturnData {
val book = GSON.fromJsonObject<Book>(postData)
val returnData = ReturnData()
if (book != null) {
book.save()
AppWebDav.uploadBookProgress(book)
if (ReadBook.book?.bookUrl == book.bookUrl) {
ReadBook.book = book
ReadBook.durChapterIndex = book.durChapterIndex
}
return returnData.setData("")
}
return returnData.setErrorMsg("格式不对")
}
private fun saveBookReadIndex(book: Book, index: Int) {
book.durChapterIndex = index
book.durChapterTime = System.currentTimeMillis()
appDb.bookChapterDao.getChapter(book.bookUrl, index)?.let {
book.durChapterTitle = it.title
}
appDb.bookDao.update(book)
AppWebDav.uploadBookProgress(book)
if (ReadBook.book?.bookUrl == book.bookUrl) {
ReadBook.book = book
ReadBook.durChapterIndex = index
ReadBook.loadContent(index)
}
}
fun addLocalBook(parameters: Map<String, List<String>>): ReturnData {
val returnData = ReturnData()
try {
val fileName = parameters["fileName"]?.firstOrNull()
?: return returnData.setErrorMsg("fileName 不能为空")
val fileData = parameters["fileData"]?.firstOrNull()
?: return returnData.setErrorMsg("fileData 不能为空")
val file = FileUtils.createFileIfNotExist(LocalBook.cacheFolder, fileName)
val fileBytes = Base64.decode(fileData.substringAfter("base64,"), Base64.DEFAULT)
file.writeBytes(fileBytes)
val nameAuthor = LocalBook.analyzeNameAuthor(fileName)
val book = Book(
bookUrl = file.absolutePath,
name = nameAuthor.first,
author = nameAuthor.second,
originName = fileName,
coverUrl = FileUtils.getPath(
appCtx.externalFiles,
"covers",
"${MD5Utils.md5Encode16(file.absolutePath)}.jpg"
)
)
if (book.isEpub()) EpubFile.upBookInfo(book)
if (book.isUmd()) UmdFile.upBookInfo(book)
appDb.bookDao.insert(book)
} catch (e: Exception) {
Timber.e(e)
return returnData.setErrorMsg(
e.localizedMessage ?: appCtx.getString(R.string.unknown_error)
)
}
return returnData.setData(true)
}
}
@@ -0,0 +1,84 @@
package io.legado.app.api.controller
import android.text.TextUtils
import io.legado.app.api.ReturnData
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.msg
object BookSourceController {
val sources: ReturnData
get() {
val bookSources = appDb.bookSourceDao.all
val returnData = ReturnData()
return if (bookSources.isEmpty()) {
returnData.setErrorMsg("设备源列表为空")
} else returnData.setData(bookSources)
}
fun saveSource(postData: String?): ReturnData {
val returnData = ReturnData()
postData ?: return returnData.setErrorMsg("数据不能为空")
kotlin.runCatching {
val bookSource = BookSource.fromJson(postData)
if (bookSource != null) {
if (TextUtils.isEmpty(bookSource.bookSourceName) || TextUtils.isEmpty(bookSource.bookSourceUrl)) {
returnData.setErrorMsg("源名称和URL不能为空")
} else {
appDb.bookSourceDao.insert(bookSource)
returnData.setData("")
}
} else {
returnData.setErrorMsg("转换源失败")
}
}.onFailure {
returnData.setErrorMsg(it.msg)
}
return returnData
}
fun saveSources(postData: String?): ReturnData {
postData ?: return ReturnData().setErrorMsg("数据为空")
val okSources = arrayListOf<BookSource>()
val bookSources = BookSource.fromJsonArray(postData)
if (bookSources.isNotEmpty()) {
bookSources.forEach { bookSource ->
if (bookSource.bookSourceName.isNotBlank()
&& bookSource.bookSourceUrl.isNotBlank()
) {
appDb.bookSourceDao.insert(bookSource)
okSources.add(bookSource)
}
}
} else {
return ReturnData().setErrorMsg("转换源失败")
}
return ReturnData().setData(okSources)
}
fun getSource(parameters: Map<String, List<String>>): ReturnData {
val url = parameters["url"]?.firstOrNull()
val returnData = ReturnData()
if (url.isNullOrEmpty()) {
return returnData.setErrorMsg("参数url不能为空,请指定源地址")
}
val bookSource = appDb.bookSourceDao.getBookSource(url)
?: return returnData.setErrorMsg("未找到源,请检查书源地址")
return returnData.setData(bookSource)
}
fun deleteSources(postData: String?): ReturnData {
kotlin.runCatching {
GSON.fromJsonArray<BookSource>(postData)?.let {
it.forEach { source ->
appDb.bookSourceDao.delete(source)
}
}
}
return ReturnData().setData("已执行"/*okSources*/)
}
}
@@ -0,0 +1,82 @@
package io.legado.app.api.controller
import android.text.TextUtils
import io.legado.app.api.ReturnData
import io.legado.app.data.appDb
import io.legado.app.data.entities.RssSource
import io.legado.app.utils.msg
object RssSourceController {
val sources: ReturnData
get() {
val source = appDb.rssSourceDao.all
val returnData = ReturnData()
return if (source.isEmpty()) {
returnData.setErrorMsg("源列表为空")
} else returnData.setData(source)
}
fun saveSource(postData: String?): ReturnData {
val returnData = ReturnData()
postData ?: return returnData.setErrorMsg("数据不能为空")
kotlin.runCatching {
val source = RssSource.fromJson(postData)
if (source != null) {
if (TextUtils.isEmpty(source.sourceName) || TextUtils.isEmpty(source.sourceUrl)) {
returnData.setErrorMsg("源名称和URL不能为空")
} else {
appDb.rssSourceDao.insert(source)
returnData.setData("")
}
} else {
returnData.setErrorMsg("转换源失败")
}
}.onFailure {
returnData.setErrorMsg(it.msg)
}
return returnData
}
fun saveSources(postData: String?): ReturnData {
postData ?: return ReturnData().setErrorMsg("数据不能为空")
val okSources = arrayListOf<RssSource>()
val source = RssSource.fromJsonArray(postData)
if (source.isNotEmpty()) {
for (rssSource in source) {
if (rssSource.sourceName.isBlank() || rssSource.sourceUrl.isBlank()) {
continue
}
appDb.rssSourceDao.insert(rssSource)
okSources.add(rssSource)
}
} else {
return ReturnData().setErrorMsg("转换源失败")
}
return ReturnData().setData(okSources)
}
fun getSource(parameters: Map<String, List<String>>): ReturnData {
val url = parameters["url"]?.firstOrNull()
val returnData = ReturnData()
if (url.isNullOrEmpty()) {
return returnData.setErrorMsg("参数url不能为空,请指定书源地址")
}
val source = appDb.rssSourceDao.getByKey(url)
?: return returnData.setErrorMsg("未找到源,请检查源地址")
return returnData.setData(source)
}
fun deleteSources(postData: String?): ReturnData {
postData ?: return ReturnData().setErrorMsg("没有传递数据")
kotlin.runCatching {
RssSource.fromJsonArray(postData).let {
it.forEach { source ->
appDb.rssSourceDao.delete(source)
}
}
}
return ReturnData().setData("已执行"/*okSources*/)
}
}
@@ -0,0 +1,96 @@
package io.legado.app.base
import android.content.Context
import android.content.res.Configuration
import android.content.res.Resources
import android.os.Build
import android.os.LocaleList
import io.legado.app.constant.PreferKey
import io.legado.app.utils.getPrefInt
import io.legado.app.utils.getPrefString
import io.legado.app.utils.sysConfiguration
import java.util.*
@Suppress("unused")
object AppContextWrapper {
fun wrap(context: Context): Context {
val resources: Resources = context.resources
val configuration: Configuration = resources.configuration
val targetLocale = getSetLocale(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
configuration.setLocale(targetLocale)
configuration.setLocales(LocaleList(targetLocale))
} else {
@Suppress("DEPRECATION")
configuration.locale = targetLocale
}
configuration.fontScale = getFontScale(context)
return context.createConfigurationContext(configuration)
}
fun getFontScale(context: Context): Float {
var fontScale = context.getPrefInt(PreferKey.fontScale) / 10f
if (fontScale !in 0.8f..1.6f) {
fontScale = sysConfiguration.fontScale
}
return fontScale
}
/**
* 当前系统语言
*/
private fun getSystemLocale(): Locale {
val locale: Locale
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //7.0有多语言设置获取顶部的语言
locale = sysConfiguration.locales.get(0)
} else {
@Suppress("DEPRECATION")
locale = sysConfiguration.locale
}
return locale
}
/**
* 当前App语言
*/
private fun getAppLocale(context: Context): Locale {
val locale: Locale
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
locale = context.resources.configuration.locales[0]
} else {
@Suppress("DEPRECATION")
locale = context.resources.configuration.locale
}
return locale
}
/**
* 当前设置语言
*/
private fun getSetLocale(context: Context): Locale {
return when (context.getPrefString(PreferKey.language)) {
"zh" -> Locale.SIMPLIFIED_CHINESE
"tw" -> Locale.TRADITIONAL_CHINESE
"en" -> Locale.ENGLISH
else -> getSystemLocale()
}
}
/**
* 判断App语言和设置语言是否相同
*/
fun isSameWithSetting(context: Context): Boolean {
val locale = getAppLocale(context)
val language = locale.language
val country = locale.country
val pfLocale = getSetLocale(context)
val pfLanguage = pfLocale.language
val pfCountry = pfLocale.country
return language == pfLanguage && country == pfCountry
}
}
@@ -0,0 +1,190 @@
package io.legado.app.base
import android.content.Context
import android.content.res.Configuration
import android.graphics.drawable.BitmapDrawable
import android.os.Build
import android.os.Bundle
import android.util.AttributeSet
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.viewbinding.ViewBinding
import io.legado.app.R
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.constant.Theme
import io.legado.app.help.AppConfig
import io.legado.app.help.ThemeConfig
import io.legado.app.lib.theme.ThemeStore
import io.legado.app.lib.theme.backgroundColor
import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.widget.TitleBar
import io.legado.app.utils.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
abstract class BaseActivity<VB : ViewBinding>(
val fullScreen: Boolean = true,
private val theme: Theme = Theme.Auto,
private val toolBarTheme: Theme = Theme.Auto,
private val transparent: Boolean = false,
private val imageBg: Boolean = true
) : AppCompatActivity(), CoroutineScope by MainScope() {
protected abstract val binding: VB
val isInMultiWindow: Boolean
get() {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
isInMultiWindowMode
} else {
false
}
}
override fun attachBaseContext(newBase: Context) {
super.attachBaseContext(AppContextWrapper.wrap(newBase))
}
override fun onCreateView(
parent: View?,
name: String,
context: Context,
attrs: AttributeSet
): View? {
if (AppConst.menuViewNames.contains(name) && parent?.parent is FrameLayout) {
(parent.parent as View).setBackgroundColor(backgroundColor)
}
return super.onCreateView(parent, name, context, attrs)
}
override fun onCreate(savedInstanceState: Bundle?) {
window.decorView.disableAutoFill()
initTheme()
super.onCreate(savedInstanceState)
setContentView(binding.root)
setupSystemBar()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
findViewById<TitleBar>(R.id.title_bar)
?.onMultiWindowModeChanged(isInMultiWindowMode, fullScreen)
}
onActivityCreated(savedInstanceState)
observeLiveBus()
}
override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration?) {
super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig)
findViewById<TitleBar>(R.id.title_bar)
?.onMultiWindowModeChanged(isInMultiWindowMode, fullScreen)
setupSystemBar()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
findViewById<TitleBar>(R.id.title_bar)
?.onMultiWindowModeChanged(isInMultiWindow, fullScreen)
setupSystemBar()
}
override fun onDestroy() {
super.onDestroy()
cancel()
}
abstract fun onActivityCreated(savedInstanceState: Bundle?)
final override fun onCreateOptionsMenu(menu: Menu): Boolean {
return menu.let {
val bool = onCompatCreateOptionsMenu(it)
it.applyTint(this, toolBarTheme)
bool
}
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.applyOpenTint(this)
return super.onMenuOpened(featureId, menu)
}
open fun onCompatCreateOptionsMenu(menu: Menu) = super.onCreateOptionsMenu(menu)
final override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
supportFinishAfterTransition()
return true
}
return onCompatOptionsItemSelected(item)
}
open fun onCompatOptionsItemSelected(item: MenuItem) = super.onOptionsItemSelected(item)
private fun initTheme() {
when (theme) {
Theme.Transparent -> setTheme(R.style.AppTheme_Transparent)
Theme.Dark -> {
setTheme(R.style.AppTheme_Dark)
window.decorView.applyBackgroundTint(backgroundColor)
}
Theme.Light -> {
setTheme(R.style.AppTheme_Light)
window.decorView.applyBackgroundTint(backgroundColor)
}
else -> {
if (ColorUtils.isColorLight(primaryColor)) {
setTheme(R.style.AppTheme_Light)
} else {
setTheme(R.style.AppTheme_Dark)
}
window.decorView.applyBackgroundTint(backgroundColor)
}
}
if (imageBg) {
try {
ThemeConfig.getBgImage(this, windowSize)?.let {
window.decorView.background = BitmapDrawable(resources, it)
}
} catch (e: OutOfMemoryError) {
toastOnUi("背景图片太大,内存溢出")
} catch (e: Exception) {
AppLog.put("加载背景出错\n${e.localizedMessage}", e)
}
}
}
private fun setupSystemBar() {
if (fullScreen && !isInMultiWindow) {
fullScreen()
}
val isTransparentStatusBar = AppConfig.isTransparentStatusBar
val statusBarColor = ThemeStore.statusBarColor(this, isTransparentStatusBar)
setStatusBarColorAuto(statusBarColor, isTransparentStatusBar, fullScreen)
if (toolBarTheme == Theme.Dark) {
setLightStatusBar(false)
} else if (toolBarTheme == Theme.Light) {
setLightStatusBar(true)
}
upNavigationBarColor()
}
open fun upNavigationBarColor() {
if (AppConfig.immNavigationBar) {
setNavigationBarColorAuto(ThemeStore.navigationBarColor(this))
} else {
val nbColor = ColorUtils.darkenColor(ThemeStore.navigationBarColor(this))
setNavigationBarColorAuto(nbColor)
}
}
open fun observeLiveBus() {
}
override fun finish() {
currentFocus?.hideSoftInput()
super.finish()
}
}
@@ -0,0 +1,50 @@
package io.legado.app.base
import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.lib.theme.ThemeStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext
abstract class BaseDialogFragment(@LayoutRes layoutID: Int) : DialogFragment(layoutID),
CoroutineScope by MainScope() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.setBackgroundColor(ThemeStore.backgroundColor())
onFragmentCreated(view, savedInstanceState)
observeLiveBus()
}
abstract fun onFragmentCreated(view: View, savedInstanceState: Bundle?)
override fun show(manager: FragmentManager, tag: String?) {
kotlin.runCatching {
//在每个add事务前增加一个remove事务,防止连续的add
manager.beginTransaction().remove(this).commit()
super.show(manager, tag)
}
}
override fun onDestroy() {
super.onDestroy()
cancel()
}
fun <T> execute(
scope: CoroutineScope = this,
context: CoroutineContext = Dispatchers.IO,
block: suspend CoroutineScope.() -> T
) = Coroutine.async(scope, context) { block() }
open fun observeLiveBus() {
}
}
@@ -0,0 +1,87 @@
package io.legado.app.base
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.annotation.LayoutRes
import androidx.appcompat.view.SupportMenuInflater
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import io.legado.app.R
import io.legado.app.ui.widget.TitleBar
import io.legado.app.utils.applyTint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
@Suppress("MemberVisibilityCanBePrivate")
abstract class BaseFragment(@LayoutRes layoutID: Int) : Fragment(layoutID),
CoroutineScope by MainScope() {
var supportToolbar: Toolbar? = null
private set
val menuInflater: MenuInflater
@SuppressLint("RestrictedApi")
get() = SupportMenuInflater(requireContext())
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onMultiWindowModeChanged()
onFragmentCreated(view, savedInstanceState)
observeLiveBus()
}
abstract fun onFragmentCreated(view: View, savedInstanceState: Bundle?)
override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean) {
super.onMultiWindowModeChanged(isInMultiWindowMode)
onMultiWindowModeChanged()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
onMultiWindowModeChanged()
}
private fun onMultiWindowModeChanged() {
(activity as? BaseActivity<*>)?.let {
view?.findViewById<TitleBar>(R.id.title_bar)
?.onMultiWindowModeChanged(it.isInMultiWindow, it.fullScreen)
}
}
override fun onDestroy() {
super.onDestroy()
cancel()
}
fun setSupportToolbar(toolbar: Toolbar) {
supportToolbar = toolbar
supportToolbar?.let {
it.menu.apply {
onCompatCreateOptionsMenu(this)
applyTint(requireContext())
}
it.setOnMenuItemClickListener { item ->
onCompatOptionsItemSelected(item)
true
}
}
}
open fun observeLiveBus() {
}
open fun onCompatCreateOptionsMenu(menu: Menu) {
}
open fun onCompatOptionsItemSelected(item: MenuItem) {
}
}
@@ -0,0 +1,63 @@
package io.legado.app.base
import android.annotation.SuppressLint
import androidx.fragment.app.DialogFragment
import androidx.preference.*
import io.legado.app.ui.widget.prefs.EditTextPreferenceDialog
import io.legado.app.ui.widget.prefs.ListPreferenceDialog
import io.legado.app.ui.widget.prefs.MultiSelectListPreferenceDialog
abstract class BasePreferenceFragment : PreferenceFragmentCompat() {
private val dialogFragmentTag = "androidx.preference.PreferenceFragment.DIALOG"
@SuppressLint("RestrictedApi")
override fun onDisplayPreferenceDialog(preference: Preference) {
var handled = false
if (callbackFragment is OnPreferenceDisplayDialogCallback) {
handled =
(callbackFragment as OnPreferenceDisplayDialogCallback)
.onPreferenceDisplayDialog(this, preference)
}
if (!handled && activity is OnPreferenceDisplayDialogCallback) {
handled = (activity as OnPreferenceDisplayDialogCallback)
.onPreferenceDisplayDialog(this, preference)
}
if (handled) {
return
}
// check if dialog is already showing
if (parentFragmentManager.findFragmentByTag(dialogFragmentTag) != null) {
return
}
val f: DialogFragment = when (preference) {
is EditTextPreference -> {
EditTextPreferenceDialog.newInstance(preference.getKey())
}
is ListPreference -> {
ListPreferenceDialog.newInstance(preference.getKey())
}
is MultiSelectListPreference -> {
MultiSelectListPreferenceDialog.newInstance(preference.getKey())
}
else -> {
throw IllegalArgumentException(
"Cannot display dialog for an unknown Preference type: "
+ preference.javaClass.simpleName
+ ". Make sure to implement onPreferenceDisplayDialog() to handle "
+ "displaying a custom dialog for this Preference."
)
}
}
@Suppress("DEPRECATION")
f.setTargetFragment(this, 0)
f.show(parentFragmentManager, dialogFragmentTag)
}
}
@@ -0,0 +1,45 @@
package io.legado.app.base
import android.app.Service
import android.content.Intent
import android.os.IBinder
import androidx.annotation.CallSuper
import io.legado.app.help.LifecycleHelp
import io.legado.app.help.coroutine.Coroutine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext
abstract class BaseService : Service(), CoroutineScope by MainScope() {
fun <T> execute(
scope: CoroutineScope = this,
context: CoroutineContext = Dispatchers.IO,
block: suspend CoroutineScope.() -> T
) = Coroutine.async(scope, context) { block() }
@CallSuper
override fun onCreate() {
super.onCreate()
LifecycleHelp.onServiceCreate(this)
}
@CallSuper
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
stopSelf()
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
@CallSuper
override fun onDestroy() {
super.onDestroy()
cancel()
LifecycleHelp.onServiceDestroy(this)
}
}
@@ -0,0 +1,35 @@
package io.legado.app.base
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.App
import io.legado.app.help.coroutine.Coroutine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlin.coroutines.CoroutineContext
@Suppress("unused")
open class BaseViewModel(application: Application) : AndroidViewModel(application) {
val context: Context by lazy { this.getApplication<App>() }
fun <T> execute(
scope: CoroutineScope = viewModelScope,
context: CoroutineContext = Dispatchers.IO,
block: suspend CoroutineScope.() -> T
): Coroutine<T> {
return Coroutine.async(scope, context) { block() }
}
fun <R> submit(
scope: CoroutineScope = viewModelScope,
context: CoroutineContext = Dispatchers.IO,
block: suspend CoroutineScope.() -> Deferred<R>
): Coroutine<R> {
return Coroutine.async(scope, context) { block().await() }
}
}
@@ -0,0 +1 @@
# 基类
@@ -0,0 +1,17 @@
package io.legado.app.base
import androidx.lifecycle.ViewModel
import androidx.viewbinding.ViewBinding
import io.legado.app.constant.Theme
abstract class VMBaseActivity<VB : ViewBinding, VM : ViewModel>(
fullScreen: Boolean = true,
theme: Theme = Theme.Auto,
toolBarTheme: Theme = Theme.Auto,
transparent: Boolean = false,
imageBg: Boolean = true
) : BaseActivity<VB>(fullScreen, theme, toolBarTheme, transparent, imageBg) {
protected abstract val viewModel: VM
}
@@ -0,0 +1,9 @@
package io.legado.app.base
import androidx.lifecycle.ViewModel
abstract class VMBaseFragment<VM : ViewModel>(layoutID: Int) : BaseFragment(layoutID) {
protected abstract val viewModel: VM
}
@@ -0,0 +1,216 @@
package io.legado.app.base.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import splitties.views.onLongClick
/**
* Created by Invincible on 2017/12/15.
*/
@Suppress("unused", "MemberVisibilityCanBePrivate")
abstract class DiffRecyclerAdapter<ITEM, VB : ViewBinding>(protected val context: Context) :
RecyclerView.Adapter<ItemViewHolder>() {
val inflater: LayoutInflater = LayoutInflater.from(context)
private val asyncListDiffer: AsyncListDiffer<ITEM> by lazy {
AsyncListDiffer(this, diffItemCallback).apply {
addListListener { _, _ ->
onCurrentListChanged()
}
}
}
private var itemClickListener: ((holder: ItemViewHolder, item: ITEM) -> Unit)? = null
private var itemLongClickListener: ((holder: ItemViewHolder, item: ITEM) -> Boolean)? = null
var itemAnimation: ItemAnimation? = null
abstract val diffItemCallback: DiffUtil.ItemCallback<ITEM>
fun setOnItemClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Unit) {
itemClickListener = listener
}
fun setOnItemLongClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Boolean) {
itemLongClickListener = listener
}
fun bindToRecyclerView(recyclerView: RecyclerView) {
recyclerView.adapter = this
}
@Synchronized
fun setItems(items: List<ITEM>?) {
kotlin.runCatching {
asyncListDiffer.submitList(items)
}
}
@Synchronized
fun setItem(position: Int, item: ITEM) {
kotlin.runCatching {
val list = ArrayList(asyncListDiffer.currentList)
list[position] = item
asyncListDiffer.submitList(list)
}
}
@Synchronized
fun updateItem(item: ITEM) {
kotlin.runCatching {
val index = asyncListDiffer.currentList.indexOf(item)
if (index >= 0) {
asyncListDiffer.currentList[index] = item
notifyItemChanged(index)
}
}
}
@Synchronized
fun updateItem(position: Int, payload: Any) {
kotlin.runCatching {
val size = itemCount
if (position in 0 until size) {
notifyItemChanged(position, payload)
}
}
}
@Synchronized
fun updateItems(fromPosition: Int, toPosition: Int, payloads: Any) {
kotlin.runCatching {
val size = itemCount
if (fromPosition in 0 until size && toPosition in 0 until size) {
notifyItemRangeChanged(
fromPosition,
toPosition - fromPosition + 1,
payloads
)
}
}
}
fun isEmpty() = asyncListDiffer.currentList.isEmpty()
fun isNotEmpty() = asyncListDiffer.currentList.isNotEmpty()
fun getItem(position: Int): ITEM? = asyncListDiffer.currentList.getOrNull(position)
fun getItems(): List<ITEM> = asyncListDiffer.currentList
/**
* grid 模式下使用
*/
protected open fun getSpanSize(viewType: Int, position: Int) = 1
final override fun getItemCount() = getItems().size
final override fun getItemViewType(position: Int): Int {
return 0
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
val holder = ItemViewHolder(getViewBinding(parent))
@Suppress("UNCHECKED_CAST")
registerListener(holder, (holder.binding as VB))
if (itemClickListener != null) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
itemClickListener?.invoke(holder, it)
}
}
}
if (itemLongClickListener != null) {
holder.itemView.onLongClick {
getItem(holder.layoutPosition)?.let {
itemLongClickListener?.invoke(holder, it)
}
}
}
return holder
}
protected abstract fun getViewBinding(parent: ViewGroup): VB
final override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {}
open fun onCurrentListChanged() {
}
@Suppress("UNCHECKED_CAST")
final override fun onBindViewHolder(
holder: ItemViewHolder,
position: Int,
payloads: MutableList<Any>
) {
getItem(holder.layoutPosition)?.let {
convert(holder, (holder.binding as VB), it, payloads)
}
}
override fun onViewAttachedToWindow(holder: ItemViewHolder) {
super.onViewAttachedToWindow(holder)
addAnimation(holder)
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
val manager = recyclerView.layoutManager
if (manager is GridLayoutManager) {
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return getSpanSize(getItemViewType(position), position)
}
}
}
}
private fun addAnimation(holder: ItemViewHolder) {
itemAnimation?.let {
if (it.itemAnimEnabled) {
if (!it.itemAnimFirstOnly || holder.layoutPosition > it.itemAnimStartPosition) {
startAnimation(holder, it)
it.itemAnimStartPosition = holder.layoutPosition
}
}
}
}
protected open fun startAnimation(holder: ItemViewHolder, item: ItemAnimation) {
item.itemAnimation?.let {
for (anim in it.getAnimators(holder.itemView)) {
anim.setDuration(item.itemAnimDuration).start()
anim.interpolator = item.itemAnimInterpolator
}
}
}
/**
* 如果使用了事件回调,回调里不要直接使用item,会出现不更新的问题,
* 使用getItem(holder.layoutPosition)来获取item
*/
abstract fun convert(
holder: ItemViewHolder,
binding: VB,
item: ITEM,
payloads: MutableList<Any>
)
/**
* 注册事件
*/
abstract fun registerListener(holder: ItemViewHolder, binding: VB)
}
@@ -0,0 +1,85 @@
package io.legado.app.base.adapter
import android.view.animation.Interpolator
import android.view.animation.LinearInterpolator
import io.legado.app.base.adapter.animations.*
/**
* Created by Invincible on 2017/12/15.
*/
@Suppress("unused")
class ItemAnimation private constructor() {
var itemAnimEnabled = false
var itemAnimFirstOnly = true
var itemAnimation: BaseAnimation? = null
var itemAnimInterpolator: Interpolator = LinearInterpolator()
var itemAnimDuration: Long = 300L
var itemAnimStartPosition: Int = -1
fun interpolator(interpolator: Interpolator) = apply {
itemAnimInterpolator = interpolator
}
fun duration(duration: Long) = apply {
itemAnimDuration = duration
}
fun startPosition(startPos: Int) = apply {
itemAnimStartPosition = startPos
}
fun animation(animationType: Int = NONE, animation: BaseAnimation? = null) = apply {
if (animation != null) {
itemAnimation = animation
} else {
when (animationType) {
FADE_IN -> itemAnimation = AlphaInAnimation()
SCALE_IN -> itemAnimation = ScaleInAnimation()
BOTTOM_SLIDE_IN -> itemAnimation = SlideInBottomAnimation()
LEFT_SLIDE_IN -> itemAnimation = SlideInLeftAnimation()
RIGHT_SLIDE_IN -> itemAnimation = SlideInRightAnimation()
}
}
}
fun enabled(enabled: Boolean) = apply {
itemAnimEnabled = enabled
}
fun firstOnly(firstOnly: Boolean) = apply {
itemAnimFirstOnly = firstOnly
}
companion object {
const val NONE: Int = 0x00000000
/**
* Use with [.openLoadAnimation]
*/
const val FADE_IN: Int = 0x00000001
/**
* Use with [.openLoadAnimation]
*/
const val SCALE_IN: Int = 0x00000002
/**
* Use with [.openLoadAnimation]
*/
const val BOTTOM_SLIDE_IN: Int = 0x00000003
/**
* Use with [.openLoadAnimation]
*/
const val LEFT_SLIDE_IN: Int = 0x00000004
/**
* Use with [.openLoadAnimation]
*/
const val RIGHT_SLIDE_IN: Int = 0x00000005
fun create() = ItemAnimation()
}
}
@@ -0,0 +1,10 @@
package io.legado.app.base.adapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
/**
* Created by Invincible on 2017/11/28.
*/
@Suppress("MemberVisibilityCanBePrivate")
class ItemViewHolder(val binding: ViewBinding) : RecyclerView.ViewHolder(binding.root)
@@ -0,0 +1,458 @@
package io.legado.app.base.adapter
import android.annotation.SuppressLint
import android.content.Context
import android.util.SparseArray
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import splitties.views.onLongClick
import java.util.*
/**
* Created by Invincible on 2017/11/24.
*
* 通用的adapter 可添加headerfooter,以及不同类型item
*/
@Suppress("unused", "MemberVisibilityCanBePrivate")
abstract class RecyclerAdapter<ITEM, VB : ViewBinding>(protected val context: Context) :
RecyclerView.Adapter<ItemViewHolder>() {
val inflater: LayoutInflater = LayoutInflater.from(context)
private val headerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() }
private val footerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() }
private val items: MutableList<ITEM> = mutableListOf()
private var itemClickListener: ((holder: ItemViewHolder, item: ITEM) -> Unit)? = null
private var itemLongClickListener: ((holder: ItemViewHolder, item: ITEM) -> Boolean)? = null
var itemAnimation: ItemAnimation? = null
fun setOnItemClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Unit) {
itemClickListener = listener
}
fun setOnItemLongClickListener(listener: (holder: ItemViewHolder, item: ITEM) -> Boolean) {
itemLongClickListener = listener
}
fun bindToRecyclerView(recyclerView: RecyclerView) {
recyclerView.adapter = this
}
@Synchronized
fun addHeaderView(header: ((parent: ViewGroup) -> ViewBinding)) {
kotlin.runCatching {
val index = headerItems.size()
headerItems.put(TYPE_HEADER_VIEW + headerItems.size(), header)
notifyItemInserted(index)
}
}
@Synchronized
fun addFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
kotlin.runCatching {
val index = getActualItemCount() + footerItems.size()
footerItems.put(TYPE_FOOTER_VIEW + footerItems.size(), footer)
notifyItemInserted(index)
}
}
@Synchronized
fun removeHeaderView(header: ((parent: ViewGroup) -> ViewBinding)) {
kotlin.runCatching {
val index = headerItems.indexOfValue(header)
if (index >= 0) {
headerItems.remove(index)
notifyItemRemoved(index)
}
}
}
@Synchronized
fun removeFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
kotlin.runCatching {
val index = footerItems.indexOfValue(footer)
if (index >= 0) {
footerItems.remove(index)
notifyItemRemoved(getActualItemCount() + index - 2)
}
}
}
@SuppressLint("NotifyDataSetChanged")
@Synchronized
fun setItems(items: List<ITEM>?) {
kotlin.runCatching {
if (this.items.isNotEmpty()) {
this.items.clear()
}
if (items != null) {
this.items.addAll(items)
}
notifyDataSetChanged()
onCurrentListChanged()
}
}
@Synchronized
fun setItems(items: List<ITEM>?, itemCallback: DiffUtil.ItemCallback<ITEM>) {
kotlin.runCatching {
val callback = object : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return itemCount
}
override fun getNewListSize(): Int {
return (items?.size ?: 0) + getHeaderCount() + getFooterCount()
}
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = getItem(oldItemPosition - getHeaderCount())
?: return true
val newItem = items?.getOrNull(newItemPosition - getHeaderCount())
?: return true
return itemCallback.areItemsTheSame(oldItem, newItem)
}
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
val oldItem = getItem(oldItemPosition - getHeaderCount())
?: return true
val newItem = items?.getOrNull(newItemPosition - getHeaderCount())
?: return true
return itemCallback.areContentsTheSame(oldItem, newItem)
}
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
val oldItem = getItem(oldItemPosition - getHeaderCount())
?: return null
val newItem = items?.getOrNull(newItemPosition - getHeaderCount())
?: return null
return itemCallback.getChangePayload(oldItem, newItem)
}
}
val diffResult = DiffUtil.calculateDiff(callback)
if (this.items.isNotEmpty()) {
this.items.clear()
}
if (items != null) {
this.items.addAll(items)
}
diffResult.dispatchUpdatesTo(this)
onCurrentListChanged()
}
}
@Synchronized
fun setItem(position: Int, item: ITEM) {
kotlin.runCatching {
val oldSize = getActualItemCount()
if (position in 0 until oldSize) {
this.items[position] = item
notifyItemChanged(position + getHeaderCount())
}
onCurrentListChanged()
}
}
@Synchronized
fun addItem(item: ITEM) {
kotlin.runCatching {
val oldSize = getActualItemCount()
if (this.items.add(item)) {
notifyItemInserted(oldSize + getHeaderCount())
}
onCurrentListChanged()
}
}
@Synchronized
fun addItems(position: Int, newItems: List<ITEM>) {
kotlin.runCatching {
if (this.items.addAll(position, newItems)) {
notifyItemRangeInserted(position + getHeaderCount(), newItems.size)
}
onCurrentListChanged()
}
}
@SuppressLint("NotifyDataSetChanged")
@Synchronized
fun addItems(newItems: List<ITEM>) {
kotlin.runCatching {
val oldSize = getActualItemCount()
if (this.items.addAll(newItems)) {
if (oldSize == 0 && getHeaderCount() == 0) {
notifyDataSetChanged()
} else {
notifyItemRangeInserted(oldSize + getHeaderCount(), newItems.size)
}
}
onCurrentListChanged()
}
}
@Synchronized
fun removeItem(position: Int) {
kotlin.runCatching {
if (this.items.removeAt(position) != null) {
notifyItemRemoved(position + getHeaderCount())
}
onCurrentListChanged()
}
}
@Synchronized
fun removeItem(item: ITEM) {
kotlin.runCatching {
if (this.items.remove(item)) {
notifyItemRemoved(this.items.indexOf(item) + getHeaderCount())
}
onCurrentListChanged()
}
}
@SuppressLint("NotifyDataSetChanged")
@Synchronized
fun removeItems(items: List<ITEM>) {
kotlin.runCatching {
if (this.items.removeAll(items)) {
notifyDataSetChanged()
}
onCurrentListChanged()
}
}
@Synchronized
fun swapItem(oldPosition: Int, newPosition: Int) {
kotlin.runCatching {
val size = getActualItemCount()
if (oldPosition in 0 until size && newPosition in 0 until size) {
val srcPosition = oldPosition + getHeaderCount()
val targetPosition = newPosition + getHeaderCount()
Collections.swap(this.items, srcPosition, targetPosition)
notifyItemMoved(srcPosition, targetPosition)
}
onCurrentListChanged()
}
}
@Synchronized
fun updateItem(item: ITEM) {
kotlin.runCatching {
val index = this.items.indexOf(item)
if (index >= 0) {
this.items[index] = item
notifyItemChanged(index)
}
onCurrentListChanged()
}
}
@Synchronized
fun updateItem(position: Int, payload: Any) {
kotlin.runCatching {
val size = getActualItemCount()
if (position in 0 until size) {
notifyItemChanged(position + getHeaderCount(), payload)
}
}
}
@Synchronized
fun updateItems(fromPosition: Int, toPosition: Int, payloads: Any) {
kotlin.runCatching {
val size = getActualItemCount()
if (fromPosition in 0 until size && toPosition in 0 until size) {
notifyItemRangeChanged(
fromPosition + getHeaderCount(),
toPosition - fromPosition + 1,
payloads
)
}
}
}
@SuppressLint("NotifyDataSetChanged")
@Synchronized
fun clearItems() {
kotlin.runCatching {
this.items.clear()
notifyDataSetChanged()
onCurrentListChanged()
}
}
fun isEmpty() = items.isEmpty()
fun isNotEmpty() = items.isNotEmpty()
/**
* 除去header和footer
*/
fun getActualItemCount() = items.size
fun getHeaderCount() = headerItems.size()
fun getFooterCount() = footerItems.size()
fun getItem(position: Int): ITEM? = items.getOrNull(position)
fun getItemByLayoutPosition(position: Int) = items.getOrNull(position - getHeaderCount())
fun getItems(): List<ITEM> = items
protected open fun getItemViewType(item: ITEM, position: Int) = 0
/**
* grid 模式下使用
*/
protected open fun getSpanSize(viewType: Int, position: Int) = 1
final override fun getItemCount() = getActualItemCount() + getHeaderCount() + getFooterCount()
final override fun getItemViewType(position: Int) = when {
isHeader(position) -> TYPE_HEADER_VIEW + position
isFooter(position) -> TYPE_FOOTER_VIEW + position - getActualItemCount() - getHeaderCount()
else -> getItem(getActualPosition(position))?.let {
getItemViewType(it, getActualPosition(position))
} ?: 0
}
open fun onCurrentListChanged() {
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when {
viewType < TYPE_HEADER_VIEW + getHeaderCount() -> {
ItemViewHolder(headerItems.get(viewType).invoke(parent))
}
viewType >= TYPE_FOOTER_VIEW -> {
ItemViewHolder(footerItems.get(viewType).invoke(parent))
}
else -> {
val holder = ItemViewHolder(getViewBinding(parent))
@Suppress("UNCHECKED_CAST")
registerListener(holder, (holder.binding as VB))
if (itemClickListener != null) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
itemClickListener?.invoke(holder, it)
}
}
}
if (itemLongClickListener != null) {
holder.itemView.onLongClick {
getItem(holder.layoutPosition)?.let {
itemLongClickListener?.invoke(holder, it)
}
}
}
holder
}
}
protected abstract fun getViewBinding(parent: ViewGroup): VB
final override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {}
@Suppress("UNCHECKED_CAST")
final override fun onBindViewHolder(
holder: ItemViewHolder,
position: Int,
payloads: MutableList<Any>
) {
if (!isHeader(holder.layoutPosition) && !isFooter(holder.layoutPosition)) {
getItem(holder.layoutPosition - getHeaderCount())?.let {
convert(holder, (holder.binding as VB), it, payloads)
}
}
}
override fun onViewAttachedToWindow(holder: ItemViewHolder) {
super.onViewAttachedToWindow(holder)
if (!isHeader(holder.layoutPosition) && !isFooter(holder.layoutPosition)) {
addAnimation(holder)
}
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
val manager = recyclerView.layoutManager
if (manager is GridLayoutManager) {
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return getSpanSize(getItemViewType(position), position)
}
}
}
}
private fun isHeader(position: Int) = position < getHeaderCount()
private fun isFooter(position: Int) = position >= getActualItemCount() + getHeaderCount()
private fun getActualPosition(position: Int) = position - getHeaderCount()
private fun addAnimation(holder: ItemViewHolder) {
itemAnimation?.let {
if (it.itemAnimEnabled) {
if (!it.itemAnimFirstOnly || holder.layoutPosition > it.itemAnimStartPosition) {
startAnimation(holder, it)
it.itemAnimStartPosition = holder.layoutPosition
}
}
}
}
protected open fun startAnimation(holder: ItemViewHolder, item: ItemAnimation) {
item.itemAnimation?.let {
for (anim in it.getAnimators(holder.itemView)) {
anim.setDuration(item.itemAnimDuration).start()
anim.interpolator = item.itemAnimInterpolator
}
}
}
/**
* 如果使用了事件回调,回调里不要直接使用item,会出现不更新的问题,
* 使用getItem(holder.layoutPosition)来获取item
*/
abstract fun convert(
holder: ItemViewHolder,
binding: VB,
item: ITEM,
payloads: MutableList<Any>
)
/**
* 注册事件
*/
abstract fun registerListener(holder: ItemViewHolder, binding: VB)
companion object {
private const val TYPE_HEADER_VIEW = Int.MIN_VALUE
private const val TYPE_FOOTER_VIEW = Int.MAX_VALUE - 999
}
}
@@ -0,0 +1,18 @@
package io.legado.app.base.adapter.animations
import android.animation.Animator
import android.animation.ObjectAnimator
import android.view.View
class AlphaInAnimation @JvmOverloads constructor(private val mFrom: Float = DEFAULT_ALPHA_FROM) :
BaseAnimation {
override fun getAnimators(view: View): Array<Animator> =
arrayOf(ObjectAnimator.ofFloat(view, "alpha", mFrom, 1f))
companion object {
private const val DEFAULT_ALPHA_FROM = 0f
}
}
@@ -0,0 +1,13 @@
package io.legado.app.base.adapter.animations
import android.animation.Animator
import android.view.View
/**
* adapter item 动画
*/
interface BaseAnimation {
fun getAnimators(view: View): Array<Animator>
}
@@ -0,0 +1,21 @@
package io.legado.app.base.adapter.animations
import android.animation.Animator
import android.animation.ObjectAnimator
import android.view.View
class ScaleInAnimation @JvmOverloads constructor(private val mFrom: Float = DEFAULT_SCALE_FROM) :
BaseAnimation {
override fun getAnimators(view: View): Array<Animator> {
val scaleX = ObjectAnimator.ofFloat(view, "scaleX", mFrom, 1f)
val scaleY = ObjectAnimator.ofFloat(view, "scaleY", mFrom, 1f)
return arrayOf(scaleX, scaleY)
}
companion object {
private const val DEFAULT_SCALE_FROM = .5f
}
}
@@ -0,0 +1,12 @@
package io.legado.app.base.adapter.animations
import android.animation.Animator
import android.animation.ObjectAnimator
import android.view.View
class SlideInBottomAnimation : BaseAnimation {
override fun getAnimators(view: View): Array<Animator> =
arrayOf(ObjectAnimator.ofFloat(view, "translationY", view.measuredHeight.toFloat(), 0f))
}
@@ -0,0 +1,13 @@
package io.legado.app.base.adapter.animations
import android.animation.Animator
import android.animation.ObjectAnimator
import android.view.View
class SlideInLeftAnimation : BaseAnimation {
override fun getAnimators(view: View): Array<Animator> =
arrayOf(ObjectAnimator.ofFloat(view, "translationX", -view.rootView.width.toFloat(), 0f))
}
@@ -0,0 +1,13 @@
package io.legado.app.base.adapter.animations
import android.animation.Animator
import android.animation.ObjectAnimator
import android.view.View
class SlideInRightAnimation : BaseAnimation {
override fun getAnimators(view: View): Array<Animator> =
arrayOf(ObjectAnimator.ofFloat(view, "translationX", view.rootView.width.toFloat(), 0f))
}
@@ -0,0 +1,114 @@
package io.legado.app.constant
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.provider.Settings
import io.legado.app.BuildConfig
import io.legado.app.R
import splitties.init.appCtx
import java.text.SimpleDateFormat
import javax.script.ScriptEngine
import javax.script.ScriptEngineManager
@SuppressLint("SimpleDateFormat")
object AppConst {
const val APP_TAG = "Legado"
const val channelIdDownload = "channel_download"
const val channelIdReadAloud = "channel_read_aloud"
const val channelIdWeb = "channel_web"
const val UA_NAME = "User-Agent"
const val MAX_THREAD = 9
val SCRIPT_ENGINE: ScriptEngine by lazy {
ScriptEngineManager().getEngineByName("rhino")
}
val timeFormat: SimpleDateFormat by lazy {
SimpleDateFormat("HH:mm")
}
val dateFormat: SimpleDateFormat by lazy {
SimpleDateFormat("yyyy/MM/dd HH:mm")
}
val fileNameFormat: SimpleDateFormat by lazy {
SimpleDateFormat("yy-MM-dd-HH-mm-ss")
}
val keyboardToolChars: List<String> by lazy {
arrayListOf(
"", "@css:", "<js></js>", "{{}}", "##", "&&", "%%", "||", "//", "\\", "$.",
"@", ":", "class", "text", "href", "textNodes", "ownText", "all", "html",
"[", "]", "<", ">", "#", "!", ".", "+", "-", "*", "=", "{'webView': true}"
)
}
const val bookGroupAllId = -1L
const val bookGroupLocalId = -2L
const val bookGroupAudioId = -3L
const val bookGroupNoneId = -4L
const val notificationIdRead = -1122391
const val notificationIdAudio = -1122392
const val notificationIdCache = -1122393
const val notificationIdWeb = -1122394
const val notificationIdDownload = -1122395
const val notificationIdCheckSource = -1122395
val urlOption: String by lazy {
"""
,{
'charset': '',
'method': 'POST',
'body': '',
'headers': {
'User-Agent': ''
}
}
""".trimIndent()
}
val menuViewNames = arrayOf(
"com.android.internal.view.menu.ListMenuItemView",
"androidx.appcompat.view.menu.ListMenuItemView"
)
val sysElevation = appCtx.resources.getDimension(R.dimen.design_appbar_elevation).toInt()
val androidId: String by lazy {
Settings.System.getString(appCtx.contentResolver, Settings.Secure.ANDROID_ID)
}
val appInfo: AppInfo by lazy {
val appInfo = AppInfo()
appCtx.packageManager.getPackageInfo(appCtx.packageName, PackageManager.GET_ACTIVITIES)
?.let {
appInfo.versionName = it.versionName
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
appInfo.versionCode = it.longVersionCode
} else {
@Suppress("DEPRECATION")
appInfo.versionCode = it.versionCode.toLong()
}
}
appInfo
}
val charsets =
arrayListOf("UTF-8", "GB2312", "GB18030", "GBK", "Unicode", "UTF-16", "UTF-16LE", "ASCII")
data class AppInfo(
var versionCode: Long = 0L,
var versionName: String = ""
)
/**
* The authority of a FileProvider defined in a <provider> element in your app's manifest.
*/
const val authority = BuildConfig.APPLICATION_ID + ".fileProvider"
}
@@ -0,0 +1,23 @@
package io.legado.app.constant
object AppLog {
private val mLogs = arrayListOf<Triple<Long, String, Throwable?>>()
val logs get() = mLogs.toList()
@Synchronized
fun put(message: String?, throwable: Throwable? = null) {
message ?: return
if (mLogs.size > 100) {
mLogs.removeLastOrNull()
}
mLogs.add(0, Triple(System.currentTimeMillis(), message, throwable))
}
@Synchronized
fun clear() {
mLogs.clear()
}
}
@@ -0,0 +1,33 @@
package io.legado.app.constant
import java.util.regex.Pattern
@Suppress("RegExpRedundantEscape")
object AppPattern {
val JS_PATTERN: Pattern =
Pattern.compile("<js>([\\w\\W]*?)</js>|@js:([\\w\\W]*)", Pattern.CASE_INSENSITIVE)
val EXP_PATTERN: Pattern = Pattern.compile("\\{\\{([\\w\\W]*?)\\}\\}")
//匹配格式化后的图片格式
val imgPattern: Pattern = Pattern.compile("<img[^>]*src=\"([^\"]*(?:\"[^>]+\\})?)\"[^>]*>")
val nameRegex = Regex("\\s+作\\s*者.*|\\s+\\S+\\s+著")
val authorRegex = Regex("^\\s*作\\s*者[:\\s]+|\\s+著")
val fileNameRegex = Regex("[\\\\/:*?\"<>|.]")
val splitGroupRegex = Regex("[,;,;]")
/**
* 所有标点
*/
val bdRegex = Regex("(\\p{P})+")
/**
* 换行
*/
val rnRegex = Regex("[\\r\\n]")
/**
* 不发音段落判断
*/
val notReadAloudRegex = Regex("^(\\s|\\p{C}|\\p{P}|\\p{Z}|\\p{S})+$")
}
@@ -0,0 +1,8 @@
package io.legado.app.constant
object BookType {
const val default = 0 // 0 文本
const val audio = 1 // 1 音频
const val image = 3 //图片
const val local = "loc_book"
}
@@ -0,0 +1,31 @@
package io.legado.app.constant
object EventBus {
const val MEDIA_BUTTON = "mediaButton"
const val RECREATE = "RECREATE"
const val UP_BOOKSHELF = "upBookToc"
const val BOOKSHELF_REFRESH = "bookshelfRefresh"
const val ALOUD_STATE = "aloud_state"
const val TTS_PROGRESS = "ttsStart"
const val TTS_DS = "ttsDs"
const val BATTERY_CHANGED = "batteryChanged"
const val TIME_CHANGED = "timeChanged"
const val UP_CONFIG = "upConfig"
const val OPEN_CHAPTER = "openChapter"
const val AUDIO_SUB_TITLE = "audioSubTitle"
const val AUDIO_STATE = "audioState"
const val AUDIO_PROGRESS = "audioProgress"
const val AUDIO_SIZE = "audioSize"
const val AUDIO_SPEED = "audioSpeed"
const val AUDIO_ERROR = "audioError"
const val NOTIFY_MAIN = "notifyMain"
const val WEB_SERVICE = "webService"
const val UP_DOWNLOAD = "upDownload"
const val SAVE_CONTENT = "saveContent"
const val CHECK_SOURCE = "checkSource"
const val CHECK_SOURCE_MESSAGE = "checkSourceMessage"
const val CHECK_SOURCE_DONE = "checkSourceDone"
const val TIP_COLOR = "tipColor"
const val SOURCE_CHANGED = "sourceChanged"
const val SEARCH_RESULT = "searchResult"
}
@@ -0,0 +1,21 @@
package io.legado.app.constant
object IntentAction {
const val start = "start"
const val play = "play"
const val stop = "stop"
const val resume = "resume"
const val pause = "pause"
const val addTimer = "addTimer"
const val setTimer = "setTimer"
const val prevParagraph = "prevParagraph"
const val nextParagraph = "nextParagraph"
const val upTtsSpeechRate = "upTtsSpeechRate"
const val adjustProgress = "adjustProgress"
const val adjustSpeed = "adjustSpeed"
const val prev = "prev"
const val next = "next"
const val moveTo = "moveTo"
const val init = "init"
const val remove = "remove"
}
@@ -0,0 +1,108 @@
package io.legado.app.constant
object PreferKey {
const val language = "language"
const val fontScale = "fontScale"
const val themeMode = "themeMode"
const val userAgent = "userAgent"
const val showUnread = "showUnread"
const val bookGroupStyle = "bookGroupStyle"
const val useDefaultCover = "useDefaultCover"
const val coverShowName = "coverShowName"
const val coverShowAuthor = "coverShowAuthor"
const val coverShowNameN = "coverShowNameN"
const val coverShowAuthorN = "coverShowAuthorN"
const val hideStatusBar = "hideStatusBar"
const val clickActionTL = "clickActionTopLeft"
const val clickActionTC = "clickActionTopCenter"
const val clickActionTR = "clickActionTopRight"
const val clickActionML = "clickActionMiddleLeft"
const val clickActionMC = "clickActionMiddleCenter"
const val clickActionMR = "clickActionMiddleRight"
const val clickActionBL = "clickActionBottomLeft"
const val clickActionBC = "clickActionBottomCenter"
const val clickActionBR = "clickActionBottomRight"
const val hideNavigationBar = "hideNavigationBar"
const val precisionSearch = "precisionSearch"
const val readAloudByPage = "readAloudByPage"
const val ttsEngine = "appTtsEngine"
const val ttsSpeechRate = "ttsSpeechRate"
const val prevKeys = "prevKeyCodes"
const val nextKeys = "nextKeyCodes"
const val showDiscovery = "showDiscovery"
const val showRss = "showRss"
const val bookshelfLayout = "bookshelfLayout"
const val bookshelfSort = "bookshelfSort"
const val bookExportFileName = "bookExportFileName"
const val bookImportFileName = "bookImportFileName"
const val recordLog = "recordLog"
const val processText = "process_text"
const val cleanCache = "cleanCache"
const val saveTabPosition = "saveTabPosition"
const val fontFolder = "fontFolder"
const val backupPath = "backupUri"
const val restoreIgnore = "restoreIgnore"
const val threadCount = "threadCount"
const val webPort = "webPort"
const val keepLight = "keep_light"
const val webService = "webService"
const val webDavUrl = "web_dav_url"
const val webDavAccount = "web_dav_account"
const val webDavPassword = "web_dav_password"
const val webDavCreateDir = "webDavCreateDir"
const val exportToWebDav = "webDavCacheBackup"
const val exportNoChapterName = "exportNoChapterName"
const val exportType = "exportType"
const val changeSourceCheckAuthor = "changeSourceCheckAuthor"
const val changeSourceLoadToc = "changeSourceLoadToc"
const val changeSourceLoadInfo = "changeSourceLoadInfo"
const val chineseConverterType = "chineseConverterType"
const val launcherIcon = "launcherIcon"
const val textSelectAble = "selectText"
const val lastBackup = "lastBackup"
const val shareLayout = "shareLayout"
const val readStyleSelect = "readStyleSelect"
const val systemTypefaces = "system_typefaces"
const val readBodyToLh = "readBodyToLh"
const val textFullJustify = "textFullJustify"
const val textBottomJustify = "textBottomJustify"
const val autoReadSpeed = "autoReadSpeed"
const val barElevation = "barElevation"
const val transparentStatusBar = "transparentStatusBar"
const val immNavigationBar = "immNavigationBar"
const val defaultCover = "defaultCover"
const val defaultCoverDark = "defaultCoverDark"
const val replaceEnableDefault = "replaceEnableDefault"
const val showBrightnessView = "showBrightnessView"
const val autoClearExpired = "autoClearExpired"
const val autoChangeSource = "autoChangeSource"
const val importKeepName = "importKeepName"
const val screenOrientation = "screenOrientation"
const val syncBookProgress = "syncBookProgress"
const val preDownloadNum = "preDownloadNum"
const val autoRefresh = "auto_refresh"
const val defaultToRead = "defaultToRead"
const val exportCharset = "exportCharset"
const val exportUseReplace = "exportUseReplace"
const val useZhLayout = "useZhLayout"
const val brightness = "brightness"
const val nightBrightness = "nightBrightness"
const val expandTextMenu = "expandTextMenu"
const val doublePageHorizontal = "doublePageHorizontal"
const val readUrlOpenInBrowser = "readUrlInBrowser"
const val defaultBookTreeUri = "defaultBookTreeUri"
const val cPrimary = "colorPrimary"
const val cAccent = "colorAccent"
const val cBackground = "colorBackground"
const val cBBackground = "colorBottomBackground"
const val bgImage = "backgroundImage"
const val bgImageBlurring = "backgroundImageBlurring"
const val cNPrimary = "colorPrimaryNight"
const val cNAccent = "colorAccentNight"
const val cNBackground = "colorBackgroundNight"
const val cNBBackground = "colorBottomBackgroundNight"
const val bgImageN = "backgroundImageNight"
const val bgImageNBlurring = "backgroundImageNightBlurring"
}
@@ -0,0 +1,7 @@
package io.legado.app.constant
object Status {
const val STOP = 0
const val PLAY = 1
const val PAUSE = 3
}
@@ -0,0 +1,22 @@
package io.legado.app.constant
import io.legado.app.help.AppConfig
import io.legado.app.utils.ColorUtils
enum class Theme {
Dark, Light, Auto, Transparent, EInk;
companion object {
fun getTheme() = when {
AppConfig.isEInkMode -> EInk
AppConfig.isNightTheme -> Dark
else -> Light
}
@Suppress("unused")
fun fromBackground(backgroundColor: Int) =
if (ColorUtils.isColorLight(backgroundColor)) Light
else Dark
}
}
@@ -0,0 +1,95 @@
package io.legado.app.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import io.legado.app.constant.AppConst
import io.legado.app.data.dao.*
import io.legado.app.data.entities.*
import splitties.init.appCtx
import java.util.*
val appDb by lazy {
AppDatabase.createDatabase(appCtx)
}
@Database(
version = 42,
exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
RssSource::class, Bookmark::class, RssArticle::class, RssReadRecord::class,
RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class,
RuleSub::class]
)
abstract class AppDatabase : RoomDatabase() {
abstract val bookDao: BookDao
abstract val bookGroupDao: BookGroupDao
abstract val bookSourceDao: BookSourceDao
abstract val bookChapterDao: BookChapterDao
abstract val replaceRuleDao: ReplaceRuleDao
abstract val searchBookDao: SearchBookDao
abstract val searchKeywordDao: SearchKeywordDao
abstract val rssSourceDao: RssSourceDao
abstract val bookmarkDao: BookmarkDao
abstract val rssArticleDao: RssArticleDao
abstract val rssStarDao: RssStarDao
abstract val cookieDao: CookieDao
abstract val txtTocRuleDao: TxtTocRuleDao
abstract val readRecordDao: ReadRecordDao
abstract val httpTTSDao: HttpTTSDao
abstract val cacheDao: CacheDao
abstract val ruleSubDao: RuleSubDao
companion object {
private const val DATABASE_NAME = "legado.db"
fun createDatabase(context: Context) =
Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
.fallbackToDestructiveMigrationFrom(1, 2, 3, 4, 5, 6, 7, 8, 9)
.addMigrations(*DatabaseMigrations.migrations)
.allowMainThreadQueries()
.addCallback(dbCallback)
.build()
private val dbCallback = object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
db.setLocale(Locale.CHINESE)
}
override fun onOpen(db: SupportSQLiteDatabase) {
db.execSQL(
"""insert into book_groups(groupId, groupName, 'order', show)
select ${AppConst.bookGroupAllId}, '全部', -10, 1
where not exists (select * from book_groups where groupId = ${AppConst.bookGroupAllId})"""
)
db.execSQL(
"""insert into book_groups(groupId, groupName, 'order', show)
select ${AppConst.bookGroupLocalId}, '本地', -9, 1
where not exists (select * from book_groups where groupId = ${AppConst.bookGroupLocalId})"""
)
db.execSQL(
"""insert into book_groups(groupId, groupName, 'order', show)
select ${AppConst.bookGroupAudioId}, '音频', -8, 1
where not exists (select * from book_groups where groupId = ${AppConst.bookGroupAudioId})"""
)
db.execSQL(
"""insert into book_groups(groupId, groupName, 'order', show)
select ${AppConst.bookGroupNoneId}, '未分组', -7, 1
where not exists (select * from book_groups where groupId = ${AppConst.bookGroupNoneId})"""
)
db.execSQL("update book_sources set loginUi = null where loginUi = 'null'")
db.execSQL("update rssSources set loginUi = null where loginUi = 'null'")
db.execSQL("update httpTTS set loginUi = null where loginUi = 'null'")
db.execSQL("update httpTTS set concurrentRate = '0' where loginUi is null")
}
}
}
}
@@ -0,0 +1,339 @@
package io.legado.app.data
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import io.legado.app.constant.AppConst
object DatabaseMigrations {
val migrations: Array<Migration> by lazy {
arrayOf(
migration_10_11,
migration_11_12,
migration_12_13,
migration_13_14,
migration_14_15,
migration_15_17,
migration_17_18,
migration_18_19,
migration_19_20,
migration_20_21,
migration_21_22,
migration_22_23,
migration_23_24,
migration_24_25,
migration_25_26,
migration_26_27,
migration_27_28,
migration_28_29,
migration_29_30,
migration_30_31,
migration_31_32,
migration_32_33,
migration_33_34,
migration_34_35,
migration_35_36,
migration_36_37,
migration_37_38,
migration_38_39,
migration_39_40,
migration_40_41,
migration_41_42
)
}
private val migration_10_11 = object : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("DROP TABLE txtTocRules")
database.execSQL(
"""CREATE TABLE txtTocRules(id INTEGER NOT NULL,
name TEXT NOT NULL, rule TEXT NOT NULL, serialNumber INTEGER NOT NULL,
enable INTEGER NOT NULL, PRIMARY KEY (id))"""
)
}
}
private val migration_11_12 = object : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE rssSources ADD style TEXT ")
}
}
private val migration_12_13 = object : Migration(12, 13) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE rssSources ADD articleStyle INTEGER NOT NULL DEFAULT 0 ")
}
}
private val migration_13_14 = object : Migration(13, 14) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""CREATE TABLE IF NOT EXISTS `books_new` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL,
`originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT,
`customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL,
`latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL, `lastCheckCount` INTEGER NOT NULL,
`totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL, `durChapterPos` INTEGER NOT NULL,
`durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL, `order` INTEGER NOT NULL,
`originOrder` INTEGER NOT NULL, `useReplaceRule` INTEGER NOT NULL, `variable` TEXT, PRIMARY KEY(`bookUrl`))"""
)
database.execSQL("INSERT INTO books_new select * from books ")
database.execSQL("DROP TABLE books")
database.execSQL("ALTER TABLE books_new RENAME TO books")
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `books` (`name`, `author`) ")
}
}
private val migration_14_15 = object : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE bookmarks ADD bookAuthor TEXT NOT NULL DEFAULT ''")
}
}
private val migration_15_17 = object : Migration(15, 17) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `readRecord` (`bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`bookName`))")
}
}
private val migration_17_18 = object : Migration(17, 18) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `httpTTS` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))")
}
}
private val migration_18_19 = object : Migration(18, 19) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""CREATE TABLE IF NOT EXISTS `readRecordNew` (`androidId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL,
PRIMARY KEY(`androidId`, `bookName`))"""
)
database.execSQL("INSERT INTO readRecordNew(androidId, bookName, readTime) select '${AppConst.androidId}' as androidId, bookName, readTime from readRecord")
database.execSQL("DROP TABLE readRecord")
database.execSQL("ALTER TABLE readRecordNew RENAME TO readRecord")
}
}
private val migration_19_20 = object : Migration(19, 20) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE book_sources ADD bookSourceComment TEXT")
}
}
private val migration_20_21 = object : Migration(20, 21) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE book_groups ADD show INTEGER NOT NULL DEFAULT 1")
}
}
private val migration_21_22 = object : Migration(21, 22) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""CREATE TABLE IF NOT EXISTS `books_new` (`bookUrl` TEXT NOT NULL, `tocUrl` TEXT NOT NULL, `origin` TEXT NOT NULL,
`originName` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `customTag` TEXT,
`coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `charset` TEXT, `type` INTEGER NOT NULL,
`group` INTEGER NOT NULL, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL, `lastCheckTime` INTEGER NOT NULL,
`lastCheckCount` INTEGER NOT NULL, `totalChapterNum` INTEGER NOT NULL, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL,
`durChapterPos` INTEGER NOT NULL, `durChapterTime` INTEGER NOT NULL, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL,
`order` INTEGER NOT NULL, `originOrder` INTEGER NOT NULL, `variable` TEXT, `readConfig` TEXT, PRIMARY KEY(`bookUrl`))"""
)
database.execSQL(
"""INSERT INTO books_new select `bookUrl`, `tocUrl`, `origin`, `originName`, `name`, `author`, `kind`, `customTag`, `coverUrl`,
`customCoverUrl`, `intro`, `customIntro`, `charset`, `type`, `group`, `latestChapterTitle`, `latestChapterTime`, `lastCheckTime`,
`lastCheckCount`, `totalChapterNum`, `durChapterTitle`, `durChapterIndex`, `durChapterPos`, `durChapterTime`, `wordCount`, `canUpdate`,
`order`, `originOrder`, `variable`, null
from books"""
)
database.execSQL("DROP TABLE books")
database.execSQL("ALTER TABLE books_new RENAME TO books")
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name_author` ON `books` (`name`, `author`) ")
}
}
private val migration_22_23 = object : Migration(22, 23) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE chapters ADD baseUrl TEXT NOT NULL DEFAULT ''")
}
}
private val migration_23_24 = object : Migration(23, 24) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `caches` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))")
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `caches` (`key`)")
}
}
private val migration_24_25 = object : Migration(24, 25) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""CREATE TABLE IF NOT EXISTS `sourceSubs`
(`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL,
PRIMARY KEY(`id`))"""
)
}
}
private val migration_25_26 = object : Migration(25, 26) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""CREATE TABLE IF NOT EXISTS `ruleSubs` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL,
`customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))"""
)
database.execSQL(" insert into `ruleSubs` select *, 0, 0 from `sourceSubs` ")
database.execSQL("DROP TABLE `sourceSubs`")
}
}
private val migration_26_27 = object : Migration(26, 27) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(" ALTER TABLE rssSources ADD singleUrl INTEGER NOT NULL DEFAULT 0 ")
database.execSQL(
"""CREATE TABLE IF NOT EXISTS `bookmarks1` (`time` INTEGER NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL,
`bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL,
`bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))"""
)
database.execSQL(
"""insert into `bookmarks1`
select `time`, `bookUrl`, `bookName`, `bookAuthor`, `chapterIndex`, `pageIndex`, `chapterName`, '', `content`
from bookmarks"""
)
database.execSQL(" DROP TABLE `bookmarks` ")
database.execSQL(" ALTER TABLE bookmarks1 RENAME TO bookmarks ")
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_bookmarks_time` ON `bookmarks` (`time`)")
}
}
private val migration_27_28 = object : Migration(27, 28) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE rssArticles ADD variable TEXT")
database.execSQL("ALTER TABLE rssStars ADD variable TEXT")
}
}
private val migration_28_29 = object : Migration(28, 29) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE rssSources ADD sourceComment TEXT")
}
}
private val migration_29_30 = object : Migration(29, 30) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE chapters ADD `startFragmentId` TEXT")
database.execSQL("ALTER TABLE chapters ADD `endFragmentId` TEXT")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `epubChapters`
(`bookUrl` TEXT NOT NULL, `href` TEXT NOT NULL, `parentHref` TEXT,
PRIMARY KEY(`bookUrl`, `href`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )
"""
)
database.execSQL("CREATE INDEX IF NOT EXISTS `index_epubChapters_bookUrl` ON `epubChapters` (`bookUrl`)")
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_epubChapters_bookUrl_href` ON `epubChapters` (`bookUrl`, `href`)")
}
}
private val migration_30_31 = object : Migration(30, 31) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE readRecord RENAME TO readRecord1")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `readRecord` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `bookName`))
"""
)
database.execSQL("insert into readRecord (deviceId, bookName, readTime) select androidId, bookName, readTime from readRecord1")
}
}
private val migration_31_32 = object : Migration(31, 32) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("DROP TABLE `epubChapters`")
}
}
private val migration_32_33 = object : Migration(32, 33) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE bookmarks RENAME TO bookmarks_old")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `bookmarks` (`time` INTEGER NOT NULL,
`bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL,
`chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL,
`content` TEXT NOT NULL, PRIMARY KEY(`time`))
"""
)
database.execSQL(
"""
CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `bookmarks` (`bookName`, `bookAuthor`)
"""
)
database.execSQL(
"""
insert into bookmarks (time, bookName, bookAuthor, chapterIndex, chapterPos, chapterName, bookText, content)
select time, ifNull(b.name, bookName) bookName, ifNull(b.author, bookAuthor) bookAuthor,
chapterIndex, chapterPos, chapterName, bookText, content from bookmarks_old o
left join books b on o.bookUrl = b.bookUrl
"""
)
}
}
private val migration_33_34 = object : Migration(33, 34) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `book_groups` ADD `cover` TEXT")
}
}
private val migration_34_35 = object : Migration(34, 35) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `book_sources` ADD `concurrentRate` TEXT")
}
}
private val migration_35_36 = object : Migration(35, 36) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `book_sources` ADD `loginUi` TEXT")
database.execSQL("ALTER TABLE `book_sources` ADD`loginCheckJs` TEXT")
}
}
private val migration_36_37 = object : Migration(36, 37) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `rssSources` ADD `loginUrl` TEXT")
database.execSQL("ALTER TABLE `rssSources` ADD `loginUi` TEXT")
database.execSQL("ALTER TABLE `rssSources` ADD `loginCheckJs` TEXT")
}
}
private val migration_37_38 = object : Migration(37, 38) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `book_sources` ADD `respondTime` INTEGER NOT NULL DEFAULT 180000")
}
}
private val migration_38_39 = object : Migration(38, 39) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `rssSources` ADD `concurrentRate` TEXT")
}
}
private val migration_39_40 = object : Migration(39, 40) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `chapters` ADD `isVip` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE `chapters` ADD `isPay` INTEGER NOT NULL DEFAULT 0")
}
}
private val migration_40_41 = object : Migration(40, 41) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `httpTTS` ADD `loginUrl` TEXT")
database.execSQL("ALTER TABLE `httpTTS` ADD `loginUi` TEXT")
database.execSQL("ALTER TABLE `httpTTS` ADD `loginCheckJs` TEXT")
database.execSQL("ALTER TABLE `httpTTS` ADD `header` TEXT")
database.execSQL("ALTER TABLE `httpTTS` ADD `concurrentRate` TEXT")
}
}
private val migration_41_42 = object : Migration(41, 42) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE 'httpTTS' ADD `contentType` TEXT")
}
}
}
@@ -0,0 +1,17 @@
# 存储数据用
* dao 数据操作
* entities 数据模型
* \Book 书籍信息
* \BookChapter 目录信息
* \BookGroup 书籍分组
* \Bookmark 书签
* \BookSource 书源
* \Cookie http cookie
* \ReplaceRule 替换规则
* \RssArticle rss条目
* \RssReadRecord rss阅读记录
* \RssSource rss源
* \RssStar rss收藏
* \SearchBook 搜索结果
* \SearchKeyword 搜索关键字
* \TxtTocRule txt文件目录规则
@@ -0,0 +1,40 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.BookChapter
import kotlinx.coroutines.flow.Flow
@Dao
interface BookChapterDao {
@Query("select * from chapters where bookUrl = :bookUrl order by `index`")
fun flowByBook(bookUrl: String): Flow<List<BookChapter>>
@Query("SELECT * FROM chapters where bookUrl = :bookUrl and title like '%'||:key||'%' order by `index`")
fun flowSearch(bookUrl: String, key: String): Flow<List<BookChapter>>
@Query("select * from chapters where bookUrl = :bookUrl order by `index`")
fun getChapterList(bookUrl: String): List<BookChapter>
@Query("select * from chapters where bookUrl = :bookUrl and `index` >= :start and `index` <= :end order by `index`")
fun getChapterList(bookUrl: String, start: Int, end: Int): List<BookChapter>
@Query("select * from chapters where bookUrl = :bookUrl and `index` = :index")
fun getChapter(bookUrl: String, index: Int): BookChapter?
@Query("select * from chapters where bookUrl = :bookUrl and `title` = :title")
fun getChapter(bookUrl: String, title: String): BookChapter?
@Query("select count(url) from chapters where bookUrl = :bookUrl")
fun getChapterCount(bookUrl: String): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg bookChapter: BookChapter)
@Update
fun upDate(vararg bookChapter: BookChapter)
@Query("delete from chapters where bookUrl = :bookUrl")
fun delByBook(bookUrl: String)
}
@@ -0,0 +1,89 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.constant.BookType
import io.legado.app.data.entities.Book
import kotlinx.coroutines.flow.Flow
@Dao
interface BookDao {
@Query("SELECT * FROM books order by durChapterTime desc")
fun flowAll(): Flow<List<Book>>
@Query("SELECT * FROM books WHERE type = ${BookType.audio}")
fun flowAudio(): Flow<List<Book>>
@Query("SELECT * FROM books WHERE origin = '${BookType.local}'")
fun flowLocal(): Flow<List<Book>>
@Query("select * from books where type != ${BookType.audio} and origin != '${BookType.local}' and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0")
fun flowNoGroup(): Flow<List<Book>>
@Query("SELECT bookUrl FROM books WHERE origin = '${BookType.local}'")
fun flowLocalUri(): Flow<List<String>>
@Query("SELECT * FROM books WHERE (`group` & :group) > 0")
fun flowByGroup(group: Long): Flow<List<Book>>
@Query("SELECT * FROM books WHERE name like '%'||:key||'%' or author like '%'||:key||'%'")
fun flowSearch(key: String): Flow<List<Book>>
@Query("SELECT * FROM books WHERE (`group` & :group) > 0")
fun getBooksByGroup(group: Long): List<Book>
@Query("SELECT * FROM books WHERE `name` in (:names)")
fun findByName(vararg names: String): List<Book>
@Query("SELECT * FROM books WHERE bookUrl = :bookUrl")
fun getBook(bookUrl: String): Book?
@Query("SELECT * FROM books WHERE name = :name and author = :author")
fun getBook(name: String, author: String): Book?
@get:Query("select count(bookUrl) from books where (SELECT sum(groupId) FROM book_groups) & `group` = 0")
val noGroupSize: Int
@get:Query("SELECT * FROM books where origin <> '${BookType.local}' and type = 0")
val webBooks: List<Book>
@get:Query("SELECT * FROM books where origin <> '${BookType.local}' and canUpdate = 1")
val hasUpdateBooks: List<Book>
@get:Query("SELECT * FROM books")
val all: List<Book>
@get:Query("SELECT * FROM books where type = 0 ORDER BY durChapterTime DESC limit 1")
val lastReadBook: Book?
@get:Query("SELECT bookUrl FROM books")
val allBookUrls: List<String>
@get:Query("SELECT COUNT(*) FROM books")
val allBookCount: Int
@get:Query("select min(`order`) from books")
val minOrder: Int
@get:Query("select max(`order`) from books")
val maxOrder: Int
@Query("select 1 from books where bookUrl = :bookUrl")
fun has(bookUrl: String): Boolean?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg book: Book)
@Update
fun update(vararg book: Book)
@Delete
fun delete(vararg book: Book)
@Query("update books set durChapterPos = :pos where bookUrl = :bookUrl")
fun upProgress(bookUrl: String, pos: Int)
@Query("update books set `group` = :newGroupId where `group` = :oldGroupId")
fun upGroup(oldGroupId: Long, newGroupId: Long)
}
@@ -0,0 +1,74 @@
package io.legado.app.data.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import io.legado.app.constant.BookType
import io.legado.app.data.entities.BookGroup
import kotlinx.coroutines.flow.Flow
@Dao
interface BookGroupDao {
@Query("select * from book_groups where groupId = :id")
fun getByID(id: Long): BookGroup?
@Query("select * from book_groups where groupName = :groupName")
fun getByName(groupName: String): BookGroup?
@Query("SELECT * FROM book_groups ORDER BY `order`")
fun flowAll(): Flow<List<BookGroup>>
@get:Query(
"""
SELECT * FROM book_groups where (groupId >= 0 and show > 0)
or (groupId = -4 and show > 0 and (select count(bookUrl) from books where type != ${BookType.audio} and origin != '${BookType.local}' and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0) > 0)
or (groupId = -3 and show > 0 and (select count(bookUrl) from books where type = ${BookType.audio}) > 0)
or (groupId = -2 and show > 0 and (select count(bookUrl) from books where origin = '${BookType.local}') > 0)
or (groupId = -1 and show > 0)
ORDER BY `order`"""
)
val show: LiveData<List<BookGroup>>
@Query("SELECT * FROM book_groups where groupId >= 0 ORDER BY `order`")
fun flowSelect(): Flow<List<BookGroup>>
@get:Query("SELECT sum(groupId) FROM book_groups where groupId >= 0")
val idsSum: Long
@get:Query("SELECT MAX(`order`) FROM book_groups where groupId >= 0")
val maxOrder: Int
@get:Query("SELECT * FROM book_groups ORDER BY `order`")
val all: List<BookGroup>
@Query("update book_groups set show = 1 where groupId = :groupId")
fun enableGroup(groupId: Long)
@Query("select groupName from book_groups where groupId > 0 and (groupId & :id) > 0")
fun getGroupNames(id: Long): List<String>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg bookGroup: BookGroup)
@Update
fun update(vararg bookGroup: BookGroup)
@Delete
fun delete(vararg bookGroup: BookGroup)
fun isInRules(id: Long): Boolean {
if (id < 0) {
return true
}
return id and (id - 1) == 0L
}
fun getUnusedId(): Long {
var id = 1L
val idsSum = idsSum
while (id and idsSum != 0L) {
id = id.shl(1)
}
return id
}
}
@@ -0,0 +1,118 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.BookSource
import kotlinx.coroutines.flow.Flow
@Dao
interface BookSourceDao {
@Query("select * from book_sources order by customOrder asc")
fun flowAll(): Flow<List<BookSource>>
@Query(
"""select * from book_sources
where bookSourceName like :searchKey
or bookSourceGroup like :searchKey
or bookSourceUrl like :searchKey
or bookSourceComment like :searchKey
order by customOrder asc"""
)
fun flowSearch(searchKey: String): Flow<List<BookSource>>
@Query("select * from book_sources where bookSourceGroup like :searchKey order by customOrder asc")
fun flowGroupSearch(searchKey: String): Flow<List<BookSource>>
@Query("select * from book_sources where enabled = 1 order by customOrder asc")
fun flowEnabled(): Flow<List<BookSource>>
@Query("select * from book_sources where enabled = 0 order by customOrder asc")
fun flowDisabled(): Flow<List<BookSource>>
@Query("select * from book_sources where enabledExplore = 1 and trim(exploreUrl) <> '' order by customOrder asc")
fun flowExplore(): Flow<List<BookSource>>
@Query("select * from book_sources where loginUrl is not null and loginUrl != ''")
fun flowLogin(): Flow<List<BookSource>>
@Query(
"""select * from book_sources
where enabledExplore = 1
and trim(exploreUrl) <> ''
and (bookSourceGroup like :key or bookSourceName like :key)
order by customOrder asc"""
)
fun flowExplore(key: String): Flow<List<BookSource>>
@Query(
"""select * from book_sources
where enabledExplore = 1
and trim(exploreUrl) <> ''
and (bookSourceGroup like :key)
order by customOrder asc"""
)
fun flowGroupExplore(key: String): Flow<List<BookSource>>
@Query("select distinct bookSourceGroup from book_sources where trim(bookSourceGroup) <> ''")
fun flowGroup(): Flow<List<String>>
@Query("select distinct bookSourceGroup from book_sources where enabled = 1 and trim(bookSourceGroup) <> ''")
fun flowGroupEnabled(): Flow<List<String>>
@Query(
"""select distinct bookSourceGroup from book_sources
where enabledExplore = 1
and trim(exploreUrl) <> ''
and trim(bookSourceGroup) <> ''
order by customOrder"""
)
fun flowExploreGroup(): Flow<List<String>>
@Query("select * from book_sources where bookSourceGroup like '%' || :group || '%'")
fun getByGroup(group: String): List<BookSource>
@Query("select * from book_sources where enabled = 1 and bookSourceGroup like '%' || :group || '%'")
fun getEnabledByGroup(group: String): List<BookSource>
@get:Query("select * from book_sources where trim(bookUrlPattern) <> ''")
val hasBookUrlPattern: List<BookSource>
@get:Query("select * from book_sources where bookSourceGroup is null or bookSourceGroup = ''")
val noGroup: List<BookSource>
@get:Query("select * from book_sources order by customOrder asc")
val all: List<BookSource>
@get:Query("select * from book_sources where enabled = 1 order by customOrder")
val allEnabled: List<BookSource>
@get:Query("select * from book_sources where enabled = 1 and bookSourceType = 0 order by customOrder")
val allTextEnabled: List<BookSource>
@get:Query("select distinct bookSourceGroup from book_sources where trim(bookSourceGroup) <> ''")
val allGroup: List<String>
@Query("select * from book_sources where bookSourceUrl = :key")
fun getBookSource(key: String): BookSource?
@Query("select count(*) from book_sources")
fun allCount(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg bookSource: BookSource)
@Update
fun update(vararg bookSource: BookSource)
@Delete
fun delete(vararg bookSource: BookSource)
@Query("delete from book_sources where bookSourceUrl = :key")
fun delete(key: String)
@get:Query("select min(customOrder) from book_sources")
val minOrder: Int
@get:Query("select max(customOrder) from book_sources")
val maxOrder: Int
}
@@ -0,0 +1,38 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.Bookmark
import kotlinx.coroutines.flow.Flow
@Dao
interface BookmarkDao {
@get:Query("select * from bookmarks")
val all: List<Bookmark>
@Query(
"""select * from bookmarks
where bookName = :bookName and bookAuthor = :bookAuthor
order by chapterIndex"""
)
fun flowByBook(bookName: String, bookAuthor: String): Flow<List<Bookmark>>
@Query(
"""SELECT * FROM bookmarks
where bookName = :bookName and bookAuthor = :bookAuthor
and chapterName like '%'||:key||'%' or content like '%'||:key||'%'
order by chapterIndex"""
)
fun flowSearch(bookName: String, bookAuthor: String, key: String): Flow<List<Bookmark>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg bookmark: Bookmark)
@Update
fun update(bookmark: Bookmark)
@Delete
fun delete(vararg bookmark: Bookmark)
}
@@ -0,0 +1,24 @@
package io.legado.app.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.legado.app.data.entities.Cache
@Dao
interface CacheDao {
@Query("select value from caches where `key` = :key and (deadline = 0 or deadline > :now)")
fun get(key: String, now: Long): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg cache: Cache)
@Query("delete from caches where `key` = :key")
fun delete(key: String)
@Query("delete from caches where deadline > 0 and deadline < :now")
fun clearDeadline(now: Long)
}
@@ -0,0 +1,26 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.Cookie
@Dao
interface CookieDao {
@Query("SELECT * FROM cookies Where url = :url")
fun get(url: String): Cookie?
@Query("select * from cookies where url like '%|%'")
fun getOkHttpCookies(): List<Cookie>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg cookie: Cookie)
@Update
fun update(vararg cookie: Cookie)
@Query("delete from cookies where url = :url")
fun delete(url: String)
@Query("delete from cookies where url like '%|%'")
fun deleteOkHttp()
}
@@ -0,0 +1,36 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.HttpTTS
import kotlinx.coroutines.flow.Flow
@Dao
interface HttpTTSDao {
@get:Query("select * from httpTTS order by name")
val all: List<HttpTTS>
@Query("select * from httpTTS order by name")
fun flowAll(): Flow<List<HttpTTS>>
@get:Query("select count(*) from httpTTS")
val count: Int
@Query("select * from httpTTS where id = :id")
fun get(id: Long): HttpTTS?
@Query("select name from httpTTS where id = :id")
fun getName(id: Long): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg httpTTS: HttpTTS)
@Delete
fun delete(vararg httpTTS: HttpTTS)
@Update
fun update(vararg httpTTS: HttpTTS)
@Query("delete from httpTTS where id < 0")
fun deleteDefault()
}
@@ -0,0 +1,39 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.ReadRecord
import io.legado.app.data.entities.ReadRecordShow
@Dao
interface ReadRecordDao {
@get:Query("select * from readRecord")
val all: List<ReadRecord>
@get:Query("select bookName, sum(readTime) as readTime from readRecord group by bookName order by bookName collate localized")
val allShow: List<ReadRecordShow>
@get:Query("select sum(readTime) from readRecord")
val allTime: Long
@Query("select sum(readTime) from readRecord where bookName = :bookName")
fun getReadTime(bookName: String): Long?
@Query("select readTime from readRecord where deviceId = :androidId and bookName = :bookName")
fun getReadTime(androidId: String, bookName: String): Long?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg readRecord: ReadRecord)
@Update
fun update(vararg record: ReadRecord)
@Delete
fun delete(vararg record: ReadRecord)
@Query("delete from readRecord")
fun clear()
@Query("delete from readRecord where bookName = :bookName")
fun deleteByName(bookName: String)
}
@@ -0,0 +1,71 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.ReplaceRule
import kotlinx.coroutines.flow.Flow
@Dao
interface ReplaceRuleDao {
@Query("SELECT * FROM replace_rules ORDER BY sortOrder ASC")
fun flowAll(): Flow<List<ReplaceRule>>
@Query("SELECT * FROM replace_rules where `group` like :key or name like :key ORDER BY sortOrder ASC")
fun flowSearch(key: String): Flow<List<ReplaceRule>>
@Query("SELECT * FROM replace_rules where `group` like :key ORDER BY sortOrder ASC")
fun flowGroupSearch(key: String): Flow<List<ReplaceRule>>
@Query("select `group` from replace_rules where `group` is not null and `group` <> ''")
fun flowGroup(): Flow<List<String>>
@get:Query("SELECT MIN(sortOrder) FROM replace_rules")
val minOrder: Int
@get:Query("SELECT MAX(sortOrder) FROM replace_rules")
val maxOrder: Int
@get:Query("SELECT * FROM replace_rules ORDER BY sortOrder ASC")
val all: List<ReplaceRule>
@get:Query("select distinct `group` from replace_rules where trim(`group`) <> ''")
val allGroup: List<String>
@get:Query("SELECT * FROM replace_rules WHERE isEnabled = 1 ORDER BY sortOrder ASC")
val allEnabled: List<ReplaceRule>
@Query("SELECT * FROM replace_rules WHERE id = :id")
fun findById(id: Long): ReplaceRule?
@Query("SELECT * FROM replace_rules WHERE id in (:ids)")
fun findByIds(vararg ids: Long): List<ReplaceRule>
@Query(
"""SELECT * FROM replace_rules WHERE isEnabled = 1
AND (scope LIKE '%' || :name || '%' or scope LIKE '%' || :origin || '%' or scope is null or scope = '')
order by sortOrder"""
)
fun findEnabledByScope(name: String, origin: String): List<ReplaceRule>
@Query("select * from replace_rules where `group` like '%' || :group || '%'")
fun getByGroup(group: String): List<ReplaceRule>
@get:Query("select * from replace_rules where `group` is null or `group` = ''")
val noGroup: List<ReplaceRule>
@get:Query("SELECT COUNT(*) - SUM(isEnabled) FROM replace_rules")
val summary: Int
@Query("UPDATE replace_rules SET isEnabled = :enable")
fun enableAll(enable: Boolean)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg replaceRule: ReplaceRule): List<Long>
@Update
fun update(vararg replaceRules: ReplaceRule)
@Delete
fun delete(vararg replaceRules: ReplaceRule)
}
@@ -0,0 +1,39 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.RssArticle
import io.legado.app.data.entities.RssReadRecord
import kotlinx.coroutines.flow.Flow
@Dao
interface RssArticleDao {
@Query("select * from rssArticles where origin = :origin and link = :link")
fun get(origin: String, link: String): RssArticle?
@Query(
"""select t1.link, t1.sort, t1.origin, t1.`order`, t1.title, t1.content,
t1.description, t1.image, t1.pubDate, t1.variable, ifNull(t2.read, 0) as read
from rssArticles as t1 left join rssReadRecords as t2
on t1.link = t2.record where origin = :origin and sort = :sort
order by `order` desc"""
)
fun flowByOriginSort(origin: String, sort: String): Flow<List<RssArticle>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg rssArticle: RssArticle)
@Query("delete from rssArticles where origin = :origin and sort = :sort and `order` < :order")
fun clearOld(origin: String, sort: String, order: Long)
@Update
fun update(vararg rssArticle: RssArticle)
@Query("delete from rssArticles where origin = :origin")
fun delete(origin: String)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertRecord(vararg rssReadRecord: RssReadRecord)
}
@@ -0,0 +1,74 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.RssSource
import kotlinx.coroutines.flow.Flow
@Dao
interface RssSourceDao {
@Query("select * from rssSources where sourceUrl = :key")
fun getByKey(key: String): RssSource?
@Query("select * from rssSources where sourceUrl in (:sourceUrls)")
fun getRssSources(vararg sourceUrls: String): List<RssSource>
@get:Query("SELECT * FROM rssSources")
val all: List<RssSource>
@get:Query("select count(sourceUrl) from rssSources")
val size: Int
@Query("SELECT * FROM rssSources order by customOrder")
fun flowAll(): Flow<List<RssSource>>
@Query("SELECT * FROM rssSources where sourceName like :key or sourceUrl like :key or sourceGroup like :key order by customOrder")
fun flowSearch(key: String): Flow<List<RssSource>>
@Query("SELECT * FROM rssSources where sourceGroup like :key order by customOrder")
fun flowGroupSearch(key: String): Flow<List<RssSource>>
@Query("SELECT * FROM rssSources where enabled = 1 order by customOrder")
fun flowEnabled(): Flow<List<RssSource>>
@Query(
"""SELECT * FROM rssSources
where enabled = 1
and (sourceName like :searchKey or sourceGroup like :searchKey or sourceUrl like :searchKey)
order by customOrder"""
)
fun flowEnabled(searchKey: String): Flow<List<RssSource>>
@Query("SELECT * FROM rssSources where enabled = 1 and sourceGroup like :searchKey order by customOrder")
fun flowEnabledByGroup(searchKey: String): Flow<List<RssSource>>
@Query("select distinct sourceGroup from rssSources where trim(sourceGroup) <> ''")
fun flowGroup(): Flow<List<String>>
@get:Query("select distinct sourceGroup from rssSources where trim(sourceGroup) <> ''")
val allGroup: List<String>
@get:Query("select min(customOrder) from rssSources")
val minOrder: Int
@get:Query("select max(customOrder) from rssSources")
val maxOrder: Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg rssSource: RssSource)
@Update
fun update(vararg rssSource: RssSource)
@Delete
fun delete(vararg rssSource: RssSource)
@Query("delete from rssSources where sourceUrl = :sourceUrl")
fun delete(sourceUrl: String)
@get:Query("select * from rssSources where sourceGroup is null or sourceGroup = ''")
val noGroup: List<RssSource>
@Query("select * from rssSources where sourceGroup like '%' || :group || '%'")
fun getByGroup(group: String): List<RssSource>
}
@@ -0,0 +1,30 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.RssStar
import kotlinx.coroutines.flow.Flow
@Dao
interface RssStarDao {
@get:Query("select * from rssStars order by starTime desc")
val all: List<RssStar>
@Query("select * from rssStars where origin = :origin and link = :link")
fun get(origin: String, link: String): RssStar?
@Query("select * from rssStars order by starTime desc")
fun liveAll(): Flow<List<RssStar>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg rssStar: RssStar)
@Update
fun update(vararg rssStar: RssStar)
@Query("delete from rssStars where origin = :origin")
fun delete(origin: String)
@Query("delete from rssStars where origin = :origin and link = :link")
fun delete(origin: String, link: String)
}
@@ -0,0 +1,30 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.RuleSub
import kotlinx.coroutines.flow.Flow
@Dao
interface RuleSubDao {
@get:Query("select * from ruleSubs order by customOrder")
val all: List<RuleSub>
@Query("select * from ruleSubs order by customOrder")
fun flowAll(): Flow<List<RuleSub>>
@get:Query("select customOrder from ruleSubs order by customOrder limit 0,1")
val maxOrder: Int
@Query("select * from ruleSubs where url = :url")
fun findByUrl(url: String): RuleSub?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg ruleSub: RuleSub)
@Delete
fun delete(vararg ruleSub: RuleSub)
@Update
fun update(vararg ruleSub: RuleSub)
}
@@ -0,0 +1,70 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.SearchBook
@Dao
interface SearchBookDao {
@Query("select * from searchBooks where bookUrl = :bookUrl")
fun getSearchBook(bookUrl: String): SearchBook?
@Query("select * from searchBooks where name = :name and author = :author and origin in (select bookSourceUrl from book_sources) order by originOrder limit 1")
fun getFirstByNameAuthor(name: String, author: String): SearchBook?
@Query(
"""select t1.name, t1.author, t1.origin, t1.originName, t1.coverUrl, t1.bookUrl,
t1.type, t1.time, t1.intro, t1.kind, t1.latestChapterTitle, t1.tocUrl, t1.variable,
t1.wordCount, t2.customOrder as originOrder
from searchBooks as t1 inner join book_sources as t2
on t1.origin = t2.bookSourceUrl
where t1.name = :name and t1.author like '%'||:author||'%'
and t2.enabled = 1 and t2.bookSourceGroup like '%'||:sourceGroup||'%'
order by t2.customOrder"""
)
fun getChangeSourceSearch(name: String, author: String, sourceGroup: String): List<SearchBook>
@Query(
"""select t1.name, t1.author, t1.origin, t1.originName, t1.coverUrl, t1.bookUrl,
t1.type, t1.time, t1.intro, t1.kind, t1.latestChapterTitle, t1.tocUrl, t1.variable,
t1.wordCount, t2.customOrder as originOrder
from searchBooks as t1 inner join book_sources as t2
on t1.origin = t2.bookSourceUrl
where t1.name = :name and t1.author = :author
and originName like '%'||:key||'%' and t2.enabled = 1
and t2.bookSourceGroup like '%'||:sourceGroup||'%'
order by t2.customOrder"""
)
fun getChangeSourceSearch(
name: String,
author: String,
key: String,
sourceGroup: String
): List<SearchBook>
@Query(
"""
select t1.name, t1.author, t1.origin, t1.originName, t1.coverUrl, t1.bookUrl, t1.type, t1.time, t1.intro, t1.kind, t1.latestChapterTitle, t1.tocUrl, t1.variable, t1.wordCount, t2.customOrder as originOrder
from searchBooks as t1 inner join book_sources as t2
on t1.origin = t2.bookSourceUrl
where t1.name = :name and t1.author = :author and t1.coverUrl is not null and t1.coverUrl <> '' and t2.enabled = 1
order by t2.customOrder
"""
)
fun getEnableHasCover(name: String, author: String): List<SearchBook>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg searchBook: SearchBook): List<Long>
@Query("delete from searchBooks where name = :name and author = :author")
fun clear(name: String, author: String)
@Query("delete from searchBooks where time < :time")
fun clearExpired(time: Long)
@Update
fun update(vararg searchBook: SearchBook)
@Delete
fun delete(vararg searchBook: SearchBook)
}
@@ -0,0 +1,38 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.SearchKeyword
import kotlinx.coroutines.flow.Flow
@Dao
interface SearchKeywordDao {
@get:Query("SELECT * FROM search_keywords")
val all: List<SearchKeyword>
@Query("SELECT * FROM search_keywords ORDER BY usage DESC")
fun flowByUsage(): Flow<List<SearchKeyword>>
@Query("SELECT * FROM search_keywords ORDER BY lastUseTime DESC")
fun flowByTime(): Flow<List<SearchKeyword>>
@Query("SELECT * FROM search_keywords where word like '%'||:key||'%' ORDER BY usage DESC")
fun flowSearch(key: String): Flow<List<SearchKeyword>>
@Query("select * from search_keywords where word = :key")
fun get(key: String): SearchKeyword?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg keywords: SearchKeyword)
@Update
fun update(vararg keywords: SearchKeyword)
@Delete
fun delete(vararg keywords: SearchKeyword)
@Query("DELETE FROM search_keywords")
fun deleteAll()
}
@@ -0,0 +1,36 @@
package io.legado.app.data.dao
import androidx.room.*
import io.legado.app.data.entities.TxtTocRule
import kotlinx.coroutines.flow.Flow
@Dao
interface TxtTocRuleDao {
@Query("select * from txtTocRules order by serialNumber")
fun observeAll(): Flow<List<TxtTocRule>>
@get:Query("select * from txtTocRules order by serialNumber")
val all: List<TxtTocRule>
@get:Query("select * from txtTocRules where enable = 1 order by serialNumber")
val enabled: List<TxtTocRule>
@get:Query("select ifNull(min(serialNumber), 0) from txtTocRules")
val minOrder: Int
@get:Query("select ifNull(max(serialNumber), 0) from txtTocRules")
val maxOrder: Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg rule: TxtTocRule)
@Update(onConflict = OnConflictStrategy.REPLACE)
fun update(vararg rule: TxtTocRule)
@Delete
fun delete(vararg rule: TxtTocRule)
@Query("delete from txtTocRules where id < 0")
fun deleteDefault()
}
@@ -0,0 +1,27 @@
package io.legado.app.data.entities
import io.legado.app.model.analyzeRule.RuleDataInterface
import io.legado.app.utils.splitNotBlank
interface BaseBook : RuleDataInterface {
var name: String
var author: String
var bookUrl: String
var kind: String?
var wordCount: String?
var infoHtml: String?
var tocHtml: String?
fun getKindList(): List<String> {
val kindList = arrayListOf<String>()
wordCount?.let {
if (it.isNotBlank()) kindList.add(it)
}
kind?.let {
val kinds = it.splitNotBlank(",", "\n")
kindList.addAll(kinds)
}
return kindList
}
}
@@ -0,0 +1,169 @@
package io.legado.app.data.entities
import android.util.Base64
import io.legado.app.constant.AppConst
import io.legado.app.data.entities.rule.RowUi
import io.legado.app.help.AppConfig
import io.legado.app.help.CacheManager
import io.legado.app.help.JsExtensions
import io.legado.app.help.http.CookieStore
import io.legado.app.utils.EncoderUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.fromJsonObject
import timber.log.Timber
import javax.script.SimpleBindings
/**
* 可在js里调用,source.xxx()
*/
@Suppress("unused")
interface BaseSource : JsExtensions {
var concurrentRate: String? // 并发率
var loginUrl: String? // 登录地址
var loginUi: String? // 登录UI
var header: String? // 请求头
fun getTag(): String
fun getKey(): String
fun loginUi(): List<RowUi>? {
return GSON.fromJsonArray(loginUi)
}
fun getLoginJs(): String? {
val loginJs = loginUrl
return when {
loginJs == null -> null
loginJs.startsWith("@js:") -> loginJs.substring(4)
loginJs.startsWith("<js>") ->
loginJs.substring(4, loginJs.lastIndexOf("<"))
else -> loginJs
}
}
fun login() {
getLoginJs()?.let {
evalJS(it)
}
}
/**
* 解析header规则
*/
fun getHeaderMap(hasLoginHeader: Boolean = false) = HashMap<String, String>().apply {
this[AppConst.UA_NAME] = AppConfig.userAgent
header?.let {
GSON.fromJsonObject<Map<String, String>>(
when {
it.startsWith("@js:", true) ->
evalJS(it.substring(4)).toString()
it.startsWith("<js>", true) ->
evalJS(it.substring(4, it.lastIndexOf("<"))).toString()
else -> it
}
)?.let { map ->
putAll(map)
}
}
if (hasLoginHeader) {
getLoginHeaderMap()?.let {
putAll(it)
}
}
}
/**
* 获取用于登录的头部信息
*/
fun getLoginHeader(): String? {
return CacheManager.get("loginHeader_${getKey()}")
}
fun getLoginHeaderMap(): Map<String, String>? {
val cache = getLoginHeader() ?: return null
return GSON.fromJsonObject(cache)
}
/**
* 保存登录头部信息,map格式,访问时自动添加
*/
fun putLoginHeader(header: String) {
CacheManager.put("loginHeader_${getKey()}", header)
}
fun removeLoginHeader() {
CacheManager.delete("loginHeader_${getKey()}")
}
/**
* 获取用户信息,可以用来登录
* 用户信息采用aes加密存储
*/
fun getLoginInfo(): String? {
try {
val key = AppConst.androidId.encodeToByteArray(0, 8)
val cache = CacheManager.get("userInfo_${getKey()}") ?: return null
val encodeBytes = Base64.decode(cache, Base64.DEFAULT)
val decodeBytes = EncoderUtils.decryptAES(encodeBytes, key)
?: return null
return String(decodeBytes)
} catch (e: Exception) {
Timber.e(e)
return null
}
}
fun getLoginInfoMap(): Map<String, String>? {
return GSON.fromJsonObject(getLoginInfo())
}
/**
* 保存用户信息,aes加密
*/
fun putLoginInfo(info: String): Boolean {
return try {
val key = (AppConst.androidId).encodeToByteArray(0, 8)
val encodeBytes = EncoderUtils.encryptAES(info.toByteArray(), key)
val encodeStr = Base64.encodeToString(encodeBytes, Base64.DEFAULT)
CacheManager.put("userInfo_${getKey()}", encodeStr)
true
} catch (e: Exception) {
Timber.e(e)
false
}
}
fun removeLoginInfo() {
CacheManager.delete("userInfo_${getKey()}")
}
fun setVariable(variable: String?) {
if (variable != null) {
CacheManager.put("sourceVariable_${getKey()}", variable)
} else {
CacheManager.delete("sourceVariable_${getKey()}")
}
}
fun getVariable(): String? {
return CacheManager.get("sourceVariable_${getKey()}")
}
/**
* 执行JS
*/
@Throws(Exception::class)
fun evalJS(jsStr: String, bindingsConfig: SimpleBindings.() -> Unit = {}): Any? {
val bindings = SimpleBindings()
bindings.apply(bindingsConfig)
bindings["java"] = this
bindings["source"] = this
bindings["baseUrl"] = getKey()
bindings["cookie"] = CookieStore
bindings["cache"] = CacheManager
return AppConst.SCRIPT_ENGINE.eval(jsStr, bindings)
}
}
@@ -0,0 +1,294 @@
package io.legado.app.data.entities
import android.os.Parcelable
import androidx.room.*
import io.legado.app.constant.AppPattern
import io.legado.app.constant.BookType
import io.legado.app.data.appDb
import io.legado.app.help.AppConfig
import io.legado.app.model.ReadBook
import io.legado.app.utils.GSON
import io.legado.app.utils.MD5Utils
import io.legado.app.utils.fromJsonObject
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.nio.charset.Charset
import kotlin.math.max
import kotlin.math.min
@Parcelize
@TypeConverters(Book.Converters::class)
@Entity(
tableName = "books",
indices = [Index(value = ["name", "author"], unique = true)]
)
data class Book(
@PrimaryKey
override var bookUrl: String = "", // 详情页Url(本地书源存储完整文件路径)
var tocUrl: String = "", // 目录页Url (toc=table of Contents)
var origin: String = BookType.local, // 书源URL(默认BookType.local)
var originName: String = "", //书源名称 or 本地书籍文件名
override var name: String = "", // 书籍名称(书源获取)
override var author: String = "", // 作者名称(书源获取)
override var kind: String? = null, // 分类信息(书源获取)
var customTag: String? = null, // 分类信息(用户修改)
var coverUrl: String? = null, // 封面Url(书源获取)
var customCoverUrl: String? = null, // 封面Url(用户修改)
var intro: String? = null, // 简介内容(书源获取)
var customIntro: String? = null, // 简介内容(用户修改)
var charset: String? = null, // 自定义字符集名称(仅适用于本地书籍)
var type: Int = 0, // 0:text 1:audio
var group: Long = 0, // 自定义分组索引号
var latestChapterTitle: String? = null, // 最新章节标题
var latestChapterTime: Long = System.currentTimeMillis(), // 最新章节标题更新时间
var lastCheckTime: Long = System.currentTimeMillis(), // 最近一次更新书籍信息的时间
var lastCheckCount: Int = 0, // 最近一次发现新章节的数量
var totalChapterNum: Int = 0, // 书籍目录总数
var durChapterTitle: String? = null, // 当前章节名称
var durChapterIndex: Int = 0, // 当前章节索引
var durChapterPos: Int = 0, // 当前阅读的进度(首行字符的索引位置)
var durChapterTime: Long = System.currentTimeMillis(), // 最近一次阅读书籍的时间(打开正文的时间)
override var wordCount: String? = null,
var canUpdate: Boolean = true, // 刷新书架时更新书籍信息
var order: Int = 0, // 手动排序
var originOrder: Int = 0, //书源排序
var variable: String? = null, // 自定义书籍变量信息(用于书源规则检索书籍信息)
var readConfig: ReadConfig? = null
) : Parcelable, BaseBook {
fun isLocalBook(): Boolean {
return origin == BookType.local
}
fun isLocalTxt(): Boolean {
return isLocalBook() && originName.endsWith(".txt", true)
}
fun isEpub(): Boolean {
return originName.endsWith(".epub", true)
}
fun isUmd(): Boolean {
return originName.endsWith(".umd", true)
}
fun isOnLineTxt(): Boolean {
return !isLocalBook() && type == 0
}
override fun equals(other: Any?): Boolean {
if (other is Book) {
return other.bookUrl == bookUrl
}
return false
}
override fun hashCode(): Int {
return bookUrl.hashCode()
}
@delegate:Transient
@delegate:Ignore
@IgnoredOnParcel
override val variableMap by lazy {
GSON.fromJsonObject<HashMap<String, String>>(variable) ?: HashMap()
}
override fun putVariable(key: String, value: String?) {
if (value != null) {
variableMap[key] = value
} else {
variableMap.remove(key)
}
variable = GSON.toJson(variableMap)
}
@Ignore
@IgnoredOnParcel
override var infoHtml: String? = null
@Ignore
@IgnoredOnParcel
override var tocHtml: String? = null
fun getRealAuthor() = author.replace(AppPattern.authorRegex, "")
fun getUnreadChapterNum() = max(totalChapterNum - durChapterIndex - 1, 0)
fun getDisplayTag() = if (customTag.isNullOrBlank()) kind else customTag
fun getDisplayCover() = if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl
fun getDisplayIntro() = if (customIntro.isNullOrEmpty()) intro else customIntro
//自定义简介有自动更新的需求时,可通过更新intro再调用upCustomIntro()完成
@Suppress("unused")
fun upCustomIntro() {
customIntro = intro
}
fun fileCharset(): Charset {
return charset(charset ?: "UTF-8")
}
private fun config(): ReadConfig {
if (readConfig == null) {
readConfig = ReadConfig()
}
return readConfig!!
}
fun setReverseToc(reverseToc: Boolean) {
config().reverseToc = reverseToc
}
fun getReverseToc(): Boolean {
return config().reverseToc
}
fun setUseReplaceRule(useReplaceRule: Boolean) {
config().useReplaceRule = useReplaceRule
}
fun getUseReplaceRule(): Boolean {
return config().useReplaceRule
}
fun getReSegment(): Boolean {
return config().reSegment
}
fun setReSegment(reSegment: Boolean) {
config().reSegment = reSegment
}
fun getPageAnim(): Int {
return config().pageAnim
}
fun setPageAnim(pageAnim: Int) {
config().pageAnim = pageAnim
}
fun getImageStyle(): String? {
return config().imageStyle
}
fun setImageStyle(imageStyle: String?) {
config().imageStyle = imageStyle
}
fun setDelTag(tag: Long) {
config().delTag =
if ((config().delTag and tag) == tag) config().delTag and tag.inv() else config().delTag or tag
}
fun getDelTag(tag: Long): Boolean {
return config().delTag and tag == tag
}
fun getFolderName(): String {
//防止书名过长,只取9位
var folderName = name.replace(AppPattern.fileNameRegex, "")
folderName = folderName.substring(0, min(9, folderName.length))
return folderName + MD5Utils.md5Encode16(bookUrl)
}
fun toSearchBook() = SearchBook(
name = name,
author = author,
kind = kind,
bookUrl = bookUrl,
origin = origin,
originName = originName,
type = type,
wordCount = wordCount,
latestChapterTitle = latestChapterTitle,
coverUrl = coverUrl,
intro = intro,
tocUrl = tocUrl,
originOrder = originOrder,
variable = variable
).apply {
this.infoHtml = this@Book.infoHtml
this.tocHtml = this@Book.tocHtml
}
fun changeTo(newBook: Book) {
newBook.group = group
newBook.order = order
newBook.customCoverUrl = customCoverUrl
newBook.customIntro = customIntro
newBook.customTag = customTag
newBook.canUpdate = canUpdate
newBook.readConfig = readConfig
delete(this)
appDb.bookDao.insert(newBook)
}
fun upInfoFromOld(oldBook: Book?) {
oldBook?.let {
group = oldBook.group
durChapterIndex = oldBook.durChapterIndex
durChapterPos = oldBook.durChapterPos
durChapterTitle = oldBook.durChapterTitle
customCoverUrl = oldBook.customCoverUrl
customIntro = oldBook.customIntro
order = oldBook.order
if (coverUrl.isNullOrEmpty()) {
coverUrl = oldBook.getDisplayCover()
}
}
}
fun createBookMark(): Bookmark {
return Bookmark(
bookName = name,
bookAuthor = author,
)
}
fun save() {
if (appDb.bookDao.has(bookUrl) == true) {
appDb.bookDao.update(this)
} else {
appDb.bookDao.insert(this)
}
}
companion object {
const val hTag = 2L
const val rubyTag = 4L
const val imgTag = 8L
const val imgStyleDefault = "DEFAULT"
const val imgStyleFull = "FULL"
const val imgStyleText = "TEXT"
fun delete(book: Book?) {
book ?: return
if (ReadBook.book?.bookUrl == book.bookUrl) {
ReadBook.book = null
}
appDb.bookDao.delete(book)
}
}
@Parcelize
data class ReadConfig(
var reverseToc: Boolean = false,
var pageAnim: Int = -1,
var reSegment: Boolean = false,
var imageStyle: String? = null,
var useReplaceRule: Boolean = AppConfig.replaceEnableDefault,// 正文使用净化替换规则
var delTag: Long = 0L,//去除标签
) : Parcelable
class Converters {
@TypeConverter
fun readConfigToString(config: ReadConfig?): String = GSON.toJson(config)
@TypeConverter
fun stringToReadConfig(json: String?) = GSON.fromJsonObject<ReadConfig>(json)
}
}
@@ -0,0 +1,125 @@
package io.legado.app.data.entities
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Ignore
import androidx.room.Index
import com.github.liuyueyi.quick.transfer.ChineseUtils
import io.legado.app.R
import io.legado.app.constant.AppPattern
import io.legado.app.help.AppConfig
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.RuleDataInterface
import io.legado.app.utils.*
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import splitties.init.appCtx
@Suppress("unused")
@Parcelize
@Entity(
tableName = "chapters",
primaryKeys = ["url", "bookUrl"],
indices = [(Index(value = ["bookUrl"], unique = false)),
(Index(value = ["bookUrl", "index"], unique = true))],
foreignKeys = [(ForeignKey(
entity = Book::class,
parentColumns = ["bookUrl"],
childColumns = ["bookUrl"],
onDelete = ForeignKey.CASCADE
))]
) // 删除书籍时自动删除章节
data class BookChapter(
var url: String = "", // 章节地址
var title: String = "", // 章节标题
var baseUrl: String = "", // 用来拼接相对url
var bookUrl: String = "", // 书籍地址
var index: Int = 0, // 章节序号
var isVip: Boolean = false, // 是否VIP
var isPay: Boolean = false, // 是否已购买
var resourceUrl: String? = null, // 音频真实URL
var tag: String? = null, //
var start: Long? = null, // 章节起始位置
var end: Long? = null, // 章节终止位置
var startFragmentId: String? = null, //EPUB书籍当前章节的fragmentId
var endFragmentId: String? = null, //EPUB书籍下一章节的fragmentId
var variable: String? = null //变量
) : Parcelable, RuleDataInterface {
@delegate:Transient
@delegate:Ignore
@IgnoredOnParcel
override val variableMap by lazy {
GSON.fromJsonObject<HashMap<String, String>>(variable) ?: HashMap()
}
override fun putVariable(key: String, value: String?) {
if (value != null) {
variableMap[key] = value
} else {
variableMap.remove(key)
}
variable = GSON.toJson(variableMap)
}
override fun hashCode() = url.hashCode()
override fun equals(other: Any?): Boolean {
if (other is BookChapter) {
return other.url == url
}
return false
}
@Suppress("unused")
fun getDisplayTitle(
replaceRules: Array<ReplaceRule>? = null,
useReplace: Boolean = true,
chineseConvert: Boolean = true,
): String {
var displayTitle = title.replace(AppPattern.rnRegex, "")
if (useReplace && replaceRules != null) {
replaceRules.forEach { item ->
if (item.pattern.isNotEmpty()) {
try {
displayTitle = if (item.isRegex) {
displayTitle.replace(item.pattern.toRegex(), item.replacement)
} else {
displayTitle.replace(item.pattern, item.replacement)
}
} catch (e: Exception) {
appCtx.toastOnUi("${item.name}替换出错")
}
}
}
}
if (chineseConvert) {
when (AppConfig.chineseConverterType) {
1 -> displayTitle = ChineseUtils.t2s(displayTitle)
2 -> displayTitle = ChineseUtils.s2t(displayTitle)
}
}
return when {
!isVip -> displayTitle
isPay -> appCtx.getString(R.string.payed_title, displayTitle)
else -> appCtx.getString(R.string.vip_title, displayTitle)
}
}
fun getAbsoluteURL(): String {
val urlMatcher = AnalyzeUrl.paramPattern.matcher(url)
val urlBefore = if (urlMatcher.find()) url.substring(0, urlMatcher.start()) else url
val urlAbsoluteBefore = NetworkUtils.getAbsoluteURL(baseUrl, urlBefore)
return if (urlBefore.length == url.length) {
urlAbsoluteBefore
} else {
"$urlAbsoluteBefore," + url.substring(urlMatcher.end())
}
}
fun getFileName(): String = String.format("%05d-%s.nb", index, MD5Utils.md5Encode16(title))
fun getFontName(): String = String.format("%05d-%s.ttf", index, MD5Utils.md5Encode16(title))
}
@@ -0,0 +1,47 @@
package io.legado.app.data.entities
import android.content.Context
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import io.legado.app.R
import io.legado.app.constant.AppConst
import kotlinx.parcelize.Parcelize
@Parcelize
@Entity(tableName = "book_groups")
data class BookGroup(
@PrimaryKey
val groupId: Long = 0b1,
var groupName: String,
var cover: String? = null,
var order: Int = 0,
var show: Boolean = true
) : Parcelable {
fun getManageName(context: Context): String {
return when (groupId) {
AppConst.bookGroupAllId -> "$groupName(${context.getString(R.string.all)})"
AppConst.bookGroupAudioId -> "$groupName(${context.getString(R.string.audio)})"
AppConst.bookGroupLocalId -> "$groupName(${context.getString(R.string.local)})"
AppConst.bookGroupNoneId -> "$groupName(${context.getString(R.string.no_group)})"
else -> groupName
}
}
override fun hashCode(): Int {
return groupId.hashCode()
}
override fun equals(other: Any?): Boolean {
if (other is BookGroup) {
return other.groupId == groupId
&& other.groupName == groupName
&& other.cover == cover
&& other.order == order
&& other.show == show
}
return false
}
}
@@ -0,0 +1,21 @@
package io.legado.app.data.entities
data class BookProgress(
val name: String,
val author: String,
val durChapterIndex: Int,
val durChapterPos: Int,
val durChapterTime: Long,
val durChapterTitle: String?
) {
constructor(book: Book) : this(
name = book.name,
author = book.author,
durChapterIndex = book.durChapterIndex,
durChapterPos = book.durChapterPos,
durChapterTime = book.durChapterTime,
durChapterTitle = book.durChapterTitle
)
}
@@ -0,0 +1,202 @@
package io.legado.app.data.entities
import android.os.Parcelable
import android.text.TextUtils
import androidx.room.*
import io.legado.app.constant.BookType
import io.legado.app.data.entities.rule.*
import io.legado.app.help.SourceAnalyzer
import io.legado.app.utils.*
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import splitties.init.appCtx
import timber.log.Timber
@Parcelize
@TypeConverters(BookSource.Converters::class)
@Entity(
tableName = "book_sources",
indices = [(Index(value = ["bookSourceUrl"], unique = false))]
)
data class BookSource(
@PrimaryKey
var bookSourceUrl: String = "", // 地址,包括 http/https
var bookSourceName: String = "", // 名称
var bookSourceGroup: String? = null, // 分组
var bookSourceType: Int = BookType.default, // 类型,0 文本,1 音频, 3 图片
var bookUrlPattern: String? = null, // 详情页url正则
var customOrder: Int = 0, // 手动排序编号
var enabled: Boolean = true, // 是否启用
var enabledExplore: Boolean = true, // 启用发现
override var concurrentRate: String? = null, // 并发率
override var header: String? = null, // 请求头
override var loginUrl: String? = null, // 登录地址
override var loginUi: String? = null, // 登录UI
var loginCheckJs: String? = null, // 登录检测js
var bookSourceComment: String? = null, // 注释
var lastUpdateTime: Long = 0, // 最后更新时间,用于排序
var respondTime: Long = 180000L, // 响应时间,用于排序
var weight: Int = 0, // 智能排序的权重
var exploreUrl: String? = null, // 发现url
var ruleExplore: ExploreRule? = null, // 发现规则
var searchUrl: String? = null, // 搜索url
var ruleSearch: SearchRule? = null, // 搜索规则
var ruleBookInfo: BookInfoRule? = null, // 书籍信息页规则
var ruleToc: TocRule? = null, // 目录页规则
var ruleContent: ContentRule? = null // 正文页规则
) : Parcelable, BaseSource {
override fun getTag(): String {
return bookSourceName
}
override fun getKey(): String {
return bookSourceUrl
}
override fun getSource(): BaseSource {
return this
}
@delegate:Transient
@delegate:Ignore
@IgnoredOnParcel
val exploreKinds: List<ExploreKind> by lazy {
val exploreUrl = exploreUrl ?: return@lazy emptyList()
val kinds = arrayListOf<ExploreKind>()
var ruleStr = exploreUrl
if (ruleStr.isNotBlank()) {
kotlin.runCatching {
if (exploreUrl.startsWith("<js>", false)
|| exploreUrl.startsWith("@js:", false)
) {
val aCache = ACache.get(appCtx, "explore")
ruleStr = aCache.getAsString(bookSourceUrl) ?: ""
if (ruleStr.isBlank()) {
val jsStr = if (exploreUrl.startsWith("@")) {
exploreUrl.substring(4)
} else {
exploreUrl.substring(4, exploreUrl.lastIndexOf("<"))
}
ruleStr = evalJS(jsStr).toString().trim()
aCache.put(bookSourceUrl, ruleStr)
}
}
if (ruleStr.isJsonArray()) {
GSON.fromJsonArray<ExploreKind>(ruleStr)?.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()))
Timber.e(it)
}
}
return@lazy kinds
}
override fun hashCode(): Int {
return bookSourceUrl.hashCode()
}
override fun equals(other: Any?) =
if (other is BookSource) other.bookSourceUrl == bookSourceUrl else false
fun getSearchRule() = ruleSearch ?: SearchRule()
fun getExploreRule() = ruleExplore ?: ExploreRule()
fun getBookInfoRule() = ruleBookInfo ?: BookInfoRule()
fun getTocRule() = ruleToc ?: TocRule()
fun getContentRule() = ruleContent ?: ContentRule()
fun addGroup(group: String) {
bookSourceGroup?.let {
if (!it.contains(group)) {
bookSourceGroup = "$it,$group"
}
} ?: let {
bookSourceGroup = group
}
}
fun removeGroup(group: String) {
bookSourceGroup?.splitNotBlank("[,;,;]".toRegex())?.toHashSet()?.let {
it.remove(group)
bookSourceGroup = TextUtils.join(",", it)
}
}
fun equal(source: BookSource) =
equal(bookSourceName, source.bookSourceName)
&& equal(bookSourceUrl, source.bookSourceUrl)
&& equal(bookSourceGroup, source.bookSourceGroup)
&& bookSourceType == source.bookSourceType
&& equal(bookUrlPattern, source.bookUrlPattern)
&& equal(bookSourceComment, source.bookSourceComment)
&& enabled == source.enabled
&& enabledExplore == source.enabledExplore
&& equal(header, source.header)
&& loginUrl == source.loginUrl
&& equal(exploreUrl, source.exploreUrl)
&& equal(searchUrl, source.searchUrl)
&& getSearchRule() == source.getSearchRule()
&& getExploreRule() == source.getExploreRule()
&& getBookInfoRule() == source.getBookInfoRule()
&& getTocRule() == source.getTocRule()
&& getContentRule() == source.getContentRule()
private fun equal(a: String?, b: String?) = a == b || (a.isNullOrEmpty() && b.isNullOrEmpty())
companion object {
fun fromJson(json: String): BookSource? {
return SourceAnalyzer.jsonToBookSource(json)
}
fun fromJsonArray(json: String): List<BookSource> {
return SourceAnalyzer.jsonToBookSources(json)
}
}
class Converters {
@TypeConverter
fun exploreRuleToString(exploreRule: ExploreRule?): String = GSON.toJson(exploreRule)
@TypeConverter
fun stringToExploreRule(json: String?) = GSON.fromJsonObject<ExploreRule>(json)
@TypeConverter
fun searchRuleToString(searchRule: SearchRule?): String = GSON.toJson(searchRule)
@TypeConverter
fun stringToSearchRule(json: String?) = GSON.fromJsonObject<SearchRule>(json)
@TypeConverter
fun bookInfoRuleToString(bookInfoRule: BookInfoRule?): String = GSON.toJson(bookInfoRule)
@TypeConverter
fun stringToBookInfoRule(json: String?) = GSON.fromJsonObject<BookInfoRule>(json)
@TypeConverter
fun tocRuleToString(tocRule: TocRule?): String = GSON.toJson(tocRule)
@TypeConverter
fun stringToTocRule(json: String?) = GSON.fromJsonObject<TocRule>(json)
@TypeConverter
fun contentRuleToString(contentRule: ContentRule?): String = GSON.toJson(contentRule)
@TypeConverter
fun stringToContentRule(json: String?) = GSON.fromJsonObject<ContentRule>(json)
}
}
@@ -0,0 +1,24 @@
package io.legado.app.data.entities
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
@Parcelize
@Entity(
tableName = "bookmarks",
indices = [(Index(value = ["bookName", "bookAuthor"], unique = false))]
)
data class Bookmark(
@PrimaryKey
val time: Long = System.currentTimeMillis(),
val bookName: String = "",
val bookAuthor: String = "",
var chapterIndex: Int = 0,
var chapterPos: Int = 0,
var chapterName: String = "",
var bookText: String = "",
var content: String = ""
) : Parcelable
@@ -0,0 +1,13 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "caches", indices = [(Index(value = ["key"], unique = true))])
data class Cache(
@PrimaryKey
val key: String = "",
var value: String? = null,
var deadline: Long = 0L
)
@@ -0,0 +1,12 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "cookies", indices = [(Index(value = ["url"], unique = true))])
data class Cookie(
@PrimaryKey
var url: String = "",
var cookie: String = ""
)
@@ -0,0 +1,78 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.jayway.jsonpath.DocumentContext
import io.legado.app.utils.GSON
import io.legado.app.utils.jsonPath
import io.legado.app.utils.readLong
import io.legado.app.utils.readString
/**
* 在线朗读引擎
*/
@Entity(tableName = "httpTTS")
data class HttpTTS(
@PrimaryKey
val id: Long = System.currentTimeMillis(),
var name: String = "",
var url: String = "",
var contentType: String? = null,
override var concurrentRate: String? = "0",
override var loginUrl: String? = null,
override var loginUi: String? = null,
override var header: String? = null,
var loginCheckJs: String? = null,
) : BaseSource {
override fun getTag(): String {
return name
}
override fun getKey(): String {
return "httpTts:$id"
}
override fun getSource(): BaseSource {
return this
}
@Suppress("MemberVisibilityCanBePrivate")
companion object {
fun fromJsonDoc(doc: DocumentContext): HttpTTS? {
return kotlin.runCatching {
val loginUi = doc.read<Any>("$.loginUi")
HttpTTS(
id = doc.readLong("$.id") ?: System.currentTimeMillis(),
name = doc.readString("$.name")!!,
url = doc.readString("$.url")!!,
contentType = doc.readString("$.contentType"),
concurrentRate = doc.readString("$.concurrentRate"),
loginUrl = doc.readString("$.loginUrl"),
loginUi = if (loginUi is List<*>) GSON.toJson(loginUi) else loginUi?.toString(),
header = doc.readString("$.header"),
loginCheckJs = doc.readString("$.loginCheckJs")
)
}.getOrNull()
}
fun fromJson(json: String): HttpTTS? {
return fromJsonDoc(jsonPath.parse(json))
}
fun fromJsonArray(jsonArray: String): ArrayList<HttpTTS> {
val sources = arrayListOf<HttpTTS>()
val doc = jsonPath.parse(jsonArray).read<List<*>>("$")
doc.forEach {
val jsonItem = jsonPath.parse(it)
fromJsonDoc(jsonItem)?.let { source ->
sources.add(source)
}
}
return sources
}
}
}
@@ -0,0 +1,10 @@
package io.legado.app.data.entities
import androidx.room.Entity
@Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName"])
data class ReadRecord(
var deviceId: String = "",
var bookName: String = "",
var readTime: Long = 0L
)
@@ -0,0 +1,6 @@
package io.legado.app.data.entities
data class ReadRecordShow(
var bookName: String = "",
var readTime: Long = 0L
)
@@ -0,0 +1,57 @@
package io.legado.app.data.entities
import android.os.Parcelable
import android.text.TextUtils
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import java.util.regex.Pattern
import java.util.regex.PatternSyntaxException
@Parcelize
@Entity(
tableName = "replace_rules",
indices = [(Index(value = ["id"]))]
)
data class ReplaceRule(
@PrimaryKey(autoGenerate = true)
var id: Long = System.currentTimeMillis(),
var name: String = "",
var group: String? = null,
var pattern: String = "",
var replacement: String = "",
var scope: String? = null,
var isEnabled: Boolean = true,
var isRegex: Boolean = true,
@ColumnInfo(name = "sortOrder")
var order: Int = 0
) : Parcelable {
override fun equals(other: Any?): Boolean {
if (other is ReplaceRule) {
return other.id == id
}
return super.equals(other)
}
override fun hashCode(): Int {
return id.hashCode()
}
fun isValid(): Boolean {
if (TextUtils.isEmpty(pattern)) {
return false
}
//判断正则表达式是否正确
if (isRegex) {
try {
Pattern.compile(pattern)
} catch (ex: PatternSyntaxException) {
return false
}
}
return true
}
}
@@ -0,0 +1,63 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.Ignore
import io.legado.app.model.analyzeRule.RuleDataInterface
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject
import kotlinx.parcelize.IgnoredOnParcel
@Entity(
tableName = "rssArticles",
primaryKeys = ["origin", "link"]
)
data class RssArticle(
var origin: String = "",
var sort: String = "",
var title: String = "",
var order: Long = 0,
var link: String = "",
var pubDate: String? = null,
var description: String? = null,
var content: String? = null,
var image: String? = null,
var read: Boolean = false,
var variable: String? = null
) : RuleDataInterface {
override fun hashCode() = link.hashCode()
override fun equals(other: Any?): Boolean {
other ?: return false
return if (other is RssArticle) origin == other.origin && link == other.link else false
}
@delegate:Transient
@delegate:Ignore
@IgnoredOnParcel
override val variableMap by lazy {
GSON.fromJsonObject<HashMap<String, String>>(variable) ?: HashMap()
}
override fun putVariable(key: String, value: String?) {
if (value != null) {
variableMap[key] = value
} else {
variableMap.remove(key)
}
variable = GSON.toJson(variableMap)
}
fun toStar() = RssStar(
origin = origin,
sort = sort,
title = title,
starTime = System.currentTimeMillis(),
link = link,
pubDate = pubDate,
description = description,
content = content,
image = image
)
}
@@ -0,0 +1,7 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "rssReadRecords")
data class RssReadRecord(@PrimaryKey val record: String, val read: Boolean = true)
@@ -0,0 +1,170 @@
package io.legado.app.data.entities
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.jayway.jsonpath.DocumentContext
import io.legado.app.utils.*
import kotlinx.parcelize.Parcelize
import splitties.init.appCtx
@Parcelize
@Entity(tableName = "rssSources", indices = [(Index(value = ["sourceUrl"], unique = false))])
data class RssSource(
@PrimaryKey
var sourceUrl: String = "",
var sourceName: String = "",
var sourceIcon: String = "",
var sourceGroup: String? = null,
var sourceComment: String? = null,
var enabled: Boolean = true,
override var concurrentRate: String? = null, //并发率
override var header: String? = null, // 请求头
override var loginUrl: String? = null, // 登录地址
override var loginUi: String? = null, //登录UI
var loginCheckJs: String? = null, //登录检测js
var sortUrl: String? = null,
var singleUrl: Boolean = false,
//列表规则
var articleStyle: Int = 0, //列表样式,0,1,2
var ruleArticles: String? = null,
var ruleNextPage: String? = null,
var ruleTitle: String? = null,
var rulePubDate: String? = null,
//webView规则
var ruleDescription: String? = null,
var ruleImage: String? = null,
var ruleLink: String? = null,
var ruleContent: String? = null,
var style: String? = null,
var enableJs: Boolean = true,
var loadWithBaseUrl: Boolean = true,
var customOrder: Int = 0
) : Parcelable, BaseSource {
override fun getTag(): String {
return sourceName
}
override fun getKey(): String {
return sourceUrl
}
override fun getSource(): BaseSource {
return this
}
override fun equals(other: Any?): Boolean {
if (other is RssSource) {
return other.sourceUrl == sourceUrl
}
return false
}
override fun hashCode() = sourceUrl.hashCode()
fun equal(source: RssSource): Boolean {
return equal(sourceUrl, source.sourceUrl)
&& equal(sourceIcon, source.sourceIcon)
&& enabled == source.enabled
&& equal(sourceGroup, source.sourceGroup)
&& equal(ruleArticles, source.ruleArticles)
&& equal(ruleNextPage, source.ruleNextPage)
&& equal(ruleTitle, source.ruleTitle)
&& equal(rulePubDate, source.rulePubDate)
&& equal(ruleDescription, source.ruleDescription)
&& equal(ruleLink, source.ruleLink)
&& equal(ruleContent, source.ruleContent)
&& enableJs == source.enableJs
&& loadWithBaseUrl == source.loadWithBaseUrl
}
private fun equal(a: String?, b: String?): Boolean {
return a == b || (a.isNullOrEmpty() && b.isNullOrEmpty())
}
fun sortUrls(): List<Pair<String, String>> = arrayListOf<Pair<String, String>>().apply {
kotlin.runCatching {
var a = sortUrl
if (sortUrl?.startsWith("<js>", false) == true
|| sortUrl?.startsWith("@js:", false) == true
) {
val aCache = ACache.get(appCtx, "rssSortUrl")
a = aCache.getAsString(sourceUrl) ?: ""
if (a.isBlank()) {
val jsStr = if (sortUrl!!.startsWith("@")) {
sortUrl!!.substring(4)
} else {
sortUrl!!.substring(4, sortUrl!!.lastIndexOf("<"))
}
a = evalJS(jsStr).toString()
aCache.put(sourceUrl, a)
}
}
a?.split("(&&|\n)+".toRegex())?.forEach { c ->
val d = c.split("::")
if (d.size > 1)
add(Pair(d[0], d[1]))
}
if (isEmpty()) {
add(Pair("", sourceUrl))
}
}
}
@Suppress("MemberVisibilityCanBePrivate")
companion object {
fun fromJsonDoc(doc: DocumentContext): RssSource? {
return kotlin.runCatching {
val loginUi = doc.read<Any>("$.loginUi")
RssSource(
sourceUrl = doc.readString("$.sourceUrl")!!,
sourceName = doc.readString("$.sourceName")!!,
sourceIcon = doc.readString("$.sourceIcon") ?: "",
sourceGroup = doc.readString("$.sourceGroup"),
sourceComment = doc.readString("$.sourceComment"),
enabled = doc.readBool("$.enabled") ?: true,
concurrentRate = doc.readString("$.concurrentRate"),
header = doc.readString("$.header"),
loginUrl = doc.readString("$.loginUrl"),
loginUi = if (loginUi is List<*>) GSON.toJson(loginUi) else loginUi?.toString(),
loginCheckJs = doc.readString("$.loginCheckJs"),
sortUrl = doc.readString("$.sortUrl"),
singleUrl = doc.readBool("$.singleUrl") ?: false,
articleStyle = doc.readInt("$.articleStyle") ?: 0,
ruleArticles = doc.readString("$.ruleArticles"),
ruleNextPage = doc.readString("$.ruleNextPage"),
ruleTitle = doc.readString("$.ruleTitle"),
rulePubDate = doc.readString("$.rulePubDate"),
ruleDescription = doc.readString("$.ruleDescription"),
ruleImage = doc.readString("$.ruleImage"),
ruleLink = doc.readString("$.ruleLink"),
ruleContent = doc.readString("$.ruleContent"),
style = doc.readString("$.style"),
enableJs = doc.readBool("$.enableJs") ?: true,
loadWithBaseUrl = doc.readBool("$.loadWithBaseUrl") ?: true,
customOrder = doc.readInt("$.customOrder") ?: 0
)
}.getOrNull()
}
fun fromJson(json: String): RssSource? {
return fromJsonDoc(jsonPath.parse(json))
}
fun fromJsonArray(jsonArray: String): ArrayList<RssSource> {
val sources = arrayListOf<RssSource>()
val doc = jsonPath.parse(jsonArray).read<List<*>>("$")
doc.forEach {
val jsonItem = jsonPath.parse(it)
fromJsonDoc(jsonItem)?.let { source ->
sources.add(source)
}
}
return sources
}
}
}
@@ -0,0 +1,54 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.Ignore
import io.legado.app.model.analyzeRule.RuleDataInterface
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject
import kotlinx.parcelize.IgnoredOnParcel
@Entity(
tableName = "rssStars",
primaryKeys = ["origin", "link"]
)
data class RssStar(
var origin: String = "",
var sort: String = "",
var title: String = "",
var starTime: Long = 0,
var link: String = "",
var pubDate: String? = null,
var description: String? = null,
var content: String? = null,
var image: String? = null,
var variable: String? = null
) : RuleDataInterface {
@delegate:Transient
@delegate:Ignore
@IgnoredOnParcel
override val variableMap by lazy {
GSON.fromJsonObject<HashMap<String, String>>(variable) ?: HashMap()
}
override fun putVariable(key: String, value: String?) {
if (value != null) {
variableMap[key] = value
} else {
variableMap.remove(key)
}
variable = GSON.toJson(variableMap)
}
fun toRssArticle() = RssArticle(
origin = origin,
sort = sort,
title = title,
link = link,
pubDate = pubDate,
description = description,
content = content,
image = image
)
}
@@ -0,0 +1,16 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "ruleSubs")
data class RuleSub(
@PrimaryKey
val id: Long = System.currentTimeMillis(),
var name: String = "",
var url: String = "",
var type: Int = 0,
var customOrder: Int = 0,
var autoUpdate: Boolean = false,
var update: Long = System.currentTimeMillis()
)
@@ -0,0 +1,121 @@
package io.legado.app.data.entities
import android.content.Context
import android.os.Parcelable
import androidx.room.*
import io.legado.app.R
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
@Entity(
tableName = "searchBooks",
indices = [(Index(value = ["bookUrl"], unique = true)),
(Index(value = ["origin"], unique = false))],
foreignKeys = [(ForeignKey(
entity = BookSource::class,
parentColumns = ["bookSourceUrl"],
childColumns = ["origin"],
onDelete = ForeignKey.CASCADE
))]
)
data class SearchBook(
@PrimaryKey
override var bookUrl: String = "",
var origin: String = "", // 书源规则
var originName: String = "",
var type: Int = 0, // @BookType
override var name: String = "",
override var author: String = "",
override var kind: String? = null,
var coverUrl: String? = null,
var intro: String? = null,
override var wordCount: String? = null,
var latestChapterTitle: String? = null,
var tocUrl: String = "", // 目录页Url (toc=table of Contents)
var time: Long = System.currentTimeMillis(),
var variable: String? = null,
var originOrder: Int = 0
) : Parcelable, BaseBook, Comparable<SearchBook> {
@Ignore
@IgnoredOnParcel
override var infoHtml: String? = null
@Ignore
@IgnoredOnParcel
override var tocHtml: String? = null
override fun equals(other: Any?) = other is SearchBook && other.bookUrl == bookUrl
override fun hashCode() = bookUrl.hashCode()
override fun compareTo(other: SearchBook): Int {
return other.originOrder - this.originOrder
}
@delegate:Transient
@delegate:Ignore
@IgnoredOnParcel
override val variableMap by lazy {
GSON.fromJsonObject<HashMap<String, String>>(variable) ?: HashMap()
}
override fun putVariable(key: String, value: String?) {
if (value != null) {
variableMap[key] = value
} else {
variableMap.remove(key)
}
variable = GSON.toJson(variableMap)
}
@delegate:Transient
@delegate:Ignore
@IgnoredOnParcel
val origins: LinkedHashSet<String> by lazy { linkedSetOf(origin) }
fun addOrigin(origin: String) {
origins.add(origin)
}
fun getDisplayLastChapterTitle(): String {
latestChapterTitle?.let {
if (it.isNotEmpty()) {
return it
}
}
return "无最新章节"
}
fun trimIntro(context: Context): String {
val trimIntro = intro?.trim()
return if (trimIntro.isNullOrEmpty()) {
context.getString(R.string.intro_show_null)
} else {
context.getString(R.string.intro_show, trimIntro)
}
}
fun toBook() = Book(
name = name,
author = author,
kind = kind,
bookUrl = bookUrl,
origin = origin,
originName = originName,
type = type,
wordCount = wordCount,
latestChapterTitle = latestChapterTitle,
coverUrl = coverUrl,
intro = intro,
tocUrl = tocUrl,
originOrder = originOrder,
variable = variable
).apply {
this.infoHtml = this@SearchBook.infoHtml
this.tocUrl = this@SearchBook.tocUrl
}
}
@@ -0,0 +1,17 @@
package io.legado.app.data.entities
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
@Parcelize
@Entity(tableName = "search_keywords", indices = [(Index(value = ["word"], unique = true))])
data class SearchKeyword(
@PrimaryKey
var word: String = "", // 搜索关键词
var usage: Int = 1, // 使用次数
var lastUseTime: Long = System.currentTimeMillis() // 最后一次使用时间
) : Parcelable
@@ -0,0 +1,21 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
@Entity(tableName = "txtTocRules")
data class TxtTocRule(
@PrimaryKey
var id: Long = System.currentTimeMillis(),
var name: String = "",
var rule: String = "",
var serialNumber: Int = -1,
var enable: Boolean = true
) {
@Ignore
constructor() : this(name = "")
}
@@ -0,0 +1,20 @@
package io.legado.app.data.entities.rule
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class BookInfoRule(
var init: String? = null,
var name: String? = null,
var author: String? = null,
var intro: String? = null,
var kind: String? = null,
var lastChapter: String? = null,
var updateTime: String? = null,
var coverUrl: String? = null,
var tocUrl: String? = null,
var wordCount: String? = null,
var canReName: String? = null
) : Parcelable
@@ -0,0 +1,14 @@
package io.legado.app.data.entities.rule
interface BookListRule {
var bookList: String?
var name: String?
var author: String?
var intro: String?
var kind: String?
var lastChapter: String?
var updateTime: String?
var bookUrl: String?
var coverUrl: String?
var wordCount: String?
}
@@ -0,0 +1,15 @@
package io.legado.app.data.entities.rule
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class ContentRule(
var content: String? = null,
var nextContentUrl: String? = null,
var webJs: String? = null,
var sourceRegex: String? = null,
var replaceRegex: String? = null, //替换规则
var imageStyle: String? = null, //默认大小居中,FULL最大宽度
var payAction: String? = null, //购买操作,url/js
) : Parcelable
@@ -0,0 +1,39 @@
package io.legado.app.data.entities.rule
data class ExploreKind(
val title: String,
val url: String? = null,
val style: Style? = null
) {
companion object {
val defaultStyle = Style()
}
fun style(): Style {
return style ?: defaultStyle
}
data class Style(
val layout_flexGrow: Float = 0F,
val layout_flexShrink: Float = 1F,
val layout_alignSelf: String = "auto",
val layout_flexBasisPercent: Float = -1F,
val layout_wrapBefore: Boolean = false,
) {
fun alignSelf(): Int {
return when (layout_alignSelf) {
"auto" -> -1
"flex_start" -> 0
"flex_end" -> 1
"center" -> 2
"baseline" -> 3
"stretch" -> 4
else -> -1
}
}
}
}
@@ -0,0 +1,18 @@
package io.legado.app.data.entities.rule
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class ExploreRule(
override var bookList: String? = null,
override var name: String? = null,
override var author: String? = null,
override var intro: String? = null,
override var kind: String? = null,
override var lastChapter: String? = null,
override var updateTime: String? = null,
override var bookUrl: String? = null,
override var coverUrl: String? = null,
override var wordCount: String? = null
) : BookListRule, Parcelable
@@ -0,0 +1,11 @@
package io.legado.app.data.entities.rule
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class RowUi(
var name: String,
var type: String?,
var action: String?
) : Parcelable
@@ -0,0 +1,20 @@
package io.legado.app.data.entities.rule
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class SearchRule(
var checkKeyWord: String? = null, // 校验关键字
override var bookList: String? = null,
override var name: String? = null,
override var author: String? = null,
override var intro: String? = null,
override var kind: String? = null,
override var lastChapter: String? = null,
override var updateTime: String? = null,
override var bookUrl: String? = null,
override var coverUrl: String? = null,
override var wordCount: String? = null
) : BookListRule, Parcelable
@@ -0,0 +1,15 @@
package io.legado.app.data.entities.rule
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class TocRule(
var chapterList: String? = null,
var chapterName: String? = null,
var chapterUrl: String? = null,
var isVip: String? = null,
var isPay: String? = null,
var updateTime: String? = null,
var nextTocUrl: String? = null
) : Parcelable
@@ -0,0 +1,279 @@
package io.legado.app.help
import android.content.Context
import android.content.SharedPreferences
import io.legado.app.constant.AppConst
import io.legado.app.constant.PreferKey
import io.legado.app.utils.*
import splitties.init.appCtx
@Suppress("MemberVisibilityCanBePrivate")
object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
val isGooglePlay = appCtx.channel == "google"
val isCronet = appCtx.getPrefBoolean("Cronet")
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)
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
PreferKey.themeMode -> isEInkMode = appCtx.getPrefString(PreferKey.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, 2)
PreferKey.clickActionML -> clickActionML =
appCtx.getPrefInt(PreferKey.clickActionML, 2)
PreferKey.clickActionMC -> clickActionMC =
appCtx.getPrefInt(PreferKey.clickActionMC, 2)
PreferKey.clickActionMR -> clickActionMR =
appCtx.getPrefInt(PreferKey.clickActionMR, 2)
PreferKey.clickActionBL -> clickActionBL =
appCtx.getPrefInt(PreferKey.clickActionBL, 2)
PreferKey.clickActionBC -> clickActionBC =
appCtx.getPrefInt(PreferKey.clickActionBC, 2)
PreferKey.clickActionBR -> clickActionBR =
appCtx.getPrefInt(PreferKey.clickActionBR, 2)
PreferKey.readBodyToLh -> ReadBookConfig.readBodyToLh =
appCtx.getPrefBoolean(PreferKey.readBodyToLh, true)
PreferKey.useZhLayout -> ReadBookConfig.useZhLayout =
appCtx.getPrefBoolean(PreferKey.useZhLayout)
PreferKey.userAgent -> userAgent = getPrefUserAgent()
}
}
fun isNightTheme(context: Context): Boolean {
return when (context.getPrefString(PreferKey.themeMode, "0")) {
"1" -> false
"2" -> true
"3" -> false
else -> sysConfiguration.isNightMode
}
}
var isNightTheme: Boolean
get() = isNightTheme(appCtx)
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 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 useDefaultCover: Boolean
get() = appCtx.getPrefBoolean(PreferKey.useDefaultCover, false)
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 bookExportFileName: String?
get() = appCtx.getPrefString(PreferKey.bookExportFileName)
set(value) {
appCtx.putPrefString(PreferKey.bookExportFileName, 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 threadCount: Int
get() = appCtx.getPrefInt(PreferKey.threadCount, 16)
set(value) {
appCtx.putPrefInt(PreferKey.threadCount, value)
}
var importBookPath: String?
get() = appCtx.getPrefString("importBookPath")
set(value) {
if (value == null) {
appCtx.removePref("importBookPath")
} else {
appCtx.putPrefString("importBookPath", value)
}
}
var ttsSpeechRate: Int
get() = appCtx.getPrefInt(PreferKey.ttsSpeechRate, 5)
set(value) {
appCtx.putPrefInt(PreferKey.ttsSpeechRate, value)
}
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() = 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)
}
var exportType: Int
get() = appCtx.getPrefInt(PreferKey.exportType)
set(value) {
appCtx.putPrefInt(PreferKey.exportType, 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)
}
val autoChangeSource: Boolean
get() = appCtx.getPrefBoolean(PreferKey.autoChangeSource, true)
val changeSourceLoadInfo get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadInfo)
val changeSourceLoadToc get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadToc)
val importKeepName get() = appCtx.getPrefBoolean(PreferKey.importKeepName)
val syncBookProgress get() = appCtx.getPrefBoolean(PreferKey.syncBookProgress, true)
var preDownloadNum
get() = appCtx.getPrefInt(PreferKey.preDownloadNum, 10)
set(value) {
appCtx.putPrefInt(PreferKey.preDownloadNum, value)
}
val mediaButtonOnExit get() = appCtx.getPrefBoolean("mediaButtonOnExit", true)
val replaceEnableDefault get() = appCtx.getPrefBoolean(PreferKey.replaceEnableDefault, true)
val doublePageHorizontal: Boolean
get() = appCtx.getPrefBoolean(PreferKey.doublePageHorizontal, true)
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/91.0.4472.124 Safari/537.36"
}
return ua
}
}
@@ -0,0 +1,53 @@
package io.legado.app.help
import io.legado.app.constant.AppConst
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.http.newCallStrResponse
import io.legado.app.help.http.okHttpClient
import io.legado.app.model.NoStackTraceException
import io.legado.app.utils.jsonPath
import io.legado.app.utils.readString
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.CoroutineScope
import splitties.init.appCtx
object AppUpdate {
fun checkFromGitHub(
scope: CoroutineScope,
showErrorMsg: Boolean = true,
callback: (newVersion: String, updateBody: String, url: String, fileName: String) -> Unit
) {
Coroutine.async(scope) {
val lastReleaseUrl = "https://api.github.com/repos/gedoor/legado/releases/latest"
val body = okHttpClient.newCallStrResponse {
url(lastReleaseUrl)
}.body
if (body.isNullOrBlank()) {
throw NoStackTraceException("获取新版本出错")
}
val rootDoc = jsonPath.parse(body)
val tagName = rootDoc.readString("$.tag_name")
?: throw NoStackTraceException("获取新版本出错")
if (tagName > AppConst.appInfo.versionName) {
val updateBody = rootDoc.readString("$.body")
?: throw NoStackTraceException("获取新版本出错")
val downloadUrl = rootDoc.readString("$.assets[0].browser_download_url")
?: throw NoStackTraceException("获取新版本出错")
val fileName = rootDoc.readString("$.assets[0].name")
?: throw NoStackTraceException("获取新版本出错")
return@async arrayOf(tagName, updateBody, downloadUrl, fileName)
} else {
throw NoStackTraceException("已是最新版本")
}
}.timeout(10000)
.onSuccess {
callback.invoke(it[0], it[1], it[2], it[3])
}.onError {
if (showErrorMsg) {
appCtx.toastOnUi("检测更新\n${it.localizedMessage}")
}
}
}
}
@@ -0,0 +1,70 @@
package io.legado.app.help
import android.annotation.TargetApi
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.renderscript.Allocation
import android.renderscript.Element
import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicBlur
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import java.security.MessageDigest
import kotlin.math.min
import kotlin.math.roundToInt
/**
* 模糊
* @radius: 0..25
*/
class BlurTransformation(context: Context, private val radius: Int) : CenterCrop() {
private val rs: RenderScript = RenderScript.create(context)
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
override fun transform(
pool: BitmapPool,
toTransform: Bitmap,
outWidth: Int,
outHeight: Int
): Bitmap {
val transform = super.transform(pool, toTransform, outWidth, outHeight)
//图片缩小1/2
val width = (min(outWidth, transform.width) / 2f).roundToInt()
val height = (min(outHeight, transform.height) / 2f).roundToInt()
val blurredBitmap = Bitmap.createScaledBitmap(transform, width, height, false)
// Allocate memory for Renderscript to work with
//分配用于渲染脚本的内存
val input = Allocation.createFromBitmap(
rs,
blurredBitmap,
Allocation.MipmapControl.MIPMAP_FULL,
Allocation.USAGE_SHARED
)
val output = Allocation.createTyped(rs, input.type)
// Load up an instance of the specific script that we want to use.
//加载我们想要使用的特定脚本的实例。
val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
script.setInput(input)
// Set the blur radius
//设置模糊半径0..25
script.setRadius(radius.toFloat())
// Start the ScriptIntrinsicBlur
//启动 ScriptIntrinsicBlur,
script.forEach(output)
// Copy the output to the blurred bitmap
//将输出复制到模糊的位图
output.copyTo(blurredBitmap)
return blurredBitmap
}
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update("blur transformation".toByteArray())
}
}
@@ -0,0 +1,401 @@
package io.legado.app.help
import io.legado.app.constant.AppPattern
import io.legado.app.constant.EventBus
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.localBook.LocalBook
import io.legado.app.utils.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import org.apache.commons.text.similarity.JaccardSimilarity
import splitties.init.appCtx
import timber.log.Timber
import java.io.File
import java.util.concurrent.CopyOnWriteArraySet
import java.util.regex.Pattern
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@Suppress("unused")
object BookHelp {
val downloadDir: File = appCtx.externalFiles
const val cacheFolderName = "book_cache"
private const val cacheImageFolderName = "images"
private val downloadImages = CopyOnWriteArraySet<String>()
fun clearCache() {
FileUtils.deleteFile(
FileUtils.getPath(downloadDir, cacheFolderName)
)
}
fun clearCache(book: Book) {
val filePath = FileUtils.getPath(downloadDir, cacheFolderName, book.getFolderName())
FileUtils.deleteFile(filePath)
}
/**
* 清除已删除书的缓存
*/
fun clearRemovedCache() {
Coroutine.async {
val bookFolderNames = arrayListOf<String>()
appDb.bookDao.all.forEach {
bookFolderNames.add(it.getFolderName())
}
val file = downloadDir.getFile(cacheFolderName)
file.listFiles()?.forEach { bookFile ->
if (!bookFolderNames.contains(bookFile.name)) {
FileUtils.deleteFile(bookFile.absolutePath)
}
}
}
}
suspend fun saveContent(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
bookChapter: BookChapter,
content: String
) {
saveText(book, bookChapter, content)
saveImages(scope, bookSource, book, bookChapter, content)
postEvent(EventBus.SAVE_CONTENT, bookChapter)
}
private fun saveText(
book: Book,
bookChapter: BookChapter,
content: String
) {
if (content.isEmpty()) return
//保存文本
FileUtils.createFileIfNotExist(
downloadDir,
cacheFolderName,
book.getFolderName(),
bookChapter.getFileName(),
).writeText(content)
}
private suspend fun saveImages(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
bookChapter: BookChapter,
content: String
) {
val awaitList = arrayListOf<Deferred<Unit>>()
content.split("\n").forEach {
val matcher = AppPattern.imgPattern.matcher(it)
if (matcher.find()) {
matcher.group(1)?.let { src ->
val mSrc = NetworkUtils.getAbsoluteURL(bookChapter.url, src)
awaitList.add(scope.async {
saveImage(bookSource, book, mSrc)
})
}
}
}
awaitList.forEach {
it.await()
}
}
suspend fun saveImage(bookSource: BookSource?, book: Book, src: String) {
while (downloadImages.contains(src)) {
delay(100)
}
if (getImage(book, src).exists()) {
return
}
downloadImages.add(src)
val analyzeUrl = AnalyzeUrl(src, source = bookSource)
try {
analyzeUrl.getByteArrayAwait().let {
FileUtils.createFileIfNotExist(
downloadDir,
cacheFolderName,
book.getFolderName(),
cacheImageFolderName,
"${MD5Utils.md5Encode16(src)}${getImageSuffix(src)}"
).writeBytes(it)
}
} catch (e: Exception) {
Timber.e(e)
} finally {
downloadImages.remove(src)
}
}
fun getImage(book: Book, src: String): File {
return downloadDir.getFile(
cacheFolderName,
book.getFolderName(),
cacheImageFolderName,
"${MD5Utils.md5Encode16(src)}${getImageSuffix(src)}"
)
}
fun getImageSuffix(src: String): String {
var suffix = src.substringAfterLast(".").substringBefore(",")
if (suffix.length > 5) {
suffix = ".jpg"
}
return suffix
}
fun getChapterFiles(book: Book): List<String> {
val fileNameList = arrayListOf<String>()
if (book.isLocalTxt()) {
return fileNameList
}
FileUtils.createFolderIfNotExist(
downloadDir,
subDirs = arrayOf(cacheFolderName, book.getFolderName())
).list()?.let {
fileNameList.addAll(it)
}
return fileNameList
}
/**
* 检测该章节是否下载
*/
fun hasContent(book: Book, bookChapter: BookChapter): Boolean {
return if (book.isLocalTxt()) {
true
} else {
downloadDir.exists(
cacheFolderName,
book.getFolderName(),
bookChapter.getFileName()
)
}
}
/**
* 检测图片是否下载
*/
fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean {
if (!hasContent(book, bookChapter)) {
return false
}
getContent(book, bookChapter)?.let {
val matcher = AppPattern.imgPattern.matcher(it)
while (matcher.find()) {
matcher.group(1)?.let { src ->
val image = getImage(book, src)
if (!image.exists()) {
return false
}
}
}
}
return true
}
/**
* 读取章节内容
*/
fun getContent(book: Book, bookChapter: BookChapter): String? {
if (book.isLocalTxt() || book.isUmd()) {
return LocalBook.getContent(book, bookChapter)
} else if (book.isEpub() && !hasContent(book, bookChapter)) {
val string = LocalBook.getContent(book, bookChapter)
string?.let {
saveText(book, bookChapter, it)
}
return string
} else {
val file = downloadDir.getFile(
cacheFolderName,
book.getFolderName(),
bookChapter.getFileName()
)
if (file.exists()) {
return file.readText()
}
}
return null
}
/**
* 反转章节内容
*/
fun reverseContent(book: Book, bookChapter: BookChapter) {
if (!book.isLocalBook()) {
val file = downloadDir.getFile(
cacheFolderName,
book.getFolderName(),
bookChapter.getFileName()
)
if (file.exists()) {
val text = file.readText()
val stringBuilder = StringBuilder()
text.toStringArray().forEach {
stringBuilder.insert(0, it)
}
file.writeText(stringBuilder.toString())
}
}
}
/**
* 删除章节内容
*/
fun delContent(book: Book, bookChapter: BookChapter) {
if (book.isLocalTxt()) {
return
} else {
FileUtils.createFileIfNotExist(
downloadDir,
cacheFolderName,
book.getFolderName(),
bookChapter.getFileName()
).delete()
}
}
/**
* 格式化书名
*/
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,
oldChapterListSize: Int,
oldDurChapterName: String?,
newChapterList: List<BookChapter>
): Int {
if (oldChapterListSize == 0) return oldDurChapterIndex
if (newChapterList.isEmpty()) return oldDurChapterIndex
val oldChapterNum = getChapterNum(oldDurChapterName)
val oldName = getPureChapterName(oldDurChapterName)
val newChapterSize = newChapterList.size
val min = max(
0,
min(
oldDurChapterIndex,
oldDurChapterIndex - oldChapterListSize + newChapterSize
) - 10
)
val max = min(
newChapterSize - 1,
max(
oldDurChapterIndex,
oldDurChapterIndex - oldChapterListSize + newChapterSize
) + 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)
}
}
private val chapterNamePattern1 by lazy {
Pattern.compile(".*?第([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话]")
}
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"
)
}
@Suppress("SpellCheckingInspection")
private val regexOther by lazy {
// 所有非字母数字中日韩文字 CJK区+扩展A-F区
@Suppress("RegExpDuplicateCharacterInClass")
return@lazy "[^\\w\\u4E00-\\u9FEF\\u3400-\\u4DBF\\u20000-\\u2A6DF\\u2A700-\\u2EBEF]".toRegex()
}
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,67 @@
package io.legado.app.help
import io.legado.app.data.appDb
import io.legado.app.data.entities.Cache
import io.legado.app.model.analyzeRule.QueryTTF
import io.legado.app.utils.ACache
import splitties.init.appCtx
@Suppress("unused")
object CacheManager {
private val queryTTFMap = hashMapOf<String, Pair<Long, QueryTTF>>()
/**
* 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 QueryTTF -> queryTTFMap[key] = Pair(deadline, value)
is ByteArray -> ACache.get(appCtx).put(key, value, saveTime)
else -> {
val cache = Cache(key, value.toString(), deadline)
appDb.cacheDao.insert(cache)
}
}
}
fun get(key: String): String? {
return appDb.cacheDao.get(key, System.currentTimeMillis())
}
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(appCtx).getAsBinary(key)
}
fun getQueryTTF(key: String): QueryTTF? {
val cache = queryTTFMap[key] ?: return null
if (cache.first == 0L || cache.first > System.currentTimeMillis()) {
return cache.second
}
return null
}
fun delete(key: String) {
appDb.cacheDao.delete(key)
ACache.get(appCtx).remove(key)
}
}
@@ -0,0 +1,621 @@
package io.legado.app.help
import java.util.*
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()
//初始化StringBuffer的长度,在原content的长度基础上做冗余
var buffer = StringBuffer((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 = StringBuffer((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: StringBuffer): StringBuffer {
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 = StringBuffer()
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 = StringBuffer(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 = StringBuffer((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<Integer>
</Integer> */
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,128 @@
package io.legado.app.help
import com.github.liuyueyi.quick.transfer.ChineseUtils
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.ReplaceRule
import io.legado.app.utils.toastOnUi
import splitties.init.appCtx
import java.lang.ref.WeakReference
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>>()
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 replaceRules = arrayListOf<ReplaceRule>()
init {
upReplaceRules()
}
@Synchronized
fun upReplaceRules() {
replaceRules.clear()
replaceRules.addAll(appDb.replaceRuleDao.findEnabledByScope(bookName, bookOrigin))
}
@Synchronized
fun getReplaceRules(): Array<ReplaceRule> {
return replaceRules.toTypedArray()
}
fun getContent(
book: Book,
chapter: BookChapter, //已经经过简繁转换
content: String,
includeTitle: Boolean = true,
useReplace: Boolean = true,
chineseConvert: Boolean = true,
reSegment: Boolean = true
): List<String> {
var mContent = content
//去除重复标题
try {
val name = Pattern.quote(book.name)
val title = Pattern.quote(chapter.title)
val titleRegex = "^(\\s|\\p{P}|${name})*${title}(\\s|\\p{P})+".toRegex()
mContent = mContent.replace(titleRegex, "")
} catch (e: Exception) {
AppLog.put("去除重复标题出错\n${e.localizedMessage}", e)
}
if (reSegment && book.getReSegment()) {
//重新分段
mContent = ContentHelp.reSegment(mContent, chapter.title)
}
if (includeTitle) {
//重新添加标题
mContent = chapter.getDisplayTitle() + "\n" + mContent
}
if (useReplace && book.getUseReplaceRule()) {
//替换
getReplaceRules().forEach { item ->
if (item.pattern.isNotEmpty()) {
try {
mContent = if (item.isRegex) {
mContent.replace(item.pattern.toRegex(), item.replacement)
} else {
mContent.replace(item.pattern, item.replacement)
}
} catch (e: Exception) {
AppLog.put("${item.name}替换出错\n${e.localizedMessage}")
appCtx.toastOnUi("${item.name}替换出错")
}
}
}
}
if (chineseConvert) {
//简繁转换
try {
when (AppConfig.chineseConverterType) {
1 -> mContent = ChineseUtils.t2s(mContent)
2 -> mContent = ChineseUtils.s2t(mContent)
}
} catch (e: Exception) {
appCtx.toastOnUi("简繁转换出错")
}
}
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 contents
}
}
@@ -0,0 +1,117 @@
package io.legado.app.help
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import io.legado.app.constant.AppConst
import io.legado.app.model.ReadAloud
import io.legado.app.utils.FileUtils
import io.legado.app.utils.getFile
import io.legado.app.utils.longToastOnUi
import io.legado.app.utils.msg
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
/**
* 异常管理类
*/
@Suppress("DEPRECATION")
class CrashHandler(val context: Context) : Thread.UncaughtExceptionHandler {
/**
* 系统默认UncaughtExceptionHandler
*/
private var mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler()
/**
* 存储异常和参数信息
*/
private val paramsMap = HashMap<String, String>()
/**
* 格式化时间
*/
@SuppressLint("SimpleDateFormat")
private val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss")
init {
//设置该CrashHandler为系统默认的
Thread.setDefaultUncaughtExceptionHandler(this)
}
/**
* uncaughtException 回调函数
*/
override fun uncaughtException(thread: Thread, ex: Throwable) {
ReadAloud.stop(context)
handleException(ex)
mDefaultHandler?.uncaughtException(thread, ex)
}
/**
* 处理该异常
*/
private fun handleException(ex: Throwable?) {
if (ex == null) return
//收集设备参数信息
collectDeviceInfo()
//保存日志文件
saveCrashInfo2File(ex)
context.longToastOnUi(ex.msg)
Thread.sleep(3000)
}
/**
* 收集设备参数信息
*/
private fun collectDeviceInfo() {
kotlin.runCatching {
//获取系统信息
paramsMap["MANUFACTURER"] = Build.MANUFACTURER
paramsMap["BRAND"] = Build.BRAND
//获取app版本信息
AppConst.appInfo.let {
paramsMap["versionName"] = it.versionName
paramsMap["versionCode"] = it.versionCode.toString()
}
}
}
/**
* 保存错误信息到文件中
*/
private 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 timestamp = System.currentTimeMillis()
val time = format.format(Date())
val fileName = "crash-$time-$timestamp.log"
context.externalCacheDir?.let { rootFile ->
rootFile.getFile("crash").listFiles()?.forEach {
if (it.lastModified() < System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)) {
it.delete()
}
}
FileUtils.createFileIfNotExist(rootFile, "crash", fileName)
.writeText(sb.toString())
}
}
}
@@ -0,0 +1,72 @@
package io.legado.app.help
import io.legado.app.data.appDb
import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.entities.RssSource
import io.legado.app.data.entities.TxtTocRule
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray
import splitties.init.appCtx
import java.io.File
object DefaultData {
const val httpTtsFileName = "httpTTS.json"
const val txtTocRuleFileName = "txtTocRule.json"
val httpTTS: List<HttpTTS> by lazy {
val json =
String(
appCtx.assets.open("defaultData${File.separator}$httpTtsFileName")
.readBytes()
)
HttpTTS.fromJsonArray(json)
}
val readConfigs: List<ReadBookConfig.Config> by lazy {
val json = String(
appCtx.assets.open("defaultData${File.separator}${ReadBookConfig.configFileName}")
.readBytes()
)
GSON.fromJsonArray(json)!!
}
val txtTocRules: List<TxtTocRule> by lazy {
val json = String(
appCtx.assets.open("defaultData${File.separator}$txtTocRuleFileName")
.readBytes()
)
GSON.fromJsonArray(json)!!
}
val themeConfigs: List<ThemeConfig.Config> by lazy {
val json = String(
appCtx.assets.open("defaultData${File.separator}${ThemeConfig.configFileName}")
.readBytes()
)
GSON.fromJsonArray(json)!!
}
val rssSources: List<RssSource> by lazy {
val json = String(
appCtx.assets.open("defaultData${File.separator}rssSources.json")
.readBytes()
)
RssSource.fromJsonArray(json)
}
fun importDefaultHttpTTS() {
appDb.httpTTSDao.deleteDefault()
appDb.httpTTSDao.insert(*httpTTS.toTypedArray())
}
fun importDefaultTocRules() {
appDb.txtTocRuleDao.deleteDefault()
appDb.txtTocRuleDao.insert(*txtTocRules.toTypedArray())
}
fun importDefaultRssSources() {
appDb.rssSourceDao.insert(*rssSources.toTypedArray())
}
}
@@ -0,0 +1,82 @@
package io.legado.app.help
import io.legado.app.model.NoStackTraceException
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.RuleData
import io.legado.app.utils.jsonPath
import io.legado.app.utils.readString
import splitties.init.appCtx
import java.io.File
object DirectLinkUpload {
private const val uploadUrlKey = "directLinkUploadUrl"
private const val downloadUrlRuleKey = "directLinkDownloadUrlRule"
private const val summaryKey = "directSummary"
suspend fun upLoad(fileName: String, file: Any, contentType: String): String {
val url = getUploadUrl()
if (url.isNullOrBlank()) {
throw NoStackTraceException("上传url未配置")
}
val downloadUrlRule = getDownloadUrlRule()
if (downloadUrlRule.isNullOrBlank()) {
throw NoStackTraceException("下载地址规则未配置")
}
val analyzeUrl = AnalyzeUrl(url)
val res = analyzeUrl.upload(fileName, file, contentType)
val analyzeRule = AnalyzeRule(RuleData()).setContent(res.body, res.url)
val downloadUrl = analyzeRule.getString(downloadUrlRule)
if (downloadUrl.isBlank()) {
throw NoStackTraceException("上传失败,${res.body}")
}
return downloadUrl
}
private val ruleDoc by lazy {
val json = String(
appCtx.assets.open("defaultData${File.separator}directLinkUpload.json")
.readBytes()
)
jsonPath.parse(json)
}
fun getUploadUrl(): String? {
return CacheManager.get(uploadUrlKey)
?: ruleDoc.readString("$.UploadUrl")
}
fun putUploadUrl(url: String) {
CacheManager.put(uploadUrlKey, url)
}
fun getDownloadUrlRule(): String? {
return CacheManager.get(downloadUrlRuleKey)
?: ruleDoc.readString("$.DownloadUrlRule")
}
fun putDownloadUrlRule(rule: String) {
CacheManager.put(downloadUrlRuleKey, rule)
}
fun getSummary(): String? {
return CacheManager.get(summaryKey)
?: ruleDoc.readString("summary")
}
fun putSummary(summary: String?) {
if (summary != null) {
CacheManager.put(summaryKey, summary)
} else {
CacheManager.delete(summaryKey)
}
}
fun delete() {
CacheManager.delete(uploadUrlKey)
CacheManager.delete(downloadUrlRuleKey)
CacheManager.delete(summaryKey)
}
}
@@ -0,0 +1,49 @@
package io.legado.app.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,31 @@
package io.legado.app.help
object IntentData {
private val bigData: MutableMap<String, Any> = mutableMapOf()
@Synchronized
fun put(key: String, data: Any?) {
data?.let {
bigData[key] = data
}
}
@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,35 @@
package io.legado.app.help
import android.content.Context
import android.content.Intent
import io.legado.app.R
import io.legado.app.utils.toastOnUi
@Suppress("unused")
object IntentHelp {
fun toTTSSetting(context: Context) {
//跳转到文字转语音设置界面
kotlin.runCatching {
val intent = Intent()
intent.action = "com.android.settings.TTS_SETTINGS"
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}.onFailure {
context.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,666 @@
package io.legado.app.help
import android.net.Uri
import android.util.Base64
import androidx.annotation.Keep
import io.legado.app.BuildConfig
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppConst.dateFormat
import io.legado.app.data.entities.BaseSource
import io.legado.app.help.http.*
import io.legado.app.model.Debug
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.QueryTTF
import io.legado.app.utils.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.jsoup.Connection
import org.jsoup.Jsoup
import splitties.init.appCtx
import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.URLEncoder
import java.nio.charset.Charset
import java.text.SimpleDateFormat
import java.util.*
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
/**
* js扩展类, 在js中通过java变量调用
* 所有对于文件的读写删操作都是相对路径,只能操作阅读缓存内的文件
* /android/data/{package}/cache/...
*/
@Keep
@Suppress("unused")
interface JsExtensions {
fun getSource(): BaseSource?
/**
* 访问网络,返回String
*/
fun ajax(urlStr: String): String? {
return runBlocking {
kotlin.runCatching {
val analyzeUrl = AnalyzeUrl(urlStr, source = getSource())
analyzeUrl.getStrResponseAwait().body
}.onFailure {
log("ajax(${urlStr}) error\n${it.stackTraceToString()}")
Timber.e(it)
}.getOrElse {
it.msg
}
}
}
/**
* 并发访问网络
*/
fun ajaxAll(urlList: Array<String>): Array<StrResponse?> {
return runBlocking {
val asyncArray = Array(urlList.size) {
async(IO) {
val url = urlList[it]
val analyzeUrl = AnalyzeUrl(url, source = getSource())
analyzeUrl.getStrResponseAwait()
}
}
val resArray = Array<StrResponse?>(urlList.size) {
asyncArray[it].await()
}
resArray
}
}
/**
* 访问网络,返回Response<String>
*/
fun connect(urlStr: String): StrResponse {
return runBlocking {
val analyzeUrl = AnalyzeUrl(urlStr, source = getSource())
kotlin.runCatching {
analyzeUrl.getStrResponseAwait()
}.onFailure {
log("connect(${urlStr}) error\n${it.stackTraceToString()}")
Timber.e(it)
}.getOrElse {
StrResponse(analyzeUrl.url, it.localizedMessage)
}
}
}
fun connect(urlStr: String, header: String?): StrResponse {
return runBlocking {
val headerMap = GSON.fromJsonObject<Map<String, String>>(header)
val analyzeUrl = AnalyzeUrl(urlStr, headerMapF = headerMap, source = getSource())
kotlin.runCatching {
analyzeUrl.getStrResponseAwait()
}.onFailure {
log("ajax($urlStr,$header) error\n${it.stackTraceToString()}")
Timber.e(it)
}.getOrElse {
StrResponse(analyzeUrl.url, it.localizedMessage)
}
}
}
/**
* 使用webView访问网络
* @param html 直接用webView载入的html, 如果html为空直接访问url
* @param url html内如果有相对路径的资源不传入url访问不了
* @param js 用来取返回值的js语句, 没有就返回整个源代码
* @return 返回js获取的内容
*/
fun webView(html: String?, url: String?, js: String?): String? {
return runBlocking {
BackstageWebView(
url = url,
html = html,
javaScript = js
).getStrResponse().body
}
}
/**
* 实现16进制字符串转文件
* @param content 需要转成文件的16进制字符串
* @param url 通过url里的参数来判断文件类型
* @return 相对路径
*/
fun downloadFile(content: String, url: String): String {
val type = AnalyzeUrl(url, source = getSource()).type ?: return ""
val zipPath = FileUtils.getPath(
FileUtils.createFolderIfNotExist(FileUtils.getCachePath()),
"${MD5Utils.md5Encode16(url)}.${type}"
)
FileUtils.deleteFile(zipPath)
val zipFile = FileUtils.createFileIfNotExist(zipPath)
StringUtils.hexStringToByte(content).let {
if (it.isNotEmpty()) {
zipFile.writeBytes(it)
}
}
return zipPath.substring(FileUtils.getCachePath().length)
}
/**
* js实现重定向拦截,网络访问get
*/
fun get(urlStr: String, headers: Map<String, String>): Connection.Response {
return Jsoup.connect(urlStr)
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
.ignoreContentType(true)
.followRedirects(false)
.headers(headers)
.method(Connection.Method.GET)
.execute()
}
/**
* 网络访问post
*/
fun post(urlStr: String, body: String, headers: Map<String, String>): Connection.Response {
return Jsoup.connect(urlStr)
.sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
.ignoreContentType(true)
.followRedirects(false)
.requestBody(body)
.headers(headers)
.method(Connection.Method.POST)
.execute()
}
/**
*js实现读取cookie
*/
fun getCookie(tag: String, key: String? = null): String {
val cookie = CookieStore.getCookie(tag)
val cookieMap = CookieStore.cookieToMap(cookie)
return if (key != null) {
cookieMap[key] ?: ""
} else {
cookie
}
}
/**
* js实现解码,不能删
*/
fun base64Decode(str: String): String {
return EncoderUtils.base64Decode(str, Base64.NO_WRAP)
}
fun base64Decode(str: String, flags: Int): String {
return EncoderUtils.base64Decode(str, flags)
}
fun base64DecodeToByteArray(str: String?): ByteArray? {
if (str.isNullOrBlank()) {
return null
}
return Base64.decode(str, Base64.DEFAULT)
}
fun base64DecodeToByteArray(str: String?, flags: Int): ByteArray? {
if (str.isNullOrBlank()) {
return null
}
return Base64.decode(str, flags)
}
fun base64Encode(str: String): String? {
return EncoderUtils.base64Encode(str, Base64.NO_WRAP)
}
fun base64Encode(str: String, flags: Int): String? {
return EncoderUtils.base64Encode(str, flags)
}
fun md5Encode(str: String): String {
return MD5Utils.md5Encode(str)
}
fun md5Encode16(str: String): String {
return MD5Utils.md5Encode16(str)
}
/**
* 格式化时间
*/
fun timeFormatUTC(time: Long, format: String, sh: Int): String? {
val utc = SimpleTimeZone(sh, "UTC")
return SimpleDateFormat(format, Locale.getDefault()).run {
timeZone = utc
format(Date(time))
}
}
/**
* 时间格式化
*/
fun timeFormat(time: Long): String {
return dateFormat.format(Date(time))
}
/**
* utf8编码转gbk编码
*/
fun utf8ToGbk(str: String): String {
val utf8 = String(str.toByteArray(charset("UTF-8")))
val unicode = String(utf8.toByteArray(), charset("UTF-8"))
return String(unicode.toByteArray(charset("GBK")))
}
fun encodeURI(str: String): String {
return try {
URLEncoder.encode(str, "UTF-8")
} catch (e: Exception) {
""
}
}
fun encodeURI(str: String, enc: String): String {
return try {
URLEncoder.encode(str, enc)
} catch (e: Exception) {
""
}
}
fun htmlFormat(str: String): String {
return HtmlFormatter.formatKeepImg(str)
}
//****************文件操作******************//
/**
* 获取本地文件
* @param path 相对路径
* @return File
*/
fun getFile(path: String): File {
val cachePath = appCtx.externalCache.absolutePath
val aPath = if (path.startsWith(File.separator)) {
cachePath + path
} else {
cachePath + File.separator + path
}
return File(aPath)
}
fun readFile(path: String): ByteArray? {
val file = getFile(path)
if (file.exists()) {
return file.readBytes()
}
return null
}
fun readTxtFile(path: String): String {
val file = getFile(path)
if (file.exists()) {
val charsetName = EncodingDetect.getEncode(file)
return String(file.readBytes(), charset(charsetName))
}
return ""
}
fun readTxtFile(path: String, charsetName: String): String {
val file = getFile(path)
if (file.exists()) {
return String(file.readBytes(), charset(charsetName))
}
return ""
}
/**
* 删除本地文件
*/
fun deleteFile(path: String) {
val file = getFile(path)
FileUtils.delete(file, true)
}
/**
* js实现压缩文件解压
* @param zipPath 相对路径
* @return 相对路径
*/
fun unzipFile(zipPath: String): String {
if (zipPath.isEmpty()) return ""
val unzipPath = FileUtils.getPath(
FileUtils.createFolderIfNotExist(FileUtils.getCachePath()),
FileUtils.getNameExcludeExtension(zipPath)
)
FileUtils.deleteFile(unzipPath)
val zipFile = getFile(zipPath)
val unzipFolder = FileUtils.createFolderIfNotExist(unzipPath)
ZipUtils.unzipFile(zipFile, unzipFolder)
FileUtils.deleteFile(zipFile.absolutePath)
return unzipPath.substring(FileUtils.getCachePath().length)
}
/**
* js实现文件夹内所有文件读取
*/
fun getTxtInFolder(unzipPath: String): String {
if (unzipPath.isEmpty()) return ""
val unzipFolder = getFile(unzipPath)
val contents = StringBuilder()
unzipFolder.listFiles().let {
if (it != null) {
for (f in it) {
val charsetName = EncodingDetect.getEncode(f)
contents.append(String(f.readBytes(), charset(charsetName)))
.append("\n")
}
contents.deleteCharAt(contents.length - 1)
}
}
FileUtils.deleteFile(unzipFolder.absolutePath)
return contents.toString()
}
/**
* 获取网络zip文件里面的数据
* @param url zip文件的链接或十六进制字符串
* @param path 所需获取文件在zip内的路径
* @return zip指定文件的数据
*/
fun getZipStringContent(url: String, path: String): String {
val byteArray = getZipByteArrayContent(url, path) ?: return ""
val charsetName = EncodingDetect.getEncode(byteArray)
return String(byteArray, Charset.forName(charsetName))
}
fun getZipStringContent(url: String, path: String, charsetName: String): String {
val byteArray = getZipByteArrayContent(url, path) ?: return ""
return String(byteArray, Charset.forName(charsetName))
}
/**
* 获取网络zip文件里面的数据
* @param url zip文件的链接或十六进制字符串
* @param path 所需获取文件在zip内的路径
* @return zip指定文件的数据
*/
fun getZipByteArrayContent(url: String, path: String): ByteArray? {
val bytes = if (url.startsWith("http://") || url.startsWith("https://")) {
runBlocking {
return@runBlocking okHttpClient.newCallResponseBody { url(url) }.bytes()
}
} else {
StringUtils.hexStringToByte(url)
}
val bos = ByteArrayOutputStream()
val zis = ZipInputStream(ByteArrayInputStream(bytes))
var entry: ZipEntry? = zis.nextEntry
while (entry != null) {
if (entry.name.equals(path)) {
zis.use { it.copyTo(bos) }
return bos.toByteArray()
}
entry = zis.nextEntry
}
log("getZipContent 未发现内容")
return null
}
//******************文件操作************************//
/**
* 解析字体,返回字体解析类
*/
fun queryBase64TTF(base64: String?): QueryTTF? {
base64DecodeToByteArray(base64)?.let {
return QueryTTF(it)
}
return null
}
/**
* 返回字体解析类
* @param str 支持url,本地文件,base64,自动判断,自动缓存
*/
fun queryTTF(str: String?): QueryTTF? {
str ?: return null
val key = md5Encode16(str)
var qTTF = CacheManager.getQueryTTF(key)
if (qTTF != null) return qTTF
val font: ByteArray? = when {
str.isAbsUrl() -> runBlocking {
var x = CacheManager.getByteArray(key)
if (x == null) {
x = okHttpClient.newCallResponseBody { url(str) }.bytes()
x.let {
CacheManager.put(key, it)
}
}
return@runBlocking x
}
str.isContentScheme() -> Uri.parse(str).readBytes(appCtx)
str.startsWith("/storage") -> File(str).readBytes()
else -> base64DecodeToByteArray(str)
}
font ?: return null
qTTF = QueryTTF(font)
CacheManager.put(key, qTTF)
return qTTF
}
/**
* @param text 包含错误字体的内容
* @param font1 错误的字体
* @param font2 正确的字体
*/
fun replaceFont(
text: String,
font1: QueryTTF?,
font2: QueryTTF?
): String {
if (font1 == null || font2 == null) return text
val contentArray = text.toCharArray()
contentArray.forEachIndexed { index, s ->
val oldCode = s.code
if (font1.inLimit(s)) {
val glyf = font1.getGlyfByCode(oldCode)
val code = font2.getCodeByGlyf(glyf)
if (code != 0) {
contentArray[index] = code.toChar()
}
}
}
return contentArray.joinToString("")
}
/**
* 输出调试日志
*/
fun log(msg: String): String {
getSource()?.let {
Debug.log(it.getKey(), msg)
} ?: Debug.log(msg)
if (BuildConfig.DEBUG) {
Timber.d(msg)
}
return msg
}
/**
* 输出对象类型
*/
fun logType(any: Any?) {
if (any == null) {
log("null")
} else {
log(any.javaClass.name)
}
}
/**
* 生成UUID
*/
fun randomUUID(): String {
return UUID.randomUUID().toString()
}
/**
* AES 解码为 ByteArray
* @param str 传入的AES加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesDecodeToByteArray(
str: String, key: String, transformation: String, iv: String
): ByteArray? {
return try {
EncoderUtils.decryptAES(
data = str.encodeToByteArray(),
key = key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
} catch (e: Exception) {
Timber.e(e)
log(e.localizedMessage ?: "aesDecodeToByteArrayERROR")
null
}
}
/**
* AES 解码为 String
* @param str 传入的AES加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesDecodeToString(
str: String, key: String, transformation: String, iv: String
): String? {
return aesDecodeToByteArray(str, key, transformation, iv)?.let { String(it) }
}
/**
* 已经base64的AES 解码为 ByteArray
* @param str 传入的AES Base64加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesBase64DecodeToByteArray(
str: String, key: String, transformation: String, iv: String
): ByteArray? {
return try {
EncoderUtils.decryptBase64AES(
str.encodeToByteArray(),
key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
} catch (e: Exception) {
Timber.e(e)
log(e.localizedMessage ?: "aesDecodeToByteArrayERROR")
null
}
}
/**
* 已经base64的AES 解码为 String
* @param str 传入的AES Base64加密的数据
* @param key AES 解密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesBase64DecodeToString(
str: String, key: String, transformation: String, iv: String
): String? {
return aesBase64DecodeToByteArray(str, key, transformation, iv)?.let { String(it) }
}
/**
* 加密aes为ByteArray
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToByteArray(
data: String, key: String, transformation: String, iv: String
): ByteArray? {
return try {
EncoderUtils.encryptAES(
data.encodeToByteArray(),
key = key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
} catch (e: Exception) {
Timber.e(e)
log(e.localizedMessage ?: "aesEncodeToByteArrayERROR")
null
}
}
/**
* 加密aes为String
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToString(
data: String, key: String, transformation: String, iv: String
): String? {
return aesEncodeToByteArray(data, key, transformation, iv)?.let { String(it) }
}
/**
* 加密aes后Base64化的ByteArray
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToBase64ByteArray(
data: String, key: String, transformation: String, iv: String
): ByteArray? {
return try {
EncoderUtils.encryptAES2Base64(
data.encodeToByteArray(),
key.encodeToByteArray(),
transformation,
iv.encodeToByteArray()
)
} catch (e: Exception) {
Timber.e(e)
log(e.localizedMessage ?: "aesEncodeToBase64ByteArrayERROR")
null
}
}
/**
* 加密aes后Base64化的String
* @param data 传入的原始数据
* @param key AES加密的key
* @param transformation AES加密的方式
* @param iv ECB模式的偏移向量
*/
fun aesEncodeToBase64String(
data: String, key: String, transformation: String, iv: String
): String? {
return aesEncodeToBase64ByteArray(data, key, transformation, iv)?.let { String(it) }
}
fun android(): String {
return AppConst.androidId
}
}

Some files were not shown because too many files have changed in this diff Show More