5 Commits
Author SHA1 Message Date
shenlei 3e60bf1869 修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
2026-07-30 20:52:21 +09:00
shenlei e01cbc169d 修复 EPUB 与 PDF 阅读器朗读交互
PDF 朗读的逐词回调会在同一页内持续触发高亮,原实现每次都调用翻页入口,使用户在朗读过程中反复看到页面转场。现仅在目标页变化时定位,并对朗读产生的跨页定位禁用动画,保留高亮刷新而不重复翻页。\n\nEPUB/TXT 此前只提供内容源,缺少将朗读进度回写到阅读器的会话层,无法从当前阅读位置开始朗读、随内容跨页及显示临时高亮。新增 EPUB 朗读会话,使用 href 与 UTF-16 偏移恢复位置;文本重排 EPUB 与 TXT 按实际页面定位并高亮,Web EPUB 则按章节安全定位以避免词级回调导致跳页。URL 阅读器同时提供可选会话入口,图片型阅读器保持不可朗读。\n\n已执行 ReadViewDemo 的 Debug Simulator 构建,结果为 BUILD SUCCEEDED。
2026-07-30 18:33:04 +09:00
shenleiandClaude Opus 5 529663829f 修复退出 EPUB 阅读器后宿主侧滑返回手势被改成阅读器的配置
阅读器在 viewWillAppear 中无条件把导航控制器的 interactivePopGestureRecognizer
接管为自己的 delegate 并强制开启,退出时却不做任何还原。宿主如果原本关闭了
侧滑返回,或为它设置过自定义 delegate,从阅读器返回后这些配置就被永久改写,
其它页面的返回手势行为随之变化。阅读器自身接管横向翻页,进入期间开启侧滑返回
也会与翻页手势争抢边缘触摸。

改为进入时记录原有的开关状态与 delegate 并直接关闭该手势,退出时按记录值还原。
记录只在首次 viewWillAppear 时进行,避免阅读器内部多次前后台切换把自己写入的
状态当成宿主原值。

该修复原本以本地补丁形式存在于 readoor 项目的 Vendor 副本中,现回流上游,避免
后续同步 SDK 时反复丢失。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:03:43 +09:00
shenleiandClaude Opus 5 d830311894 修复 PDF 仿真翻页模式下翻页后页面全白
打开普通 PDF 并使用仿真翻页阅读时,除首页外每翻一页都是纯白:没有页面
内容,也没有页面四周的衬底色。横向滚动与竖滑模式不受影响。样张
英汉大词典App需求说明.pdf 上第 2 到第 9 页无一例外。

根因是页面数据回来时找不到对应的视图。UIPageViewController 会通过
viewControllerBefore/After 提前构建相邻页的内容视图,构建时页面描述尚未
就绪,configure 发起异步 readerPage 请求后先返回一张空白页。描述返回后
acceptPageDescriptor 调用 refreshVisiblePage,而它经由
RDPDFReaderView.pageContentView(pageNum:) 定位视图,该方法在仿真翻页下
只检索 nativeCurlPageController.viewControllers,也就是已经上屏的 child。
预建的 child 此时还不在其中,图片因此从未被装配上去;翻页动画结束后又
没有任何路径重新 configure 该视图,白页就固定下来。此前偶尔能看到内容,
是 OCR 完成回调顺带触发了一次刷新,并非稳定行为。

改为按页码刷新该页的全部活动视图:控制器用弱引用表记录已创建的页面视图,
refreshVisiblePage 改名为 refreshPageViews(at:),除已上屏视图外一并装配
尚未上屏的预建视图,pageContentView 创建时即写入 tag 以便按页码匹配。
翻页落定时在 pageNum 回调中补一次装配,使内存告警清掉页面描述的场景也能
重新发起请求并恢复。另外把 image 为空的页面描述改按无效结果处理,走既有
的重试与失败重载入口,不再缓存出一张永远不会重试的白页。

新增 PDFPageTurnBlankPageTests 作为回归:翻页稳定后截屏统计画面中部深色
像素占比,白页为 0,扉页约 0.002,正文页在 0.01 以上,以 0.0005 为判定
阈值,覆盖仿真翻页正翻、仿真回翻和横向滚动三种路径。

验证:iPhone 17 Pro / iOS 26.4.1 模拟器执行 PDFPageTurnBlankPageTests、
PDFAnnotationTests、PDFDrawingModeTests、PageNavigationTests、
DisplayTypeTests 共 23 条用例,22 条通过。修复前仿真翻页第 2 到 9 页墨迹
占比全为 0.0000,修复后与横向滚动模式逐页吻合。唯一失败的
DisplayTypeTests.testDisplayTypeSwitchMidSession 属 EPUB 设置面板既有问题,
将本次改动还原后同样失败。文档补充了 Demo 因链接 FoundationModels 而必须
使用 iOS 26 及以上模拟器的说明。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:52:29 +09:00
shenleiandClaude Opus 5 735c14c824 隐藏 PDF Demo 阅读器中的 AI 助手入口与 TTS 朗读控件
PDF Demo 宿主在阅读界面右上角常驻一个 "AI" 按钮,底部常驻一组 TTS 播放
控件。这两处是示例性质的能力入口,当前不希望在阅读界面上出现。

在宿主控制器上引入 showsAIAssistant 与 showsSpeechControls 两个开关,
viewDidLoad 中据此决定是否创建 AI 按钮、是否装配朗读控件,两者都不再被
加入视图层级。底层的 presentAIAssistant、speechSession 及播放、语速控制
逻辑全部保留,把开关置为 true 即可恢复,避免以后重新接入时再写一遍。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:51:57 +09:00
49 changed files with 3581 additions and 2726 deletions
+7 -3
View File
@@ -251,13 +251,17 @@ public protocol RDEPUBReaderPersistence: AnyObject {
} }
``` ```
默认实现 `RDEPUBUserDefaultsPersistence` 使用 UserDefaults 存储: 默认实现 `RDEPUBUserDefaultsPersistence` 将阅读位置和全局设置存入 UserDefaults
书签与高亮(可能含书籍正文摘录)存入带文件保护且排除备份的 Application Support 文件。
升级后的首次读取会自动迁移历史 UserDefaults 数据,并仅在新副本成功落盘后删除旧副本。
不同的 `UserDefaults` 容器会自动使用独立的文件命名空间,避免多账号或测试 suite
之间混用同一本书的标注;标准容器保留对旧版未分区文件的只读兼容。
| 数据 | Key 格式 | | 数据 | Key 格式 |
|------|----------| |------|----------|
| 阅读位置 | `ssreader.epub.location.{bookID}` | | 阅读位置 | `ssreader.epub.location.{bookID}` |
| 书签 | `ssreader.epub.bookmarks.{bookID}` | | 书签 | 受保护文件(历史键:`ssreader.epub.bookmarks.{bookID}` |
| 高亮 | `ssreader.epub.highlights.{bookID}` | | 高亮 | 受保护文件(历史键:`ssreader.epub.highlights.{bookID}` |
| 全局设置 | `ssreader.epub.settings` | | 全局设置 | `ssreader.epub.settings` |
--- ---
+12
View File
@@ -1,6 +1,18 @@
# 代码库风险与关注点 # 代码库风险与关注点
**分析日期:** 2026-05-21 **分析日期:** 2026-05-21
**状态复核:** 2026-07-30(逐条对照源码,结论见下表;正文段落保留原始分析,勿当作现状)
| # | 项目 | 现状 |
|---|------|------|
| 1 | User Script Sandboxing 被关 | **部分处理**:已移除 Podfile 的全局强制覆盖;但显式打成 `YES` 会让 CocoaPods 的 `[CP] Embed Pods Frameworks` rsync 阶段被沙箱拒绝而构建失败(已实测)。要真正开启需先改掉 `use_frameworks!` 动态框架集成方式。 |
| 2 | 外链未做 allowlist | **已修**`RDEPUBReaderController+ExternalLinks.swift` 有 scheme allowlist + 二次确认弹窗 + delegate 否决。 |
| 3 | Zip Slip 未加固 | **已修**`RDEPUBParser+Archive.swift``validatedExtractionDestination` 做 standardized 前缀校验并拒绝 `..`/绝对路径。 |
| 4 | 高亮/选中文本明文写 UserDefaults | **已修**:新增 `RDEPUBProtectedContentStore`,高亮与书签改写 Application Support 下带 file protection 的文件、排除备份,首次读取自动迁移并删除明文;另加 `clearReadingData(for:)` / `clearAllReadingContent()`。 |
| 5 | Scheme handler 整文件读内存 | **原判断有误**>512KB 早已走 `respondWithStreaming` 64KB 分块。本次补上 `maximumInMemoryResourceBytes`(默认 64MB)给走不了流式的加密 provider 分支兜底。 |
| 6 | 分页 WKWebView 可能加载外部资源 | 未复核。 |
| 7 | 解压缓存无限累积 | **已修**`pruneExtractionCache(keeping:)` 先按 `extractionCacheMaximumAge`30 天)再按 `extractionCacheMaximumTotalBytes`(512MB)做 LRU 淘汰;复用时 touch mtime;另有 `clearExtractionCache()`。 |
| 8+ | 其余性能/可维护性项 | 未复核。 |
## 高风险:安全与隐私 ## 高风险:安全与隐私
+7
View File
@@ -34,6 +34,7 @@
| LocationPersistenceTests.swift | 阅读位置持久化 | | LocationPersistenceTests.swift | 阅读位置持久化 |
| MetadataParseBenchmarkTests.swift | 元数据解析性能基准 | | MetadataParseBenchmarkTests.swift | 元数据解析性能基准 |
| PageNavigationTests.swift | 页面导航 | | PageNavigationTests.swift | 页面导航 |
| PDFPageTurnBlankPageTests.swift | PDF 翻页后白页回归(截屏墨迹占比判定) |
| ReaderAnnotationTests.swift | 标注基础功能 | | ReaderAnnotationTests.swift | 标注基础功能 |
| ReaderAnnotationExtendedTests.swift | 标注扩展功能 | | ReaderAnnotationExtendedTests.swift | 标注扩展功能 |
| ReaderOpenCloseTests.swift | 阅读器打开/关闭 | | ReaderOpenCloseTests.swift | 阅读器打开/关闭 |
@@ -72,6 +73,12 @@ cd ReadViewDemo && pod install
(依赖入口:`ReadViewDemo/Podfile` (依赖入口:`ReadViewDemo/Podfile`
### 模拟器要求
Demo 链接了 `FoundationModels.framework`(AI 助手示例入口)。该框架自 iOS 26 起提供,
在 iOS 18 及更早的模拟器上 App 会在启动时因 `Library not loaded` 直接崩溃,测试表现为
卡在 Launch 阶段。请选择 iOS 26 及以上的模拟器运行 UI 测试。
### 运行 UI 测试 ### 运行 UI 测试
```bash ```bash
+1 -1
View File
@@ -25,7 +25,7 @@
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [CONCERNS.md](CONCERNS.md) | 代码库风险与关注点:12 项安全/性能/可维护性风险 | | [CONCERNS.md](CONCERNS.md) | 代码库风险与关注点:12 项安全/性能/可维护性风险(顶部有 2026-07-30 状态复核表) |
| [TESTING.md](TESTING.md) | 测试基础设施:UI 测试文件清单、运行方式、覆盖率 | | [TESTING.md](TESTING.md) | 测试基础设施:UI 测试文件清单、运行方式、覆盖率 |
| [CONVENTIONS.md](CONVENTIONS.md) | 编码规范:命名约定、代码风格、导入规范、错误处理 | | [CONVENTIONS.md](CONVENTIONS.md) | 编码规范:命名约定、代码风格、导入规范、错误处理 |
+8 -1
View File
@@ -17,8 +17,15 @@ target 'ReadViewDemo' do
end end
post_install do |installer| post_install do |installer|
# 不再由本 Podfile 全局关闭 ENABLE_USER_SCRIPT_SANDBOXING——之前的写法会把
# 宿主工程一起强制改成 NO,属于我们主动降级安全基线。现在交回工程默认值。
#
# 注意:本工程用 use_frameworks!CocoaPods 生成的 [CP] Embed Pods Frameworks
# 脚本阶段用 rsync 往 .app/Frameworks 拷贝产物;一旦把该设置显式打成 YES,
# 这一步会被沙箱拒绝(file-write-unlink / utimensat: Operation not permitted
# 而整个构建失败——已实测确认。真要打开必须先改掉动态框架集成方式
# (静态库 / SPM),别只翻这个开关。
apply_settings = lambda do |config| apply_settings = lambda do |config|
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end end
+2 -2
View File
@@ -90,13 +90,13 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDAIReaderView: 56783c71853c59bf6810f719a57a7f580eb7855c RDAIReaderView: 01a156e412fb7a52a6963c097ffbe2f1792021fd
RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9 RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9
RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c
RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 7a0e6ee6c7d410a5128fb20775a450fd0d47292c PODFILE CHECKSUM: 72bd92309c87ce13a247b8134a321f6b325af731
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2
@@ -43,7 +43,7 @@
"~> 0.1" "~> 0.1"
] ]
}, },
"frameworks": "FoundationModels" "weak_frameworks": "FoundationModels"
}, },
{ {
"name": "UI", "name": "UI",
+2 -2
View File
@@ -90,13 +90,13 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDAIReaderView: 56783c71853c59bf6810f719a57a7f580eb7855c RDAIReaderView: 01a156e412fb7a52a6963c097ffbe2f1792021fd
RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9 RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9
RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c
RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 7a0e6ee6c7d410a5128fb20775a450fd0d47292c PODFILE CHECKSUM: 72bd92309c87ce13a247b8134a321f6b325af731
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView/RDAIReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2" HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView/RDAIReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks' LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -l"swiftCoreGraphics" -l"xml2" -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "DTFoundation" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -framework "ZIPFoundation" OTHER_LDFLAGS = $(inherited) -l"sqlite3" -l"swiftCoreGraphics" -l"xml2" -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -framework "ZIPFoundation" -weak_framework "FoundationModels"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
@@ -5,7 +5,7 @@ GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView/RDAIReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2" HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView/RDAIReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks' LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -l"swiftCoreGraphics" -l"xml2" -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "DTFoundation" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -framework "ZIPFoundation" OTHER_LDFLAGS = $(inherited) -l"sqlite3" -l"swiftCoreGraphics" -l"xml2" -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -framework "ZIPFoundation" -weak_framework "FoundationModels"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
@@ -2,7 +2,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -framework "CryptoKit" -framework "FoundationModels" -framework "NaturalLanguage" -framework "UIKit" OTHER_LDFLAGS = $(inherited) -l"sqlite3" -framework "CryptoKit" -framework "NaturalLanguage" -framework "UIKit" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -2,7 +2,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -framework "CryptoKit" -framework "FoundationModels" -framework "NaturalLanguage" -framework "UIKit" OTHER_LDFLAGS = $(inherited) -l"sqlite3" -framework "CryptoKit" -framework "NaturalLanguage" -framework "UIKit" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "ZIPFoundation" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "ZIPFoundation" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "ZIPFoundation" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "ZIPFoundation" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreImage" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreImage" -framework "CryptoKit" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreImage" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreImage" -framework "CryptoKit" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "RDAIReaderView" -framework "UIKit" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CryptoKit" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "RDAIReaderView" -framework "UIKit" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "RDAIReaderView" -framework "UIKit" OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CryptoKit" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "RDAIReaderView" -framework "UIKit" -weak_framework "FoundationModels"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
@@ -42,6 +42,7 @@
9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 697A5BCF0D93DBEC18E21A09 /* Foundation.framework */; }; 9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 697A5BCF0D93DBEC18E21A09 /* Foundation.framework */; };
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */; }; A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */; };
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; }; C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
C0FFEE0100000001AABBCC01 /* PDFPageTurnBlankPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE0100000001AABBCC02 /* PDFPageTurnBlankPageTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; }; DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; }; FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -95,6 +96,7 @@
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; }; 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFAnnotationTests.swift; sourceTree = "<group>"; }; A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFAnnotationTests.swift; sourceTree = "<group>"; };
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; }; BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
C0FFEE0100000001AABBCC02 /* PDFPageTurnBlankPageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFPageTurnBlankPageTests.swift; sourceTree = "<group>"; };
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; }; CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; }; FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
@@ -213,6 +215,7 @@
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */, FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */, 1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */,
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */, A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */,
C0FFEE0100000001AABBCC02 /* PDFPageTurnBlankPageTests.swift */,
2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */, 2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */,
2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */, 2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */,
2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */, 2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */,
@@ -394,6 +397,7 @@
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */, 23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */, 1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */, A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */,
C0FFEE0100000001AABBCC01 /* PDFPageTurnBlankPageTests.swift in Sources */,
2B3C4D5E00000001AABBCC01 /* BookmarkChromeStateTests.swift in Sources */, 2B3C4D5E00000001AABBCC01 /* BookmarkChromeStateTests.swift in Sources */,
2B3C4D5E00000002AABBCC01 /* BookmarkManagementTests.swift in Sources */, 2B3C4D5E00000002AABBCC01 /* BookmarkManagementTests.swift in Sources */,
2B3C4D5E00000003AABBCC01 /* ErrorAndEdgeCaseTests.swift in Sources */, 2B3C4D5E00000003AABBCC01 /* ErrorAndEdgeCaseTests.swift in Sources */,
@@ -452,7 +456,6 @@
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests; PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
@@ -477,7 +480,6 @@
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests; PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
@@ -504,7 +506,6 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ReadViewDemo/Info.plist; INFOPLIST_FILE = ReadViewDemo/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -543,7 +544,6 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ReadViewDemo/Info.plist; INFOPLIST_FILE = ReadViewDemo/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -608,7 +608,6 @@
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@@ -671,7 +670,6 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -152,6 +152,9 @@ private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
/// Demo 宿 /// Demo 宿
final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewControllerDelegate { final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewControllerDelegate {
private static let annotationStorageFolderName = "RDPDFImageReaderAnnotations" private static let annotationStorageFolderName = "RDPDFImageReaderAnnotations"
/// AI TTS 便
private static let showsAIAssistant = false
private static let showsSpeechControls = false
private let reader: RDPDFReaderViewController private let reader: RDPDFReaderViewController
private lazy var speechSession: RDPDFSpeechSession = { private lazy var speechSession: RDPDFSpeechSession = {
let session = reader.makeSpeechSession() let session = reader.makeSpeechSession()
@@ -224,15 +227,17 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
reader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] reader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(reader.view) view.addSubview(reader.view)
reader.didMove(toParent: self) reader.didMove(toParent: self)
configureSpeechControls() if Self.showsSpeechControls { configureSpeechControls() }
let assistantButton = UIButton(type: .system) if Self.showsAIAssistant {
assistantButton.setTitle("AI", for: .normal) let assistantButton = UIButton(type: .system)
assistantButton.titleLabel?.font = .preferredFont(forTextStyle: .headline) assistantButton.setTitle("AI", for: .normal)
assistantButton.addTarget(self, action: #selector(presentAIAssistant), for: .touchUpInside) assistantButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
assistantButton.accessibilityLabel = "打开阅读助手" assistantButton.addTarget(self, action: #selector(presentAIAssistant), for: .touchUpInside)
view.addSubview(assistantButton) assistantButton.accessibilityLabel = "打开阅读助手"
assistantButton.translatesAutoresizingMaskIntoConstraints = false view.addSubview(assistantButton)
NSLayoutConstraint.activate([assistantButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), assistantButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), assistantButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44), assistantButton.heightAnchor.constraint(equalToConstant: 44)]) assistantButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([assistantButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), assistantButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), assistantButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44), assistantButton.heightAnchor.constraint(equalToConstant: 44)])
}
stateLabel.translatesAutoresizingMaskIntoConstraints = false stateLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stateLabel) view.addSubview(stateLabel)
NSLayoutConstraint.activate([stateLabel.widthAnchor.constraint(equalToConstant: 1), stateLabel.heightAnchor.constraint(equalToConstant: 1), stateLabel.topAnchor.constraint(equalTo: view.topAnchor), stateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor)]) NSLayoutConstraint.activate([stateLabel.widthAnchor.constraint(equalToConstant: 1), stateLabel.heightAnchor.constraint(equalToConstant: 1), stateLabel.topAnchor.constraint(equalTo: view.topAnchor), stateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor)])
@@ -353,6 +353,9 @@ final class ViewController: UIViewController {
defaults.removeObject(forKey: key) defaults.removeObject(forKey: key)
} }
} }
// / UserDefaults
// defaults
RDEPUBProtectedContentStore().removeAll()
PDFDemoReaderViewController.resetPersistedAnnotations() PDFDemoReaderViewController.resetPersistedAnnotations()
} }
@@ -74,10 +74,13 @@ enum IDs {
static let searchClose = "epub.reader.search.close" static let searchClose = "epub.reader.search.close"
static let searchCount = "epub.reader.search.count" static let searchCount = "epub.reader.search.count"
/// EPUB pill scrollView
static let settingsPanel = "epub.reader.settings.panel"
/// PDF scrollView`RDPDFReaderSettingsPanelViewController`
static let settingsScroll = "epub.reader.settings.scroll" static let settingsScroll = "epub.reader.settings.scroll"
static let settingsBrightness = "epub.reader.settings.brightness" static let settingsBrightness = "epub.reader.settings.brightness"
static let settingsFontIncrease = "epub.reader.settings.font.increase" /// SDK font.increase / font.decrease
static let settingsFontDecrease = "epub.reader.settings.font.decrease" static let settingsFontSize = "epub.reader.settings.font.size"
static let settingsFontValue = "epub.reader.settings.font.value" static let settingsFontValue = "epub.reader.settings.font.value"
static let settingsFontChoice = "epub.reader.settings.font.choice" static let settingsFontChoice = "epub.reader.settings.font.choice"
static let settingsLineHeight = "epub.reader.settings.lineHeight" static let settingsLineHeight = "epub.reader.settings.lineHeight"
@@ -169,4 +169,74 @@ extension XCUIApplication {
private func readerChromeIsVisible(requiredButtonIDs: [String]) -> Bool { private func readerChromeIsVisible(requiredButtonIDs: [String]) -> Bool {
requiredButtonIDs.allSatisfy { buttons[$0].exists } requiredButtonIDs.allSatisfy { buttons[$0].exists }
} }
// MARK: -
/// EPUB
/// scrollView
@discardableResult
func waitForSettingsPanel(timeout: TimeInterval = 5) -> Bool {
if otherElements[IDs.settingsPanel].waitForExistence(timeout: timeout) {
return true
}
// otherElement 退
return buttons[IDs.settingsDone].waitForExistence(timeout: 1)
}
/// // pill + action sheet
/// segmentedControl pill
@discardableResult
func selectSettingsPillOption(
pillIdentifier: String,
optionTitle: String,
timeout: TimeInterval = 5
) -> Bool {
let pill = buttons[pillIdentifier]
guard pill.waitForExistence(timeout: timeout) else { return false }
pill.tap()
// action sheet iOS button
let option = sheets.buttons[optionTitle].exists
? sheets.buttons[optionTitle]
: buttons[optionTitle]
guard option.waitForExistence(timeout: timeout) else {
// sheet
if sheets.buttons["取消"].exists {
sheets.buttons["取消"].tap()
}
return false
}
option.tap()
return true
}
/// slider`epub.reader.settings.font.size`/
///
@discardableResult
func adjustSettingsFontSize(toNormalizedPosition position: CGFloat, timeout: TimeInterval = 5) -> Bool {
let slider = sliders[IDs.settingsFontSize]
guard slider.waitForExistence(timeout: timeout) else { return false }
slider.adjust(toNormalizedSliderPosition: position)
return true
}
/// font.increase / font.decrease
@discardableResult
func nudgeSettingsFontSize(by delta: CGFloat, timeout: TimeInterval = 5) -> Bool {
let slider = sliders[IDs.settingsFontSize]
guard slider.waitForExistence(timeout: timeout) else { return false }
let target = min(max(slider.normalizedSliderPosition + delta, 0), 1)
slider.adjust(toNormalizedSliderPosition: target)
return true
}
@discardableResult
func increaseSettingsFontSize(timeout: TimeInterval = 5) -> Bool {
nudgeSettingsFontSize(by: 0.25, timeout: timeout)
}
@discardableResult
func decreaseSettingsFontSize(timeout: TimeInterval = 5) -> Bool {
nudgeSettingsFontSize(by: -0.25, timeout: timeout)
}
} }
@@ -465,12 +465,8 @@ final class AsyncChapterLoadingTests: XCTestCase {
// Change font size via settings // Change font size via settings
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease] XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块不存在")
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮不存在") RunLoop.current.run(until: Date().addingTimeInterval(0.6))
fontIncrease.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
fontIncrease.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
// Close settings // Close settings
let doneButton = app.buttons[IDs.settingsDone] let doneButton = app.buttons[IDs.settingsDone]
@@ -41,9 +41,10 @@ final class DisplayTypeTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3))
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType] XCTAssertTrue(
XCTAssertTrue(displayTypeControl.waitForExistence(timeout: 5)) app.selectSettingsPillOption(pillIdentifier: IDs.settingsDisplayType, optionTitle: "竖滑"),
displayTypeControl.buttons.element(boundBy: 2).tap() "翻页方式应可切换到竖滑"
)
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 8, description: "display=verticalScroll after switch") { $0.display == "verticalScroll" } app.waitForDemoReaderState(timeout: 8, description: "display=verticalScroll after switch") { $0.display == "verticalScroll" }
@@ -45,23 +45,20 @@ final class ErrorAndEdgeCaseTests: XCTestCase {
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened } app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
} }
func testSettingsPanelScrollReachesAllControls() throws { func testSettingsPanelReachesAllControls() throws {
app.launchAndOpenSampleBook() app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let settingsScroll = app.scrollViews[IDs.settingsScroll] XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
XCTAssertTrue(settingsScroll.waitForExistence(timeout: 5), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3)) XCTAssertTrue(app.sliders[IDs.settingsFontSize].waitForExistence(timeout: 3))
XCTAssertTrue(app.buttons[IDs.settingsBrightness].waitForExistence(timeout: 3)) XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
settingsScroll.swipeUp() XCTAssertTrue(app.buttons[IDs.settingsDisplayType].waitForExistence(timeout: 5))
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 5))
settingsScroll.swipeUp()
XCTAssertTrue(app.buttons[IDs.settingsTheme(5)].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons[IDs.settingsTheme(5)].waitForExistence(timeout: 5))
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -8,7 +8,9 @@ final class LocationPersistenceTests: XCTestCase {
} }
func testReadingPositionRestoredOnReopen() throws { func testReadingPositionRestoredOnReopen() throws {
app.launchAndOpenSampleBook() // 仿 `epub.reader.paging`
// collectionViewswipeLeft 西 1
app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
_ = app.waitForDemoReaderState(timeout: 5, description: "存在页码") { $0.page != nil } _ = app.waitForDemoReaderState(timeout: 5, description: "存在页码") { $0.page != nil }
@@ -30,7 +32,8 @@ final class LocationPersistenceTests: XCTestCase {
let backButton = app.buttons[IDs.readerBack] let backButton = app.buttons[IDs.readerBack]
if backButton.waitForExistence(timeout: 3) { backButton.tap() } if backButton.waitForExistence(timeout: 3) { backButton.tap() }
app.launchAndOpenSampleBook() // --demo-reset-state
app.launchAndOpenSampleBook(displayType: "scroll", resetsReaderState: false)
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
let restoredState = app.waitForDemoReaderState(timeout: 8, description: "恢复阅读位置") { state in let restoredState = app.waitForDemoReaderState(timeout: 8, description: "恢复阅读位置") { state in
@@ -61,7 +64,7 @@ final class LocationPersistenceTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() } if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() }
if app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 5) { app.buttons[IDs.settingsFontIncrease].tap() } app.increaseSettingsFontSize()
if app.buttons[IDs.settingsDone].waitForExistence(timeout: 3) { app.buttons[IDs.settingsDone].tap() } if app.buttons[IDs.settingsDone].waitForExistence(timeout: 3) { app.buttons[IDs.settingsDone].tap() }
let backButton = app.buttons[IDs.readerBack] let backButton = app.buttons[IDs.readerBack]
@@ -74,7 +77,8 @@ final class LocationPersistenceTests: XCTestCase {
} }
func testPositionSurvivesFontChoiceAndLineHeightChange() throws { func testPositionSurvivesFontChoiceAndLineHeightChange() throws {
app.launchAndOpenSampleBook(resetsReaderState: true) //
app.launchAndOpenSampleBook(displayType: "scroll", resetsReaderState: true)
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
let paging = app.collectionViews[IDs.readerPaging] let paging = app.collectionViews[IDs.readerPaging]
@@ -97,17 +101,14 @@ final class LocationPersistenceTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3))
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice] XCTAssertTrue(
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5), "字体选择控件应出现") app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
if fontChoiceControl.buttons.count > 1 { "字体选择控件应可切换"
fontChoiceControl.buttons.element(boundBy: 1).tap() )
} XCTAssertTrue(
app.selectSettingsPillOption(pillIdentifier: IDs.settingsLineHeight, optionTitle: "标准"),
let lineHeightControl = app.segmentedControls[IDs.settingsLineHeight] "行距控件应可切换"
XCTAssertTrue(lineHeightControl.waitForExistence(timeout: 5), "行距控件应出现") )
if lineHeightControl.buttons.count > 1 {
lineHeightControl.buttons.element(boundBy: min(1, lineHeightControl.buttons.count - 1)).tap()
}
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3))
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -131,8 +132,9 @@ final class LocationPersistenceTests: XCTestCase {
app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 } app.waitForDemoReaderState(timeout: 5, description: "bookmarks=1") { $0.bookmarks == 1 }
app.hideReaderChromeIfNeeded() app.hideReaderChromeIfNeeded()
let content = app.otherElements[IDs.readerSelectionText].firstMatch //
XCTAssertTrue(content.waitForExistence(timeout: 5)) //
let content = try app.requireSelectionText()
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)) let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5)) let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end) start.press(forDuration: 0.8, thenDragTo: end)
@@ -174,11 +176,10 @@ final class LocationPersistenceTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice] XCTAssertTrue(
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5), "字体选择控件应出现") app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
if fontChoiceControl.buttons.count > 1 { "字体选择控件应可切换"
fontChoiceControl.buttons.element(boundBy: 1).tap() )
}
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
let content = app.otherElements[IDs.readerContent].firstMatch let content = app.otherElements[IDs.readerContent].firstMatch
@@ -92,10 +92,8 @@ final class MemoryFootprintTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5), "设置按钮不存在") XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5), "设置按钮不存在")
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现") XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在") XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块不存在")
app.buttons[IDs.settingsFontIncrease].tap()
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在") XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -0,0 +1,147 @@
import XCTest
import UIKit
/// PDF
///
/// PDF
/// 线
final class PDFPageTurnBlankPageTests: XCTestCase {
private let app = XCUIApplication()
/// 0.0000
/// 0.0020 0.0100
private let blankInkRatioThreshold = 0.0005
override func setUpWithError() throws {
continueAfterFailure = false
}
func testPageCurlForwardTurnsRenderPageContent() {
openDictionaryPDF(displayType: "pagecurl")
assertForwardTurnsRenderContent(turns: 8, mode: "仿真翻页")
}
func testHorizontalScrollForwardTurnsRenderPageContent() {
openDictionaryPDF(displayType: "horizontalscroll")
assertForwardTurnsRenderContent(turns: 8, mode: "横向滚动")
}
/// `viewControllerBefore`
func testPageCurlBackwardTurnsRenderPageContent() {
openDictionaryPDF(displayType: "pagecurl")
assertForwardTurnsRenderContent(turns: 5, mode: "仿真翻页")
assertBackwardTurnsRenderContent(from: 6, turns: 5, mode: "仿真回翻")
}
// MARK: -
private func openDictionaryPDF(displayType: String) {
app.launchAndOpenSampleBook(
bookTitleQuery: "英汉大词典",
displayType: displayType,
resetsReaderState: true
)
app.waitForReader(timeout: 30)
app.waitForReaderPage(1, timeout: 30)
//
RunLoop.current.run(until: Date().addingTimeInterval(2.5))
}
private func assertForwardTurnsRenderContent(turns: Int, mode: String) {
var blankPages: [Int] = []
recordInk(page: 1, mode: mode, blankPages: &blankPages)
for step in 1...turns {
let expectedPage = step + 1
turnToNextPage()
app.waitForDemoReaderState(timeout: 15, description: "\(mode) page=\(expectedPage)") {
$0.page == expectedPage
}
//
RunLoop.current.run(until: Date().addingTimeInterval(1.2))
recordInk(page: expectedPage, mode: mode, blankPages: &blankPages)
}
XCTAssertTrue(
blankPages.isEmpty,
"\(mode):翻页后出现白页,页码 \(blankPages.map(String.init).joined(separator: ", "))"
)
}
private func assertBackwardTurnsRenderContent(from startPage: Int, turns: Int, mode: String) {
var blankPages: [Int] = []
for step in 1...turns {
let expectedPage = startPage - step
guard expectedPage >= 1 else { break }
turnPage(forward: false)
app.waitForDemoReaderState(timeout: 15, description: "\(mode) page=\(expectedPage)") {
$0.page == expectedPage
}
RunLoop.current.run(until: Date().addingTimeInterval(1.2))
recordInk(page: expectedPage, mode: mode, blankPages: &blankPages)
}
XCTAssertTrue(
blankPages.isEmpty,
"\(mode):翻页后出现白页,页码 \(blankPages.map(String.init).joined(separator: ", "))"
)
}
private func turnToNextPage() { turnPage(forward: true) }
private func turnPage(forward: Bool) {
let content = app.otherElements[IDs.readerContent].firstMatch
let target: XCUIElement = content.exists ? content : app.windows.firstMatch
let leading = CGVector(dx: 0.08, dy: 0.5)
let trailing = CGVector(dx: 0.88, dy: 0.5)
let start = target.coordinate(withNormalizedOffset: forward ? trailing : leading)
let end = target.coordinate(withNormalizedOffset: forward ? leading : trailing)
start.press(forDuration: 0.05, thenDragTo: end)
}
private func recordInk(page: Int, mode: String, blankPages: inout [Int]) {
let screenshot = XCUIScreen.main.screenshot()
let ratio = inkRatio(of: screenshot.image)
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "\(mode)-page-\(page)-ink-\(String(format: "%.4f", ratio))"
attachment.lifetime = .keepAlways
add(attachment)
if ratio < blankInkRatioThreshold {
blankPages.append(page)
}
}
///
private func inkRatio(of image: UIImage) -> Double {
guard let cgImage = image.cgImage else { return 0 }
let cropRect = CGRect(
x: CGFloat(cgImage.width) * 0.12,
y: CGFloat(cgImage.height) * 0.16,
width: CGFloat(cgImage.width) * 0.76,
height: CGFloat(cgImage.height) * 0.68
).integral
guard let cropped = cgImage.cropping(to: cropRect) else { return 0 }
let side = 400
var pixels = [UInt8](repeating: 255, count: side * side)
guard let context = CGContext(
data: &pixels,
width: side,
height: side,
bitsPerComponent: 8,
bytesPerRow: side,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { return 0 }
context.draw(cropped, in: CGRect(x: 0, y: 0, width: side, height: side))
let darkCount = pixels.reduce(into: 0) { partial, value in
if value < 150 { partial += 1 }
}
return Double(darkCount) / Double(side * side)
}
}
@@ -301,10 +301,7 @@ final class SearchTests: XCTestCase {
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3)) XCTAssertTrue(settingsButton.waitForExistence(timeout: 3))
settingsButton.tap() settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease] XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3))
fontIncrease.tap()
fontIncrease.tap()
Thread.sleep(forTimeInterval: 0.5) Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone] let doneButton = app.buttons[IDs.settingsDone]
@@ -367,10 +364,7 @@ final class SearchTests: XCTestCase {
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在") XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在")
settingsButton.tap() settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease] XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮应存在")
fontIncrease.tap()
fontIncrease.tap()
Thread.sleep(forTimeInterval: 0.5) Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone] let doneButton = app.buttons[IDs.settingsDone]
@@ -114,10 +114,7 @@ final class SelectionAnnotateTests: XCTestCase {
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在") XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在")
settingsButton.tap() settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease] XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮应存在")
fontIncrease.tap()
fontIncrease.tap()
Thread.sleep(forTimeInterval: 0.5) Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone] let doneButton = app.buttons[IDs.settingsDone]
@@ -16,9 +16,8 @@ final class SettingsEffectTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在") XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在")
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现") XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在") XCTAssertTrue(app.increaseSettingsFontSize(), "字号滑块不存在")
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在") XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -32,8 +31,7 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现") XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
app.scrollViews[IDs.settingsScroll].swipeUp()
let darkThemeButton = app.buttons[IDs.settingsTheme(5)] let darkThemeButton = app.buttons[IDs.settingsTheme(5)]
if darkThemeButton.waitForExistence(timeout: 3) { darkThemeButton.tap() } if darkThemeButton.waitForExistence(timeout: 3) { darkThemeButton.tap() }
@@ -50,12 +48,9 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5)) XCTAssertTrue(app.waitForSettingsPanel())
let lineHeightControl = app.segmentedControls[IDs.settingsLineHeight] app.selectSettingsPillOption(pillIdentifier: IDs.settingsLineHeight, optionTitle: "标准")
if lineHeightControl.waitForExistence(timeout: 3) && lineHeightControl.buttons.count > 1 {
lineHeightControl.buttons.element(boundBy: 1).tap()
}
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened } app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
@@ -68,12 +63,9 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5)) XCTAssertTrue(app.waitForSettingsPanel())
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType] app.selectSettingsPillOption(pillIdentifier: IDs.settingsDisplayType, optionTitle: "仿真")
if displayTypeControl.waitForExistence(timeout: 3) && displayTypeControl.buttons.count > 0 {
displayTypeControl.buttons.element(boundBy: 0).tap()
}
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 8, description: "display changed") { $0.display != nil } app.waitForDemoReaderState(timeout: 8, description: "display changed") { $0.display != nil }
@@ -98,19 +90,21 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 5)) XCTAssertTrue(app.sliders[IDs.settingsFontSize].waitForExistence(timeout: 5))
let fontValueLabel = app.staticTexts[IDs.settingsFontValue] let fontValueLabel = app.staticTexts[IDs.settingsFontValue]
XCTAssertTrue(fontValueLabel.waitForExistence(timeout: 3)) XCTAssertTrue(fontValueLabel.waitForExistence(timeout: 3))
let originalFontValue = fontValueLabel.label let originalFontValue = fontValueLabel.label
app.buttons[IDs.settingsFontIncrease].tap() XCTAssertTrue(app.increaseSettingsFontSize(), "字号滑块应存在")
let newFontValue = fontValueLabel.label let newFontValue = fontValueLabel.label
XCTAssertNotEqual(newFontValue, originalFontValue, "点击增大字号 label 应变化") XCTAssertNotEqual(newFontValue, originalFontValue, "增大字号 label 应变化")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.buttons[IDs.readerBack].tap() app.buttons[IDs.readerBack].tap()
app.launchAndOpenSampleBook() // resetsReaderState
//
app.launchAndOpenSampleBook(resetsReaderState: false)
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
@@ -158,11 +152,12 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice] let fontChoiceControl = app.buttons[IDs.settingsFontChoice]
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5)) XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5))
if fontChoiceControl.buttons.count > 1 { XCTAssertTrue(
fontChoiceControl.buttons.element(boundBy: 1).tap() app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
} "字体应可切换到宋体"
)
XCTAssertEqual(fontChoiceControl.value as? String, "宋体", "切换字体后当前字体值应更新") XCTAssertEqual(fontChoiceControl.value as? String, "宋体", "切换字体后当前字体值应更新")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -174,7 +169,7 @@ final class SettingsEffectTests: XCTestCase {
app.showReaderChromeIfNeeded() app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
let reopenedFontChoiceControl = app.segmentedControls[IDs.settingsFontChoice] let reopenedFontChoiceControl = app.buttons[IDs.settingsFontChoice]
XCTAssertTrue(reopenedFontChoiceControl.waitForExistence(timeout: 5)) XCTAssertTrue(reopenedFontChoiceControl.waitForExistence(timeout: 5))
XCTAssertEqual(reopenedFontChoiceControl.value as? String, "宋体", "字体选择应在重启后保持") XCTAssertEqual(reopenedFontChoiceControl.value as? String, "宋体", "字体选择应在重启后保持")
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
@@ -14,7 +14,7 @@ final class SettingsExtendedTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5))
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5)) XCTAssertTrue(app.waitForSettingsPanel())
} }
func testBrightnessSliderExists() { func testBrightnessSliderExists() {
@@ -25,12 +25,13 @@ final class SettingsExtendedTests: XCTestCase {
func testLineHeightControl() { func testLineHeightControl() {
openSettings() openSettings()
let lineHeight = app.segmentedControls[IDs.settingsLineHeight]
XCTAssertTrue(lineHeight.waitForExistence(timeout: 3), "行距控件未出现")
lineHeight.buttons["紧凑"].tap() for title in ["紧凑", "标准", "宽松"] {
lineHeight.buttons["标准"].tap() XCTAssertTrue(
lineHeight.buttons["宽松"].tap() app.selectSettingsPillOption(pillIdentifier: IDs.settingsLineHeight, optionTitle: title),
"行距应可切换到\(title)"
)
}
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
@@ -38,10 +39,11 @@ final class SettingsExtendedTests: XCTestCase {
func testDisplayTypeFromSettings() { func testDisplayTypeFromSettings() {
openSettings() openSettings()
let displayType = app.segmentedControls[IDs.settingsDisplayType] XCTAssertTrue(
XCTAssertTrue(displayType.waitForExistence(timeout: 3), "翻页方式控件未出现") app.selectSettingsPillOption(pillIdentifier: IDs.settingsDisplayType, optionTitle: "横滑"),
"翻页方式应可切换到横滑"
)
displayType.buttons["横滑"].tap()
app.buttons[IDs.settingsDone].tap() app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" } app.waitForDemoReaderState(timeout: 5, description: "display=horizontalScroll") { $0.display == "horizontalScroll" }
} }
@@ -54,13 +56,13 @@ final class SettingsExtendedTests: XCTestCase {
} }
} }
func testScrollSettingsToReachAllControls() { ///
func testAllSettingsControlsReachableWithoutScrolling() {
openSettings() openSettings()
let scroll = app.scrollViews[IDs.settingsScroll]
XCTAssertTrue(scroll.waitForExistence(timeout: 3))
XCTAssertTrue(app.sliders[IDs.settingsFontSize].waitForExistence(timeout: 3))
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3)) XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.settingsLineHeight].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons[IDs.settingsDisplayType].waitForExistence(timeout: 3))
} }
} }
@@ -15,18 +15,19 @@ final class SettingsPanelTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5))
app.buttons[IDs.readerSettings].tap() app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5)) XCTAssertTrue(app.waitForSettingsPanel())
let fontValue = app.staticTexts[IDs.settingsFontValue] let fontValue = app.staticTexts[IDs.settingsFontValue]
XCTAssertTrue(fontValue.waitForExistence(timeout: 3)) XCTAssertTrue(fontValue.waitForExistence(timeout: 3))
let before = fontValue.label let before = fontValue.label
app.buttons[IDs.settingsFontIncrease].tap() XCTAssertTrue(app.increaseSettingsFontSize(), "字号滑块应存在")
XCTAssertNotEqual(before, fontValue.label) XCTAssertNotEqual(before, fontValue.label)
app.buttons[IDs.settingsFontDecrease].tap() XCTAssertTrue(app.decreaseSettingsFontSize(), "字号滑块应存在")
let fontChoice = app.segmentedControls[IDs.settingsFontChoice] XCTAssertTrue(
XCTAssertTrue(fontChoice.waitForExistence(timeout: 3)) app.selectSettingsPillOption(pillIdentifier: IDs.settingsFontChoice, optionTitle: "宋体"),
fontChoice.buttons["宋体"].tap() "字体应可切换到宋体"
)
let darkTheme = app.buttons[IDs.settingsTheme(5)] let darkTheme = app.buttons[IDs.settingsTheme(5)]
XCTAssertTrue(darkTheme.waitForExistence(timeout: 3)) XCTAssertTrue(darkTheme.waitForExistence(timeout: 3))
@@ -26,7 +26,10 @@ Pod::Spec.new do |s|
s.subspec "FoundationModels" do |foundation_models| s.subspec "FoundationModels" do |foundation_models|
foundation_models.source_files = "FoundationModels/**/*.swift" foundation_models.source_files = "FoundationModels/**/*.swift"
foundation_models.dependency "RDAIReaderView/Core", "~> 0.1" foundation_models.dependency "RDAIReaderView/Core", "~> 0.1"
foundation_models.frameworks = "FoundationModels" # 弱链接:FoundationModels 仅 iOS 26+ 提供,硬链接会让宿主 App 在
# iOS 18 上启动即 dyld 失败。文件内所有类型都是 @available(iOS 26.0, *)
# 低版本不会触达这些符号。
foundation_models.weak_frameworks = "FoundationModels"
end end
s.subspec "UI" do |ui| s.subspec "UI" do |ui|
@@ -25,12 +25,22 @@ extension RDEPUBParser {
} }
func extractArchiveIfNeeded(epubURL: URL) throws -> URL { func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
try Self.extractionQueue.sync {
try extractArchiveIfNeededLocked(epubURL: epubURL)
}
}
/// parser
/// `container.xml` parser
private func extractArchiveIfNeededLocked(epubURL: URL) throws -> URL {
let fileManager = FileManager.default let fileManager = FileManager.default
let extractionURL = temporaryExtractionDirectory(for: epubURL) let extractionURL = temporaryExtractionDirectory(for: epubURL)
if fileManager.fileExists(atPath: extractionURL.path) { if fileManager.fileExists(atPath: extractionURL.path) {
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml") let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
if fileManager.fileExists(atPath: containerURL.path) { if fileManager.fileExists(atPath: containerURL.path) {
// 访 LRU FIFO
Self.touchExtractionDirectory(extractionURL)
return extractionURL return extractionURL
} }
try? fileManager.removeItem(at: extractionURL) try? fileManager.removeItem(at: extractionURL)
@@ -67,6 +77,8 @@ extension RDEPUBParser {
throw error throw error
} }
Self.touchExtractionDirectory(extractionURL)
Self.pruneExtractionCache(keeping: extractionURL)
return extractionURL return extractionURL
} }
@@ -107,9 +119,7 @@ extension RDEPUBParser {
} }
func temporaryExtractionDirectory(for epubURL: URL) -> URL { func temporaryExtractionDirectory(for epubURL: URL) -> URL {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? let baseURL = Self.extractionCacheRootURL
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path) let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0" let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0"
let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
@@ -133,6 +143,97 @@ extension RDEPUBParser {
} }
} }
// MARK: -
extension RDEPUBParser {
private static let extractionQueue = DispatchQueue(label: "com.ssreaderview.epub.extraction-cache")
/// 使
public static var extractionCacheMaximumTotalBytes: UInt64 = 512 * 1024 * 1024
/// 30
public static var extractionCacheMaximumAge: TimeInterval = 30 * 24 * 60 * 60
public static var extractionCacheRootURL: URL {
let fileManager = FileManager.default
return fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
?? fileManager.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
}
/// 宿
public static func clearExtractionCache() {
try? FileManager.default.removeItem(at: extractionCacheRootURL)
}
///
/// - Parameter keeping: 使
public static func pruneExtractionCache(keeping survivor: URL? = nil) {
let fileManager = FileManager.default
let root = extractionCacheRootURL
guard let children = try? fileManager.contentsOfDirectory(
at: root,
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return
}
let survivorPath = survivor?.standardizedFileURL.path
var entries: [(url: URL, size: UInt64, modifiedAt: Date)] = []
for child in children {
let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey])
guard values?.isDirectory == true else { continue }
let modifiedAt = values?.contentModificationDate ?? .distantPast
if child.standardizedFileURL.path == survivorPath {
continue
}
if Date().timeIntervalSince(modifiedAt) > extractionCacheMaximumAge {
try? fileManager.removeItem(at: child)
continue
}
entries.append((child, directorySize(at: child), modifiedAt))
}
// survivor
var total = entries.reduce(UInt64(0)) { $0 + $1.size }
if let survivor {
total += directorySize(at: survivor)
}
guard total > extractionCacheMaximumTotalBytes else { return }
for entry in entries.sorted(by: { $0.modifiedAt < $1.modifiedAt }) {
try? fileManager.removeItem(at: entry.url)
total = total > entry.size ? total - entry.size : 0
if total <= extractionCacheMaximumTotalBytes { return }
}
}
fileprivate static func touchExtractionDirectory(_ url: URL) {
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
}
private static func directorySize(at url: URL) -> UInt64 {
guard let enumerator = FileManager.default.enumerator(
at: url,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey],
options: []
) else {
return 0
}
var total: UInt64 = 0
for case let fileURL as URL in enumerator {
let values = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
let size = values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0
total += UInt64(size)
}
return total
}
}
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate { private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
private(set) var rootFilePath: String? private(set) var rootFilePath: String?
@@ -12,6 +12,11 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
public static let host = "book" public static let host = "book"
///
///
/// provider
public static var maximumInMemoryResourceBytes: UInt64 = 64 * 1024 * 1024
private weak var parser: RDEPUBParser? private weak var parser: RDEPUBParser?
private let fileManager = FileManager.default private let fileManager = FileManager.default
@@ -87,9 +92,18 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved") RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
let fileSize = resourceSize(for: fileURL) let fileSize = resourceSize(for: fileURL)
// provider // Provider nil IO
// provider // provider
if let fileSize, fileSize > 524_288, parser.resourceDataProvider == nil { //
if parser.resourceDataProvider != nil {
respondWithProvidedDataOrFile(
fileURL: fileURL,
requestURL: requestURL,
taskID: taskID,
urlSchemeTask: urlSchemeTask,
fileSize: fileSize
)
} else if let fileSize, fileSize > 524_288 {
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize) respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
} else { } else {
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask) respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
@@ -121,12 +135,75 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
return size return size
} }
private func respondWithProvidedDataOrFile(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
fileSize: UInt64?
) {
ioQueue.async { [weak self] in
guard let self else { return }
guard self.isTaskActive(taskID) else { return }
if let provided = self.parser?.providedResourceData(at: fileURL) {
guard UInt64(provided.count) <= Self.maximumInMemoryResourceBytes else {
self.respondWithOversizeFailure(
fileURL: fileURL,
requestURL: requestURL,
taskID: taskID,
urlSchemeTask: urlSchemeTask,
byteCount: UInt64(provided.count)
)
return
}
self.respondWithData(provided, fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
} else if let fileSize, fileSize > 524_288 {
self.respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
} else {
self.respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, skipsProvider: true)
}
}
}
private func respondWithOversizeFailure(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
byteCount: UInt64
) {
RDEPUBWebViewDebug.logSchemeTask(
"ResourceScheme",
requestURL: requestURL,
fileURL: fileURL,
event: "rejected-oversize-\(byteCount)"
)
Self.recordFailure()
DispatchQueue.main.async { [weak self] in
guard let self, self.isTaskActive(taskID) else { return }
urlSchemeTask.didFailWithError(
NSError(
domain: NSURLErrorDomain,
code: NSURLErrorDataLengthExceedsMaximum,
userInfo: [NSLocalizedDescriptionKey: "资源超过单资源内存上限(\(byteCount) 字节)"]
)
)
self.clearTask(taskID)
}
}
/// H-08 fix: Dispatch all WKURLSchemeTask completion callbacks to the main queue, /// H-08 fix: Dispatch all WKURLSchemeTask completion callbacks to the main queue,
/// ensuring they run on the same serial queue that WebKit calls start() on. /// ensuring they run on the same serial queue that WebKit calls start() on.
/// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead /// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead
/// of silently returning, satisfying the protocol requirement that every started /// of silently returning, satisfying the protocol requirement that every started
/// task must receive a completion callback. /// task must receive a completion callback.
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) { private func respondWithInMemoryData(
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask,
skipsProvider: Bool = false
) {
ioQueue.async { [weak self] in ioQueue.async { [weak self] in
guard let self else { guard let self else {
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -137,7 +214,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
do { do {
// provider provider // provider provider
let data: Data let data: Data
if let provided = self.parser?.providedResourceData(at: fileURL) { if !skipsProvider, let provided = self.parser?.providedResourceData(at: fileURL) {
data = provided data = provided
} else { } else {
data = try Data(contentsOf: fileURL) data = try Data(contentsOf: fileURL)
@@ -148,22 +225,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
} }
return return
} }
let response = URLResponse( self.respondWithData(data, fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
self.dispatchTaskSuccessCallback(
taskID: taskID,
urlSchemeTask: urlSchemeTask
) {
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
return return
} catch { } catch {
guard self.isTaskActive(taskID) else { guard self.isTaskActive(taskID) else {
@@ -182,6 +244,31 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
} }
} }
private func respondWithData(
_ data: Data,
fileURL: URL,
requestURL: URL,
taskID: ObjectIdentifier,
urlSchemeTask: any WKURLSchemeTask
) {
let response = URLResponse(
url: requestURL,
mimeType: Self.mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
)
dispatchTaskSuccessCallback(
taskID: taskID,
urlSchemeTask: urlSchemeTask
) {
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
Self.recordInMemoryResponse()
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
}
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) { private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
ioQueue.async { [weak self] in ioQueue.async { [weak self] in
guard let self else { guard let self else {
@@ -157,6 +157,10 @@ public final class RDEPUBReaderController: UIViewController {
/// 宿使 /// 宿使
/// 退 /// 退
private var navigationBarHiddenBeforeReader: Bool? private var navigationBarHiddenBeforeReader: Bool?
/// 宿
/// 退宿
private var interactivePopWasEnabledBeforeReader: Bool?
private weak var interactivePopDelegateBeforeReader: UIGestureRecognizerDelegate?
private(set) var isSearchBarVisible = false private(set) var isSearchBarVisible = false
@@ -287,9 +291,12 @@ public final class RDEPUBReaderController: UIViewController {
if navigationBarHiddenBeforeReader == nil { if navigationBarHiddenBeforeReader == nil {
navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden
} }
if interactivePopWasEnabledBeforeReader == nil {
interactivePopWasEnabledBeforeReader = navigationController?.interactivePopGestureRecognizer?.isEnabled
interactivePopDelegateBeforeReader = navigationController?.interactivePopGestureRecognizer?.delegate
}
navigationController?.setNavigationBarHidden(true, animated: animated) navigationController?.setNavigationBarHidden(true, animated: animated)
navigationController?.interactivePopGestureRecognizer?.delegate = self navigationController?.interactivePopGestureRecognizer?.isEnabled = false
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
} }
public override func viewWillDisappear(_ animated: Bool) { public override func viewWillDisappear(_ animated: Bool) {
@@ -300,6 +307,10 @@ public final class RDEPUBReaderController: UIViewController {
animated: animated animated: animated
) )
} }
if let interactivePopWasEnabledBeforeReader {
navigationController?.interactivePopGestureRecognizer?.delegate = interactivePopDelegateBeforeReader
navigationController?.interactivePopGestureRecognizer?.isEnabled = interactivePopWasEnabledBeforeReader
}
} }
public override func viewDidAppear(_ animated: Bool) { public override func viewDidAppear(_ animated: Bool) {
@@ -16,6 +16,10 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"]) private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"])
/// tableView `backgroundView`
/// backgroundView VoiceOver UI
private let emptyStateLabel = UILabel()
private var filteredHighlights: [RDEPUBHighlight] { private var filteredHighlights: [RDEPUBHighlight] {
switch filterControl.selectedSegmentIndex { switch filterControl.selectedSegmentIndex {
case 1: case 1:
@@ -120,19 +124,28 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar" navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
} }
private func updateEmptyState() { private func installEmptyStateLabelIfNeeded() {
guard filteredHighlights.isEmpty else { guard emptyStateLabel.superview == nil else { return }
tableView.backgroundView = nil emptyStateLabel.textAlignment = .center
return emptyStateLabel.numberOfLines = 0
} emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
emptyStateLabel.accessibilityIdentifier = "epub.reader.highlights.empty"
emptyStateLabel.isAccessibilityElement = true
tableView.addSubview(emptyStateLabel)
//
NSLayoutConstraint.activate([
emptyStateLabel.centerXAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerXAnchor),
emptyStateLabel.centerYAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerYAnchor),
emptyStateLabel.leadingAnchor.constraint(greaterThanOrEqualTo: tableView.frameLayoutGuide.leadingAnchor, constant: 24),
emptyStateLabel.trailingAnchor.constraint(lessThanOrEqualTo: tableView.frameLayoutGuide.trailingAnchor, constant: -24)
])
}
let label = UILabel() private func updateEmptyState() {
label.text = emptyStateText() installEmptyStateLabelIfNeeded()
label.textAlignment = .center emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.textColor = theme.contentTextColor.withAlphaComponent(0.7) emptyStateLabel.text = emptyStateText()
label.numberOfLines = 0 emptyStateLabel.isHidden = !filteredHighlights.isEmpty
label.accessibilityIdentifier = "epub.reader.highlights.empty"
tableView.backgroundView = label
} }
@objc private func filterChangedAction() { @objc private func filterChangedAction() {
@@ -209,6 +222,9 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
return formatter return formatter
}() }()
/// highlights `tableView.backgroundView`
private let emptyStateLabel = UILabel()
init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) { init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) {
self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt } self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt }
self.theme = theme self.theme = theme
@@ -279,18 +295,27 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar" navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
} }
private func installEmptyStateLabelIfNeeded() {
guard emptyStateLabel.superview == nil else { return }
emptyStateLabel.text = "暂无书签"
emptyStateLabel.textAlignment = .center
emptyStateLabel.numberOfLines = 0
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
emptyStateLabel.accessibilityIdentifier = "epub.reader.bookmarks.empty"
emptyStateLabel.isAccessibilityElement = true
tableView.addSubview(emptyStateLabel)
NSLayoutConstraint.activate([
emptyStateLabel.centerXAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerXAnchor),
emptyStateLabel.centerYAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerYAnchor),
emptyStateLabel.leadingAnchor.constraint(greaterThanOrEqualTo: tableView.frameLayoutGuide.leadingAnchor, constant: 24),
emptyStateLabel.trailingAnchor.constraint(lessThanOrEqualTo: tableView.frameLayoutGuide.trailingAnchor, constant: -24)
])
}
private func updateEmptyState() { private func updateEmptyState() {
guard bookmarks.isEmpty == false else { installEmptyStateLabelIfNeeded()
let label = UILabel() emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.text = "暂无书签" emptyStateLabel.isHidden = !bookmarks.isEmpty
label.textAlignment = .center
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
label.numberOfLines = 0
label.accessibilityIdentifier = "epub.reader.bookmarks.empty"
tableView.backgroundView = label
return
}
tableView.backgroundView = nil
} }
private func titleText(for bookmark: RDEPUBBookmark) -> String { private func titleText(for bookmark: RDEPUBBookmark) -> String {
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation import Foundation
public protocol RDEPUBReaderPersistence: AnyObject { public protocol RDEPUBReaderPersistence: AnyObject {
@@ -47,6 +48,113 @@ public extension RDEPUBReaderPersistence {
} }
} }
///
/// UserDefaults Application Support
/// `NSFileProtection` iCloud/iTunes
public final class RDEPUBProtectedContentStore {
private let directoryURL: URL
private let protection: FileProtectionType
private let fileManager = FileManager.default
private let namespace: String?
private let allowsLegacyFallback: Bool
/// `.completeUntilFirstUserAuthentication`访
/// `.complete` `.complete`
/// 退
/// 宿 `.complete`
public init(
directoryName: String = "ssreaderview-epub/protected",
namespace: String? = nil,
allowsLegacyFallback: Bool = true,
protection: FileProtectionType = .completeUntilFirstUserAuthentication
) {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
self.directoryURL = base.appendingPathComponent(directoryName, isDirectory: true)
self.namespace = namespace
self.allowsLegacyFallback = allowsLegacyFallback
self.protection = protection
}
/// bookIdentifier `/`
/// 255 SHA256
private func fileURL(forKey key: String) -> URL {
let storageKey: String
if let namespace {
storageKey = namespace + "\u{0}" + key
} else {
storageKey = key
}
let digest = SHA256.hash(data: Data(storageKey.utf8))
let name = digest.map { String(format: "%02x", $0) }.joined()
return directoryURL.appendingPathComponent("\(name).json", isDirectory: false)
}
private func legacyFileURL(forKey key: String) -> URL {
let digest = SHA256.hash(data: Data(key.utf8))
let name = digest.map { String(format: "%02x", $0) }.joined()
return directoryURL.appendingPathComponent("\(name).json", isDirectory: false)
}
private func ensureDirectory() throws {
guard !fileManager.fileExists(atPath: directoryURL.path) else { return }
try fileManager.createDirectory(
at: directoryURL,
withIntermediateDirectories: true,
attributes: [.protectionKey: protection]
)
var url = directoryURL
var values = URLResourceValues()
values.isExcludedFromBackup = true
try? url.setResourceValues(values)
}
public func read(forKey key: String) -> Data? {
if let data = try? Data(contentsOf: fileURL(forKey: key)) {
return data
}
guard namespace != nil, allowsLegacyFallback else { return nil }
return try? Data(contentsOf: legacyFileURL(forKey: key))
}
public func write(_ data: Data, forKey key: String) {
_ = writeIfPossible(data, forKey: key)
}
///
@discardableResult
func writeIfPossible(_ data: Data, forKey key: String) -> Bool {
do {
try ensureDirectory()
try data.write(to: fileURL(forKey: key), options: .atomic)
// atomic
try fileManager.setAttributes([.protectionKey: protection], ofItemAtPath: fileURL(forKey: key).path)
return true
} catch {
#if DEBUG
print("[RDEPUBProtectedContentStore] ⚠️ Failed to write '\(key)': \(error)")
#endif
return false
}
}
public func removeValue(forKey key: String) {
try? fileManager.removeItem(at: fileURL(forKey: key))
if namespace != nil, allowsLegacyFallback {
try? fileManager.removeItem(at: legacyFileURL(forKey: key))
}
}
public func removeAll() {
try? fileManager.removeItem(at: directoryURL)
}
}
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence { public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
private let defaults: UserDefaults private let defaults: UserDefaults
@@ -59,18 +167,80 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
private let settingsKey: String private let settingsKey: String
///
private let contentStore: RDEPUBProtectedContentStore
public init( public init(
defaults: UserDefaults = .standard, defaults: UserDefaults = .standard,
locationPrefix: String = "ssreader.epub.location.", locationPrefix: String = "ssreader.epub.location.",
bookmarksPrefix: String = "ssreader.epub.bookmarks.", bookmarksPrefix: String = "ssreader.epub.bookmarks.",
highlightsPrefix: String = "ssreader.epub.highlights.", highlightsPrefix: String = "ssreader.epub.highlights.",
settingsKey: String = "ssreader.epub.settings" settingsKey: String = "ssreader.epub.settings",
contentStore: RDEPUBProtectedContentStore? = nil
) { ) {
self.defaults = defaults self.defaults = defaults
self.locationPrefix = locationPrefix self.locationPrefix = locationPrefix
self.bookmarksPrefix = bookmarksPrefix self.bookmarksPrefix = bookmarksPrefix
self.highlightsPrefix = highlightsPrefix self.highlightsPrefix = highlightsPrefix
self.settingsKey = settingsKey self.settingsKey = settingsKey
if let contentStore {
self.contentStore = contentStore
} else {
let namespaceKey = settingsKey + ".protectedContentNamespace"
let isStandardDefaults = defaults === UserDefaults.standard
let namespace: String
if isStandardDefaults {
namespace = "standard"
} else if let storedNamespace = defaults.string(forKey: namespaceKey) {
namespace = storedNamespace
} else {
namespace = UUID().uuidString
defaults.set(namespace, forKey: namespaceKey)
}
self.contentStore = RDEPUBProtectedContentStore(
namespace: namespace,
// suite
allowsLegacyFallback: isStandardDefaults
)
}
}
/// 访
private func loadContentData(key: String) -> Data? {
if let data = contentStore.read(forKey: key) {
return data
}
guard let legacy = defaults.data(forKey: key) else { return nil }
if contentStore.writeIfPossible(legacy, forKey: key) {
defaults.removeObject(forKey: key)
}
return legacy
}
private func saveContentData(_ data: Data, key: String) {
//
if contentStore.writeIfPossible(data, forKey: key),
defaults.object(forKey: key) != nil {
defaults.removeObject(forKey: key)
}
}
///
public func clearReadingData(for bookIdentifier: String) {
contentStore.removeValue(forKey: highlightsPrefix + bookIdentifier)
contentStore.removeValue(forKey: bookmarksPrefix + bookIdentifier)
defaults.removeObject(forKey: highlightsPrefix + bookIdentifier)
defaults.removeObject(forKey: bookmarksPrefix + bookIdentifier)
defaults.removeObject(forKey: locationPrefix + bookIdentifier)
}
/// UserDefaults
public func clearAllReadingContent() {
contentStore.removeAll()
for key in defaults.dictionaryRepresentation().keys
where key.hasPrefix(highlightsPrefix) || key.hasPrefix(bookmarksPrefix) {
defaults.removeObject(forKey: key)
}
} }
public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? { public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? {
@@ -99,7 +269,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
} }
public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else { guard let data = loadContentData(key: bookmarksPrefix + bookIdentifier) else {
return [] return []
} }
do { do {
@@ -115,7 +285,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) { public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
do { do {
let data = try JSONEncoder().encode(bookmarks) let data = try JSONEncoder().encode(bookmarks)
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier) saveContentData(data, key: bookmarksPrefix + bookIdentifier)
} catch { } catch {
#if DEBUG #if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)") print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)")
@@ -124,7 +294,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
} }
public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] { public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] {
guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else { guard let data = loadContentData(key: highlightsPrefix + bookIdentifier) else {
return [] return []
} }
do { do {
@@ -145,7 +315,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)") print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
#endif #endif
} }
defaults.set(data, forKey: highlightsPrefix + bookIdentifier) saveContentData(data, key: highlightsPrefix + bookIdentifier)
} catch { } catch {
#if DEBUG #if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)") print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)")
@@ -65,12 +65,18 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
private let emptyStateLabel = UILabel() private let emptyStateLabel = UILabel()
private let navigationRowView = UIView()
private let previousButton = RDEPUBReaderTintButton(type: .system) private let previousButton = RDEPUBReaderTintButton(type: .system)
private let nextButton = RDEPUBReaderTintButton(type: .system) private let nextButton = RDEPUBReaderTintButton(type: .system)
private let countLabel = UILabel() private let countLabel = UILabel()
/// Collapsed to 0 in the pristine state so the prompt keeps its original
/// spacing under the search field. See `updateNavigationRow(total:)`.
private var navigationRowHeightConstraint: NSLayoutConstraint?
private var searchSections: [RDEPUBReaderSearchSection] = [] private var searchSections: [RDEPUBReaderSearchSection] = []
private var keyword = "" private var keyword = ""
@@ -85,7 +91,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
setupSubviews() setupSubviews()
setupConstraints() setupConstraints()
setupActions() setupActions()
updateLegacyNavigationEnabled(false) updateNavigationRow(total: 0)
showInitialState() showInitialState()
} }
@@ -94,20 +100,14 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
} }
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Only claim touches that fall within the panelView or the // Only claim touches that fall within the panelView. Taps on the dimmed
// accessibility-only navigation buttons. Taps on the dimmed scrim // scrim area pass through to the reader content below, allowing chrome
// area pass through to the reader content below, allowing chrome // toggle taps to work. The step controls live inside the panel, so they
// toggle taps to work. // no longer need a special case here.
let panelPoint = convert(point, to: panelView) let panelPoint = convert(point, to: panelView)
if panelView.point(inside: panelPoint, with: event) { if panelView.point(inside: panelPoint, with: event) {
return super.hitTest(point, with: event) return super.hitTest(point, with: event)
} }
// Also claim touches on the accessibility-only navigation buttons
if previousButton.frame.contains(point)
|| nextButton.frame.contains(point)
|| countLabel.frame.contains(point) {
return super.hitTest(point, with: event)
}
return nil return nil
} }
@@ -149,9 +149,10 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
tableView.backgroundColor = .clear tableView.backgroundColor = .clear
tableView.separatorStyle = .none tableView.separatorStyle = .none
navigationRowView.backgroundColor = rowColor
previousButton.tintColor = textColor previousButton.tintColor = textColor
nextButton.tintColor = textColor nextButton.tintColor = textColor
countLabel.textColor = textColor countLabel.textColor = secondaryTextColor
countLabel.backgroundColor = .clear countLabel.backgroundColor = .clear
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
@@ -168,9 +169,11 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
} }
func updateMatchCount(current: Int, total: Int) { func updateMatchCount(current: Int, total: Int) {
// label "N/M" UI
// accessibilityLabel XCUIElement.label
countLabel.text = "\(current)/\(total)" countLabel.text = "\(current)/\(total)"
textField.accessibilityValue = "\(current)/\(total)" textField.accessibilityValue = "\(current)/\(total)"
updateLegacyNavigationEnabled(total > 0) updateNavigationRow(total: total)
} }
func showNoResults() { func showNoResults() {
@@ -223,6 +226,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
private func setupSubviews() { private func setupSubviews() {
backgroundButton.translatesAutoresizingMaskIntoConstraints = false backgroundButton.translatesAutoresizingMaskIntoConstraints = false
panelView.translatesAutoresizingMaskIntoConstraints = false panelView.translatesAutoresizingMaskIntoConstraints = false
navigationRowView.translatesAutoresizingMaskIntoConstraints = false
grabberView.translatesAutoresizingMaskIntoConstraints = false grabberView.translatesAutoresizingMaskIntoConstraints = false
searchRowView.translatesAutoresizingMaskIntoConstraints = false searchRowView.translatesAutoresizingMaskIntoConstraints = false
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -241,6 +245,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
panelView.addSubview(grabberView) panelView.addSubview(grabberView)
panelView.addSubview(searchRowView) panelView.addSubview(searchRowView)
panelView.addSubview(navigationRowView)
panelView.addSubview(tableView) panelView.addSubview(tableView)
panelView.addSubview(emptyStateLabel) panelView.addSubview(emptyStateLabel)
@@ -250,14 +255,15 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
searchFieldContainer.addSubview(searchIcon) searchFieldContainer.addSubview(searchIcon)
searchFieldContainer.addSubview(textField) searchFieldContainer.addSubview(textField)
addSubview(previousButton) navigationRowView.addSubview(countLabel)
addSubview(nextButton) navigationRowView.addSubview(previousButton)
addSubview(countLabel) navigationRowView.addSubview(nextButton)
if #available(iOS 13.0, *) { if #available(iOS 13.0, *) {
searchIcon.image = UIImage(systemName: "magnifyingglass") searchIcon.image = UIImage(systemName: "magnifyingglass")
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal) let stepSymbol = UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal) previousButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: stepSymbol), for: .normal)
nextButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: stepSymbol), for: .normal)
} else { } else {
previousButton.setTitle("", for: .normal) previousButton.setTitle("", for: .normal)
nextButton.setTitle("", for: .normal) nextButton.setTitle("", for: .normal)
@@ -287,11 +293,16 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
nextButton.accessibilityIdentifier = "epub.reader.search.next" nextButton.accessibilityIdentifier = "epub.reader.search.next"
countLabel.accessibilityIdentifier = "epub.reader.search.count" countLabel.accessibilityIdentifier = "epub.reader.search.count"
textField.accessibilityIdentifier = "epub.reader.search.field" textField.accessibilityIdentifier = "epub.reader.search.field"
// 0.02 0.01CALayer.opacity Float320.01
// 0.01 UIKit previousButton.accessibilityLabel = "上一个匹配"
previousButton.alpha = 0.02 nextButton.accessibilityLabel = "下一个匹配"
nextButton.alpha = 0.02
countLabel.alpha = 0.02 countLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium)
countLabel.textAlignment = .left
countLabel.isAccessibilityElement = true
navigationRowView.layer.cornerRadius = 18
navigationRowView.clipsToBounds = true
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier) tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
tableView.dataSource = self tableView.dataSource = self
@@ -345,33 +356,40 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18), cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor), cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
navigationRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
navigationRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
navigationRowView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 12),
countLabel.leadingAnchor.constraint(equalTo: navigationRowView.leadingAnchor, constant: 16),
countLabel.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
nextButton.trailingAnchor.constraint(equalTo: navigationRowView.trailingAnchor, constant: -8),
nextButton.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 44),
nextButton.topAnchor.constraint(equalTo: navigationRowView.topAnchor),
nextButton.bottomAnchor.constraint(equalTo: navigationRowView.bottomAnchor),
previousButton.trailingAnchor.constraint(equalTo: nextButton.leadingAnchor, constant: -4),
previousButton.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 44),
previousButton.topAnchor.constraint(equalTo: navigationRowView.topAnchor),
previousButton.bottomAnchor.constraint(equalTo: navigationRowView.bottomAnchor),
countLabel.trailingAnchor.constraint(lessThanOrEqualTo: previousButton.leadingAnchor, constant: -8),
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0), tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0), tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18), tableView.topAnchor.constraint(equalTo: navigationRowView.bottomAnchor, constant: 12),
tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor), tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32), emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32), emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56), emptyStateLabel.topAnchor.constraint(equalTo: navigationRowView.bottomAnchor, constant: 50)
// /UI panel
//
//
previousButton.topAnchor.constraint(equalTo: panelView.topAnchor),
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 1),
previousButton.heightAnchor.constraint(equalToConstant: 1),
nextButton.topAnchor.constraint(equalTo: panelView.topAnchor),
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 1),
nextButton.heightAnchor.constraint(equalToConstant: 1),
countLabel.topAnchor.constraint(equalTo: panelView.topAnchor),
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
countLabel.widthAnchor.constraint(equalToConstant: 1),
countLabel.heightAnchor.constraint(equalToConstant: 1)
]) ])
let heightConstraint = navigationRowView.heightAnchor.constraint(equalToConstant: 0)
heightConstraint.isActive = true
navigationRowHeightConstraint = heightConstraint
} }
private func setupActions() { private func setupActions() {
@@ -384,9 +402,18 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside) backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
} }
private func updateLegacyNavigationEnabled(_ enabled: Bool) { /// Stepping is only enabled once there are matches, but the row stays
previousButton.isEnabled = enabled /// visible (showing `0/0` with disabled arrows) for any keyword the user has
nextButton.isEnabled = enabled /// actually searched, so the result count is never silently missing. It
/// collapses away only in the pristine "type a keyword" state.
private func updateNavigationRow(total: Int) {
let hasMatches = total > 0
previousButton.isEnabled = hasMatches
nextButton.isEnabled = hasMatches
let isVisible = hasMatches || !keyword.isEmpty
navigationRowView.isHidden = !isVisible
navigationRowHeightConstraint?.constant = isVisible ? 36 : 0
} }
private func showInitialState() { private func showInitialState() {
@@ -225,7 +225,8 @@ public final class RDEpubURLReaderController: UIViewController {
emitDemoState() emitDemoState()
} }
private var readerController: RDEPUBReaderController? { /// `Speech` Pod API
var readerController: RDEPUBReaderController? {
embeddedController as? RDEPUBReaderController embeddedController as? RDEPUBReaderController
} }
@@ -198,8 +198,7 @@ final class RDEPUBReaderAnnotationCoordinator {
func presentHighlightsManager() { func presentHighlightsManager() {
guard let controller else { return } guard let controller else { return }
guard controller.configuration.allowsHighlights else { return } guard controller.configuration.allowsHighlights else { return }
guard !controller.activeHighlights.isEmpty else { return } // presentBookmarksManager
let highlightsController = RDEPUBReaderHighlightsViewController( let highlightsController = RDEPUBReaderHighlightsViewController(
highlights: controller.activeHighlights, highlights: controller.activeHighlights,
theme: controller.configuration.theme, theme: controller.configuration.theme,
@@ -325,8 +324,8 @@ final class RDEPUBReaderAnnotationCoordinator {
func presentBookmarksManager() { func presentBookmarksManager() {
guard let controller else { return } guard let controller else { return }
guard !controller.activeBookmarks.isEmpty else { return } // 退
//
let bookmarksController = RDEPUBReaderBookmarksViewController( let bookmarksController = RDEPUBReaderBookmarksViewController(
bookmarks: controller.activeBookmarks, bookmarks: controller.activeBookmarks,
theme: controller.configuration.theme theme: controller.configuration.theme
@@ -0,0 +1,130 @@
import Foundation
import RDSpeechReaderView
/// EPUB/TXT
///
@MainActor
public final class RDEPUBSpeechSession: NSObject, RDSpeechReaderControllerDelegate {
public let controller: RDSpeechReaderController
public let contentProvider: RDEPUBSpeechContentProvider
public var onStateChange: ((RDSpeechReaderState) -> Void)?
private weak var reader: RDEPUBReaderController?
init(reader: RDEPUBReaderController, configuration: RDSpeechReaderConfiguration) {
self.reader = reader
contentProvider = reader.makeSpeechContentProvider()
controller = RDSpeechReaderController(contentProvider: contentProvider, configuration: configuration)
super.init()
controller.delegate = self
}
///
public func start(from location: RDSpeechLocation? = nil) async throws {
try await controller.start(from: location ?? currentSpeechLocation())
}
public func pause() { controller.pause() }
public func resume() { controller.resume() }
public func stop() {
controller.stop()
reader?.clearTransientHighlights()
}
public func speechReaderController(_ controller: RDSpeechReaderController, didChange state: RDSpeechReaderState) {
switch state {
case .idle, .finished, .failed:
reader?.clearTransientHighlights()
case .preparing, .speaking, .paused:
break
}
onStateChange?(state)
}
public func speechReaderController(_ controller: RDSpeechReaderController, willSpeak range: RDSpeechSpokenRange) {
guard let reader else { return }
let href = range.unit.location.resourceIdentifier
let absoluteRange = NSRange(
location: range.unit.textRange.location + range.range.location,
length: max(range.range.length, 1)
)
if let chapterData = reader.textChapterData(forNormalizedHref: href),
chapterData.attributedContent.length > 0 {
let boundedLocation = min(max(absoluteRange.location, 0), max(chapterData.attributedContent.length - 1, 0))
let boundedLength = min(max(absoluteRange.length, 1), max(chapterData.attributedContent.length - boundedLocation, 1))
let boundedRange = NSRange(location: boundedLocation, length: boundedLength)
if let page = chapterData.page(containing: boundedRange.location),
reader.currentPageNumber != page.absolutePageIndex + 1 {
_ = reader.go(toPageNumber: page.absolutePageIndex + 1, animated: false)
}
let highlight = RDEPUBHighlight(
id: "rd-speech-highlight",
bookIdentifier: reader.currentBookIdentifier,
location: chapterData.location(for: boundedRange, bookIdentifier: reader.currentBookIdentifier),
text: chapterData.attributedContent.attributedSubstring(from: boundedRange).string,
rangeInfo: RDEPUBTextOffsetRangeInfo(
href: chapterData.href,
start: boundedRange.location,
end: NSMaxRange(boundedRange)
).jsonString(),
style: .highlight,
color: "#86D7FF"
)
reader.setTransientHighlights([highlight])
return
}
// Web EPUB HTML
//
if reader.currentLocation?.href != href {
reader.go(to: RDEPUBLocation(
bookIdentifier: reader.currentBookIdentifier,
href: href,
progression: progression(for: range)
))
}
reader.clearTransientHighlights()
}
private func currentSpeechLocation() -> RDSpeechLocation? {
guard let reader,
let location = reader.currentLocation else {
return nil
}
let offset = reader.textChapterData(forNormalizedHref: location.href)
.flatMap { $0.absoluteRange(for: location)?.location }
?? 0
return RDSpeechLocation(
bookIdentifier: contentProvider.speechBookDescriptor().identifier,
resourceIdentifier: location.href,
textOffset: offset
)
}
private func progression(for range: RDSpeechSpokenRange) -> Double {
let totalLength = max(range.unit.textRange.location + range.unit.textRange.length, 1)
let offset = range.unit.textRange.location + range.range.location
return min(max(Double(offset) / Double(totalLength), 0), 1)
}
}
public extension RDEPUBReaderController {
func makeSpeechSession(
configuration: RDSpeechReaderConfiguration = .default
) -> RDEPUBSpeechSession {
RDEPUBSpeechSession(reader: self, configuration: configuration)
}
}
public extension RDEpubURLReaderController {
/// CBZ `nil`
func makeSpeechSession(
configuration: RDSpeechReaderConfiguration = .default
) -> RDEPUBSpeechSession? {
readerController.map { $0.makeSpeechSession(configuration: configuration) }
}
}
@@ -48,6 +48,10 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
private var book: RDPDFReaderBookDescriptor private var book: RDPDFReaderBookDescriptor
private var currentTheme: RDPDFReaderThemeOption private var currentTheme: RDPDFReaderThemeOption
private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:] private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:]
/// UIPageViewController UICollectionView
///
/// `visiblePageContentViews()`
private let livePageViews = NSHashTable<RDPDFReaderPageView>.weakObjects()
/// Provider cell /// Provider cell
private var pageDescriptorRequestTokens: [Int: UUID] = [:] private var pageDescriptorRequestTokens: [Int: UUID] = [:]
private var pageDescriptorRequestTimeouts: [Int: DispatchWorkItem] = [:] private var pageDescriptorRequestTimeouts: [Int: DispatchWorkItem] = [:]
@@ -244,7 +248,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
guard !runs.isEmpty else { return [] } guard !runs.isEmpty else { return [] }
ocrRuns[pageIndex] = runs ocrRuns[pageIndex] = runs
ocrDiskCache?.save(runs, pageIndex: pageIndex) ocrDiskCache?.save(runs, pageIndex: pageIndex)
refreshVisiblePage(pageIndex) refreshPageViews(at: pageIndex)
return runs return runs
} }
@@ -265,14 +269,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func showSpeechHighlight(pageIndex: Int, normalizedRects: [CGRect], animated: Bool = true) { public func showSpeechHighlight(pageIndex: Int, normalizedRects: [CGRect], animated: Bool = true) {
guard pageIndex >= 0, pageIndex < book.totalPages else { return } guard pageIndex >= 0, pageIndex < book.totalPages else { return }
speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects) speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects)
goToPage(pageIndex, animated: animated) // `willSpeakRangeOfSpeechString`
refreshVisiblePage(pageIndex) // 仿
if currentPageIndex != pageIndex {
goToPage(pageIndex, animated: animated)
}
refreshPageViews(at: pageIndex)
} }
public func clearSpeechHighlight() { public func clearSpeechHighlight() {
let highlightedPage = speechHighlight?.pageIndex let highlightedPage = speechHighlight?.pageIndex
speechHighlight = nil speechHighlight = nil
if let highlightedPage { refreshVisiblePage(highlightedPage) } if let highlightedPage { refreshPageViews(at: highlightedPage) }
} }
/// ///
@@ -292,6 +300,10 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView { public func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView {
let page = RDPDFReaderPageView() let page = RDPDFReaderPageView()
page.delegate = self page.delegate = self
// ReaderView tag
//
page.tag = pageNum
livePageViews.add(page)
configure(page, at: pageNum) configure(page, at: pageNum)
return page return page
} }
@@ -323,6 +335,10 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
guard pageNum >= 0 else { return } guard pageNum >= 0 else { return }
// OCR // OCR
cancelOutstandingOCRRequests() cancelOutstandingOCRRequests()
//
// configure
//
refreshPageViews(at: pageNum)
// //
// cell // cell
startOCRIfNeeded(pageNum) startOCRIfNeeded(pageNum)
@@ -394,8 +410,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
pageDescriptors[index] = descriptor pageDescriptors[index] = descriptor
// cell // cell
readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index) readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index)
// cell // cell 仿
refreshVisiblePage(index) //
refreshPageViews(at: index)
} }
private func handleInvalidPageDescriptor(at index: Int, token: UUID, attempt: Int) { private func handleInvalidPageDescriptor(at index: Int, token: UUID, attempt: Int) {
@@ -404,7 +421,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
if attempt >= maximumPageDescriptorRequestAttempts { if attempt >= maximumPageDescriptorRequestAttempts {
pageLoadFailedPages.insert(index) pageLoadFailedPages.insert(index)
} }
refreshVisiblePage(index) refreshPageViews(at: index)
} }
private func trimOCRCache(around pageIndex: Int, radius: Int) { private func trimOCRCache(around pageIndex: Int, radius: Int) {
@@ -474,7 +491,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
self.pageLoadFailedPages.insert(index) self.pageLoadFailedPages.insert(index)
} }
// //
self.refreshVisiblePage(index) self.refreshPageViews(at: index)
} }
pageDescriptorRequestTimeouts[index] = timeout pageDescriptorRequestTimeouts[index] = timeout
let timeoutSeconds = pow(2.0, Double(attempt - 1)) * 8.0 let timeoutSeconds = pow(2.0, Double(attempt - 1)) * 8.0
@@ -482,7 +499,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
pageProvider.readerPage(at: index) { [weak self] descriptor in pageProvider.readerPage(at: index) { [weak self] descriptor in
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
guard descriptor.index == index else { //
//
guard descriptor.index == index, descriptor.image != nil else {
self.handleInvalidPageDescriptor(at: index, token: token, attempt: attempt) self.handleInvalidPageDescriptor(at: index, token: token, attempt: attempt)
return return
} }
@@ -550,8 +569,8 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
recognizingPages.remove(pageIndex) recognizingPages.remove(pageIndex)
ocrRuns[pageIndex] = runs ocrRuns[pageIndex] = runs
if shouldPersist { ocrDiskCache?.save(runs, pageIndex: pageIndex) } if shouldPersist { ocrDiskCache?.save(runs, pageIndex: pageIndex) }
// UI // UI
refreshVisiblePage(pageIndex) refreshPageViews(at: pageIndex)
} }
private func cancelOutstandingOCRRequests() { private func cancelOutstandingOCRRequests() {
@@ -560,9 +579,19 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
ocrRequestTokens.removeAll() ocrRequestTokens.removeAll()
} }
private func refreshVisiblePage(_ index: Int) { /// 仿 UIPageViewController
guard let page = readerView.pageContentView(pageNum: index) as? RDPDFReaderPageView else { return } /// `viewControllers`
configure(page, at: index) ///
private func refreshPageViews(at index: Int) {
var targets: [RDPDFReaderPageView] = []
if let displayed = readerView.pageContentView(pageNum: index) as? RDPDFReaderPageView {
targets.append(displayed)
}
for page in livePageViews.allObjects
where page.tag == index && !targets.contains(where: { $0 === page }) {
targets.append(page)
}
targets.forEach { configure($0, at: index) }
} }
/// `visiblePageContentViews` spread /// `visiblePageContentViews` spread
@@ -821,7 +850,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
view.backgroundColor = theme.contentBackgroundColor view.backgroundColor = theme.contentBackgroundColor
readerView.backgroundColor = pageSurroundingColor readerView.backgroundColor = pageSurroundingColor
applyChromeTheme() applyChromeTheme()
if readerView.currentPage >= 0 { refreshVisiblePage(readerView.currentPage); readerView.refreshCurrentPageIfNeeded() } if readerView.currentPage >= 0 { refreshPageViews(at: readerView.currentPage); readerView.refreshCurrentPageIfNeeded() }
} }
private func applyChromeTheme() { private func applyChromeTheme() {
@@ -834,7 +863,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do { do {
_ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source)) _ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source))
reloadAnnotationCache() reloadAnnotationCache()
refreshVisiblePage(page) refreshPageViews(at: page)
} }
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
} }
@@ -843,7 +872,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do { do {
_ = try annotationPersistence?.deleteAnnotation(id: annotation.id) _ = try annotationPersistence?.deleteAnnotation(id: annotation.id)
reloadAnnotationCache() reloadAnnotationCache()
refreshVisiblePage(annotation.pageIndex) refreshPageViews(at: annotation.pageIndex)
} }
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
} }
@@ -855,7 +884,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String) { add(selection, page: pageView.pageIndex, color: color, note: nil) } public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String) { add(selection, page: pageView.pageIndex, color: color, note: nil) }
public func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection) { presentEditor(selection: selection, page: pageView.pageIndex) } public func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection) { presentEditor(selection: selection, page: pageView.pageIndex) }
public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) } public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) }
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction, highlight: RDPDFReaderAnnotation) { if action == .deleteUnderline { delete(highlight) } else if action == .deleteAnnotation { var item = highlight; item.note = nil; do { _ = try annotationPersistence?.updateAnnotation(item); refreshVisiblePage(item.pageIndex) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } } else if action == .annotate { presentEditor(annotation: highlight) } } public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction, highlight: RDPDFReaderAnnotation) { if action == .deleteUnderline { delete(highlight) } else if action == .deleteAnnotation { var item = highlight; item.note = nil; do { _ = try annotationPersistence?.updateAnnotation(item); refreshPageViews(at: item.pageIndex) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } } else if action == .annotate { presentEditor(annotation: highlight) } }
private func presentEditor(selection: RDPDFReaderImageTextSelection, page: Int) { private func presentEditor(selection: RDPDFReaderImageTextSelection, page: Int) {
let editor = RDPDFReaderAnnotationEditorViewController(quote: selection.text?.isEmpty == false ? selection.text! : "区域标注", theme: currentTheme, onSave: { [weak self] note in self?.add(selection, page: page, note: note) }) let editor = RDPDFReaderAnnotationEditorViewController(quote: selection.text?.isEmpty == false ? selection.text! : "区域标注", theme: currentTheme, onSave: { [weak self] note in self?.add(selection, page: page, note: note) })
@@ -869,7 +898,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do { do {
_ = try self?.annotationPersistence?.updateAnnotation(item) _ = try self?.annotationPersistence?.updateAnnotation(item)
self?.reloadAnnotationCache() self?.reloadAnnotationCache()
self?.refreshVisiblePage(item.pageIndex) self?.refreshPageViews(at: item.pageIndex)
} catch { } catch {
if let self { self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } if let self { self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
} }
@@ -45,7 +45,9 @@ public final class RDPDFSpeechSession: NSObject, RDSpeechReaderControllerDelegat
guard let pageIndex = Int(range.unit.location.resourceIdentifier) else { return } guard let pageIndex = Int(range.unit.location.resourceIdentifier) else { return }
reader?.showSpeechHighlight( reader?.showSpeechHighlight(
pageIndex: pageIndex, pageIndex: pageIndex,
normalizedRects: contentProvider.normalizedRects(for: range) normalizedRects: contentProvider.normalizedRects(for: range),
//
animated: false
) )
} }
} }
+5 -5
View File
@@ -37,10 +37,10 @@ try await session.start(from: 0)
## Use with EPUB ## Use with EPUB
```swift ```swift
let provider = reader.makeSpeechContentProvider() let session = reader.makeSpeechSession()
let speech = RDSpeechReaderController(contentProvider: provider) try await session.start()
try await speech.start(from: nil)
``` ```
Speech locations use EPUB `href` plus a UTF-16 text offset. They remain stable EPUB/TXT 会话默认从当前阅读位置开始。文本重排 EPUB 与 TXT 会临时高亮当前朗读范围,
when the user changes fonts or page size, unlike screen page numbers. 并且只在朗读跨页时定位;朗读位置使用 EPUB `href` 与 UTF-16 文本偏移,因此用户修改
字体或页面大小后仍可稳定恢复。
@@ -20,11 +20,16 @@ public final class RDSpeechReaderController: NSObject {
private var queuedUnits: [RDSpeechTextUnit] = [] private var queuedUnits: [RDSpeechTextUnit] = []
private var completedUnits: [RDSpeechTextUnit] = [] private var completedUnits: [RDSpeechTextUnit] = []
private var nextLocation: RDSpeechLocation? private var nextLocation: RDSpeechLocation?
private var isLoadingBatch = false private var loadingGeneration: UInt?
private var playbackGeneration: UInt = 0
private var activeUtterance: AVSpeechUtterance?
private var isStopping = false private var isStopping = false
private var shouldResumeAfterInterruption = false private var shouldResumeAfterInterruption = false
private var sleepTimerTask: Task<Void, Never>? private var sleepTimerTask: Task<Void, Never>?
private var notificationTokens: [NSObjectProtocol] = [] private var notificationTokens: [NSObjectProtocol] = []
private var remoteCommandTargets: [(command: MPRemoteCommand, token: Any)] = []
private static weak var activeRemoteController: RDSpeechReaderController?
public init( public init(
contentProvider: RDSpeechContentProvider, contentProvider: RDSpeechContentProvider,
@@ -42,24 +47,25 @@ public final class RDSpeechReaderController: NSObject {
deinit { deinit {
notificationTokens.forEach(NotificationCenter.default.removeObserver) notificationTokens.forEach(NotificationCenter.default.removeObserver)
MPRemoteCommandCenter.shared().playCommand.removeTarget(nil) remoteCommandTargets.forEach { $0.command.removeTarget($0.token) }
MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().togglePlayPauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(nil)
} }
public func start(from location: RDSpeechLocation? = nil) async throws { public func start(from location: RDSpeechLocation? = nil) async throws {
stop(clearProgress: false) playbackGeneration &+= 1
let generation = playbackGeneration
resetPlayback(clearProgress: false)
isStopping = false isStopping = false
completedUnits.removeAll() completedUnits.removeAll()
claimRemoteControlsIfNeeded()
state = .preparing state = .preparing
do { do {
if configuration.configuresAudioSession { try configureAudioSession() } if configuration.configuresAudioSession { try configureAudioSession() }
let initialLocation = location ?? progressStore?.restoreSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) let initialLocation = location ?? progressStore?.restoreSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
try await loadBatch(startingAt: initialLocation, requiresContent: true) try await loadBatch(startingAt: initialLocation, requiresContent: true, generation: generation)
try await speakNextUnit() guard isCurrentPlaybackGeneration(generation) else { return }
try await speakNextUnit(generation: generation)
} catch { } catch {
guard isCurrentPlaybackGeneration(generation) else { return }
state = .failed(error.localizedDescription) state = .failed(error.localizedDescription)
throw error throw error
} }
@@ -72,31 +78,40 @@ public final class RDSpeechReaderController: NSObject {
public func resume() { public func resume() {
guard synthesizer.isPaused else { return } guard synthesizer.isPaused else { return }
claimRemoteControlsIfNeeded()
synthesizer.continueSpeaking() synthesizer.continueSpeaking()
} }
public func stop() { stop(clearProgress: false) } public func stop() { stop(clearProgress: false) }
public func stop(clearProgress: Bool) { public func stop(clearProgress: Bool) {
playbackGeneration &+= 1
resetPlayback(clearProgress: clearProgress)
state = .idle
}
private func resetPlayback(clearProgress: Bool) {
isStopping = true isStopping = true
synthesizer.stopSpeaking(at: .immediate) synthesizer.stopSpeaking(at: .immediate)
activeUtterance = nil
currentUnit = nil currentUnit = nil
queuedUnits.removeAll() queuedUnits.removeAll()
nextLocation = nil nextLocation = nil
isLoadingBatch = false loadingGeneration = nil
sleepTimerTask?.cancel() sleepTimerTask?.cancel()
sleepTimerTask = nil sleepTimerTask = nil
if clearProgress { progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) } if clearProgress { progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) }
state = .idle
} }
public func skipToNextSentence() { public func skipToNextSentence() {
guard state.isActive else { return } guard state.isActive else { return }
let generation = playbackGeneration
stopCurrentUtteranceForNavigation() stopCurrentUtteranceForNavigation()
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
do { try await self.speakNextUnit() } do { try await self.speakNextUnit(generation: generation) }
catch { self.state = .failed(error.localizedDescription) } catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
catch {}
} }
} }
@@ -143,18 +158,26 @@ public final class RDSpeechReaderController: NSObject {
} }
} }
private func loadBatch(startingAt location: RDSpeechLocation?, requiresContent: Bool) async throws { private func loadBatch(
guard !isLoadingBatch, !isStopping else { return } startingAt location: RDSpeechLocation?,
isLoadingBatch = true requiresContent: Bool,
defer { isLoadingBatch = false } generation: UInt
) async throws {
guard loadingGeneration != generation, isCurrentPlaybackGeneration(generation) else { return }
loadingGeneration = generation
defer {
if loadingGeneration == generation {
loadingGeneration = nil
}
}
var requestedLocation = location var requestedLocation = location
var mustContainContent = requiresContent var mustContainContent = requiresContent
while !isStopping { while isCurrentPlaybackGeneration(generation) {
let batch = try await contentProvider.speechContentBatch( let batch = try await contentProvider.speechContentBatch(
startingAt: requestedLocation, startingAt: requestedLocation,
limit: configuration.batchSize limit: configuration.batchSize
) )
guard !isStopping else { return } guard isCurrentPlaybackGeneration(generation) else { return }
if !batch.units.isEmpty { if !batch.units.isEmpty {
queuedUnits.append(contentsOf: batch.units) queuedUnits.append(contentsOf: batch.units)
nextLocation = batch.nextLocation nextLocation = batch.nextLocation
@@ -171,12 +194,12 @@ public final class RDSpeechReaderController: NSObject {
} }
} }
private func speakNextUnit() async throws { private func speakNextUnit(generation: UInt) async throws {
guard !isStopping else { return } guard isCurrentPlaybackGeneration(generation) else { return }
if queuedUnits.isEmpty, let nextLocation { if queuedUnits.isEmpty, let nextLocation {
try await loadBatch(startingAt: nextLocation, requiresContent: false) try await loadBatch(startingAt: nextLocation, requiresContent: false, generation: generation)
} }
guard !isStopping else { return } guard isCurrentPlaybackGeneration(generation) else { return }
guard !queuedUnits.isEmpty else { guard !queuedUnits.isEmpty else {
if state != .finished { if state != .finished {
state = .finished state = .finished
@@ -193,6 +216,7 @@ public final class RDSpeechReaderController: NSObject {
let utterance = AVSpeechUtterance(string: unit.text) let utterance = AVSpeechUtterance(string: unit.text)
utterance.rate = configuration.rate utterance.rate = configuration.rate
utterance.voice = resolvedVoice(for: unit) utterance.voice = resolvedVoice(for: unit)
activeUtterance = utterance
synthesizer.speak(utterance) synthesizer.speak(utterance)
} }
@@ -208,9 +232,14 @@ public final class RDSpeechReaderController: NSObject {
private func stopCurrentUtteranceForNavigation() { private func stopCurrentUtteranceForNavigation() {
isStopping = true isStopping = true
synthesizer.stopSpeaking(at: .immediate) synthesizer.stopSpeaking(at: .immediate)
activeUtterance = nil
isStopping = false isStopping = false
} }
private func isCurrentPlaybackGeneration(_ generation: UInt) -> Bool {
playbackGeneration == generation && !isStopping
}
private func resolvedVoice(for unit: RDSpeechTextUnit) -> AVSpeechSynthesisVoice? { private func resolvedVoice(for unit: RDSpeechTextUnit) -> AVSpeechSynthesisVoice? {
if let identifier = configuration.voiceIdentifier, if let identifier = configuration.voiceIdentifier,
let voice = AVSpeechSynthesisVoice(identifier: identifier) { let voice = AVSpeechSynthesisVoice(identifier: identifier) {
@@ -266,33 +295,43 @@ public final class RDSpeechReaderController: NSObject {
commands.togglePlayPauseCommand.isEnabled = true commands.togglePlayPauseCommand.isEnabled = true
commands.nextTrackCommand.isEnabled = true commands.nextTrackCommand.isEnabled = true
commands.previousTrackCommand.isEnabled = true commands.previousTrackCommand.isEnabled = true
commands.playCommand.addTarget { [weak self] _ in remoteCommandTargets.append((commands.playCommand, commands.playCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.resume() } Task { @MainActor [weak self] in self?.resume() }
return .success return .success
} }))
commands.pauseCommand.addTarget { [weak self] _ in remoteCommandTargets.append((commands.pauseCommand, commands.pauseCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.pause() } Task { @MainActor [weak self] in self?.pause() }
return .success return .success
} }))
commands.togglePlayPauseCommand.addTarget { [weak self] _ in remoteCommandTargets.append((commands.togglePlayPauseCommand, commands.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
self.synthesizer.isPaused ? self.resume() : self.pause() self.synthesizer.isPaused ? self.resume() : self.pause()
} }
return .success return .success
} }))
commands.nextTrackCommand.addTarget { [weak self] _ in remoteCommandTargets.append((commands.nextTrackCommand, commands.nextTrackCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.skipToNextSentence() } Task { @MainActor [weak self] in self?.skipToNextSentence() }
return .success return .success
} }))
commands.previousTrackCommand.addTarget { [weak self] _ in remoteCommandTargets.append((commands.previousTrackCommand, commands.previousTrackCommand.addTarget { [weak self] _ in
guard let self, Self.activeRemoteController === self else { return .commandFailed }
Task { @MainActor [weak self] in self?.skipToPreviousSentence() } Task { @MainActor [weak self] in self?.skipToPreviousSentence() }
return .success return .success
} }))
}
private func claimRemoteControlsIfNeeded() {
guard configuration.enablesRemoteControls else { return }
Self.activeRemoteController = self
} }
private func updateNowPlayingInfo() { private func updateNowPlayingInfo() {
guard configuration.enablesRemoteControls else { return } guard configuration.enablesRemoteControls, Self.activeRemoteController === self else { return }
let book = contentProvider.speechBookDescriptor() let book = contentProvider.speechBookDescriptor()
var info: [String: Any] = [ var info: [String: Any] = [
MPMediaItemPropertyTitle: book.title, MPMediaItemPropertyTitle: book.title,
@@ -305,7 +344,7 @@ public final class RDSpeechReaderController: NSObject {
extension RDSpeechReaderController: AVSpeechSynthesizerDelegate { extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) { public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidStart() } Task { @MainActor [weak self] in self?.handleDidStart(utterance) }
} }
public nonisolated func speechSynthesizer( public nonisolated func speechSynthesizer(
@@ -313,55 +352,58 @@ extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
willSpeakRangeOfSpeechString characterRange: NSRange, willSpeakRangeOfSpeechString characterRange: NSRange,
utterance: AVSpeechUtterance utterance: AVSpeechUtterance
) { ) {
Task { @MainActor [weak self, characterRange] in self?.handleWillSpeak(characterRange) } Task { @MainActor [weak self, characterRange] in self?.handleWillSpeak(characterRange, utterance: utterance) }
} }
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) { public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidPause() } Task { @MainActor [weak self] in self?.handleDidPause(utterance) }
} }
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) { public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidContinue() } Task { @MainActor [weak self] in self?.handleDidContinue(utterance) }
} }
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidFinish() } Task { @MainActor [weak self] in self?.handleDidFinish(utterance) }
} }
} }
private extension RDSpeechReaderController { private extension RDSpeechReaderController {
func handleDidStart() { func handleDidStart(_ utterance: AVSpeechUtterance) {
guard let currentUnit else { return } guard utterance === activeUtterance, let currentUnit else { return }
progressStore?.saveSpeechLocation(currentUnit.location, for: contentProvider.speechBookDescriptor().identifier) progressStore?.saveSpeechLocation(currentUnit.location, for: contentProvider.speechBookDescriptor().identifier)
state = .speaking(currentUnit.location) state = .speaking(currentUnit.location)
} }
func handleWillSpeak(_ characterRange: NSRange) { func handleWillSpeak(_ characterRange: NSRange, utterance: AVSpeechUtterance) {
guard let currentUnit else { return } guard utterance === activeUtterance, let currentUnit else { return }
var location = currentUnit.location var location = currentUnit.location
location.textOffset = currentUnit.textRange.location + characterRange.location location.textOffset = currentUnit.textRange.location + characterRange.location
progressStore?.saveSpeechLocation(location, for: contentProvider.speechBookDescriptor().identifier) progressStore?.saveSpeechLocation(location, for: contentProvider.speechBookDescriptor().identifier)
delegate?.speechReaderController(self, willSpeak: RDSpeechSpokenRange(unit: currentUnit, range: characterRange)) delegate?.speechReaderController(self, willSpeak: RDSpeechSpokenRange(unit: currentUnit, range: characterRange))
} }
func handleDidPause() { func handleDidPause(_ utterance: AVSpeechUtterance) {
guard let currentUnit else { return } guard utterance === activeUtterance, let currentUnit else { return }
state = .paused(currentUnit.location) state = .paused(currentUnit.location)
} }
func handleDidContinue() { func handleDidContinue(_ utterance: AVSpeechUtterance) {
guard let currentUnit else { return } guard utterance === activeUtterance, let currentUnit else { return }
state = .speaking(currentUnit.location) state = .speaking(currentUnit.location)
} }
func handleDidFinish() { func handleDidFinish(_ utterance: AVSpeechUtterance) {
guard !isStopping, let finished = currentUnit else { return } guard utterance === activeUtterance, !isStopping, let finished = currentUnit else { return }
completedUnits.append(finished) completedUnits.append(finished)
activeUtterance = nil
currentUnit = nil currentUnit = nil
let generation = playbackGeneration
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
do { try await self.speakNextUnit() } do { try await self.speakNextUnit(generation: generation) }
catch { self.state = .failed(error.localizedDescription) } catch where self.isCurrentPlaybackGeneration(generation) { self.state = .failed(error.localizedDescription) }
catch {}
} }
} }
} }