更多自定义设置 @325506 (#994)

* 第一次上传

* 新增深度个性化设置-可以给主题选择自定义字体了

* 新增深度个性化设置-存在背景图片时,应用栏和导航栏会对背景图片进行模糊处理

* 新增深度个性化设置-主题颜色高度自定义

* 新增深度个性化设置-新增边框设置

* 合并后bug修复

# Conflicts:
#	app/build.gradle.kts
#	app/google-services.json
#	app/src/main/java/io/legado/app/constant/PreferKey.kt
#	app/src/main/java/io/legado/app/di/appModule.kt
#	app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt
#	app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
#	app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt
#	app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt
#	app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
#	app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt
#	app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt
#	app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
#	app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt
#	app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt
#	app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
#	app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt
#	app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt
#	app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt
#	app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt
#	app/src/main/java/io/legado/app/ui/config/readConfig/PageKeySheet.kt
#	app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt
#	app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
#	app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt
#	app/src/main/java/io/legado/app/ui/main/MainActivity.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt
#	app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt
#	app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt
#	app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt
#	app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt
#	app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt
#	app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt
#	app/src/main/java/io/legado/app/ui/theme/AppBackground.kt
#	app/src/main/java/io/legado/app/ui/theme/AppTheme.kt
#	app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt
#	app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt
#	app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt
#	app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt
#	app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt
#	app/src/main/java/io/legado/app/ui/theme/Typography.kt
#	app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppTextField.kt
#	app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt
#	app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt
#	app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/ToggleChip.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/SettingCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/BookCover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/BookshelfCover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/Cover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt
#	app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt
#	app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt
#	app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt
#	app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt
#	app/src/main/res/drawable/ic_add.xml
#	app/src/main/res/drawable/ic_add_online.xml
#	app/src/main/res/drawable/ic_arrange.xml
#	app/src/main/res/drawable/ic_arrow_down.xml
#	app/src/main/res/drawable/ic_arrow_right.xml
#	app/src/main/res/drawable/ic_cfg_about.xml
#	app/src/main/res/drawable/ic_cfg_backup.xml
#	app/src/main/res/drawable/ic_cfg_other.xml
#	app/src/main/res/drawable/ic_cfg_replace.xml
#	app/src/main/res/drawable/ic_cfg_source.xml
#	app/src/main/res/drawable/ic_cfg_theme.xml
#	app/src/main/res/drawable/ic_cfg_web.xml
#	app/src/main/res/drawable/ic_groups.xml
#	app/src/main/res/drawable/ic_help.xml
#	app/src/main/res/drawable/ic_more.xml
#	app/src/main/res/drawable/ic_search.xml
#	app/src/main/res/drawable/ic_view_quilt.xml
#	app/src/main/res/layout/dialog_tip_config.xml
#	app/src/main/res/layout/view_preference.xml
#	app/src/main/res/layout/view_preference_category.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values-zh-rHK/strings.xml
#	app/src/main/res/values-zh-rTW/strings.xml
#	app/src/main/res/values/arrays.xml
#	app/src/main/res/values/strings.xml
#	app/src/main/res/xml/pref_main.xml
#	avd.sh
#	gradle.properties
#	gradle/libs.versions.toml
#	gradle/wrapper/gradle-wrapper.properties
#	gradlew
#	modules/web/index.html

* 边框设置bug修复

# Conflicts:
#	app/build.gradle.kts
#	app/google-services.json
#	app/src/main/java/io/legado/app/constant/PreferKey.kt
#	app/src/main/java/io/legado/app/di/appModule.kt
#	app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt
#	app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
#	app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt
#	app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt
#	app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
#	app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt
#	app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt
#	app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
#	app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt
#	app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt
#	app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
#	app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt
#	app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt
#	app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt
#	app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt
#	app/src/main/java/io/legado/app/ui/config/readConfig/PageKeySheet.kt
#	app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt
#	app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
#	app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt
#	app/src/main/java/io/legado/app/ui/main/MainActivity.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt
#	app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt
#	app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt
#	app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt
#	app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt
#	app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt
#	app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt
#	app/src/main/java/io/legado/app/ui/theme/AppBackground.kt
#	app/src/main/java/io/legado/app/ui/theme/AppTheme.kt
#	app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt
#	app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt
#	app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt
#	app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt
#	app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt
#	app/src/main/java/io/legado/app/ui/theme/Typography.kt
#	app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppTextField.kt
#	app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt
#	app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt
#	app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/ToggleChip.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/SettingCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/BookCover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/BookshelfCover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/Cover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt
#	app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt
#	app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt
#	app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt
#	app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt
#	app/src/main/res/drawable/ic_add.xml
#	app/src/main/res/drawable/ic_add_online.xml
#	app/src/main/res/drawable/ic_arrange.xml
#	app/src/main/res/drawable/ic_arrow_down.xml
#	app/src/main/res/drawable/ic_arrow_right.xml
#	app/src/main/res/drawable/ic_cfg_about.xml
#	app/src/main/res/drawable/ic_cfg_backup.xml
#	app/src/main/res/drawable/ic_cfg_other.xml
#	app/src/main/res/drawable/ic_cfg_replace.xml
#	app/src/main/res/drawable/ic_cfg_source.xml
#	app/src/main/res/drawable/ic_cfg_theme.xml
#	app/src/main/res/drawable/ic_cfg_web.xml
#	app/src/main/res/drawable/ic_groups.xml
#	app/src/main/res/drawable/ic_help.xml
#	app/src/main/res/drawable/ic_more.xml
#	app/src/main/res/drawable/ic_search.xml
#	app/src/main/res/drawable/ic_view_quilt.xml
#	app/src/main/res/layout/dialog_tip_config.xml
#	app/src/main/res/layout/view_preference.xml
#	app/src/main/res/layout/view_preference_category.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values-zh-rHK/strings.xml
#	app/src/main/res/values-zh-rTW/strings.xml
#	app/src/main/res/values/arrays.xml
#	app/src/main/res/values/strings.xml
#	app/src/main/res/xml/pref_main.xml
#	avd.sh
#	gradle.properties
#	gradle/libs.versions.toml
#	gradle/wrapper/gradle-wrapper.properties
#	gradlew
#	modules/web/index.html

* 实现播放音频显示歌词,但是布局需要调整

* 优化音频显示布局

* 合并代码

* 合并主题色设置,新增主题保存

* 修改一下颜色设置

* 边框bug修复

* 悬浮胶囊主题颜色修复

* 给发现和订阅项增加边框

* 内置浏览器设置非全屏

* 实现正则表达式匹配字段高亮

* 增加自定义正则表达式匹配颜色

* 正则表达式配置持久化保存

* 更新下载路径

* 修复png透明图片

* 修改当前版本号

* 同步lyc版本订阅源功能

* 同步lyc版本订阅源功能

* 合并后修复

* 书架样式调整,但有bug

* 合并后提交

* bug修复

* 订阅源日志修复

* 合并后修复

* 正文标题可以自定义字体

* 列表模式可以显示更多内容

* 实现自定义标签

* 主题背景和封面背景支持动图

* 自定义标签颜色

* 自定义标签颜色

* 主题保存bug修复

* 实现个性化主题设置的备份与恢复

* 页眉页脚可自定义字号,颜色,字体

* 边距设置

* bug修复

* 主题应用bug修复

* 虚线延展

* 模糊效果设置

* 字体页面新增搜索

* 描述性文字添加

* 列表简介内容显示

* 主题导入bug修复

* 标题可以自定义字体颜色

* 匹配自定义正则表达式规则文字的字体和颜色

* 匹配自定义正则表达式规则文字的字体和颜色bug修复

* 自定义底栏图标

* 自定义底栏图标bug修复

* 实现主题设置统一管理

* 实现主题设置统一管理界面美化

* 文件夹列表模式显示美化

* 底栏bug修复

* 底栏bug修复

* 底栏液化玻璃参数提取

* 主题管理删除当前阅读背景保存

* 主题管理bug修复

* 使用自定义封面时不动态取色

* 规范性更改

* 正确的数据库迁移schema

* 移动设置项

* 书架优化

* 书架优化

* 删除 EmptyMessageView.kt — 已有 EmptyMessage 完全覆盖且功能更全,该文件为无效重复

EmptyMessageView 与 EmptyMessage 实现了完全相同的空状态 UI(Kaomoji 动画、加载指示器、消息文字),但 EmptyMessageView 缺少 buttonText/buttonImageVector/onButtonClick 参数,无任何调用者。

* 消除重复代码:提取共享的 loadFontFiles、JsonObject 扩展函数,删除无效重复的 EmptyMessageView 和 BookGroupUi 常量

- 删除 EmptyMessageView.kt(已有 EmptyMessage 完全覆盖,零调用者)
- 提取 loadFontFiles 到 help/FontLoader.kt(消除 ThemeConfigScreen 和 FontSelectScreen 间的重复)
- 提取 JsonObject.getString/getInt/getFloat/getBoolean 到 utils/JsonExtensions.kt(消除 3 个配置类中的三份相同实现)
- 移除 BookGroupUi 中与 BookGroup 实体重复的 12 个 Id* 常量,改用直接引用

* 拆分大文件:提取 ThemePackSerializer 和 ThemeAppearanceJson 到独立文件

- 提取 ThemePackSerializer 到 help/config/ThemePackSerializer.kt(106 行),从 ThemePackConfig.kt 移除
- 提取 ThemeAppearanceJson 序列化对象到 help/config/ThemeAppearanceJson.kt(140 行),从 ThemeAppearanceConfig.kt 移除
- ThemePackConfig 从 440 行减少到约 325 行,ThemeAppearanceConfig 从 267 行减少到约 117 行

BookshelfViewModel 的拆分留待后续——涉及状态管理 refactor 需要更深入的并发模型理解。

* 提取共用模式:添加 SplicedColumnDivider 和 secondaryColorOr 共用函数

- SplicedColumnGroup.kt 新增 SplicedColumnDivider() composable,消除 5 个 SettingItem 文件中各 7 行重复的 divider 前导代码
- GlassDefaults.kt 新增 secondaryColorOr() composable,统一处理深度个性化 secondaryThemeColor 覆盖逻辑(该模式曾出现 12 次)

TypefaceLoader 提取跳过——三个调用点(PageView/TextColumn/ChapterProvider)在缓存策略和系统字体回退上存在差异,需更仔细的并发分析。

* 修复架构问题:移动 RegexColorRule 和 TagColorGenerator 到正确包,提取 Haze containerColor

- 将 RegexColorRule 从 ui/book/read/config 移至 help/config,消除 ReadBookConfig 对 UI 层的反向依赖
- 将 TagColorGenerator 从 help 移至 help/config,消除对 ui.config.themeConfig 的跨层级依赖
- HazeStyle 中 3 处重复的 containerColor 解析改用 GlassDefaults.secondaryColorOr

* 书架优化

* 书架优化

* 书架优化

---------

Co-authored-by: HapeLee <63206378+HapeLee@users.noreply.github.com>
This commit is contained in:
HlllllHandHapeLee authored and GitHub committed 2026-05-10 04:38:51 +08:00
1 parent 46c9496bdc
commit 64f7de8120
131 files changed
+8956 -1126

No files matched your search

+31 -1
View File
@@ -14,7 +14,37 @@
"Bash(cp -r \"D:/AndroidPrj/legado-with-MD3/.codex/skills/legado-compose-review/references\" \"D:/AndroidPrj/legado-with-MD3/.claude/skills/legado-compose-review/\")", "Bash(cp -r \"D:/AndroidPrj/legado-with-MD3/.codex/skills/legado-compose-review/references\" \"D:/AndroidPrj/legado-with-MD3/.claude/skills/legado-compose-review/\")",
"WebSearch", "WebSearch",
"Bash(./gradlew assembleAppDebug)", "Bash(./gradlew assembleAppDebug)",
"Bash(git checkout *)" "Bash(git checkout *)",
"Bash(./gradlew :app:compileAppDebugKotlin --no-daemon)",
"Bash(Select-Object -Last 50)",
"PowerShell(./gradlew :app:compileAppDebugKotlin --no-daemon 2>&1 | Select-Object -Last 50)",
"PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 30)",
"PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 40)",
"PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 15)",
"PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 5)",
"Bash(./gradlew assembleAppNoR8)",
"Bash(.\\\\gradlew.bat :app:compileAppDebugKotlin)",
"PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 30)",
"PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 15)",
"PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 10)",
"PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 8)",
"Bash(Get-ChildItem \"app/src/main/java/io/legado/app/ui/main/bookshelf/\" -Name)",
"Bash(Sort-Object)",
"Bash(git rm *)",
"Bash(git commit *)",
"Bash(Select-Object -Last 30)",
"Bash(./gradlew.bat :app:compileAppDebugKotlin)",
"Bash(git add *)",
"Bash(git mv *)",
"PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1)",
"WebFetch(domain:cs.android.com)",
"WebFetch(domain:github.com)",
"PowerShell(Get-ChildItem *)",
"PowerShell(Get-ChildItem -Path \"D:\\\\Android\\\\.gradle\\\\caches\\\\modules-2\\\\files-2.1\\\\androidx.navigation3\\\\navigation3-runtime-android\\\\1.0.1\" -Recurse | ForEach-Object { $_.FullName + \" \\(\" + $_.Length + \" bytes\\)\" })",
"Bash(jar tf *)",
"Read(//d/tmp/**)",
"Bash(jar xf *)",
"PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin)"
] ]
} }
} }
+3
View File
@@ -40,6 +40,9 @@ Transform tasks into verifiable goals:
## Build / Test / Run ## Build / Test / Run
```bash ```bash
# Quick compile check (Kotlin only, no dex/package — fastest for verifying code compiles)
.\gradlew.bat :app:compileAppDebugKotlin
# Assemble all variants # Assemble all variants
./gradlew assembleAppRelease ./gradlew assembleAppRelease
+1
View File
@@ -278,5 +278,6 @@ dependencies {
implementation(libs.miuix.shapes.android) implementation(libs.miuix.shapes.android)
implementation(libs.capsule) implementation(libs.capsule)
implementation(libs.backdrop) implementation(libs.backdrop)
implementation(libs.lyricViewx)
implementation(libs.timber) implementation(libs.timber)
} }
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,7 @@ package io.legado.app.constant
object PreferKey { object PreferKey {
const val language = "language" const val language = "language"
const val fontScale = "fontScale" const val fontScale = "fontScale"
const val appFontPath = "appFontPath"
const val appTheme = "app_theme" const val appTheme = "app_theme"
const val themeMode = "themeMode" const val themeMode = "themeMode"
const val userAgent = "userAgent" const val userAgent = "userAgent"
@@ -101,6 +102,7 @@ object PreferKey {
const val replaceEnableDefault = "replaceEnableDefault" const val replaceEnableDefault = "replaceEnableDefault"
const val showBrightnessView = "showBrightnessView" const val showBrightnessView = "showBrightnessView"
const val useUnderline = "useUnderline" const val useUnderline = "useUnderline"
const val regexColorRules = "regexColorRules"
const val adaptSpecialStyle = "adaptSpecialStyle" const val adaptSpecialStyle = "adaptSpecialStyle"
const val autoClearExpired = "autoClearExpired" const val autoClearExpired = "autoClearExpired"
const val autoChangeSource = "autoChangeSource" const val autoChangeSource = "autoChangeSource"
@@ -193,9 +195,32 @@ object PreferKey {
const val bottomBarOpacity = "bottomBarOpacity" const val bottomBarOpacity = "bottomBarOpacity"
const val enableBlur = "enableBlur" const val enableBlur = "enableBlur"
const val enableProgressiveBlur = "enableProgressiveBlur" const val enableProgressiveBlur = "enableProgressiveBlur"
const val topBarBlurRadius = "topBarBlurRadius"
const val bottomBarBlurRadius = "bottomBarBlurRadius"
const val topBarBlurAlpha = "topBarBlurAlpha"
const val bottomBarBlurAlpha = "bottomBarBlurAlpha"
const val bottomBarLensRadius = "bottomBarLensRadius"
const val useFlexibleTopAppBar = "useFlexibleTopAppBar" const val useFlexibleTopAppBar = "useFlexibleTopAppBar"
const val cBackground = "colorBackground" const val cBackground = "colorBackground"
const val cBBackground = "colorBottomBackground" const val cBBackground = "colorBottomBackground"
const val enableDeepPersonalization = "enableDeepPersonalization"
const val themeColor = "colorMD3Primary"
const val secondaryThemeColor = "colorMD3Secondary"
const val primaryTextColor = "colorMD3OnSurface"
const val secondaryTextColor = "colorMD3OnPrimaryContainer"
const val themeBackgroundColor = "colorMD3Background"
const val labelContainerColor = "colorMD3SurfaceContainerLow"
const val enableContainerBorder = "enableContainerBorder"
const val containerBorderWidth = "containerBorderWidth"
const val containerBorderStyle = "containerBorderStyle"
const val containerBorderColor = "containerBorderColor"
const val containerBorderDashWidth = "containerBorderDashWidth"
// 中间单线间隔设置
const val enableItemDivider = "enableItemDivider"
const val itemDividerWidth = "itemDividerWidth"
const val itemDividerLength = "itemDividerLength"
const val itemDividerColor = "itemDividerColor"
const val bookInfoInputColor = "colorBookInfoInput"
const val bgImage = "backgroundImage" const val bgImage = "backgroundImage"
const val bgImageBlurring = "backgroundImageBlurring" const val bgImageBlurring = "backgroundImageBlurring"
const val cNPrimary = "colorPrimaryNight" const val cNPrimary = "colorPrimaryNight"
@@ -274,8 +299,16 @@ object PreferKey {
const val showTip = "showTip" const val showTip = "showTip"
const val showBookCount = "showBookCount" const val showBookCount = "showBookCount"
const val showBookIntro = "showBookIntro"
const val bookshelfIntroMaxLines = "bookshelfIntroMaxLines"
const val sliderVibrator = "sliderVibrator" const val sliderVibrator = "sliderVibrator"
const val selectVibrator = "selectVibrator" const val selectVibrator = "selectVibrator"
const val audioCacheCleanTime = "audioCacheCleanTime" const val audioCacheCleanTime = "audioCacheCleanTime"
const val audioPreDownloadNum = "audioPreDownloadNum" const val audioPreDownloadNum = "audioPreDownloadNum"
const val customTagColors = "customTagColors"
const val enableCustomTagColors = "enableCustomTagColors"
const val navIconBookshelf = "navIconBookshelf"
const val navIconExplore = "navIconExplore"
const val navIconRss = "navIconRss"
const val navIconMy = "navIconMy"
} }
@@ -69,7 +69,7 @@ val appDb by lazy {
} }
@Database( @Database(
version = 85, version = 86,
exportSchema = true, exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -121,7 +121,8 @@ val appDb by lazy {
AutoMigration(from = 81, to = 82), AutoMigration(from = 81, to = 82),
AutoMigration(from = 82, to = 83), AutoMigration(from = 82, to = 83),
AutoMigration(from = 83, to = 84), AutoMigration(from = 83, to = 84),
AutoMigration(from = 84, to = 85) AutoMigration(from = 84, to = 85),
AutoMigration(from = 85, to = 86)
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -17,6 +17,11 @@ import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
data class GroupBookCount(
val groupId: Long,
val count: Int
)
@Dao @Dao
interface BookDao { interface BookDao {
@@ -91,7 +96,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
where type & ${BookType.text} > 0 where type & ${BookType.text} > 0
and type & ${BookType.local} = 0 and type & ${BookType.local} = 0
@@ -125,7 +132,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
ORDER BY durChapterTime DESC ORDER BY durChapterTime DESC
""" """
@@ -156,7 +165,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE type & ${BookType.audio} > 0 WHERE type & ${BookType.audio} > 0
""" """
@@ -187,7 +198,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE type & ${BookType.local} > 0 WHERE type & ${BookType.local} > 0
""" """
@@ -223,7 +236,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0 where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0
and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0
@@ -260,7 +275,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
where type & ${BookType.local} > 0 where type & ${BookType.local} > 0
and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0
@@ -292,7 +309,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE (`group` & :group) > 0 WHERE (`group` & :group) > 0
""" """
@@ -325,7 +344,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE name like '%'||:key||'%' or author like '%'||:key||'%' or originName like '%'||:key||'%' WHERE name like '%'||:key||'%' or author like '%'||:key||'%' or originName like '%'||:key||'%'
""" """
@@ -356,7 +377,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
where type & ${BookType.updateError} > 0 where type & ${BookType.updateError} > 0
order by durChapterTime desc order by durChapterTime desc
@@ -388,7 +411,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE durChapterIndex = 0 AND durChapterPos = 0 WHERE durChapterIndex = 0 AND durChapterPos = 0
""" """
@@ -419,7 +444,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
""" """
@@ -450,7 +477,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1 WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
""" """
@@ -481,7 +510,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE type & ${BookType.image} > 0 WHERE type & ${BookType.image} > 0
""" """
@@ -512,7 +543,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE type & ${BookType.text} > 0 WHERE type & ${BookType.text} > 0
""" """
@@ -627,4 +660,268 @@ interface BookDao {
@Query("delete from books where type & ${BookType.notShelf} > 0") @Query("delete from books where type & ${BookType.notShelf} > 0")
fun deleteNotShelfBook() fun deleteNotShelfBook()
// ── Group preview / count queries (DB-level, replaces in-memory buildGroupPreviewState) ──
@Query("SELECT COUNT(*) FROM books")
fun flowAllBookShelfCount(): Flow<Int>
@Query(
"""
SELECT ${BookGroup.IdAll} AS groupId, COUNT(*) AS count FROM books
UNION ALL SELECT ${BookGroup.IdRoot}, COUNT(*) FROM books
WHERE type & ${BookType.text} > 0 AND type & ${BookType.local} = 0
AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
AND (SELECT show FROM book_groups WHERE groupId = ${BookGroup.IdNetNone}) != 1
UNION ALL SELECT ${BookGroup.IdLocal}, COUNT(*) FROM books WHERE type & ${BookType.local} > 0
UNION ALL SELECT ${BookGroup.IdAudio}, COUNT(*) FROM books WHERE type & ${BookType.audio} > 0
UNION ALL SELECT ${BookGroup.IdNetNone}, COUNT(*) FROM books
WHERE type & ${BookType.audio} = 0 AND type & ${BookType.local} = 0
AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
UNION ALL SELECT ${BookGroup.IdLocalNone}, COUNT(*) FROM books
WHERE type & ${BookType.local} > 0
AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
UNION ALL SELECT ${BookGroup.IdManga}, COUNT(*) FROM books WHERE type & ${BookType.image} > 0
UNION ALL SELECT ${BookGroup.IdText}, COUNT(*) FROM books WHERE type & ${BookType.text} > 0
UNION ALL SELECT ${BookGroup.IdError}, COUNT(*) FROM books WHERE type & ${BookType.updateError} > 0
UNION ALL SELECT ${BookGroup.IdUnread}, COUNT(*) FROM books WHERE durChapterIndex = 0 AND durChapterPos = 0
UNION ALL SELECT ${BookGroup.IdReading}, COUNT(*) FROM books WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
UNION ALL SELECT ${BookGroup.IdReadFinished}, COUNT(*) FROM books WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
"""
)
fun flowSystemGroupCounts(): Flow<List<GroupBookCount>>
@Query("SELECT COUNT(*) FROM books WHERE (`group` & :groupId) > 0")
fun flowUserGroupBookCount(groupId: Long): Flow<Int>
fun flowGroupPreview(groupId: Long): Flow<List<BookShelfItem>> {
return when (groupId) {
BookGroup.IdRoot -> flowBookShelfRootPreview()
BookGroup.IdAll -> flowBookShelfPreview()
BookGroup.IdLocal -> flowBookShelfLocalPreview()
BookGroup.IdAudio -> flowBookShelfAudioPreview()
BookGroup.IdNetNone -> flowBookShelfNetNoGroupPreview()
BookGroup.IdLocalNone -> flowBookShelfLocalNoGroupPreview()
BookGroup.IdManga -> flowBookShelfMangaPreview()
BookGroup.IdText -> flowBookShelfTextPreview()
BookGroup.IdError -> flowBookShelfUpdateErrorPreview()
BookGroup.IdUnread -> flowBookShelfUnreadPreview()
BookGroup.IdReading -> flowBookShelfReadingPreview()
BookGroup.IdReadFinished -> flowBookShelfReadFinishedPreview()
else -> flowBookShelfPreviewByUserGroup(groupId)
}.map { list ->
list.filterNot { it.isNotShelf }
}
}
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE type & ${BookType.text} > 0 AND type & ${BookType.local} = 0
AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
AND (SELECT show FROM book_groups WHERE groupId = ${BookGroup.IdNetNone}) != 1
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfRootPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE type & ${BookType.local} > 0
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfLocalPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE type & ${BookType.audio} > 0
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfAudioPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE type & ${BookType.audio} = 0 AND type & ${BookType.local} = 0
AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfNetNoGroupPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE type & ${BookType.local} > 0
AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfLocalNoGroupPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE type & ${BookType.image} > 0
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfMangaPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE type & ${BookType.text} > 0
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfTextPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE type & ${BookType.updateError} > 0
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfUpdateErrorPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE durChapterIndex = 0 AND durChapterPos = 0
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfUnreadPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfReadingPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfReadFinishedPreview(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind
FROM books
WHERE (`group` & :groupId) > 0
ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
LIMIT 4
"""
)
fun flowBookShelfPreviewByUserGroup(groupId: Long): Flow<List<BookShelfItem>>
} }
@@ -86,12 +86,32 @@ data class RssSource(
var loadWithBaseUrl: Boolean = true, var loadWithBaseUrl: Boolean = true,
/**注入js**/ /**注入js**/
var injectJs: String? = null, var injectJs: String? = null,
/**提前预注入js**/
var preloadJs: String? = null,
/**web形式起始页**/
var startHtml: String? = null,
var startStyle: String? = null,
var startJs: String? = null,
/**是否输出web网页日志**/
@ColumnInfo(defaultValue = "0")
var showWebLog: Boolean = false,
/*其它规则*/ /*其它规则*/
/**最后更新时间,用于排序**/ /**最后更新时间,用于排序**/
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
var lastUpdateTime: Long = 0, var lastUpdateTime: Long = 0,
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
var customOrder: Int = 0, var customOrder: Int = 0,
/**类型 0网页,1图片,2视频**/
@ColumnInfo(defaultValue = "0")
var type: Int = 0,
/**是否启用预加载**/
@ColumnInfo(defaultValue = "0")
var preload: Boolean = false,
/**是否优先加载缓存**/
@ColumnInfo(defaultValue = "0")
var cacheFirst: Boolean = false,
/**搜索url**/
var searchUrl: String? = null,
//用户控制的重定向 //用户控制的重定向
@ColumnInfo(defaultValue = "ASK_CROSS_ORIGIN") @ColumnInfo(defaultValue = "ASK_CROSS_ORIGIN")
var redirectPolicy: String = "ASK_CROSS_ORIGIN" var redirectPolicy: String = "ASK_CROSS_ORIGIN"
@@ -143,6 +163,15 @@ data class RssSource(
&& equal(variableComment, source.variableComment) && equal(variableComment, source.variableComment)
&& equal(style, source.style) && equal(style, source.style)
&& equal(injectJs, source.injectJs) && equal(injectJs, source.injectJs)
&& equal(preloadJs, source.preloadJs)
&& equal(startHtml, source.startHtml)
&& equal(startStyle, source.startStyle)
&& equal(startJs, source.startJs)
&& showWebLog == source.showWebLog
&& type == source.type
&& preload == source.preload
&& cacheFirst == source.cacheFirst
&& equal(searchUrl, source.searchUrl)
} }
private fun equal(a: String?, b: String?): Boolean { private fun equal(a: String?, b: String?): Boolean {
@@ -0,0 +1,35 @@
package io.legado.app.help
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import io.legado.app.utils.FileDoc
import io.legado.app.utils.listFileDocs
import java.io.File
fun loadFontFiles(context: Context, folderUri: Uri?): List<FileDoc> {
val fontRegex = Regex("(?i).*\\.[ot]tf")
if (folderUri != null) {
val documentFile = DocumentFile.fromTreeUri(context, folderUri)
return documentFile?.let { docFile ->
val fontFiles = mutableListOf<FileDoc>()
docFile.listFiles()?.forEach { file ->
if (file.isFile && file.name?.matches(fontRegex) == true) {
fontFiles.add(
FileDoc(
name = file.name ?: "",
isDir = false,
size = file.length(),
lastModified = file.lastModified(),
uri = file.uri
)
)
}
}
fontFiles
} ?: emptyList()
} else {
val fontPath = "${context.getExternalFilesDir(null)?.absolutePath}/font"
return File(fontPath).listFileDocs { it.name.matches(fontRegex) }
}
}
@@ -1105,7 +1105,7 @@ interface JsExtensions : JsEncodeUtils {
* 弹窗提示 * 弹窗提示
*/ */
fun toast(msg: Any?) { fun toast(msg: Any?) {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
appCtx.toastForJs("${getSource()?.getTag()}: ${msg.toString()}") appCtx.toastForJs("${getSource()?.getTag()}: ${msg.toString()}")
} }
@@ -1113,7 +1113,7 @@ interface JsExtensions : JsEncodeUtils {
* 弹窗提示 停留时间较长 * 弹窗提示 停留时间较长
*/ */
fun longToast(msg: Any?) { fun longToast(msg: Any?) {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
appCtx.longToastForJs("${getSource()?.getTag()}: ${msg.toString()}") appCtx.longToastForJs("${getSource()?.getTag()}: ${msg.toString()}")
} }
@@ -0,0 +1,144 @@
package io.legado.app.help.config
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import io.legado.app.utils.FileUtils
import io.legado.app.utils.getString
import splitties.init.appCtx
import java.io.File
import java.lang.reflect.Type
object PersonalizationThemeConfig {
const val configFileName = "personalizationThemeConfig.json"
val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName)
private val THEME_GSON: Gson by lazy {
GsonBuilder()
.registerTypeAdapter(Config::class.java, ConfigSerializer())
.setPrettyPrinting()
.disableHtmlEscaping()
.create()
}
private val _configList: ArrayList<Config> by lazy {
ArrayList(getConfigs() ?: emptyList())
}
val configList: List<Config> get() = _configList
fun save() {
val json = THEME_GSON.toJson(_configList)
FileUtils.delete(configFilePath)
FileUtils.createFileIfNotExist(configFilePath).writeText(json)
}
fun toJson(config: Config): String {
return THEME_GSON.toJson(config)
}
fun toJson(): String {
return THEME_GSON.toJson(_configList)
}
fun fromJson(json: String): Boolean {
return kotlin.runCatching {
val configs = THEME_GSON.fromJson(json, Array<Config>::class.java)
_configList.clear()
_configList.addAll(configs)
save()
true
}.getOrDefault(false)
}
fun delConfig(index: Int) {
if (index in _configList.indices) {
_configList.removeAt(index)
save()
}
}
fun addConfig(json: String): Boolean {
return kotlin.runCatching {
val config = THEME_GSON.fromJson(json, Config::class.java)
addConfig(config)
true
}.getOrDefault(false)
}
fun addConfig(newConfig: Config) {
_configList.forEachIndexed { index, config ->
if (newConfig.themeName == config.themeName) {
_configList[index] = newConfig
save()
return
}
}
_configList.add(newConfig)
save()
}
private fun getConfigs(): List<Config>? {
val configFile = File(configFilePath)
if (configFile.exists()) {
kotlin.runCatching {
val json = configFile.readText()
return THEME_GSON.fromJson(json, Array<Config>::class.java).toList()
}
}
return null
}
fun applyConfig(config: Config) {
config.appearance.applyToThemeConfig()
}
fun savePersonalizationTheme(name: String): Config {
val config = Config(
themeName = name,
appearance = ThemeAppearanceConfig.fromCurrent()
)
addConfig(config)
return config
}
data class Config(
var themeName: String = "",
var appearance: ThemeAppearanceConfig = ThemeAppearanceConfig()
) {
val themeColor: Int get() = appearance.themeColor
}
}
class ConfigSerializer : JsonSerializer<PersonalizationThemeConfig.Config>,
JsonDeserializer<PersonalizationThemeConfig.Config> {
override fun serialize(
src: PersonalizationThemeConfig.Config,
typeOfSrc: Type,
context: JsonSerializationContext
): JsonElement {
val obj = JsonObject()
obj.addProperty("themeName", src.themeName)
ThemeAppearanceJson.writeTo(obj, src.appearance)
return obj
}
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): PersonalizationThemeConfig.Config {
val obj = json.asJsonObject
return PersonalizationThemeConfig.Config(
themeName = obj.getString("themeName", "") ?: "",
appearance = ThemeAppearanceJson.readFrom(obj)
)
}
}
@@ -26,10 +26,12 @@ import io.legado.app.utils.getFile
import io.legado.app.utils.getMeanColor import io.legado.app.utils.getMeanColor
import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefInt import io.legado.app.utils.getPrefInt
import io.legado.app.utils.getPrefString
import io.legado.app.utils.hexString import io.legado.app.utils.hexString
import io.legado.app.utils.printOnDebug import io.legado.app.utils.printOnDebug
import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.putPrefBoolean
import io.legado.app.utils.putPrefInt import io.legado.app.utils.putPrefInt
import io.legado.app.utils.putPrefString
import io.legado.app.utils.resizeAndRecycle import io.legado.app.utils.resizeAndRecycle
import splitties.init.appCtx import splitties.init.appCtx
import java.io.File import java.io.File
@@ -227,6 +229,12 @@ object ReadBookConfig {
} }
} }
val regexColorRules: ArrayList<RegexColorRule> get() = durConfig.regexColorRules
fun saveRegexColorRules() {
save()
}
/** /**
* 两端对齐 * 两端对齐
*/ */
@@ -260,6 +268,36 @@ object ReadBookConfig {
config.textFont = value config.textFont = value
} }
var titleFont: String
get() = config.titleFont
set(value) {
config.titleFont = value
}
var headerFont: String
get() = config.headerFont
set(value) {
config.headerFont = value
}
var footerFont: String
get() = config.footerFont
set(value) {
config.footerFont = value
}
var headerFontSize: Int
get() = config.headerFontSize.takeIf { it > 0 } ?: 12
set(value) {
config.headerFontSize = value
}
var footerFontSize: Int
get() = config.footerFontSize.takeIf { it > 0 } ?: 12
set(value) {
config.footerFontSize = value
}
var textBold: Int var textBold: Int
get() = config.textBold get() = config.textBold
set(value) { set(value) {
@@ -393,6 +431,12 @@ object ReadBookConfig {
config.titleBottomSpacing = value config.titleBottomSpacing = value
} }
var titleColor: Int
get() = config.titleColor
set(value) {
config.titleColor = value
}
var paragraphIndent: String var paragraphIndent: String
get() = config.paragraphIndent get() = config.paragraphIndent
set(value) { set(value) {
@@ -417,6 +461,12 @@ object ReadBookConfig {
config.underlinePadding = value config.underlinePadding = value
} }
var underlineExtend: Boolean
get() = config.underlineExtend
set(value) {
config.underlineExtend = value
}
var dottedLine: Boolean var dottedLine: Boolean
get() = config.dottedLine get() = config.dottedLine
set(value) { set(value) {
@@ -544,9 +594,14 @@ object ReadBookConfig {
} }
fun getExportConfig(): Config { fun getExportConfig(): Config {
val exportConfig = durConfig.copy() val exportConfig = durConfig.copy(regexColorRules = ArrayList(durConfig.regexColorRules.map { it.copy() }))
if (shareLayout) { if (shareLayout) {
exportConfig.textFont = shareConfig.textFont exportConfig.textFont = shareConfig.textFont
exportConfig.titleFont = shareConfig.titleFont
exportConfig.headerFont = shareConfig.headerFont
exportConfig.footerFont = shareConfig.footerFont
exportConfig.headerFontSize = shareConfig.headerFontSize
exportConfig.footerFontSize = shareConfig.footerFontSize
exportConfig.textBold = shareConfig.textBold exportConfig.textBold = shareConfig.textBold
exportConfig.textSize = shareConfig.textSize exportConfig.textSize = shareConfig.textSize
exportConfig.letterSpacing = shareConfig.letterSpacing exportConfig.letterSpacing = shareConfig.letterSpacing
@@ -556,6 +611,7 @@ object ReadBookConfig {
exportConfig.titleSize = shareConfig.titleSize exportConfig.titleSize = shareConfig.titleSize
exportConfig.titleTopSpacing = shareConfig.titleTopSpacing exportConfig.titleTopSpacing = shareConfig.titleTopSpacing
exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing
exportConfig.titleColor = shareConfig.titleColor
exportConfig.paddingBottom = shareConfig.paddingBottom exportConfig.paddingBottom = shareConfig.paddingBottom
exportConfig.paddingLeft = shareConfig.paddingLeft exportConfig.paddingLeft = shareConfig.paddingLeft
exportConfig.paddingRight = shareConfig.paddingRight exportConfig.paddingRight = shareConfig.paddingRight
@@ -576,7 +632,8 @@ object ReadBookConfig {
exportConfig.tipFooterLeft = shareConfig.tipFooterLeft exportConfig.tipFooterLeft = shareConfig.tipFooterLeft
exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle
exportConfig.tipFooterRight = shareConfig.tipFooterRight exportConfig.tipFooterRight = shareConfig.tipFooterRight
exportConfig.tipColor = shareConfig.tipColor exportConfig.tipHeaderColor = shareConfig.tipHeaderColor
exportConfig.tipFooterColor = shareConfig.tipFooterColor
exportConfig.headerMode = shareConfig.headerMode exportConfig.headerMode = shareConfig.headerMode
// MD3专有属性 // MD3专有属性
exportConfig.footerMode = shareConfig.footerMode exportConfig.footerMode = shareConfig.footerMode
@@ -628,6 +685,20 @@ object ReadBookConfig {
config.textFont = "" config.textFont = ""
} }
} }
if (config.titleFont.isNotEmpty()) {
val fontName = config.titleFont
val fontPath =
FileUtils.getPath(appCtx.externalFiles, "font", fontName)
val fontFile = configDir.getFile(fontName)
if (fontFile.exists()) {
if (!FileUtils.exist(fontPath)) {
fontFile.copyTo(File(fontPath))
}
config.titleFont = fontPath
} else {
config.titleFont = ""
}
}
if (config.bgType == 2) { if (config.bgType == 2) {
val bgName = FileUtils.getName(config.bgStr) val bgName = FileUtils.getName(config.bgStr)
config.bgStr = bgName config.bgStr = bgName
@@ -688,6 +759,11 @@ object ReadBookConfig {
private var pageAnim: Int = 0,//翻页动画 private var pageAnim: Int = 0,//翻页动画
private var pageAnimEInk: Int = 4, private var pageAnimEInk: Int = 4,
var textFont: String = "",//字体 var textFont: String = "",//字体
var titleFont: String = "",//标题字体
var headerFont: String = "",//页眉字体
var footerFont: String = "",//页脚字体
var headerFontSize: Int = 12,//页眉字号
var footerFontSize: Int = 12,//页脚字号
var textBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体 var textBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
var textSize: Int = 20,//文字大小 var textSize: Int = 20,//文字大小
var textItalic: Boolean = false,// 是否启用斜体 var textItalic: Boolean = false,// 是否启用斜体
@@ -704,6 +780,7 @@ object ReadBookConfig {
var titleSize: Int = 0, var titleSize: Int = 0,
var titleTopSpacing: Int = 0, var titleTopSpacing: Int = 0,
var titleBottomSpacing: Int = 0, var titleBottomSpacing: Int = 0,
var titleColor: Int = 0,
var titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体 var titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
var titleLineSpacingExtra: Int = 12, var titleLineSpacingExtra: Int = 12,
var titleLineSpacingSub: Int = 12, var titleLineSpacingSub: Int = 12,
@@ -715,6 +792,7 @@ object ReadBookConfig {
var underline: Boolean = false, //下划线 var underline: Boolean = false, //下划线
var underlinePadding: Int = 10, var underlinePadding: Int = 10,
var underlineHeight: Int = 1, var underlineHeight: Int = 1,
var underlineExtend: Boolean = false, //下划线延伸
var underlineColor: String = "#3E3D3B", var underlineColor: String = "#3E3D3B",
var underlineColorNight: String = "#ADADAD", var underlineColorNight: String = "#ADADAD",
var dottedLine: Boolean = false, //虚线 var dottedLine: Boolean = false, //虚线
@@ -740,10 +818,12 @@ object ReadBookConfig {
var tipFooterLeft: Int = ReadTipConfig.chapterTitle, var tipFooterLeft: Int = ReadTipConfig.chapterTitle,
var tipFooterMiddle: Int = ReadTipConfig.none, var tipFooterMiddle: Int = ReadTipConfig.none,
var tipFooterRight: Int = ReadTipConfig.pageAndTotal, var tipFooterRight: Int = ReadTipConfig.pageAndTotal,
var tipColor: Int = 0, var tipHeaderColor: Int = 0,
var tipFooterColor: Int = 0,
var tipDividerColor: Int = -1, var tipDividerColor: Int = -1,
var headerMode: Int = 0, var headerMode: Int = 0,
var footerMode: Int = 0 var footerMode: Int = 0,
var regexColorRules: ArrayList<RegexColorRule> = arrayListOf()
) { ) {
@Transient @Transient
@@ -821,6 +901,11 @@ object ReadBookConfig {
"pageAnim" to pageAnim, "pageAnim" to pageAnim,
"pageAnimEInk" to pageAnimEInk, "pageAnimEInk" to pageAnimEInk,
"textFont" to textFont, "textFont" to textFont,
"titleFont" to titleFont,
"headerFont" to headerFont,
"footerFont" to footerFont,
"headerFontSize" to headerFontSize,
"footerFontSize" to footerFontSize,
"textBold" to textBold, "textBold" to textBold,
"textSize" to textSize, "textSize" to textSize,
"letterSpacing" to letterSpacing, "letterSpacing" to letterSpacing,
@@ -830,6 +915,7 @@ object ReadBookConfig {
"titleSize" to titleSize, "titleSize" to titleSize,
"titleTopSpacing" to titleTopSpacing, "titleTopSpacing" to titleTopSpacing,
"titleBottomSpacing" to titleBottomSpacing, "titleBottomSpacing" to titleBottomSpacing,
"titleColor" to titleColor,
"paragraphIndent" to paragraphIndent, "paragraphIndent" to paragraphIndent,
"paddingBottom" to paddingBottom, "paddingBottom" to paddingBottom,
"paddingLeft" to paddingLeft, "paddingLeft" to paddingLeft,
@@ -851,10 +937,12 @@ object ReadBookConfig {
"tipFooterLeft" to tipFooterLeft, "tipFooterLeft" to tipFooterLeft,
"tipFooterMiddle" to tipFooterMiddle, "tipFooterMiddle" to tipFooterMiddle,
"tipFooterRight" to tipFooterRight, "tipFooterRight" to tipFooterRight,
"tipColor" to tipColor, "tipHeaderColor" to tipHeaderColor,
"tipFooterColor" to tipFooterColor,
"tipDividerColor" to tipDividerColor, "tipDividerColor" to tipDividerColor,
"headerMode" to headerMode, "headerMode" to headerMode,
"footerMode" to footerMode "footerMode" to footerMode,
"regexColorRules" to regexColorRules.map { mapOf("name" to it.name, "pattern" to it.pattern, "color" to it.color, "fontPath" to it.fontPath) }
) )
fun getBgPath(bgIndex: Int): String? { fun getBgPath(bgIndex: Int): String? {
@@ -85,10 +85,16 @@ object ReadTipConfig {
ReadBookConfig.config.footerMode = value ReadBookConfig.config.footerMode = value
} }
var tipColor: Int var tipHeaderColor: Int
get() = ReadBookConfig.config.tipColor get() = ReadBookConfig.config.tipHeaderColor
set(value) { set(value) {
ReadBookConfig.config.tipColor = value ReadBookConfig.config.tipHeaderColor = value
}
var tipFooterColor: Int
get() = ReadBookConfig.config.tipFooterColor
set(value) {
ReadBookConfig.config.tipFooterColor = value
} }
var tipDividerColor: Int var tipDividerColor: Int
@@ -0,0 +1,8 @@
package io.legado.app.help.config
data class RegexColorRule(
var name: String,
var pattern: String,
var color: Int,
var fontPath: String = ""
)
@@ -0,0 +1,33 @@
package io.legado.app.help.config
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.graphics.ColorUtils
import io.legado.app.ui.config.themeConfig.TagColorPair
object TagColorGenerator {
fun generateTagColors(baseColor: Color): List<TagColorPair> {
val baseHsl = FloatArray(3)
ColorUtils.colorToHSL(baseColor.toArgb(), baseHsl)
val baseHue = baseHsl[0]
val result = mutableListOf<TagColorPair>()
val hueOffsets = listOf(0, 45, 90, 135, 180, 225, 270, 315)
hueOffsets.forEach { offset ->
val newHue = (baseHue + offset) % 360
val textHsl = floatArrayOf(newHue, 0.75f, 0.45f)
val textColor = Color.hsl(textHsl[0], textHsl[1], textHsl[2]).toArgb()
val bgHsl = floatArrayOf(newHue, 0.35f, 0.90f)
val bgColor = Color.hsl(bgHsl[0], bgHsl[1], bgHsl[2]).toArgb()
result.add(TagColorPair(textColor, bgColor))
}
return result
}
}
@@ -0,0 +1,109 @@
package io.legado.app.help.config
import io.legado.app.ui.config.themeConfig.ThemeConfig
data class ThemeAppearanceConfig(
var enableDeepPersonalization: Boolean = false,
var themeColor: Int = 0,
var secondaryThemeColor: Int = 0,
var primaryTextColor: Int = 0,
var secondaryTextColor: Int = 0,
var backgroundColor: Int = 0,
var labelContainerColor: Int = 0,
var bookInfoInputColor: Int = 0,
var appFontPath: String? = null,
var enableContainerBorder: Boolean = false,
var containerBorderWidth: Float = 1f,
var containerBorderStyle: String = "solid",
var containerBorderDashWidth: Float = 4f,
var containerBorderColor: Int = 0,
var enableItemDivider: Boolean = true,
var itemDividerWidth: Float = 1f,
var itemDividerLength: Float = 80f,
var itemDividerColor: Int = 0,
var enableCustomTagColors: Boolean = false,
var customTagColorsJson: String? = null,
var enableBlur: Boolean = false,
var topBarBlurRadius: Int = 24,
var bottomBarBlurRadius: Int = 8,
var topBarBlurAlpha: Int = 73,
var bottomBarBlurAlpha: Int = 40,
var bottomBarLensRadius: Float = 24f
) {
val hasDeepColorOverrides: Boolean
get() = themeColor != 0 ||
secondaryThemeColor != 0 ||
primaryTextColor != 0 ||
secondaryTextColor != 0 ||
backgroundColor != 0 ||
labelContainerColor != 0
fun applyToThemeConfig(appFontPathOverride: String? = appFontPath) {
ThemeConfig.enableDeepPersonalization = enableDeepPersonalization
ThemeConfig.themeColor = themeColor
ThemeConfig.secondaryThemeColor = secondaryThemeColor
ThemeConfig.primaryTextColor = primaryTextColor
ThemeConfig.secondaryTextColor = secondaryTextColor
ThemeConfig.themeBackgroundColor = backgroundColor
ThemeConfig.labelContainerColor = labelContainerColor
ThemeConfig.bookInfoInputColor = bookInfoInputColor
ThemeConfig.appFontPath = appFontPathOverride
ThemeConfig.enableContainerBorder = enableContainerBorder
ThemeConfig.containerBorderWidth = containerBorderWidth
ThemeConfig.containerBorderStyle = containerBorderStyle.ifBlank { "solid" }
ThemeConfig.containerBorderDashWidth = containerBorderDashWidth
ThemeConfig.containerBorderColor = containerBorderColor
ThemeConfig.enableItemDivider = enableItemDivider
ThemeConfig.itemDividerWidth = itemDividerWidth
ThemeConfig.itemDividerLength = itemDividerLength
ThemeConfig.itemDividerColor = itemDividerColor
ThemeConfig.enableCustomTagColors = enableCustomTagColors
ThemeConfig.customTagColorsJson = ThemeAppearanceJson.convertTagColorsFromHex(customTagColorsJson)
ThemeConfig.enableBlur = enableBlur
ThemeConfig.topBarBlurRadius = topBarBlurRadius
ThemeConfig.bottomBarBlurRadius = bottomBarBlurRadius
ThemeConfig.topBarBlurAlpha = topBarBlurAlpha
ThemeConfig.bottomBarBlurAlpha = bottomBarBlurAlpha
ThemeConfig.bottomBarLensRadius = bottomBarLensRadius
}
companion object {
fun fromCurrent(): ThemeAppearanceConfig {
return ThemeAppearanceConfig(
enableDeepPersonalization = ThemeConfig.enableDeepPersonalization,
themeColor = ThemeConfig.themeColor,
secondaryThemeColor = ThemeConfig.secondaryThemeColor,
primaryTextColor = ThemeConfig.primaryTextColor,
secondaryTextColor = ThemeConfig.secondaryTextColor,
backgroundColor = ThemeConfig.themeBackgroundColor,
labelContainerColor = ThemeConfig.labelContainerColor,
bookInfoInputColor = ThemeConfig.bookInfoInputColor,
appFontPath = ThemeConfig.appFontPath,
enableContainerBorder = ThemeConfig.enableContainerBorder,
containerBorderWidth = ThemeConfig.containerBorderWidth,
containerBorderStyle = ThemeConfig.containerBorderStyle,
containerBorderDashWidth = ThemeConfig.containerBorderDashWidth,
containerBorderColor = ThemeConfig.containerBorderColor,
enableItemDivider = ThemeConfig.enableItemDivider,
itemDividerWidth = ThemeConfig.itemDividerWidth,
itemDividerLength = ThemeConfig.itemDividerLength,
itemDividerColor = ThemeConfig.itemDividerColor,
enableCustomTagColors = ThemeConfig.enableCustomTagColors,
customTagColorsJson = ThemeConfig.customTagColorsJson,
enableBlur = ThemeConfig.enableBlur,
topBarBlurRadius = ThemeConfig.topBarBlurRadius,
bottomBarBlurRadius = ThemeConfig.bottomBarBlurRadius,
topBarBlurAlpha = ThemeConfig.topBarBlurAlpha,
bottomBarBlurAlpha = ThemeConfig.bottomBarBlurAlpha,
bottomBarLensRadius = ThemeConfig.bottomBarLensRadius
)
}
}
}
@@ -0,0 +1,159 @@
package io.legado.app.help.config
import androidx.core.graphics.toColorInt
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import io.legado.app.ui.config.themeConfig.TagColorPair
import io.legado.app.utils.GSON
import io.legado.app.utils.getBoolean
import io.legado.app.utils.getFloat
import io.legado.app.utils.getString
object ThemeAppearanceJson {
fun writeTo(obj: JsonObject, src: ThemeAppearanceConfig) {
obj.addProperty("enableDeepPersonalization", src.enableDeepPersonalization)
obj.addProperty("themeColor", colorToHex(src.themeColor))
obj.addProperty("secondaryThemeColor", colorToHex(src.secondaryThemeColor))
obj.addProperty("primaryTextColor", colorToHex(src.primaryTextColor))
obj.addProperty("secondaryTextColor", colorToHex(src.secondaryTextColor))
obj.addProperty("backgroundColor", colorToHex(src.backgroundColor))
obj.addProperty("labelContainerColor", colorToHex(src.labelContainerColor))
obj.addProperty("bookInfoInputColor", colorToHex(src.bookInfoInputColor))
obj.addProperty("appFontPath", src.appFontPath)
obj.addProperty("enableContainerBorder", src.enableContainerBorder)
obj.addProperty("containerBorderWidth", src.containerBorderWidth)
obj.addProperty("containerBorderStyle", src.containerBorderStyle)
obj.addProperty("containerBorderDashWidth", src.containerBorderDashWidth)
obj.addProperty("containerBorderColor", colorToHex(src.containerBorderColor))
obj.addProperty("enableItemDivider", src.enableItemDivider)
obj.addProperty("itemDividerWidth", src.itemDividerWidth)
obj.addProperty("itemDividerLength", src.itemDividerLength)
obj.addProperty("itemDividerColor", colorToHex(src.itemDividerColor))
obj.addProperty("enableCustomTagColors", src.enableCustomTagColors)
obj.addProperty("customTagColorsJson", convertTagColorsToHex(src.customTagColorsJson))
obj.addProperty("enableBlur", src.enableBlur)
obj.addProperty("topBarBlurRadius", src.topBarBlurRadius)
obj.addProperty("bottomBarBlurRadius", src.bottomBarBlurRadius)
obj.addProperty("topBarBlurAlpha", src.topBarBlurAlpha)
obj.addProperty("bottomBarBlurAlpha", src.bottomBarBlurAlpha)
obj.addProperty("bottomBarLensRadius", src.bottomBarLensRadius)
}
fun readFrom(obj: JsonObject): ThemeAppearanceConfig {
val appearance = ThemeAppearanceConfig(
themeColor = readColor(obj, "themeColor", "md3Primary"),
secondaryThemeColor = readColor(obj, "secondaryThemeColor", "md3Secondary", "md3OnPrimary"),
primaryTextColor = readColor(obj, "primaryTextColor", "md3OnSurface"),
secondaryTextColor = readColor(obj, "secondaryTextColor", "md3OnPrimaryContainer"),
backgroundColor = readColor(obj, "backgroundColor", "md3Background"),
labelContainerColor = readColor(obj, "labelContainerColor", "md3SurfaceContainerLow"),
bookInfoInputColor = readColor(obj, "bookInfoInputColor"),
appFontPath = obj.getString("appFontPath", null),
enableContainerBorder = obj.getBoolean("enableContainerBorder", false),
containerBorderWidth = obj.getFloat("containerBorderWidth", 1f),
containerBorderStyle = obj.getString("containerBorderStyle", "solid") ?: "solid",
containerBorderDashWidth = obj.getFloat("containerBorderDashWidth", 4f),
containerBorderColor = readColor(obj, "containerBorderColor"),
enableItemDivider = obj.getBoolean("enableItemDivider", true),
itemDividerWidth = obj.getFloat("itemDividerWidth", 1f),
itemDividerLength = obj.getFloat("itemDividerLength", 80f),
itemDividerColor = readColor(obj, "itemDividerColor"),
enableCustomTagColors = obj.getBoolean("enableCustomTagColors", false),
customTagColorsJson = obj.getString("customTagColorsJson", null),
enableBlur = obj.getBoolean("enableBlur", false),
topBarBlurRadius = obj.getInt("topBarBlurRadius", 24),
bottomBarBlurRadius = obj.getInt("bottomBarBlurRadius", 8),
topBarBlurAlpha = obj.getInt("topBarBlurAlpha", 73),
bottomBarBlurAlpha = obj.getInt("bottomBarBlurAlpha", 40),
bottomBarLensRadius = obj.getFloat("bottomBarLensRadius", 24f)
)
appearance.enableDeepPersonalization = if (obj.has("enableDeepPersonalization")) {
obj.getBoolean("enableDeepPersonalization", false)
} else {
appearance.hasDeepColorOverrides
}
return appearance
}
fun convertTagColorsFromHex(json: String?): String? {
if (json.isNullOrBlank()) return json
return kotlin.runCatching {
val root = JsonParser.parseString(json)
if (root.isJsonArray) {
val arr = root.asJsonArray
val converted = arr.map { elem ->
val obj = elem.asJsonObject
val textColor = parseColorFromJson(obj.get("textColor"))
val bgColor = parseColorFromJson(obj.get("bgColor"))
"""{"textColor":$textColor,"bgColor":$bgColor}"""
}
"[\n ${converted.joinToString(",\n ")}\n]"
} else json
}.getOrNull() ?: json
}
fun readColor(obj: JsonObject, vararg names: String): Int {
for (name in names) {
val color = obj.getInt(name, Int.MIN_VALUE)
if (color != Int.MIN_VALUE) return color
}
return 0
}
fun colorToHex(color: Int): String = "#${String.format("%08X", color)}"
private fun convertTagColorsToHex(json: String?): String? {
if (json.isNullOrBlank()) return json
return kotlin.runCatching {
val colors = GSON.fromJson(json, Array<TagColorPair>::class.java)
val converted = colors.map { pair ->
val textHex = colorToHex(pair.textColor)
val bgHex = colorToHex(pair.bgColor)
"""{"textColor":"$textHex","bgColor":"$bgHex"}"""
}
"[\n ${converted.joinToString(",\n ")}\n]"
}.getOrNull() ?: json
}
private fun parseColorFromJson(elem: JsonElement?): Int {
if (elem == null || elem.isJsonNull) return 0
val prim = elem.asJsonPrimitive
return when {
prim.isNumber -> prim.asNumber.toInt()
prim.isString -> parseColorString(prim.asString)
else -> 0
}
}
private fun JsonObject.getInt(name: String, default: Int): Int {
val elem = get(name)
return when {
elem == null || elem.isJsonNull -> default
elem.isJsonPrimitive -> {
val prim = elem.asJsonPrimitive
when {
prim.isNumber -> prim.asNumber.toInt()
prim.isString -> parseColorString(prim.asString)
else -> default
}
}
else -> default
}
}
private fun parseColorString(colorStr: String): Int {
val text = colorStr.trim()
if (text.isEmpty()) return 0
return kotlin.runCatching {
if (text.startsWith("#")) {
text.substring(1).toLong(16).toInt()
} else {
text.toLong().toInt()
}
}.getOrElse {
kotlin.runCatching { text.toColorInt() }.getOrDefault(0)
}
}
}
@@ -0,0 +1,327 @@
package io.legado.app.help.config
import android.net.Uri
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.utils.FileUtils
import io.legado.app.utils.externalFiles
import splitties.init.appCtx
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
object ThemePackConfig {
private const val CONFIG_FILE = "theme.json"
private const val DIR_NAME = "theme_packs"
private val baseDir get() = File(appCtx.filesDir, DIR_NAME)
private val PACK_GSON: Gson by lazy {
GsonBuilder()
.registerTypeAdapter(ThemePack::class.java, ThemePackSerializer())
.setPrettyPrinting()
.disableHtmlEscaping()
.create()
}
private val _packList = mutableListOf<ThemePack>()
val packList: List<ThemePack> get() = _packList
init {
loadAll()
}
private fun loadAll() {
_packList.clear()
baseDir.mkdirs()
baseDir.listFiles()?.forEach { dir ->
if (dir.isDirectory) {
val configFile = File(dir, CONFIG_FILE)
if (configFile.exists()) {
kotlin.runCatching {
val json = configFile.readText()
val pack = PACK_GSON.fromJson(json, ThemePack::class.java)
if (pack.name.isNotEmpty()) {
pack.folderName = dir.name
_packList.add(pack)
}
}
}
}
}
}
fun reload() {
loadAll()
}
fun getPackDir(pack: ThemePack): File {
return File(baseDir, pack.folderName)
}
fun savePack(pack: ThemePack) {
val dir = getPackDir(pack)
dir.mkdirs()
val configFile = File(dir, CONFIG_FILE)
val json = PACK_GSON.toJson(pack)
FileUtils.createFileIfNotExist(configFile.absolutePath).writeText(json)
}
fun deletePack(pack: ThemePack) {
val dir = getPackDir(pack)
if (dir.exists()) {
dir.deleteRecursively()
}
_packList.remove(pack)
}
fun createFromCurrent(name: String): ThemePack {
val folderName = System.currentTimeMillis().toString()
val pack = ThemePack(
name = name,
folderName = folderName,
appearance = ThemeAppearanceConfig.fromCurrent(),
bgImageLight = ThemeConfig.bgImageLight,
bgImageDark = ThemeConfig.bgImageDark,
bgImageBlurring = ThemeConfig.bgImageBlurring,
bgImageNBlurring = ThemeConfig.bgImageNBlurring,
navIconBookshelf = MainConfig.navIconBookshelf,
navIconExplore = MainConfig.navIconExplore,
navIconRss = MainConfig.navIconRss,
navIconMy = MainConfig.navIconMy,
coverTextColor = CoverConfig.coverTextColor,
coverShadowColor = CoverConfig.coverShadowColor,
coverTextColorN = CoverConfig.coverTextColorN,
coverShadowColorN = CoverConfig.coverShadowColorN,
coverShowName = CoverConfig.coverShowName,
coverShowAuthor = CoverConfig.coverShowAuthor,
coverShowNameN = CoverConfig.coverShowNameN,
coverShowAuthorN = CoverConfig.coverShowAuthorN,
coverDefaultColor = CoverConfig.coverDefaultColor,
coverShowShadow = CoverConfig.coverShowShadow,
coverShowStroke = CoverConfig.coverShowStroke,
coverInfoOrientation = CoverConfig.coverInfoOrientation,
defaultCover = CoverConfig.defaultCover,
defaultCoverDark = CoverConfig.defaultCoverDark
)
val dir = File(baseDir, folderName)
dir.mkdirs()
copyResourceFile(pack.appearance.appFontPath, dir, "app_font")
copyResourceFile(pack.bgImageLight, dir, "bg_light")
copyResourceFile(pack.bgImageDark, dir, "bg_dark")
copyResourceFile(pack.navIconBookshelf, dir, "nav_bookshelf")
copyResourceFile(pack.navIconExplore, dir, "nav_explore")
copyResourceFile(pack.navIconRss, dir, "nav_rss")
copyResourceFile(pack.navIconMy, dir, "nav_my")
copyCoverImages(pack.defaultCover, dir, "cover_day")
copyCoverImages(pack.defaultCoverDark, dir, "cover_night")
savePack(pack)
_packList.add(pack)
return pack
}
fun applyPack(pack: ThemePack) {
val dir = getPackDir(pack)
val restoredAppearance = pack.appearance.copy(
appFontPath = restoreResourcePath(dir, "app_font", pack.appearance.appFontPath)
)
restoredAppearance.applyToThemeConfig()
ThemeConfig.bgImageLight = restoreResourcePath(dir, "bg_light", pack.bgImageLight)
ThemeConfig.bgImageDark = restoreResourcePath(dir, "bg_dark", pack.bgImageDark)
ThemeConfig.bgImageBlurring = pack.bgImageBlurring
ThemeConfig.bgImageNBlurring = pack.bgImageNBlurring
MainConfig.navIconBookshelf = restoreResourcePath(dir, "nav_bookshelf", pack.navIconBookshelf) ?: ""
MainConfig.navIconExplore = restoreResourcePath(dir, "nav_explore", pack.navIconExplore) ?: ""
MainConfig.navIconRss = restoreResourcePath(dir, "nav_rss", pack.navIconRss) ?: ""
MainConfig.navIconMy = restoreResourcePath(dir, "nav_my", pack.navIconMy) ?: ""
CoverConfig.coverTextColor = pack.coverTextColor
CoverConfig.coverShadowColor = pack.coverShadowColor
CoverConfig.coverTextColorN = pack.coverTextColorN
CoverConfig.coverShadowColorN = pack.coverShadowColorN
CoverConfig.coverShowName = pack.coverShowName
CoverConfig.coverShowAuthor = pack.coverShowAuthor
CoverConfig.coverShowNameN = pack.coverShowNameN
CoverConfig.coverShowAuthorN = pack.coverShowAuthorN
CoverConfig.coverDefaultColor = pack.coverDefaultColor
CoverConfig.coverShowShadow = pack.coverShowShadow
CoverConfig.coverShowStroke = pack.coverShowStroke
CoverConfig.coverInfoOrientation = pack.coverInfoOrientation
CoverConfig.defaultCover = restoreCoverImages(dir, "cover_day", pack.defaultCover)
CoverConfig.defaultCoverDark = restoreCoverImages(dir, "cover_night", pack.defaultCoverDark)
}
fun exportToZip(pack: ThemePack, destFile: File) {
val dir = getPackDir(pack)
if (!dir.exists()) return
ZipOutputStream(FileOutputStream(destFile)).use { zos ->
dir.walkTopDown().forEach { file ->
if (file.isFile) {
val entryName = file.relativeTo(dir).path.replace('\\', '/')
zos.putNextEntry(ZipEntry(entryName))
FileInputStream(file).use { fis ->
fis.copyTo(zos)
}
zos.closeEntry()
}
}
}
}
fun importFromZip(zipFile: File): ThemePack? {
val folderName = System.currentTimeMillis().toString()
val dir = File(baseDir, folderName)
dir.mkdirs()
return kotlin.runCatching {
ZipInputStream(FileInputStream(zipFile)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
val outFile = resolveZipOutputFile(dir, entry)
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { fos ->
zis.copyTo(fos)
}
}
zis.closeEntry()
entry = zis.nextEntry
}
}
val configFile = File(dir, CONFIG_FILE)
if (!configFile.exists()) {
dir.deleteRecursively()
return null
}
val json = configFile.readText()
val pack = PACK_GSON.fromJson(json, ThemePack::class.java)
pack.folderName = folderName
_packList.add(pack)
pack
}.onFailure {
dir.deleteRecursively()
}.getOrNull()
}
private fun resolveZipOutputFile(dir: File, entry: ZipEntry): File {
val outFile = File(dir, entry.name).canonicalFile
val canonicalDir = dir.canonicalFile
if (outFile.path == canonicalDir.path ||
outFile.path.startsWith(canonicalDir.path + File.separator)
) {
return outFile
}
throw IllegalArgumentException("Invalid theme pack entry: ${entry.name}")
}
private fun copyResourceFile(sourcePath: String?, destDir: File, prefix: String) {
if (sourcePath.isNullOrEmpty()) return
val sourceUri = Uri.parse(sourcePath)
val sourceName = sourceUri.lastPathSegment ?: sourcePath
val ext = sourceName.substringAfterLast(".", "dat").ifBlank { "dat" }
val destFile = File(destDir, "$prefix.$ext")
if (sourceUri.scheme == "content") {
appCtx.contentResolver.openInputStream(sourceUri)?.use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
return
}
val sourceFile = File(sourcePath)
if (!sourceFile.exists()) return
sourceFile.copyTo(destFile, overwrite = true)
}
private fun restoreResourcePath(packDir: File, prefix: String, originalPath: String?): String? {
if (originalPath.isNullOrEmpty()) return null
val candidates = packDir.listFiles { file ->
file.name.startsWith(prefix) && file.isFile
}
if (candidates.isNullOrEmpty()) return null
val sourceFile = candidates.first()
val targetDir = File(appCtx.filesDir, "theme_pack_resources")
targetDir.mkdirs()
val targetFile = File(targetDir, "${System.currentTimeMillis()}_${sourceFile.name}")
sourceFile.copyTo(targetFile, overwrite = true)
return targetFile.absolutePath
}
private fun copyCoverImages(coverPaths: String, destDir: File, prefix: String) {
val paths = coverPaths.split(",").filter { it.isNotBlank() }
paths.forEachIndexed { index, path ->
copyResourceFile(path, destDir, "${prefix}_$index")
}
}
private fun restoreCoverImages(packDir: File, prefix: String, originalPaths: String): String {
if (originalPaths.isEmpty()) return ""
val candidates = packDir.listFiles { file ->
file.name.startsWith(prefix + "_") && file.isFile
}
if (candidates.isNullOrEmpty()) return originalPaths
val sortedCandidates = candidates.sortedBy { file ->
file.name.substringAfterLast("_").substringBefore(".").toIntOrNull() ?: 0
}
val coverDir = File(appCtx.externalFiles, "covers")
coverDir.mkdirs()
val restoredPaths = mutableListOf<String>()
sortedCandidates.forEach { sourceFile ->
val targetFile = File(coverDir, sourceFile.name)
sourceFile.copyTo(targetFile, overwrite = true)
restoredPaths.add(targetFile.absolutePath)
}
return if (restoredPaths.isEmpty()) originalPaths else restoredPaths.joinToString(",")
}
data class ThemePack(
var name: String = "",
@Transient
var folderName: String = "",
var appearance: ThemeAppearanceConfig = ThemeAppearanceConfig(),
var bgImageLight: String? = null,
var bgImageDark: String? = null,
var bgImageBlurring: Int = 0,
var bgImageNBlurring: Int = 0,
var navIconBookshelf: String = "",
var navIconExplore: String = "",
var navIconRss: String = "",
var navIconMy: String = "",
var coverTextColor: Int = -16777216,
var coverShadowColor: Int = -16777216,
var coverTextColorN: Int = -1,
var coverShadowColorN: Int = -1,
var coverShowName: Boolean = true,
var coverShowAuthor: Boolean = true,
var coverShowNameN: Boolean = true,
var coverShowAuthorN: Boolean = true,
var coverDefaultColor: Boolean = true,
var coverShowShadow: Boolean = false,
var coverShowStroke: Boolean = true,
var coverInfoOrientation: String = "0",
var defaultCover: String = "",
var defaultCoverDark: String = ""
) {
val themeColor: Int get() = appearance.themeColor
val appFontPath: String? get() = appearance.appFontPath
}
}
@@ -0,0 +1,83 @@
package io.legado.app.help.config
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import io.legado.app.utils.getBoolean
import io.legado.app.utils.getInt
import io.legado.app.utils.getString
import java.lang.reflect.Type
class ThemePackSerializer : JsonSerializer<ThemePackConfig.ThemePack>,
JsonDeserializer<ThemePackConfig.ThemePack> {
override fun serialize(
src: ThemePackConfig.ThemePack,
typeOfSrc: Type,
context: JsonSerializationContext
): JsonElement {
val obj = JsonObject()
obj.addProperty("name", src.name)
ThemeAppearanceJson.writeTo(obj, src.appearance)
obj.addProperty("bgImageLight", src.bgImageLight)
obj.addProperty("bgImageDark", src.bgImageDark)
obj.addProperty("bgImageBlurring", src.bgImageBlurring)
obj.addProperty("bgImageNBlurring", src.bgImageNBlurring)
obj.addProperty("navIconBookshelf", src.navIconBookshelf)
obj.addProperty("navIconExplore", src.navIconExplore)
obj.addProperty("navIconRss", src.navIconRss)
obj.addProperty("navIconMy", src.navIconMy)
obj.addProperty("coverTextColor", ThemeAppearanceJson.colorToHex(src.coverTextColor))
obj.addProperty("coverShadowColor", ThemeAppearanceJson.colorToHex(src.coverShadowColor))
obj.addProperty("coverTextColorN", ThemeAppearanceJson.colorToHex(src.coverTextColorN))
obj.addProperty("coverShadowColorN", ThemeAppearanceJson.colorToHex(src.coverShadowColorN))
obj.addProperty("coverShowName", src.coverShowName)
obj.addProperty("coverShowAuthor", src.coverShowAuthor)
obj.addProperty("coverShowNameN", src.coverShowNameN)
obj.addProperty("coverShowAuthorN", src.coverShowAuthorN)
obj.addProperty("coverDefaultColor", src.coverDefaultColor)
obj.addProperty("coverShowShadow", src.coverShowShadow)
obj.addProperty("coverShowStroke", src.coverShowStroke)
obj.addProperty("coverInfoOrientation", src.coverInfoOrientation)
obj.addProperty("defaultCover", src.defaultCover)
obj.addProperty("defaultCoverDark", src.defaultCoverDark)
return obj
}
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): ThemePackConfig.ThemePack {
val obj = json.asJsonObject
return ThemePackConfig.ThemePack(
name = obj.getString("name", "") ?: "",
appearance = ThemeAppearanceJson.readFrom(obj),
bgImageLight = obj.getString("bgImageLight", null),
bgImageDark = obj.getString("bgImageDark", null),
bgImageBlurring = obj.getInt("bgImageBlurring", 0),
bgImageNBlurring = obj.getInt("bgImageNBlurring", 0),
navIconBookshelf = obj.getString("navIconBookshelf", "") ?: "",
navIconExplore = obj.getString("navIconExplore", "") ?: "",
navIconRss = obj.getString("navIconRss", "") ?: "",
navIconMy = obj.getString("navIconMy", "") ?: "",
coverTextColor = ThemeAppearanceJson.readColor(obj, "coverTextColor").takeIf { it != 0 } ?: -16777216,
coverShadowColor = ThemeAppearanceJson.readColor(obj, "coverShadowColor").takeIf { it != 0 } ?: -16777216,
coverTextColorN = ThemeAppearanceJson.readColor(obj, "coverTextColorN").takeIf { it != 0 } ?: -1,
coverShadowColorN = ThemeAppearanceJson.readColor(obj, "coverShadowColorN").takeIf { it != 0 } ?: -1,
coverShowName = obj.getBoolean("coverShowName", true),
coverShowAuthor = obj.getBoolean("coverShowAuthor", true),
coverShowNameN = obj.getBoolean("coverShowNameN", true),
coverShowAuthorN = obj.getBoolean("coverShowAuthorN", true),
coverDefaultColor = obj.getBoolean("coverDefaultColor", true),
coverShowShadow = obj.getBoolean("coverShowShadow", false),
coverShowStroke = obj.getBoolean("coverShowStroke", true),
coverInfoOrientation = obj.getString("coverInfoOrientation", "0") ?: "0",
defaultCover = obj.getString("defaultCover", "") ?: "",
defaultCoverDark = obj.getString("defaultCoverDark", "") ?: ""
)
}
}
@@ -14,6 +14,7 @@ import io.legado.app.help.DirectLinkUpload
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.OldThemeConfig import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.PersonalizationThemeConfig
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.BookCover import io.legado.app.model.BookCover
@@ -83,6 +84,7 @@ object Backup {
ReadBookConfig.configFileName, ReadBookConfig.configFileName,
ReadBookConfig.shareConfigFileName, ReadBookConfig.shareConfigFileName,
OldThemeConfig.configFileName, OldThemeConfig.configFileName,
PersonalizationThemeConfig.configFileName,
BookCover.configFileName, BookCover.configFileName,
"config.xml" "config.xml"
) )
@@ -181,6 +183,8 @@ object Backup {
FileUtils.createFileIfNotExist(backupPath + File.separator + BookCover.configFileName) FileUtils.createFileIfNotExist(backupPath + File.separator + BookCover.configFileName)
.writeText(GSON.toJson(it)) .writeText(GSON.toJson(it))
} }
FileUtils.createFileIfNotExist(backupPath + File.separator + PersonalizationThemeConfig.configFileName)
.writeText(PersonalizationThemeConfig.toJson())
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
appCtx.getSharedPreferences(backupPath, "config")?.let { sp -> appCtx.getSharedPreferences(backupPath, "config")?.let { sp ->
sp.edit(commit = true) { sp.edit(commit = true) {
@@ -23,6 +23,7 @@ object BackupConfig {
private const val readConfigKey = "readConfig" private const val readConfigKey = "readConfig"
private const val themeConfigKey = "themeConfig" private const val themeConfigKey = "themeConfig"
private const val coverConfigKey = "coverConfig" private const val coverConfigKey = "coverConfig"
private const val personalizationThemeConfigKey = "personalizationThemeConfig"
private const val localBookKey = "localBook" private const val localBookKey = "localBook"
//配置忽略key //配置忽略key
@@ -30,6 +31,7 @@ object BackupConfig {
readConfigKey, readConfigKey,
PreferKey.themeMode, PreferKey.themeMode,
themeConfigKey, themeConfigKey,
personalizationThemeConfigKey,
coverConfigKey, coverConfigKey,
PreferKey.bookshelfLayout, PreferKey.bookshelfLayout,
PreferKey.showRss, PreferKey.showRss,
@@ -42,6 +44,7 @@ object BackupConfig {
appCtx.getString(R.string.read_config), appCtx.getString(R.string.read_config),
appCtx.getString(R.string.theme_mode), appCtx.getString(R.string.theme_mode),
appCtx.getString(R.string.theme_config), appCtx.getString(R.string.theme_config),
"深度个性化主题",
appCtx.getString(R.string.cover_config), appCtx.getString(R.string.cover_config),
appCtx.getString(R.string.bookshelf_layout), appCtx.getString(R.string.bookshelf_layout),
appCtx.getString(R.string.show_rss), appCtx.getString(R.string.show_rss),
@@ -84,15 +87,9 @@ object BackupConfig {
private val themePrefKeys = arrayOf( private val themePrefKeys = arrayOf(
PreferKey.cPrimary, PreferKey.cPrimary,
PreferKey.cAccent, PreferKey.cNPrimary,
PreferKey.cBackground,
PreferKey.cBBackground,
PreferKey.bgImage, PreferKey.bgImage,
PreferKey.bgImageBlurring, PreferKey.bgImageBlurring,
PreferKey.cNPrimary,
PreferKey.cNAccent,
PreferKey.cNBackground,
PreferKey.cNBBackground,
PreferKey.bgImageN, PreferKey.bgImageN,
PreferKey.bgImageNBlurring PreferKey.bgImageNBlurring
) )
@@ -128,6 +125,8 @@ object BackupConfig {
get() = ignoreConfig[themeConfigKey] == true get() = ignoreConfig[themeConfigKey] == true
private val ignoreCoverConfig: Boolean private val ignoreCoverConfig: Boolean
get() = ignoreConfig[coverConfigKey] == true get() = ignoreConfig[coverConfigKey] == true
val ignorePersonalizationThemeConfig: Boolean
get() = ignoreConfig[personalizationThemeConfigKey] == true
private val ignoreBookshelfLayout: Boolean private val ignoreBookshelfLayout: Boolean
get() = ignoreConfig[PreferKey.bookshelfLayout] == true get() = ignoreConfig[PreferKey.bookshelfLayout] == true
private val ignoreShowRss: Boolean private val ignoreShowRss: Boolean
@@ -34,6 +34,7 @@ import io.legado.app.help.book.isLocal
import io.legado.app.help.book.upType import io.legado.app.help.book.upType
import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.OldThemeConfig import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.PersonalizationThemeConfig
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.model.BookCover import io.legado.app.model.BookCover
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
@@ -233,6 +234,16 @@ object Restore {
}?.onFailure { }?.onFailure {
AppLog.put("恢复封面规则出错\n${it.localizedMessage}", it) AppLog.put("恢复封面规则出错\n${it.localizedMessage}", it)
} }
if (!BackupConfig.ignorePersonalizationThemeConfig) {
File(path, PersonalizationThemeConfig.configFileName).takeIf {
it.exists()
}?.runCatching {
val json = readText()
PersonalizationThemeConfig.fromJson(json)
}?.onFailure {
AppLog.put("恢复个性化主题出错\n${it.localizedMessage}", it)
}
}
if (!BackupConfig.ignoreReadConfig) { if (!BackupConfig.ignoreReadConfig) {
//恢复阅读界面配置 //恢复阅读界面配置
File(path, ReadBookConfig.configFileName).takeIf { File(path, ReadBookConfig.configFileName).takeIf {
@@ -24,9 +24,9 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
private suspend fun getLatestRelease(): List<AppReleaseInfo> { private suspend fun getLatestRelease(): List<AppReleaseInfo> {
val url = if (checkVariant == AppVariant.OFFICIAL) val url = if (checkVariant == AppVariant.OFFICIAL)
"https://api.github.com/repos/HapeLee/legado-with-MD3/releases/latest" "https://api.github.com/repos/325506/legado-with-MD3-DIY/releases/latest"
else else
"https://api.github.com/repos/HapeLee/legado-with-MD3/releases" "https://api.github.com/repos/325506/legado-with-MD3-DIY/releases"
val res = okHttpClient.newCallResponse { url(url) } val res = okHttpClient.newCallResponse { url(url) }
if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})") if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})")
@@ -65,7 +65,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
} }
suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? { suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
val url = "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/tags/$tag" val url = "https://api.github.com/repos/325506/legado-with-MD3-DIY/releases/tags/$tag"
val res = okHttpClient.newCallResponse { url(url) } val res = okHttpClient.newCallResponse { url(url) }
if (!res.isSuccessful) return null if (!res.isSuccessful) return null
@@ -86,7 +86,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
val currentVersion = AppConst.appInfo.versionName val currentVersion = AppConst.appInfo.versionName
val releases = getLatestRelease() val releases = getLatestRelease()
val filtered = if (checkVariant == AppVariant.ALL) { val filtered = if (checkVariant == AppVariant.ALL || checkVariant == AppVariant.UNKNOWN) {
releases releases
} else { } else {
releases.filter { it.appVariant == checkVariant } releases.filter { it.appVariant == checkVariant }
@@ -153,8 +153,8 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
companion object { companion object {
fun parse(version: String): SemVer { fun parse(version: String): SemVer {
val regex = Regex("""(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?""") val regex = Regex("""(\d+)\.(\d+)\.(\d+)(?:[-_]([\w.]+))?""")
val match = regex.matchEntire(version) val match = regex.find(version)
?: throw IllegalArgumentException("Invalid version: $version") ?: throw IllegalArgumentException("Invalid version: $version")
val (maj, min, pat, pre) = match.destructured val (maj, min, pat, pre) = match.destructured
return SemVer(maj.toInt(), min.toInt(), pat.toInt(), pre.ifBlank { null }) return SemVer(maj.toInt(), min.toInt(), pat.toInt(), pre.ifBlank { null })
@@ -9,6 +9,8 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.BaseSource import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.CacheManager
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.AudioPlay import io.legado.app.model.AudioPlay
import io.legado.app.model.ReadBook import io.legado.app.model.ReadBook
@@ -17,22 +19,25 @@ import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
import io.legado.app.ui.rss.read.RssJsExtensions import io.legado.app.ui.rss.read.RssJsExtensions
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.escapeForJs
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.isJsonObject
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.UUID import java.util.UUID
@Suppress("unused") @Suppress("unused")
class WebJsExtensions( class WebJsExtensions(
source: BaseSource, activity: AppCompatActivity, source: BaseSource,
private val webView: WebView, activity: AppCompatActivity?,
private val bookType: Int = 0, webView: WebView,
bookType: Int = 0,
callback: Callback? = null callback: Callback? = null
) : RssJsExtensions(activity, source) { ) : RssJsExtensions(activity, source) {
private val callbackRef: WeakReference<Callback> = WeakReference(callback) private val callbackRef: WeakReference<Callback> = WeakReference(callback)
private val webViewRef: WeakReference<WebView?> = WeakReference(webView)
interface Callback { interface Callback {
fun upConfig(config: String) fun upConfig(config: String)
fun onNavigateToArticles(sortUrl: String? = null)
} }
@JavascriptInterface @JavascriptInterface
@@ -40,6 +45,16 @@ class WebJsExtensions(
callbackRef.get()?.upConfig(config) callbackRef.get()?.upConfig(config)
} }
@JavascriptInterface
fun navigateToArticles() {
callbackRef.get()?.onNavigateToArticles(null)
}
@JavascriptInterface
fun navigateToArticles(sortUrl: String?) {
callbackRef.get()?.onNavigateToArticles(sortUrl)
}
private val bookAndChapter by lazy { private val bookAndChapter by lazy {
var book: Book? = null var book: Book? = null
var chapter: BookChapter? = null var chapter: BookChapter? = null
@@ -63,154 +78,201 @@ class WebJsExtensions(
private val book: Book? get() = bookAndChapter.first private val book: Book? get() = bookAndChapter.first
private val chapter: BookChapter? get() = bookAndChapter.second private val chapter: BookChapter? get() = bookAndChapter.second
/** val analyzeRule by lazy {
* 由软件主动注入的js函数调用 AnalyzeRule(book, source = getSource()).setChapter(chapter)
*/ }
@JavascriptInterface @JavascriptInterface
fun request(funName: String, jsParam: Array<String>, id: String) { fun request(funName: String, jsParam: Array<String?>, id: String) {
val activity = activityRef.get() ?: return val activity = activityRef.get() ?: return
Coroutine.async(activity.lifecycleScope) { Coroutine.async(activity.lifecycleScope) {
val params = Array(6) { i -> jsParam.getOrNull(i) }
val p0 = params[0]
val p1 = params[1]
val p2 = params[2]
val p3 = params[3]
val p4 = params[4]
val p5 = params[5]
when (funName) { when (funName) {
"run" -> AnalyzeRule(book, getSource()).run { "run" -> {
setCoroutineContext(coroutineContext) analyzeRule.setCoroutineContext(coroutineContext)
setChapter(chapter) .evalJS(
evalJS(jsParam[0]).toString() p0 ?: throw NoStackTraceException("error null")
).toString()
} }
"ajaxAwait" -> { "ajaxAwait" -> {
ajax(jsParam[0], jsParam[1].toIntOrNull()).toString() ajax(
p0 ?: throw NoStackTraceException("error url null"),
p1?.toLongOrNull()
).toString()
} }
"connectAwait" -> { "connectAwait" -> {
connect(jsParam[0], jsParam[1], jsParam[2].toIntOrNull()) connect(
p0 ?: throw NoStackTraceException("error url null"),
p1,
p2?.toIntOrNull()
)
} }
"getAwait" -> { "getAwait" -> {
get(jsParam[0], jsParam[1], jsParam[2].toIntOrNull()) get(
p0 ?: throw NoStackTraceException("error url null"),
p1 ?: throw NoStackTraceException("error header null"),
p2?.toIntOrNull()
)
} }
"headAwait" -> { "headAwait" -> {
head(jsParam[0], jsParam[1], jsParam[2].toIntOrNull()) head(
p0 ?: throw NoStackTraceException("error url null"),
p1 ?: throw NoStackTraceException("error header null"),
p2?.toIntOrNull()
)
} }
"postAwait" -> { "postAwait" -> {
post(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toIntOrNull()) post(
p0 ?: throw NoStackTraceException("error url null"),
p1 ?: throw NoStackTraceException("error body null"),
p2 ?: throw NoStackTraceException("error header null"),
p3?.toIntOrNull()
)
} }
"webViewAwait" -> { "webViewAwait" -> {
webView(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toBoolean()).toString() webView(
p0,
p1,
p2,
p3.toBoolean()
).toString()
} }
"webViewGetSourceAwait" -> { "webViewGetSourceAwait" -> {
webViewGetSource( webViewGetSource(
jsParam[0], p0,
jsParam[1], p1,
jsParam[2], p2,
jsParam[3], p3 ?: throw NoStackTraceException("error sourceRegex null"),
jsParam[4].toBoolean(), p4.toBoolean(),
jsParam[5].toLongOrNull() ?: 0 p5?.toLongOrNull() ?: 0
).toString() ).toString()
} }
"decryptStrAwait" -> { "decryptStrAwait" -> {
createSymmetricCrypto(jsParam[0], jsParam[1], jsParam[2]).decryptStr(jsParam[3]) createSymmetricCrypto(
p0 ?: throw NoStackTraceException("error transformation null"),
p1 ?: throw NoStackTraceException("error key null"),
p2
).decryptStr(p3 ?: throw NoStackTraceException("error data null"))
} }
"encryptBase64Await" -> { "encryptBase64Await" -> {
createSymmetricCrypto( createSymmetricCrypto(
jsParam[0], p0 ?: throw NoStackTraceException("error transformation null"),
jsParam[1], p1 ?: throw NoStackTraceException("error key null"),
jsParam[2] p2
).encryptBase64(jsParam[3]) ).encryptBase64(p3 ?: throw NoStackTraceException("error data null"))
} }
"encryptHexAwait" -> { "encryptHexAwait" -> {
createSymmetricCrypto(jsParam[0], jsParam[1], jsParam[2]).encryptHex(jsParam[3]) createSymmetricCrypto(
p0 ?: throw NoStackTraceException("error transformation null"),
p1 ?: throw NoStackTraceException("error key null"),
p2
).encryptHex(p3 ?: throw NoStackTraceException("error data null"))
} }
"createSignHexAwait" -> { "createSignHexAwait" -> {
createSign(jsParam[0]).setPublicKey(jsParam[1]).setPrivateKey(jsParam[2]) createSign(p0 ?: throw NoStackTraceException("error algorithm null"))
.signHex(jsParam[3]) .setPublicKey(p1 ?: throw NoStackTraceException("error publicKey null"))
.setPrivateKey(p2 ?: throw NoStackTraceException("error privateKey null"))
.signHex(p3 ?: throw NoStackTraceException("error data null"))
} }
"downloadFileAwait" -> { "downloadFileAwait" -> {
downloadFile(jsParam[0]) downloadFile(p0 ?: throw NoStackTraceException("error url null"))
} }
"readTxtFileAwait" -> { "readTxtFileAwait" -> {
readTxtFile(jsParam[0]) readTxtFile(p0 ?: throw NoStackTraceException("error path null"))
} }
"importScriptAwait" -> { "importScriptAwait" -> {
importScript(jsParam[0]) importScript(p0 ?: throw NoStackTraceException("error path null"))
} }
"getStringAwait" -> AnalyzeRule(book, getSource()).run { "getStringAwait" -> {
setCoroutineContext(coroutineContext) analyzeRule.setCoroutineContext(coroutineContext)
setChapter(chapter) .getString(p0, p1)
getString(jsParam[0], jsParam[1])
} }
else -> "error funName" else -> throw NoStackTraceException("error funName")
} }
}.onSuccess { data -> }.onSuccess { data ->
webView.evaluateJavascript( CacheManager.putMemory(id, data)
"window.$JSBridgeResult('$id', '${data.escapeForJs()}', null);", webViewRef.get()?.evaluateJavascript("window.$JSBridgeResult('$id', true);", null)
null
)
}.onError { }.onError {
webView.evaluateJavascript( CacheManager.putMemory(id, it.localizedMessage ?: "err")
"window.$JSBridgeResult('$id', null, '${it.localizedMessage?.escapeForJs()}');", webViewRef.get()?.evaluateJavascript("window.$JSBridgeResult('$id', false);", null)
null
)
} }
} }
@JavascriptInterface @JavascriptInterface
fun toast(msg: String?) { fun toast(msg: String?) {
super.toast(msg) super.toast(msg)
} }
@JavascriptInterface @JavascriptInterface
fun longToast(msg: String?) { fun longToast(msg: String?) {
super.longToast(msg) super.longToast(msg)
} }
@JavascriptInterface
override fun open(name: String, url: String?, title: String?, origin: String?) {
when (name) {
"sort" -> {
val sortUrl = if (!url.isNullOrBlank()) {
if (url.isJsonObject()) url else title?.let { "{\"$it\":\"$url\"}" } ?: url
} else null
callbackRef.get()?.onNavigateToArticles(sortUrl)
}
else -> super.open(name, url, title, origin)
}
}
@JavascriptInterface @JavascriptInterface
fun log(msg: String?): String { fun log(msg: String?): String {
return super.log(msg).toString() return super.log(msg).toString()
} }
@JavascriptInterface @JavascriptInterface
fun ajax(url: String): String? { @JvmOverloads
return super.ajax(url, 9000) fun ajax(url: String, callTimeout: Int = 9000): String? {
return super.ajax(url, callTimeout.toLong())
} }
@JavascriptInterface @JavascriptInterface
fun ajax(url: String, callTimeout: Int?): String? { @JvmOverloads
return super.ajax(url, callTimeout?.toLong()) fun connect(urlStr: String, header: String? = null, callTimeout: Int? = 9000): String {
}
@JavascriptInterface
fun connect(urlStr: String?): String {
if (urlStr.isNullOrEmpty()) return "error empty url"
return super.connect(urlStr, null, 9000).toString()
}
@JavascriptInterface
fun connect(urlStr: String, header: String): String {
return super.connect(urlStr, header, 9000).toString()
}
@JavascriptInterface
fun connect(urlStr: String, header: String, callTimeout: Int?): String {
return super.connect(urlStr, header, callTimeout?.toLong()).toString() return super.connect(urlStr, header, callTimeout?.toLong()).toString()
} }
@JavascriptInterface @JavascriptInterface
fun get(urlStr: String, headers: String): String { @JvmOverloads
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap() fun get(urlStr: String, header: String, timeout: Int? = 9000): String {
return super.get(urlStr, headerMap, 9000).body() val headers = GSON.fromJsonObject<Map<String, String>>(header).getOrNull() ?: emptyMap()
return super.get(urlStr, headers, timeout).body()
} }
@JavascriptInterface @JavascriptInterface
fun get(urlStr: String, headers: String, timeout: Int?): String { @JvmOverloads
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap() fun post(urlStr: String, body: String, header: String, timeout: Int? = 9000): String {
return super.get(urlStr, headerMap, timeout).body() val headers = GSON.fromJsonObject<Map<String, String>>(header).getOrNull() ?: emptyMap()
return super.post(urlStr, body, headers, timeout).body()
} }
@JavascriptInterface @JavascriptInterface
fun post(urlStr: String, body: String, headers: String): String { @JvmOverloads
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap() fun head(urlStr: String, header: String, timeout: Int? = 9000): String {
return super.post(urlStr, body, headerMap, 9000).body() val headers = GSON.fromJsonObject<Map<String, String>>(header).getOrNull() ?: emptyMap()
return GSON.toJson(super.head(urlStr, headers, timeout).headers())
} }
@JavascriptInterface @JavascriptInterface
fun post(urlStr: String, body: String, headers: String, timeout: Int?): String { fun getStringList(rule: String?, mContent: String? = null, isUrl: Boolean = false): List<String>? {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap() return super.getStringList(rule, mContent, isUrl)
return super.post(urlStr, body, headerMap, timeout).body()
} }
@JavascriptInterface @JavascriptInterface
fun head(urlStr: String, headers: String): String { fun getString(ruleStr: String?, mContent: String? = null, isUrl: Boolean = false): String {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap() return super.getString(ruleStr, mContent, isUrl)
return GSON.toJson(super.head(urlStr, headerMap, 9000).headers())
}
@JavascriptInterface
fun head(urlStr: String, headers: String, timeout: Int?): String {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
return GSON.toJson(super.head(urlStr, headerMap, timeout).headers())
} }
companion object { companion object {
@@ -218,6 +280,7 @@ class WebJsExtensions(
val letters = "abcdefghijklmnopqrstuvwxyz" val letters = "abcdefghijklmnopqrstuvwxyz"
return letters.random() return letters.random()
} }
val uuid by lazy { val uuid by lazy {
UUID.randomUUID().toString().replace('-', getRandomLetter()).chunked(6) UUID.randomUUID().toString().replace('-', getRandomLetter()).chunked(6)
} }
@@ -234,9 +297,12 @@ class WebJsExtensions(
"<script src=\"$nameUrl\"></script>" "<script src=\"$nameUrl\"></script>"
} }
val getInjectionString = "try{var cache=$nameCache,source=$nameSource,java=$nameJava;}catch(e){}"
val JS_INJECTION by lazy { val JS_INJECTION by lazy {
""" """
const requestId = n => 'req_' + n + '_' + Date.now() + '_' + Math.random().toString(36).slice(-3); const requestId = n => 'req_' + n + '_' + Date.now() + '_' + Math.random().toString(36).slice(-3);
const params = a => a.map(p => p != null && typeof p.toString === 'function' ? p.toString() : null);
const JSBridgeCallbacks = {}; const JSBridgeCallbacks = {};
const java = window.$nameJava; const java = window.$nameJava;
delete window.$nameJava; delete window.$nameJava;
@@ -248,122 +314,124 @@ class WebJsExtensions(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("run"); const id = requestId("run");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("run", [String(jsCode)], id); java.request("run", [String(jsCode)], id);
}); });
}; };
function ajaxAwait(url, callTimeout) { function ajaxAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("ajaxAwait"); const id = requestId("ajaxAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("ajaxAwait", [String(url), String(callTimeout)], id); java.request("ajaxAwait", params(args), id);
}); });
}; };
function connectAwait(url, header, callTimeout) { function connectAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("connectAwait"); const id = requestId("connectAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("connectAwait", [String(url), String(header), String(callTimeout)], id); java.request("connectAwait", params(args), id);
}); });
}; };
function getAwait(url, header, callTimeout) { function getAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("getAwait"); const id = requestId("getAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("getAwait", [String(url), String(header), String(callTimeout)], id); java.request("getAwait", params(args), id);
}); });
}; };
function headAwait(url, header, callTimeout) { function headAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("headAwait"); const id = requestId("headAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("headAwait", [String(url), String(header), String(callTimeout)], id); java.request("headAwait", params(args), id);
}); });
}; };
function postAwait(url, body, header, callTimeout) { function postAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("postAwait"); const id = requestId("postAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("postAwait", [String(url), String(body), String(header), String(callTimeout)], id); java.request("postAwait", params(args), id);
}); });
}; };
function webViewAwait(html, url, js, cacheFirst) { function webViewAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("webViewAwait"); const id = requestId("webViewAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("webViewAwait", [String(html), String(url), String(js), String(cacheFirst)], id); java.request("webViewAwait", params(args), id);
}); });
}; };
function webViewGetSourceAwait(html, url, js, sourceRegex, cacheFirst, delayTime) { function webViewGetSourceAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("webViewGetSourceAwait"); const id = requestId("webViewGetSourceAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("webViewGetSourceAwait", [String(html), String(url), String(js), String(sourceRegex), String(cacheFirst), String(delayTime)], id); java.request("webViewGetSourceAwait", params(args), id);
}); });
} }
function decryptStrAwait(transformation, key, iv, data) { function decryptStrAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("decryptStrAwait"); const id = requestId("decryptStrAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("decryptStrAwait", [String(transformation), String(key), String(iv), String(data)], id); java.request("decryptStrAwait", params(args), id);
}); });
}; };
function encryptBase64Await(transformation, key, iv, data) { function encryptBase64Await(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("encryptBase64Await"); const id = requestId("encryptBase64Await");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("encryptBase64Await", [String(transformation), String(key), String(iv), String(data)], id); java.request("encryptBase64Await", params(args), id);
}); });
}; };
function encryptHexAwait(transformation, key, iv, data) { function encryptHexAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("encryptHexAwait"); const id = requestId("encryptHexAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("encryptHexAwait", [String(transformation), String(key), String(iv), String(data)], id); java.request("encryptHexAwait", params(args), id);
}); });
}; };
function createSignHexAwait(algorithm, publicKey, privateKey, data) { function createSignHexAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("createSignHexAwait"); const id = requestId("createSignHexAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("createSignHexAwait", [String(algorithm), String(publicKey), String(privateKey), String(data)], id); java.request("createSignHexAwait", params(args), id);
}); });
}; };
function downloadFileAwait(url) { function downloadFileAwait(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("downloadFileAwait"); const id = requestId("downloadFileAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("downloadFileAwait", [String(url)], id); java.request("downloadFileAwait", [String(url)], id);
}); });
}; };
function readTxtFileAwait(path) { function readTxtFileAwait(path) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("readTxtFileAwait"); const id = requestId("readTxtFileAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("readTxtFileAwait", [String(path)], id); java.request("readTxtFileAwait", [String(path)], id);
}); });
}; };
function importScriptAwait(url) { function importScriptAwait(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("importScriptAwait"); const id = requestId("importScriptAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("importScriptAwait", [String(url)], id); java.request("importScriptAwait", [String(url)], id);
}); });
}; };
function getStringAwait(ruleStr, mContent) { function getStringAwait(...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("getStringAwait"); const id = requestId("getStringAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("getStringAwait", [String(ruleStr), String(mContent)], id); java.request("getStringAwait", params(args), id);
}); });
}; };
window.$JSBridgeResult = function(requestId, result, error) { window.$JSBridgeResult = function(id, success) {
if (JSBridgeCallbacks[requestId]) { const callBack = JSBridgeCallbacks[id];
if (error) { if (callBack) {
JSBridgeCallbacks[requestId].reject(error); const result = cache.getFromMemory(id);
if (success) {
callBack.resolve(result);
} else { } else {
JSBridgeCallbacks[requestId].resolve(result); callBack.reject(result);
} }
delete JSBridgeCallbacks[requestId]; delete JSBridgeCallbacks[id];
} }
};""".trimIndent() };""".trimIndent()
} }
@@ -374,21 +442,25 @@ class WebJsExtensions(
const JSBridgeCallbacks = {}; const JSBridgeCallbacks = {};
const java = window.$nameJava; const java = window.$nameJava;
delete window.$nameJava; delete window.$nameJava;
const cache = window.$nameCache;
delete window.$nameCache;
function run(jsCode) { function run(jsCode) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("run"); const id = requestId("run");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("run", [String(jsCode)], id); java.request("run", [String(jsCode)], id);
}); });
}; };
window.$JSBridgeResult = function(requestId, result, error) { window.$JSBridgeResult = function(id, success) {
if (JSBridgeCallbacks[requestId]) { const callBack = JSBridgeCallbacks[id];
if (error) { if (callBack) {
JSBridgeCallbacks[requestId].reject(error); const result = cache.getFromMemory(id);
if (success) {
callBack.resolve(result);
} else { } else {
JSBridgeCallbacks[requestId].resolve(result); callBack.reject(result);
} }
delete JSBridgeCallbacks[requestId]; delete JSBridgeCallbacks[id];
} }
};""".trimIndent() };""".trimIndent()
} }
@@ -59,6 +59,7 @@ object AudioPlay : CoroutineScope by MainScope() {
var durChapterPos = 0 var durChapterPos = 0
var durChapter: BookChapter? = null var durChapter: BookChapter? = null
var durPlayUrl = "" var durPlayUrl = ""
var durLyric: String? = null
var durAudioSize = 0 var durAudioSize = 0
var inBookshelf = false var inBookshelf = false
var bookSource: BookSource? = null var bookSource: BookSource? = null
@@ -153,14 +154,23 @@ object AudioPlay : CoroutineScope by MainScope() {
.onSuccess { content -> .onSuccess { content ->
if (content.isEmpty()) { if (content.isEmpty()) {
appCtx.toastOnUi("未获取到资源链接") appCtx.toastOnUi("未获取到资源链接")
// 加载失败时清空歌词
durLyric = null
callback?.upLyric(null)
} else { } else {
contentLoadFinish(chapter, content) contentLoadFinish(chapter, content)
} }
}.onError { }.onError { error ->
AppLog.put("获取资源链接出错\n$it", it, true) AppLog.put("获取资源链接出错\n$error", error, true)
upLoading(false) upLoading(false)
// 加载失败时清空歌词
durLyric = null
callback?.upLyric(null)
}.onCancel { }.onCancel {
removeLoading(index) removeLoading(index)
// 取消加载时清空歌词
durLyric = null
callback?.upLyric(null)
}.onFinally { }.onFinally {
removeLoading(index) removeLoading(index)
} }
@@ -177,7 +187,10 @@ object AudioPlay : CoroutineScope by MainScope() {
private fun contentLoadFinish(chapter: BookChapter, content: String) { private fun contentLoadFinish(chapter: BookChapter, content: String) {
if (chapter.index == book?.durChapterIndex) { if (chapter.index == book?.durChapterIndex) {
durPlayUrl = content durPlayUrl = content
durLyric = chapter.getVariable("lyric").takeIf { it.isNotBlank() }
upPlayUrl() upPlayUrl()
// 重新加载歌词,确保循环播放时歌词进度重置
callback?.upLyric(durLyric)
} }
} }
@@ -397,6 +410,8 @@ object AudioPlay : CoroutineScope by MainScope() {
fun register(context: Context) { fun register(context: Context) {
activityContext = context activityContext = context
callback = context as CallBack callback = context as CallBack
// 主动更新歌词状态,确保重新进入时歌词控件能正确显示
callback?.upLyric(durLyric)
} }
fun unregister(context: Context) { fun unregister(context: Context) {
@@ -418,6 +433,8 @@ object AudioPlay : CoroutineScope by MainScope() {
interface CallBack { interface CallBack {
fun upLoading(loading: Boolean) fun upLoading(loading: Boolean)
fun upLyric(lyric: String?)
fun upLyricP(position: Int)
} }
@@ -138,6 +138,90 @@ object Debug {
} }
} }
fun startDebug(scope: CoroutineScope, rssSource: RssSource, key: String) {
cancelDebug()
debugSource = rssSource.sourceUrl
startTime = System.currentTimeMillis()
when {
key.contains("@js:") -> {
val ruleContent = rssSource.ruleContent
if (ruleContent.isNullOrEmpty()) {
log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000)
} else {
val rssArticle = RssArticle()
rssArticle.origin = rssSource.sourceUrl
rssArticle.link = key
log(debugSource, "⇒开始解析@js:链接:$key")
rssContentDebug(scope, rssArticle, ruleContent, rssSource)
}
}
key.contains("::") -> {
val name = key.substringBefore("::")
val url = key.substringAfter("::")
log(debugSource, "⇒开始访问分类页:$url")
log(debugSource, "︾开始解析分类页")
rssSortDebug(scope, rssSource, name, url)
}
key.isAbsUrl() -> {
val ruleContent = rssSource.ruleContent
if (!rssSource.ruleArticles.isNullOrBlank() && rssSource.ruleDescription.isNullOrBlank()) {
if (ruleContent.isNullOrEmpty()) {
log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000)
} else {
val rssArticle = RssArticle()
rssArticle.origin = rssSource.sourceUrl
rssArticle.link = key
log(debugSource, "⇒开始访问内容页:$key")
rssContentDebug(scope, rssArticle, ruleContent, rssSource)
}
} else {
log(debugSource, "⇒存在描述规则,不解析内容页")
log(debugSource, "︽解析完成", state = 1000)
}
}
else -> {
val searchUrl = rssSource.searchUrl
if (searchUrl.isNullOrEmpty()) {
log(debugSource, "⇒搜索URL为空", state = -1)
return
}
log(debugSource, "⇒开始搜索关键字:$key")
log(debugSource, "︾开始解析搜索页")
rssSortDebug(scope, rssSource, "搜索", searchUrl, key)
}
}
}
private fun rssSortDebug(scope: CoroutineScope, rssSource: RssSource, name: String, url: String, key: String? = null) {
Rss.getArticles(scope, name, url, rssSource, 1, key)
.onSuccess {
if (it.first.isEmpty()) {
log(debugSource, "⇒列表页解析成功,为空")
log(debugSource, "︽解析完成", state = 1000)
} else {
val ruleContent = rssSource.ruleContent
if (!rssSource.ruleArticles.isNullOrBlank() && rssSource.ruleDescription.isNullOrBlank()) {
log(debugSource, "︽列表页解析完成")
log(debugSource, showTime = false)
if (ruleContent.isNullOrEmpty()) {
log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000)
} else {
rssContentDebug(scope, it.first[0], ruleContent, rssSource)
}
} else {
log(debugSource, "⇒存在描述规则,不解析内容页")
log(debugSource, "︽解析完成", state = 1000)
}
}
}
.onError {
log(debugSource, it.stackTraceStr, state = -1)
}
}
private fun rssContentDebug( private fun rssContentDebug(
scope: CoroutineScope, scope: CoroutineScope,
rssArticle: RssArticle, rssArticle: RssArticle,
@@ -24,10 +24,11 @@ object Rss {
sortUrl: String, sortUrl: String,
rssSource: RssSource, rssSource: RssSource,
page: Int, page: Int,
searchKey: String? = null,
context: CoroutineContext = Dispatchers.IO context: CoroutineContext = Dispatchers.IO
): Coroutine<Pair<MutableList<RssArticle>, String?>> { ): Coroutine<Pair<MutableList<RssArticle>, String?>> {
return Coroutine.async(scope, context) { return Coroutine.async(scope, context) {
getArticlesAwait(sortName, sortUrl, rssSource, page) getArticlesAwait(sortName, sortUrl, rssSource, page, searchKey)
} }
} }
@@ -36,8 +37,13 @@ object Rss {
sortUrl: String, sortUrl: String,
rssSource: RssSource, rssSource: RssSource,
page: Int, page: Int,
searchKey: String? = null
): Pair<MutableList<RssArticle>, String?> { ): Pair<MutableList<RssArticle>, String?> {
val ruleData = RuleData() val ruleData = RuleData()
if (!searchKey.isNullOrBlank()) {
ruleData.putVariable("searchKey", searchKey)
ruleData.putVariable("key", searchKey)
}
val analyzeUrl = AnalyzeUrl( val analyzeUrl = AnalyzeUrl(
sortUrl, sortUrl,
page = page, page = page,
@@ -211,9 +211,9 @@ object BookContent {
if (!book.isAudio && !book.isVideo) { //音频和视频获取的是链接,不需要html格式化 && !book.isVideo if (!book.isAudio && !book.isVideo) { //音频和视频获取的是链接,不需要html格式化 && !book.isVideo
val useHtmlMap = mutableMapOf<String, String>() val useHtmlMap = mutableMapOf<String, String>()
if (AppConfig.adaptSpecialStyle) { if (AppConfig.adaptSpecialStyle) {
content = AppPattern.useHtmlRegex.replace(content) { matchResult -> content = AppPattern.useHtmlRegex.replace(content) {
val placeholder = "{usehtml_${useHtmlMap.size}}" val placeholder = "{usehtml_${useHtmlMap.size}}"
useHtmlMap[placeholder] = matchResult.value useHtmlMap[placeholder] = it.value
placeholder placeholder
} }
} }
@@ -225,6 +225,13 @@ object BookContent {
content = content.replace(placeholder, originalContent) content = content.replace(placeholder, originalContent)
} }
} }
//获取副文(歌词等)
contentRule.subContent?.takeIf { it.isNotBlank() }?.let {
val subContent = analyzeRule.getString(it, unescape = false)
if (subContent.isNotBlank()) {
chapter.putVariable("lyric", subContent)
}
}
//获取下一页链接 //获取下一页链接
if (getNextPageUrl) { if (getNextPageUrl) {
val nextUrlRule = contentRule.nextContentUrl val nextUrlRule = contentRule.nextContentUrl
@@ -34,6 +34,7 @@ import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.settingItem.SettingItem import io.legado.app.ui.widget.components.settingItem.SettingItem
import io.legado.app.ui.widget.components.SettingItemWithDivider
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
@@ -132,57 +133,57 @@ fun AboutScreen(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
title = "" title = ""
){ ){
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.contributors), title = stringResource(R.string.contributors),
description = null,
option = null,
onClick = { onClick = {
onOpenUrl("https://github.com/gedoor/legado/graphs/contributors") onOpenUrl("https://github.com/gedoor/legado/graphs/contributors")
} }
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.privacy_policy), title = stringResource(R.string.privacy_policy),
description = null,
option = null,
onClick = { onClick = {
onShowMdFile("隐私政策", "privacyPolicy.md") onShowMdFile("隐私政策", "privacyPolicy.md")
} }
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.license), title = stringResource(R.string.license),
description = null,
option = null,
onClick = { onClick = {
onShowMdFile("许可证", "LICENSE.md") onShowMdFile("许可证", "LICENSE.md")
} }
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.disclaimer), title = stringResource(R.string.disclaimer),
description = null,
option = null,
onClick = { onClick = {
onShowMdFile("免责声明", "disclaimer.md") onShowMdFile("免责声明", "disclaimer.md")
} }
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.crash_log), title = stringResource(R.string.crash_log),
description = null,
option = null,
onClick = onShowCrashLogs onClick = onShowCrashLogs
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.save_log), title = stringResource(R.string.save_log),
description = null,
option = null,
onClick = onSaveLog onClick = onSaveLog
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.create_heap_dump), title = stringResource(R.string.create_heap_dump),
description = null,
option = null,
onClick = onCreateHeapDump onClick = onCreateHeapDump
) )
} }
} }
} }
}
} }
@@ -75,7 +75,7 @@ fun CrashReportScreen(
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Text( AppText(
text = displayText, text = displayText,
style = LegadoTheme.typography.bodySmall, style = LegadoTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
@@ -25,10 +25,13 @@ import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.google.android.material.color.DynamicColorsOptions import com.google.android.material.color.DynamicColorsOptions
import androidx.constraintlayout.widget.ConstraintLayout
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import com.google.android.material.transition.platform.MaterialContainerTransform import com.google.android.material.transition.platform.MaterialContainerTransform
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import android.graphics.Typeface
import com.dirror.lyricviewx.OnPlayClickListener
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.VMBaseActivity import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.BookType import io.legado.app.constant.BookType
@@ -43,6 +46,7 @@ import io.legado.app.databinding.ActivityAudioPlayBinding
import io.legado.app.help.book.isAudio import io.legado.app.help.book.isAudio
import io.legado.app.help.book.removeType import io.legado.app.help.book.removeType
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.model.AudioPlay import io.legado.app.model.AudioPlay
import io.legado.app.model.BookCover import io.legado.app.model.BookCover
import io.legado.app.model.SourceCallBack import io.legado.app.model.SourceCallBack
@@ -55,6 +59,7 @@ import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.utils.StartActivityContract import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.ToolbarUtils.setAllIconsColor import io.legado.app.utils.ToolbarUtils.setAllIconsColor
import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.dpToPx
import io.legado.app.utils.gone import io.legado.app.utils.gone
import io.legado.app.utils.observeEvent import io.legado.app.utils.observeEvent
import io.legado.app.utils.observeEventSticky import io.legado.app.utils.observeEventSticky
@@ -96,6 +101,9 @@ class AudioPlayActivity :
private var secondaryContainerFinalColor: Int = 0 private var secondaryContainerFinalColor: Int = 0
private var currentJob: Job? = null private var currentJob: Job? = null
private var wrappedContext: Context? = null private var wrappedContext: Context? = null
private val lyricViewX by lazy { binding.lyricViewX }
private var lyricOn = false
private var oldLyric: String? = null
private val progressTimeFormat by lazy { private val progressTimeFormat by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -227,6 +235,21 @@ class AudioPlayActivity :
} }
private fun initView() { private fun initView() {
// 应用深度个性化中设置的字体
ThemeConfig.appFontPath?.let {
try {
val typeface = Typeface.createFromFile(it)
// 为所有文本控件设置字体
binding.tvTitle.typeface = typeface
binding.tvSubTitle.typeface = typeface
binding.tvDurTime.typeface = typeface
binding.tvAllTime.typeface = typeface
binding.tvSpeed.typeface = typeface
binding.tvTimer.typeface = typeface
} catch (e: Exception) {
e.printStackTrace()
}
}
binding.ivTimer.isEnabled = false binding.ivTimer.isEnabled = false
binding.ivFastForward.isEnabled = false binding.ivFastForward.isEnabled = false
@@ -279,7 +302,10 @@ class AudioPlayActivity :
override fun onStopTrackingTouch(slider: Slider) { override fun onStopTrackingTouch(slider: Slider) {
adjustProgress = false adjustProgress = false
AudioPlay.adjustProgress(slider.value.toInt()) val progress = slider.value.toInt()
AudioPlay.adjustProgress(progress)
// 更新歌词进度
upLyricP(progress)
} }
}) })
@@ -503,6 +529,8 @@ class AudioPlayActivity :
binding.playerProgress.trackActiveTintList = ColorStateList.valueOf(color) binding.playerProgress.trackActiveTintList = ColorStateList.valueOf(color)
binding.tvDurTime.setTextColor(color) binding.tvDurTime.setTextColor(color)
binding.tvAllTime.setTextColor(color) binding.tvAllTime.setTextColor(color)
// 更新歌词高亮颜色
binding.lyricViewX.setCurrentColor(color)
} }
} }
@@ -671,6 +699,8 @@ class AudioPlayActivity :
val safeValue = progress.toFloat().coerceIn(slider.valueFrom, slider.valueTo) val safeValue = progress.toFloat().coerceIn(slider.valueFrom, slider.valueTo)
if (!adjustProgress) slider.value = safeValue if (!adjustProgress) slider.value = safeValue
binding.tvDurTime.text = progressTimeFormat.format(progress.toLong()) binding.tvDurTime.text = progressTimeFormat.format(progress.toLong())
// 更新歌词进度
upLyricP(progress)
} }
observeEventSticky<Int>(EventBus.AUDIO_BUFFER_PROGRESS) { observeEventSticky<Int>(EventBus.AUDIO_BUFFER_PROGRESS) {
@@ -702,6 +732,56 @@ class AudioPlayActivity :
} }
} }
override fun upLyric(lyric: String?) {
if (oldLyric == lyric) return
oldLyric = lyric
if (lyric.isNullOrBlank()) {
// 没有歌词时,显示标题区域,隐藏歌词
binding.lyricViewX.gone()
binding.llTitle.visible()
return
}
// 有歌词时,隐藏标题区域,显示歌词
binding.llTitle.gone()
// 先隐藏歌词视图,避免显示旧歌词
binding.lyricViewX.gone()
lyricViewX.loadLyric(lyric)
binding.lyricViewX.visible()
if (lyricOn) {
upLyricP(AudioPlay.durChapterPos)
} else {
lyricOn = true
lyricViewX.apply {
setNormalTextSize(50F)
setCurrentTextSize(60F)
// 加载深度个性化中设置的字体
ThemeConfig.appFontPath?.let {
try {
val typeface = Typeface.createFromFile(it)
// LyricViewX 1.3.2 版本可能不支持 setTypeface 方法
// 暂时注释掉,后续可以通过 XML 或其他方式设置
// setTypeface(typeface)
} catch (e: Exception) {
e.printStackTrace()
}
}
setDraggable(true, object : OnPlayClickListener {
override fun onPlayClick(time: Long): Boolean {
AudioPlay.adjustProgress(time.toInt())
return true
}
})
}
lyricViewX.postDelayed({
upLyricP(AudioPlay.durChapterPos)
}, 100)
}
}
override fun upLyricP(position: Int) {
lyricViewX.updateTime(position.toLong(), false)
}
override fun addToBookshelf(book: Book, toc: List<BookChapter>) { override fun addToBookshelf(book: Book, toc: List<BookChapter>) {
viewModel.addToBookshelf(book, toc) { viewModel.addToBookshelf(book, toc) {
toastOnUi("已添加到书架") toastOnUi("已添加到书架")
@@ -72,6 +72,8 @@ import io.legado.app.constant.BookType
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.model.BookCover
import io.legado.app.ui.about.AppLogSheet import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
@@ -402,6 +404,9 @@ private fun BookInfoTransparentTopAppBar(
@Composable @Composable
private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? { private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
val useDefaultCover = AppConfig.useDefaultCover || book?.customCoverUrl == "use_default_cover"
if (useDefaultCover) return null
val imageLoader = koinInject<ImageLoader>() val imageLoader = koinInject<ImageLoader>()
var shouldExtractColor by remember(book?.bookUrl) { mutableStateOf(false) } var shouldExtractColor by remember(book?.bookUrl) { mutableStateOf(false) }
@@ -413,7 +418,12 @@ private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
} }
} }
val coverPath = if (shouldExtractColor) book?.getDisplayCover() else null val isNight = AppConfig.isNightTheme
val coverPath = if (shouldExtractColor) {
book?.getDisplayCover()
} else null
val sourceOrigin = if (shouldExtractColor) book?.origin else null val sourceOrigin = if (shouldExtractColor) book?.origin else null
val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) { val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
@@ -469,8 +479,15 @@ private fun BookInfoTopBarActions(
@Composable @Composable
private fun BookInfoBackdrop(book: Book) { private fun BookInfoBackdrop(book: Book) {
val cover = book.getDisplayCover() val useDefaultCover = AppConfig.useDefaultCover || book.customCoverUrl == "use_default_cover"
val sourceOrigin = book.origin val isNight = AppConfig.isNightTheme
val cover = if (useDefaultCover) {
BookCover.getRandomDefaultPath(book.bookUrl, isNight)
} else {
book.getDisplayCover()
}
val sourceOrigin = if (!useDefaultCover) book.origin else null
val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
val context = LocalContext.current val context = LocalContext.current
val imageLoader = koinInject<ImageLoader>() val imageLoader = koinInject<ImageLoader>()
@@ -709,19 +709,30 @@ class BookInfoViewModel(
private fun refreshMeta(book: Book) { private fun refreshMeta(book: Book) {
execute { execute {
val kinds = book.getKindList().toMutableList() val allKinds = book.getKindList()
val customKinds = allKinds.filter { it.startsWith("#") }
val sourceKinds = allKinds.filter { !it.startsWith("#") }.toMutableList()
if (book.isLocal) { if (book.isLocal) {
val size = FileDoc.fromFile(book.bookUrl).size val size = FileDoc.fromFile(book.bookUrl).size
if (size > 0) { if (size > 0) {
kinds.add(ConvertUtils.formatFileSize(size)) sourceKinds.add(ConvertUtils.formatFileSize(size))
} }
} }
val mergedKinds = (customKinds + sourceKinds).distinct()
val userGroupIds = appDb.bookGroupDao.idsSum val userGroupIds = appDb.bookGroupDao.idsSum
val groupAnd = userGroupIds and book.group val groupAnd = userGroupIds and book.group
val hasCustomGroup = book.group > 0L && groupAnd != 0L val hasCustomGroup = book.group > 0L && groupAnd != 0L
val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",") val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",")
val normalizedGroupNames = groupNames.ifBlank { null } val normalizedGroupNames = groupNames.ifBlank { null }
Triple(kinds.toList(), normalizedGroupNames, hasCustomGroup) val persistedCustomKinds = currentKindLabels.filter { it.startsWith("#") }
val finalKinds = if (persistedCustomKinds.isNotEmpty()) {
(persistedCustomKinds + sourceKinds).distinct()
} else {
mergedKinds
}
book.kind = finalKinds.joinToString(",")
appDb.bookDao.update(book)
Triple(finalKinds, normalizedGroupNames, hasCustomGroup)
}.onSuccess { }.onSuccess {
currentKindLabels = it.first currentKindLabels = it.first
currentGroupNames = it.second currentGroupNames = it.second
@@ -3,6 +3,7 @@ package io.legado.app.ui.book.info.edit
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
@@ -18,6 +19,8 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.ImageSearch import androidx.compose.material.icons.filled.ImageSearch
import androidx.compose.material.icons.filled.Replay import androidx.compose.material.icons.filled.Replay
@@ -27,6 +30,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults