HapeLee 1 месяц назад
Родитель
Сommit
7b1e90e02a
70 измененных файлов с 11006 добавлено и 789 удалено
  1. 354 0
      .codebuddy/plans/private-group-book-vault_40c59b15.md
  2. 1 0
      app/build.gradle.kts
  3. 4808 0
      app/schemas/io.legado.app.data.AppDatabase/107.json
  4. 4 2
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  5. 29 0
      app/src/main/java/io/legado/app/data/dao/BookDao.kt
  6. 8 0
      app/src/main/java/io/legado/app/data/entities/Book.kt
  7. 20 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  8. 38 1
      app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt
  9. 42 2
      app/src/main/java/io/legado/app/data/repository/OtherConfigAuxiliaryRepositories.kt
  10. 320 0
      app/src/main/java/io/legado/app/data/repository/PrivateAccessRepository.kt
  11. 41 0
      app/src/main/java/io/legado/app/data/repository/PrivateContentRepository.kt
  12. 24 2
      app/src/main/java/io/legado/app/di/appModule.kt
  13. 90 0
      app/src/main/java/io/legado/app/domain/gateway/PrivateAccessGateway.kt
  14. 39 0
      app/src/main/java/io/legado/app/domain/gateway/PrivateContentGateway.kt
  15. 121 0
      app/src/main/java/io/legado/app/domain/model/PrivateAccess.kt
  16. 34 0
      app/src/main/java/io/legado/app/domain/model/settings/PrivateAccessSettings.kt
  17. 91 0
      app/src/main/java/io/legado/app/help/security/BiometricEnvelopeWriter.kt
  18. 147 0
      app/src/main/java/io/legado/app/help/security/BiometricUnlockLauncher.kt
  19. 239 0
      app/src/main/java/io/legado/app/help/security/PrivatePasswordCipher.kt
  20. 9 0
      app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
  21. 52 30
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt
  22. 20 2
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt
  23. 23 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
  24. 30 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  25. 383 180
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  26. 150 25
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  27. 31 10
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt
  28. 97 32
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
  29. 7 2
      app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
  30. 6 1
      app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt
  31. 3 8
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigContract.kt
  32. 0 46
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigRouteScreen.kt
  33. 0 29
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt
  34. 3 18
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt
  35. 68 0
      app/src/main/java/io/legado/app/ui/config/privateConfig/PrivateConfigContract.kt
  36. 100 0
      app/src/main/java/io/legado/app/ui/config/privateConfig/PrivateConfigRouteScreen.kt
  37. 261 0
      app/src/main/java/io/legado/app/ui/config/privateConfig/PrivateConfigScreen.kt
  38. 185 0
      app/src/main/java/io/legado/app/ui/config/privateConfig/PrivateConfigViewModel.kt
  39. 121 100
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  40. 164 128
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  41. 4 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  42. 3 1
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  43. 1 0
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  44. 179 31
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
  45. 18 3
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt
  46. 299 71
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
  47. 66 2
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt
  48. 207 28
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt
  49. 8 0
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/BookshelfCover.kt
  50. 46 7
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt
  51. 226 0
      app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateAppStartGate.kt
  52. 111 0
      app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateCoverBlurTransformation.kt
  53. 75 0
      app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateDotMatrix.kt
  54. 97 0
      app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateListMasking.kt
  55. 320 0
      app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateLockedContent.kt
  56. 199 0
      app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateReadGate.kt
  57. 112 0
      app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateReadGateViewModel.kt
  58. 158 0
      app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateVerifyGate.kt
  59. 52 1
      app/src/main/res/values-zh-rCN/strings.xml
  60. 1 1
      app/src/main/res/values-zh-rHK/strings.xml
  61. 1 1
      app/src/main/res/values-zh-rTW/strings.xml
  62. 52 1
      app/src/main/res/values/strings.xml
  63. 132 0
      app/src/test/java/io/legado/app/data/dao/BookPrivateAccessQueryTest.kt
  64. 101 0
      app/src/test/java/io/legado/app/domain/model/PrivateAccessTest.kt
  65. 49 0
      app/src/test/java/io/legado/app/help/security/PrivatePasswordCipherTest.kt
  66. 14 0
      app/src/test/java/io/legado/app/help/storage/BackupConfigSecurityTest.kt
  67. 6 24
      app/src/test/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModelTest.kt
  68. 254 0
      app/src/test/java/io/legado/app/ui/config/privateConfig/PrivateConfigViewModelTest.kt
  69. 51 0
      app/src/test/java/io/legado/app/ui/widget/components/privacy/PrivateCoverBlurTransformationTest.kt
  70. 1 0
      gradle/libs.versions.toml

+ 354 - 0
.codebuddy/plans/private-group-book-vault_40c59b15.md

@@ -0,0 +1,354 @@
+---
+name: private-group-book-vault
+overview: 为书架实现「私密分组 / 私密书籍」功能:以应用本地密码为主凭据、Android BiometricPrompt 作为免手输的快捷解锁,锁定态下书籍封面与文字信息脱敏、点击或进入私密分组时触发验证,验证通过后本次进程内保持解锁。
+design:
+  styleKeywords:
+    - Material 3 Expressive
+    - Glassmorphism
+    - 低饱和中性
+    - 圆角卡片
+    - 微动效
+  fontSystem:
+    fontFamily: Noto Sans
+    heading:
+      size: 20sp
+      weight: 600
+    subheading:
+      size: 16sp
+      weight: 500
+    body:
+      size: 14sp
+      weight: 400
+  colorSystem:
+    primary:
+      - "#6750A4"
+      - "#7F67BE"
+    background:
+      - "#FFFBFE"
+      - "#E7E0EC"
+    text:
+      - "#1C1B1F"
+      - "#49454F"
+    functional:
+      - "#B3261E"
+      - "#146C43"
+      - "#8A6E00"
+todos:
+  - id: add-biometric-deps
+    content: 在 libs.versions.toml 与 app/build.gradle.kts 引入 androidx.biometric 稳定版依赖
+    status: completed
+  - id: build-credential-core
+    content: 实现 PrivatePasswordCipher(PBKDF2 校验值 + Keystore auth-bound 信封)与 BiometricUnlockLauncher
+    status: completed
+    dependencies:
+      - add-biometric-deps
+  - id: data-layer-private
+    content: 为 Book 增加 isPrivate 列、DB 迁移至 107、DAO 投影私密并集并新增读写查询
+    status: completed
+  - id: gateway-and-di
+    content: 定义 PrivateAccessGateway/PrivateContentGateway 与 Repository 实现,Koin 显式绑定
+    status: completed
+    dependencies:
+      - build-credential-core
+      - data-layer-private
+  - id: blur-components
+    content: 实现 PrivateCoverBlurTransformation 与 PrivateLockedContent 等可复用脱敏锁定组件
+    status: completed
+  - id: bookshelf-integration
+    content: 改造书架 UDF 与 BookItem:脱敏渲染、点击验证、私密分组整页锁定、搜索防泄漏
+    status: completed
+    dependencies:
+      - gateway-and-di
+      - blur-components
+  - id: detail-and-settings
+    content: 书籍详情页锁定态接入,并在设置页新增私密功能项与文案
+    status: completed
+    dependencies:
+      - bookshelf-integration
+  - id: verify-and-report
+    content: 运行编译、单测、lint 与架构门禁,用 [skill:legado-compose-review] 复核并输出交付说明
+    status: completed
+    dependencies:
+      - detail-and-settings
+---
+
+## 用户需求(原始)
+
+利用应用已有的**本地密码字段**与 **Android 生物验证 API**,为书架实现「私密分组 / 私密书籍」:书籍封面与文字信息在未验证时
+**模糊脱敏**;点击时**请求验证**;当**书架当前分组为私密分组时整页不显示内容**并同时触发验证;未验证状态给出明确提示;尽量
+**复用已有组件**。
+
+## 澄清后确认的边界
+
+- **凭据组合**:本地密码是权威凭据;生物验证只是"免手输"的快捷方式;**未设置本地密码则私密功能不可用**
+  ,引导去设置页先设置密码。
+- **解锁有效期**:一次启动仅验证一次(进入私密书籍或私密分组时触发),**进程重启后失效**;不做超时、不做切后台重锁。
+- **私密判定**:`所属私密分组` ∪ `单本独立标记`,取并集(需为书籍新增字段)。
+- **覆盖范围**:书架(分组页 + 书籍项)、书籍详情页/简介页、设置与分组管理。**不含**
+  发现页/搜索页(但书架内搜索不得泄漏私密书名)。
+
+## 产品概述
+
+在书架中,私密内容以两种锁定形态呈现:私密分组整页替换为"已隐藏 + 点击验证"
+占位;私密书籍仍占位显示,但封面模糊、书名/作者/简介/章节等信息替换为掩码并带锁标。点击任一锁定内容弹出生物验证(可用时),负向按钮回退到应用内密码输入。验证通过后本次进程内所有私密内容正常渲染。
+
+## 核心功能
+
+- 私密标记:分组编辑复用已有"设为私密分组"开关;书籍新增"设为私密"入口(书架编辑态 / 书籍详情菜单)。
+- 脱敏渲染:封面走全 API 生效的模糊变换,文本全部替换为掩码,不渲染真实书名、作者、简介、最新章节、未读数、标签。
+- 验证入口:点击脱敏书籍卡片、打开私密书籍详情、切换到私密分组页时触发。
+- 未验证提示:锁定态内置"该内容已隐藏,点击验证后查看"文案与锁标;验证失败给出错误提示。
+- 设置:新增"私密功能"分组(生物快捷解锁开关、修改密码入口、无密码时的引导)。
+- 泄漏边界:书架内搜索不匹配私密书籍字段,命中也仅以脱敏卡片呈现。
+
+## 技术栈
+
+- 现有栈:Kotlin + Jetpack Compose + Material 3 + Navigation 3 + Koin + Room + DataStore(
+  `LocalPreferences`)+ Coil 3.6.3。全部沿用,不引入新架构。
+- **新增依赖**:`androidx.biometric:biometric-ktx`(`gradle/libs.versions.toml` 已有
+  `biometricKtx = "1.1.0"` 版本引用但**未定义 library 别名、未在 `app/build.gradle.kts` 声明**
+  ,需补全)。选用稳定版 1.1.0 而非 `biometric = "1.4.0-alpha07"`。
+- 加密:Android Keystore(AES/GCM,`setUserAuthenticationRequired(true)` 的 auth-per-use 密钥)+
+  `PBKDF2WithHmacSHA256` 派生校验值;沿用项目 `help/crypto` 的 JCA 路线。
+
+## 实现方案
+
+### 1. 凭据模型:密码为主 + 生物快捷(加密绑定)
+
+本地密码仍是唯一权威凭据。生物路径不是"回调返回 true 就解锁"(可被 hook 伪造),而是**加密绑定**:
+
+1. Keystore 生成 `legado_private_vault` 密钥:`PURPOSE_ENCRYPT|DECRYPT`、`BLOCK_MODE_GCM`、
+   `ENCRYPTION_PADDING_NONE`、`setUserAuthenticationRequired(true)`、
+   `setInvalidatedByBiometricEnrollment(true)`(默认即 true)。
+2. 设置/修改密码时,用该密钥把密码封装成"信封"(AES-GCM 密文 + IV)存 DataStore。
+3. 解锁时 `BiometricPrompt.authenticate(promptInfo, CryptoObject(cipher))`,
+   `onAuthenticationSucceeded` 后从 `result.cryptoObject?.cipher` 解密信封取得密码 → 解锁。*
+   *解密成功才等价于密码正确**。
+
+该设计规避了官方两条硬约束:允许 `DEVICE_CREDENTIAL` 时不能传 `CryptoObject`;本设计**不需要**
+`DEVICE_CREDENTIAL` 回退(应用内密码就是回退),因此可只用 `BIOMETRIC_STRONG` + `CryptoObject`。
+
+必须处理的平台状态(显式建模,不做静默空实现):
+
+- `BiometricManager.canAuthenticate(BIOMETRIC_STRONG)` →
+  `ERROR_NONE_ENROLLED / ERROR_NO_HARDWARE / ERROR_HW_UNAVAILABLE` → 直接回落密码输入,并在设置项显示不可用原因。
+- `KeyPermanentlyInvalidatedException`(新增指纹后密钥失效)→ 清信封、回落密码、验证成功后重建信封。
+- 只允许 `BIOMETRIC_STRONG` 时**必须**调用 `setNegativeButtonText("使用密码")`(不能同时用
+  `setAllowedAuthenticators(... or DEVICE_CREDENTIAL)`)。
+
+### 2. 密码校验
+
+不新增明文比对。新增 DataStore key:`private_salt`、`private_verifier`(PBKDF2WithHmacSHA256,salt 16B
+随机,迭代 ≥ 210000,输出 32B)、`private_biometric_envelope`、`private_biometric_iv`、
+`private_biometric_enabled`。`LocalPasswordGateway.setPassword` 时**原子更新** salt/verifier/信封(一次
+`putAllAndAwait`),保证密码与信封永不同步失败。
+
+### 3. 脱敏渲染(关键:不能只用 `Modifier.blur`)
+
+项目 `minSdk 26`,而 `androidx.compose.ui.draw.blur` 在 **API < 31 为 no-op**(项目已在
+`CoverBlurBackdrop.kt` 使用)→ 单独依赖会**在 26–30 直接泄漏真实封面**。
+
+方案:统一使用 **Coil `Transformation`**(已确认 API:`coil3.transform.Transformation`,
+`override val cacheKey: String`,`override suspend fun transform(input: Bitmap, size: Size): Bitmap`
+,先例见 `MangaCoilTransformations.kt`),做「降采样 → box blur ×2 → 放大」,全 API 一致生效、结果确定;锁定态请求使用独立
+`memoryCacheKey`,不污染正常封面缓存。文本一律替换为掩码,**不渲染真实字符串**。
+
+### 4. 数据层
+
+- `Book` 新增 `@ColumnInfo(defaultValue = "0") var isPrivate: Boolean = false`;`AppDatabase`
+  `version 106 → 107`(`exportSchema = true`,简单加列走 `AutoMigration(from = 106, to = 107)`,落库前用生成的
+  schema diff 复核)。
+- `BookShelfItem` 投影新增一列,SQL 直接算并集:
+  `(books.isPrivate = 1 OR (books."group" & PRIVATE_GROUP_MASK) <> 0) AS isPrivate`(复用 `BookDao`
+  已有 `PRIVATE_GROUP_MASK`,不新增语义常量)。
+- **语义决策(需明确)**:`Book.isPrivate` **不并入** `PUBLIC_BOOK_FILTER`。理由:用户要求"封面模糊 +
+  点击验证",私密书必须仍在书架可见;而"属于私密分组的书从其他视图隐藏"是 `private_group_desc` 既定语义(
+  `PUBLIC_BOOK_FILTER`),保持不变。即:分组私密 = 整组隐藏;单本私密 = 隐藏内容但保留脱敏入口。
+- 性能:新增的是标量子查询,`PUBLIC_BOOK_FILTER` 已在同批查询中使用,增量为一个按位与;不新增表扫描。搜索过滤在内存侧对已加载列表做,不额外查库。
+
+### 5. 解锁态与 UDF 接入
+
+`PrivateAccessRepository` 持进程内 `MutableStateFlow(false)`(进程重启即失效,符合"一次启动一次"
+)。ViewModel **只订阅 `Flow<PrivateAccessState>`**,不持有 Activity、不碰 Keystore。
+
+系统 UI(BiometricPrompt)必须由 `FragmentActivity` 承载 —— 已确认
+`BaseComposeActivity : AppCompatActivity()`,`MainActivity` 满足。按 AGENTS.md「宿主动作通过 Effect
+处理」:ViewModel 发 `RequestPrivateUnlock(target)` → Screen 侧 `LocalContext.current` 取得
+FragmentActivity → 平台组件弹 BiometricPrompt;负向按钮或不可用时改为展示应用内密码覆盖层(
+`AppAlertDialog` + `AppTextField`,复用现有组件)→ `onIntent(SubmitPrivatePassword)` → Gateway 校验 →
+成功后执行挂起动作(打开书籍 / 解锁当前分组)。
+
+```mermaid
+flowchart TD
+    A[点击私密书籍 / 切到私密分组] --> B{已解锁?}
+    B -- 是 --> Z[正常渲染]
+    B -- 否 --> C[Effect: RequestPrivateUnlock]
+    C --> D{生物可用且已开启?}
+    D -- 是 --> E[BiometricPrompt + CryptoObject]
+    E -- 成功 --> F[解密信封取密码]
+    E -- 负向/失败 --> G[应用内密码覆盖层]
+    D -- 否 --> G
+    F --> H{密码校验通过?}
+    G --> H
+    H -- 是 --> I[Gateway 置 unlocked=true 进程级] --> Z
+    H -- 否 --> J[显示错误提示, 保持锁定]
+```
+
+## 实施要点(防回归)
+
+- **不在 UI/ViewModel 新增 DAO 直连**:私密读写全部走 `PrivateContentGateway`(data 层 Repository
+  实现),保持 `verifyConfigArchitecture` 基线不上升。
+- **Koin 显式绑定** Gateway 接口 → Repository 实现(不用构造函数绑定掩盖归属)。
+- 锁定态不得预取/预渲染真实数据:私密分组整页占位时,仍会加载列表(SQL 放行)但**不把真实字符串交给
+  Composition**,由脱敏组件统一替换。
+- 新增 `@Stable`:所有新 UiState / UI item 字段;集合用 `kotlinx.collections.immutable`。
+- 文案进 `strings.xml`(中/英都要),不硬编码。
+- 备份/恢复(`Backup.kt`/`Restore.kt`/`RestoreConfigNormalizer.kt`)需确认 `Book.isPrivate` 新列是否被
+  Gson 序列化带入导出 JSON,避免旧版本恢复报错。
+- 日志:不打印书名、密码、信封;失败只记错误类型。
+
+## 架构设计
+
+```text
+Compose UI (BookshelfScreen / BookInfoScreen / OtherConfigScreen)
+    ↓ Intent
+ViewModel (BookshelfViewModel / BookInfoViewModel)   ← 只订阅 Flow
+    ↓ Effect (RequestPrivateUnlock)        ↓ Gateway
+宿主 FragmentActivity / 平台组件            domain/gateway
+    BiometricPrompt + CryptoObject         PrivateAccessGateway
+    ↓                                      PrivateContentGateway
+    PrivateAccessRepository ──────────────→ data/repository (Room OK)
+    PrivatePasswordCipher (Keystore/PBKDF2)
+```
+
+平台能力不可用时显式建模为
+`PrivateBiometricStatus { Available, NotEnrolled, NoHardware, Unavailable, Disabled }`,不伪造支持。
+
+## 目录结构
+
+```
+app/src/main/java/io/legado/app/
+├── domain/
+│   ├── model/PrivateAccess.kt                     # [NEW] @Stable PrivateAccessState(isUnlocked, hasPassword, biometricStatus, biometricEnabled);enum PrivateBiometricStatus;@Stable PrivateUnlockTarget(Book(bookUrl)/Group(groupId)/Detail(bookUrl))
+│   └── gateway/
+│       ├── PrivateAccessGateway.kt                # [NEW] 解锁态与凭据:state Flow、verifyPassword、setBiometricShortcutEnabled、setPassword
+│       └── PrivateContentGateway.kt               # [NEW] 私密内容读写:isBookPrivate(bookUrl)、setBooksPrivate(bookUrls, isPrivate)、flowPrivateGroupIds()
+├── data/
+│   ├── entities/Book.kt                           # [MODIFY] 新增 isPrivate 列(defaultValue 0)
+│   ├── dao/BookDao.kt                             # [MODIFY] 投影新增并集 isPrivate 列;新增按 isPrivate 过滤/更新的查询
+│   ├── AppDatabase.kt                             # [MODIFY] version 106→107 + AutoMigration(106,107)
+│   ├── local/preferences/LocalPreferences.kt      # [MODIFY] 新增 salt/verifier/envelope/iv/biometricEnabled keys
+│   └── repository/
+│       ├── PrivateAccessRepository.kt             # [NEW] 进程级解锁态 + 密码校验 + 信封生命周期
+│       └── PrivateContentRepository.kt            # [NEW] 私密书籍标记与查询(数据层,可访问 DAO)
+├── help/security/
+│   ├── PrivatePasswordCipher.kt                   # [NEW] PBKDF2 verifier + Keystore auth-bound 密钥 + AES-GCM 信封加解密;处理 KeyPermanentlyInvalidatedException
+│   └── BiometricUnlockLauncher.kt                 # [NEW] 包装 BiometricManager.canAuthenticate + BiometricPrompt(FragmentActivity, CryptoObject)
+├── ui/widget/components/private/
+│   ├── PrivateCoverBlurTransformation.kt          # [NEW] Coil Transformation:降采样+box blur,全 API 生效
+│   └── PrivateLockedContent.kt                    # [NEW] 可复用锁定态组件:PrivateLockedPage(整页)、PrivateLockedBookCard(脱敏卡片)、PrivateLockBadge
+├── ui/main/bookshelf/
+│   ├── BookShelfItem.kt                           # [MODIFY] DTO 新增 isPrivate;BookUiItem 透出
+│   ├── BookshelfUiState.kt                        # [MODIFY] 新增 privateAccess、isCurrentGroupPrivate、Overlay.PrivateUnlock、Intent/Effect 解锁相关
+│   ├── BookshelfViewModel.kt                      # [MODIFY] 订阅 PrivateAccessGateway;组合私密态;挂起动作;搜索过滤私密书
+│   ├── BookshelfScreen.kt                         # [MODIFY] 整页锁定分支、Effect 宿主分发、密码覆盖层
+│   └── BookItem.kt                                # [MODIFY] BookItem/BookGroupItem* 在锁定态走脱敏渲染(复用 BookshelfGridItem/ListItem 骨架)
+├── ui/book/info/
+│   ├── BookInfoViewModel.kt                       # [MODIFY] 私密书未解锁时进入锁定态
+│   └── BookInfoScreen.kt                          # [MODIFY] 复用 PrivateLockedPage + 验证入口
+├── ui/config/otherConfig/
+│   ├── OtherConfigContract.kt / Screen.kt / RouteScreen.kt / ViewModel.kt   # [MODIFY] 新增"私密功能"设置块:生物快捷解锁开关、未设密码引导
+├── ui/book/group/GroupEditSheet.kt                # [MODIFY] 复用已有 isPrivate 开关,确认文案对齐新语义
+├── di/appModule.kt                                # [MODIFY] Koin 显式绑定 Gateway→Repository
+├── res/values/strings.xml                         # [MODIFY] 新增锁定/验证/设置相关文案
+gradle/libs.versions.toml                          # [MODIFY] 新增 androidx-biometric library 别名
+app/build.gradle.kts                               # [MODIFY] implementation(libs.androidx.biometric.ktx)
+app/src/test/.../PrivateAccessRepositoryTest.kt    # [NEW] 纯逻辑单测:私密并集判定、密码校验、锁定态掩码、搜索过滤
+```
+
+## 关键代码结构
+
+```
+// domain/model/PrivateAccess.kt
+@Stable
+data class PrivateAccessState(
+    val isUnlocked: Boolean = false,
+    val hasPassword: Boolean = false,
+    val biometricStatus: PrivateBiometricStatus = PrivateBiometricStatus.Unavailable,
+    val biometricEnabled: Boolean = false,
+)
+
+enum class PrivateBiometricStatus { Available, NotEnrolled, NoHardware, Unavailable, Disabled }
+
+sealed interface PrivateUnlockTarget {
+    data class Book(val bookUrl: String) : PrivateUnlockTarget
+    data class Group(val groupId: Long) : PrivateUnlockTarget
+    data class Detail(val bookUrl: String) : PrivateUnlockTarget
+}
+
+// domain/gateway/PrivateAccessGateway.kt
+interface PrivateAccessGateway {
+    val state: Flow<PrivateAccessState>
+    suspend fun verifyPassword(password: String): Boolean
+    suspend fun setBiometricShortcutEnabled(enabled: Boolean)
+    suspend fun consumeBiometricEnvelope(cipher: javax.crypto.Cipher): Boolean // 生物成功后解密信封
+    fun createBiometricCipher(): javax.crypto.Cipher? // 平台下放:无可用生物/密钥失效时返回 null
+}
+
+// domain/gateway/PrivateContentGateway.kt
+interface PrivateContentGateway {
+    suspend fun isBookPrivate(bookUrl: String): Boolean
+    suspend fun setBooksPrivate(bookUrls: Set<String>, isPrivate: Boolean)
+}
+```
+
+> 注:`Cipher` 是 JVM 类型,按 AGENTS.md「共享契约不暴露平台类型」的原则,实际落地时以上两方法应下沉到平台侧接口(
+`PrivateBiometricVault`,位于 `help/security`,由 `BiometricUnlockLauncher` 使用),Gateway 只保留
+`state/verifyPassword/setBiometricShortcutEnabled`。实现时以这一更严格的分层为准。
+
+## 设计风格
+
+沿用应用现有 Material 3 Expressive + 玻璃/毛玻璃语言,**不引入新视觉体系**:锁定态使用低饱和中性表面 +
+圆角卡片 + 锁形图标 + 细腻遮罩,与 `LegadoTheme.colorScheme`、`NormalCard`/`GlassCard` 保持一致。
+
+## 页面与区块
+
+**书架页(锁定态)**
+
+- 顶部导航条:保持现有,仅分组名旁加小锁标,不改变布局。
+- 私密分组内容区:整页居中卡片,锁图标 48dp + 主标题"该分组已隐藏" + 说明"验证后查看内容" + 主按钮"
+  验证并查看"。
+- 书籍脱敏卡片:复用 `BookshelfGridItem` / `BookshelfListItem`
+  骨架,封面走模糊变换,书名/作者/章节替换为掩码条(圆角灰条),右下角叠加小锁标;网格与列表两种模式共用同一脱敏组件。
+- 验证交互:生物可用时系统弹窗,负向按钮"使用密码";否则应用内 `AppAlertDialog` + `AppTextField`
+  。失败抖动 + 错误文案,不退出锁定态。
+- 微动效:解锁成功后脱敏层以 200ms 交叉淡出到真实内容,避免闪跳。
+
+**书籍详情页(锁定态)**
+
+- 复用 `PrivateLockedPage`:模糊化的背景封面 + 居中锁卡片 + "验证以查看"按钮;验证通过原地过渡到详情。
+
+**设置页**
+
+- 新增"私密功能"分组:生物快捷解锁开关、修改密码入口、无密码时的引导行(点击进入密码设置)。
+
+## 响应式与可达性
+
+- 锁定态文案走 `contentDescription`,保证 TalkBack 读出"已隐藏,需验证"而非真实书名。
+- 锁标与按钮触控区 ≥ 48dp;深色/浅色主题均使用主题色令牌,不硬编码。
+
+## Agent Extensions
+
+### Skill
+
+- **legado-compose-review**
+- 用途:审查新增/改造的 Compose 屏幕与状态宿主(Bookshelf、BookInfo、OtherConfig)是否符合 UDF/MVI、
+  `@Stable`、Effect 边界与依赖方向约束。
+- 预期结果:给出符合仓库门禁的 Contract/ViewModel/Screen 形态,避免在 ViewModel 层泄漏 DAO/Activity。
+
+### SubAgent
+
+- **code-explorer**
+- 用途:在实施前精确定位 `Book` 实体全部构造点、`BookShelfItem` 投影定义、备份/恢复对 Book 字段的处理、
+  `BaseComposeActivity`→`FragmentActivity` 链路、Koin 绑定位置。
+- 预期结果:产出准确的修改点清单,避免遗漏导致编译或行为回归。

+ 1 - 0
app/build.gradle.kts

@@ -263,6 +263,7 @@ dependencies {
     implementation(libs.firebase.perf)
     implementation(libs.androidx.datastore.preferences)
     implementation(libs.androidx.palette)
+    implementation(libs.androidx.biometric)
     implementation(libs.androidx.core.splashscreen)
     implementation(libs.androidx.startup.runtime)
     implementation(platform(libs.androidx.compose.bom))

+ 4808 - 0
app/schemas/io.legado.app.data.AppDatabase/107.json

@@ -0,0 +1,4808 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 107,
+    "identityHash": "4e649102fe7bd88d9a8ba9f9206d44ea",
+    "entities": [
+      {
+        "tableName": "books",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `isPrivate` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, `listIntro` TEXT, PRIMARY KEY(`bookUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'loc_book'"
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customTag",
+            "columnName": "customTag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customCoverUrl",
+            "columnName": "customCoverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customIntro",
+            "columnName": "customIntro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "remark",
+            "columnName": "remark",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "charset",
+            "columnName": "charset",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "isPrivate",
+            "columnName": "isPrivate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTime",
+            "columnName": "latestChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckTime",
+            "columnName": "lastCheckTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckCount",
+            "columnName": "lastCheckCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "totalChapterNum",
+            "columnName": "totalChapterNum",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTitle",
+            "columnName": "durChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "durChapterIndex",
+            "columnName": "durChapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterPos",
+            "columnName": "durChapterPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTime",
+            "columnName": "durChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "canUpdate",
+            "columnName": "canUpdate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readConfig",
+            "columnName": "readConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncTime",
+            "columnName": "syncTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "listIntro",
+            "columnName": "listIntro",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_books_name_author",
+            "unique": false,
+            "columnNames": [
+              "name",
+              "author"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)"
+          },
+          {
+            "name": "index_books_durChapterTime",
+            "unique": false,
+            "columnNames": [
+              "durChapterTime"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_durChapterTime` ON `${TABLE_NAME}` (`durChapterTime`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_groups",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `enableRefresh` INTEGER NOT NULL DEFAULT 1, `show` INTEGER NOT NULL DEFAULT 1, `bookSort` INTEGER NOT NULL DEFAULT -1, `isPrivate` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`groupId`))",
+        "fields": [
+          {
+            "fieldPath": "groupId",
+            "columnName": "groupId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cover",
+            "columnName": "cover",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enableRefresh",
+            "columnName": "enableRefresh",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "show",
+            "columnName": "show",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "bookSort",
+            "columnName": "bookSort",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "isPrivate",
+            "columnName": "isPrivate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "groupId"
+          ]
+        }
+      },
+      {
+        "tableName": "book_sources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceUrl` TEXT NOT NULL, `bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `enabledExplore` INTEGER NOT NULL DEFAULT 1, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `exploreScreen` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, `ruleReview` TEXT, `eventListener` INTEGER NOT NULL DEFAULT 0, `customButton` INTEGER NOT NULL DEFAULT 0, `homepageModules` TEXT, PRIMARY KEY(`bookSourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookSourceUrl",
+            "columnName": "bookSourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceName",
+            "columnName": "bookSourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceGroup",
+            "columnName": "bookSourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceType",
+            "columnName": "bookSourceType",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrlPattern",
+            "columnName": "bookUrlPattern",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "enabledExplore",
+            "columnName": "enabledExplore",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceComment",
+            "columnName": "bookSourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "weight",
+            "columnName": "weight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "exploreUrl",
+            "columnName": "exploreUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "exploreScreen",
+            "columnName": "exploreScreen",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleExplore",
+            "columnName": "ruleExplore",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleSearch",
+            "columnName": "ruleSearch",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleBookInfo",
+            "columnName": "ruleBookInfo",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleToc",
+            "columnName": "ruleToc",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleReview",
+            "columnName": "ruleReview",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "eventListener",
+            "columnName": "eventListener",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customButton",
+            "columnName": "customButton",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "homepageModules",
+            "columnName": "homepageModules",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookSourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_sources_bookSourceUrl",
+            "unique": false,
+            "columnNames": [
+              "bookSourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapters",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `isVolume` INTEGER NOT NULL, `tocLevel` INTEGER NOT NULL DEFAULT 0, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `wordCount` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, `reviewImg` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVolume",
+            "columnName": "isVolume",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "tocLevel",
+            "columnName": "tocLevel",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "index",
+            "columnName": "index",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVip",
+            "columnName": "isVip",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isPay",
+            "columnName": "isPay",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resourceUrl",
+            "columnName": "resourceUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tag",
+            "columnName": "tag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "startFragmentId",
+            "columnName": "startFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "endFragmentId",
+            "columnName": "endFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "reviewImg",
+            "columnName": "reviewImg",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url",
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapters_bookUrl",
+            "unique": false,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_chapters_bookUrl_index",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "index"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookUrl"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "replace_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `group` TEXT, `pattern` TEXT NOT NULL DEFAULT '', `replacement` TEXT NOT NULL DEFAULT '', `scope` TEXT, `scopeTitle` INTEGER NOT NULL DEFAULT 0, `scopeContent` INTEGER NOT NULL DEFAULT 1, `excludeScope` TEXT, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `timeoutMillisecond` INTEGER NOT NULL DEFAULT 3000, `sortOrder` INTEGER NOT NULL DEFAULT 0)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "replacement",
+            "columnName": "replacement",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "scope",
+            "columnName": "scope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "scopeTitle",
+            "columnName": "scopeTitle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "scopeContent",
+            "columnName": "scopeContent",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "excludeScope",
+            "columnName": "excludeScope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "isRegex",
+            "columnName": "isRegex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "timeoutMillisecond",
+            "columnName": "timeoutMillisecond",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "3000"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_replace_rules_id",
+            "unique": false,
+            "columnNames": [
+              "id"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ]
+      },
+      {
+        "tableName": "searchBooks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, `chapterWordCountText` TEXT, `chapterWordCount` INTEGER NOT NULL DEFAULT -1, `respondTime` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterWordCountText",
+            "columnName": "chapterWordCountText",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chapterWordCount",
+            "columnName": "chapterWordCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_searchBooks_bookUrl",
+            "unique": true,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_searchBooks_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "book_sources",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "origin"
+            ],
+            "referencedColumns": [
+              "bookSourceUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "search_keywords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))",
+        "fields": [
+          {
+            "fieldPath": "word",
+            "columnName": "word",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "usage",
+            "columnName": "usage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastUseTime",
+            "columnName": "lastUseTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "word"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_keywords_word",
+            "unique": true,
+            "columnNames": [
+              "word"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cookies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cookie",
+            "columnName": "cookie",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cookies_url",
+            "unique": true,
+            "columnNames": [
+              "url"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssSources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `variableComment` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL DEFAULT 0, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `contentWhitelist` TEXT, `contentBlacklist` TEXT, `shouldOverrideUrlLoading` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL DEFAULT 1, `loadWithBaseUrl` INTEGER NOT NULL DEFAULT 1, `injectJs` TEXT, `preloadJs` TEXT, `startHtml` TEXT, `startStyle` TEXT, `startJs` TEXT, `showWebLog` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 0, `preload` INTEGER NOT NULL DEFAULT 0, `cacheFirst` INTEGER NOT NULL DEFAULT 0, `searchUrl` TEXT, `redirectPolicy` TEXT NOT NULL DEFAULT 'ASK_CROSS_ORIGIN', PRIMARY KEY(`sourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceName",
+            "columnName": "sourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceIcon",
+            "columnName": "sourceIcon",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceGroup",
+            "columnName": "sourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceComment",
+            "columnName": "sourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortUrl",
+            "columnName": "sortUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "singleUrl",
+            "columnName": "singleUrl",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "articleStyle",
+            "columnName": "articleStyle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "ruleArticles",
+            "columnName": "ruleArticles",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleNextPage",
+            "columnName": "ruleNextPage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleTitle",
+            "columnName": "ruleTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "rulePubDate",
+            "columnName": "rulePubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleDescription",
+            "columnName": "ruleDescription",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleImage",
+            "columnName": "ruleImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleLink",
+            "columnName": "ruleLink",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentWhitelist",
+            "columnName": "contentWhitelist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentBlacklist",
+            "columnName": "contentBlacklist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "shouldOverrideUrlLoading",
+            "columnName": "shouldOverrideUrlLoading",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "style",
+            "columnName": "style",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enableJs",
+            "columnName": "enableJs",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "loadWithBaseUrl",
+            "columnName": "loadWithBaseUrl",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "injectJs",
+            "columnName": "injectJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "preloadJs",
+            "columnName": "preloadJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startHtml",
+            "columnName": "startHtml",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startStyle",
+            "columnName": "startStyle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startJs",
+            "columnName": "startJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "showWebLog",
+            "columnName": "showWebLog",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "preload",
+            "columnName": "preload",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "cacheFirst",
+            "columnName": "cacheFirst",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "redirectPolicy",
+            "columnName": "redirectPolicy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'ASK_CROSS_ORIGIN'"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "sourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssSources_sourceUrl",
+            "unique": false,
+            "columnNames": [
+              "sourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "bookmarks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `bookUrl` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))",
+        "fields": [
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPos",
+            "columnName": "chapterPos",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterName",
+            "columnName": "chapterName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookText",
+            "columnName": "bookText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "time"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_bookmarks_bookName_bookAuthor",
+            "unique": false,
+            "columnNames": [
+              "bookName",
+              "bookAuthor"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssArticles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link",
+            "sort"
+          ]
+        }
+      },
+      {
+        "tableName": "rssReadRecords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))",
+        "fields": [
+          {
+            "fieldPath": "record",
+            "columnName": "record",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "record"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssReadRecords_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ]
+      },
+      {
+        "tableName": "readRecordDetail",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `date` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `readWords` INTEGER NOT NULL DEFAULT 0, `firstReadTime` INTEGER NOT NULL DEFAULT 0, `lastReadTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "date",
+            "columnName": "date",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "readWords",
+            "columnName": "readWords",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "firstReadTime",
+            "columnName": "firstReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastReadTime",
+            "columnName": "lastReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor",
+            "date"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecordSession",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `bookUrl` TEXT NOT NULL DEFAULT '', `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "startTime",
+            "columnName": "startTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "endTime",
+            "columnName": "endTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "words",
+            "columnName": "words",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "rssStars",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "starTime",
+            "columnName": "starTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "txtTocRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `chapterRule` TEXT NOT NULL, `volumeRule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterRule",
+            "columnName": "chapterRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "volumeRule",
+            "columnName": "volumeRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "example",
+            "columnName": "example",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "serialNumber",
+            "columnName": "serialNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enable",
+            "columnName": "enable",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecord",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastRead",
+            "columnName": "lastRead",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor"
+          ]
+        }
+      },
+      {
+        "tableName": "httpTTS",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `speed` INTEGER, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentType",
+            "columnName": "contentType",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT",
+            "defaultValue": "'0'"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "speed",
+            "columnName": "speed",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "caches",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "deadline",
+            "columnName": "deadline",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_caches_key",
+            "unique": true,
+            "columnNames": [
+              "key"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ruleSubs",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoUpdate",
+            "columnName": "autoUpdate",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "update",
+            "columnName": "update",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "dictRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))",
+        "fields": [
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "urlRule",
+            "columnName": "urlRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showRule",
+            "columnName": "showRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "name"
+          ]
+        }
+      },
+      {
+        "tableName": "keyboardAssists",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "serialNo",
+            "columnName": "serialNo",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "type",
+            "key"
+          ]
+        }
+      },
+      {
+        "tableName": "servers",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "config",
+            "columnName": "config",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "search_content_history",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookName` TEXT DEFAULT '', `bookAuthor` TEXT DEFAULT '', `query` TEXT NOT NULL, `time` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "query",
+            "columnName": "query",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_content_history_bookName_bookAuthor_query",
+            "unique": true,
+            "columnNames": [
+              "bookName",
+              "bookAuthor",
+              "query"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_content_history_bookName_bookAuthor_query` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`, `query`)"
+          }
+        ]
+      },
+      {
+        "tableName": "homepage_modules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceUrl` TEXT NOT NULL, `moduleKey` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `args` TEXT, `layoutConfig` TEXT, `url` TEXT, `isEnabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `customSetId` TEXT, `isUserCreated` INTEGER NOT NULL, `customTitle` TEXT, `customSetTitle` TEXT, `sourceJsonHash` TEXT, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "moduleKey",
+            "columnName": "moduleKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "args",
+            "columnName": "args",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "layoutConfig",
+            "columnName": "layoutConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customSetId",
+            "columnName": "customSetId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isUserCreated",
+            "columnName": "isUserCreated",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customTitle",
+            "columnName": "customTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customSetTitle",
+            "columnName": "customSetTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceJsonHash",
+            "columnName": "sourceJsonHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncedAt",
+            "columnName": "syncedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "homepage_custom_sets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "highlightRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, `configName` TEXT, `fontPath` TEXT, `fontWeight` INTEGER NOT NULL, `isItalic` INTEGER NOT NULL, `fontSizeOffset` INTEGER NOT NULL, `npLeft` REAL NOT NULL, `npRight` REAL NOT NULL, `npTop` REAL NOT NULL, `npBottom` REAL NOT NULL, `manualNineSlice` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sampleText",
+            "columnName": "sampleText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "targetScope",
+            "columnName": "targetScope",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "textColor",
+            "columnName": "textColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "bgColor",
+            "columnName": "bgColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "underlineMode",
+            "columnName": "underlineMode",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineColor",
+            "columnName": "underlineColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "underlineWidth",
+            "columnName": "underlineWidth",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineOffset",
+            "columnName": "underlineOffset",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineSvgPath",
+            "columnName": "underlineSvgPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bgImage",
+            "columnName": "bgImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bgImageFit",
+            "columnName": "bgImageFit",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bgImageScale",
+            "columnName": "bgImageScale",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "configName",
+            "columnName": "configName",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "fontPath",
+            "columnName": "fontPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "fontWeight",
+            "columnName": "fontWeight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isItalic",
+            "columnName": "isItalic",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "fontSizeOffset",
+            "columnName": "fontSizeOffset",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npLeft",
+            "columnName": "npLeft",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npRight",
+            "columnName": "npRight",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npTop",
+            "columnName": "npTop",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npBottom",
+            "columnName": "npBottom",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "manualNineSlice",
+            "columnName": "manualNineSlice",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_provider_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `protocol` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `modelsUrl` TEXT, `apiKey` TEXT NOT NULL, `authType` TEXT NOT NULL, `secretRef` TEXT, `headersJson` TEXT, `chatPath` TEXT, `responsesPath` TEXT, `messagesPath` TEXT, `modelsPath` TEXT, `customHeadersJson` TEXT, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "protocol",
+            "columnName": "protocol",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelsUrl",
+            "columnName": "modelsUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "apiKey",
+            "columnName": "apiKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authType",
+            "columnName": "authType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "secretRef",
+            "columnName": "secretRef",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "headersJson",
+            "columnName": "headersJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chatPath",
+            "columnName": "chatPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "responsesPath",
+            "columnName": "responsesPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "messagesPath",
+            "columnName": "messagesPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "modelsPath",
+            "columnName": "modelsPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customHeadersJson",
+            "columnName": "customHeadersJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_model_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `providerId` TEXT NOT NULL, `displayName` TEXT NOT NULL, `modelId` TEXT NOT NULL, `contextWindow` INTEGER NOT NULL, `maxOutputTokens` INTEGER NOT NULL, `capabilities` TEXT NOT NULL, `defaultParamsJson` TEXT, `enabled` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "providerId",
+            "columnName": "providerId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelId",
+            "columnName": "modelId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contextWindow",
+            "columnName": "contextWindow",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "maxOutputTokens",
+            "columnName": "maxOutputTokens",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "capabilities",
+            "columnName": "capabilities",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultParamsJson",
+            "columnName": "defaultParamsJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_model_profiles_providerId",
+            "unique": false,
+            "columnNames": [
+              "providerId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_model_profiles_providerId` ON `${TABLE_NAME}` (`providerId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_task_presets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `promptTemplate` TEXT NOT NULL, `paramsJson` TEXT, `chunkPolicyJson` TEXT, `enabled` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "promptTemplate",
+            "columnName": "promptTemplate",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paramsJson",
+            "columnName": "paramsJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chunkPolicyJson",
+            "columnName": "chunkPolicyJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isDefault",
+            "columnName": "isDefault",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_task_presets_taskType",
+            "unique": false,
+            "columnNames": [
+              "taskType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_taskType` ON `${TABLE_NAME}` (`taskType`)"
+          },
+          {
+            "name": "index_ai_task_presets_modelProfileId",
+            "unique": false,
+            "columnNames": [
+              "modelProfileId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_modelProfileId` ON `${TABLE_NAME}` (`modelProfileId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_artifacts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `contentHash` TEXT NOT NULL, `promptHash` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `status` INTEGER NOT NULL, `output` TEXT, `errorMessage` TEXT, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "promptHash",
+            "columnName": "promptHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "output",
+            "columnName": "output",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "errorMessage",
+            "columnName": "errorMessage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_artifacts_bookUrl_chapterIndex_taskType",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "taskType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_bookUrl_chapterIndex_taskType` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `taskType`)"
+          },
+          {
+            "name": "index_ai_artifacts_contentHash_promptHash_modelProfileId",
+            "unique": false,
+            "columnNames": [
+              "contentHash",
+              "promptHash",
+              "modelProfileId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_contentHash_promptHash_modelProfileId` ON `${TABLE_NAME}` (`contentHash`, `promptHash`, `modelProfileId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_chat_conversations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `reasoningLevel` TEXT NOT NULL, `modelProfileId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "reasoningLevel",
+            "columnName": "reasoningLevel",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_chat_messages",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `role` TEXT NOT NULL, `partsJson` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `branchIndex` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `parentMessageId` TEXT, `thinkingDuration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "conversationId",
+            "columnName": "conversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "role",
+            "columnName": "role",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "partsJson",
+            "columnName": "partsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "branchIndex",
+            "columnName": "branchIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isSelected",
+            "columnName": "isSelected",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentMessageId",
+            "columnName": "parentMessageId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "thinkingDuration",
+            "columnName": "thinkingDuration",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_chat_messages_conversationId_createdAt",
+            "unique": false,
+            "columnNames": [
+              "conversationId",
+              "createdAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_chat_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_memory",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "conversationId",
+            "columnName": "conversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "conversationId",
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_memory_conversationId",
+            "unique": false,
+            "columnNames": [
+              "conversationId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_memory_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "highlight_tag_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `pattern` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "tag_group_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "book_content_processes",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `kind` TEXT NOT NULL, `stage` TEXT NOT NULL, `target` TEXT NOT NULL, `anchorJson` TEXT NOT NULL, `actionJson` TEXT NOT NULL, `styleJson` TEXT, `source` TEXT NOT NULL, `aiArtifactId` TEXT, `sourceContentHash` TEXT, `enabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `status` INTEGER NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "stage",
+            "columnName": "stage",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "target",
+            "columnName": "target",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "anchorJson",
+            "columnName": "anchorJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "actionJson",
+            "columnName": "actionJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "styleJson",
+            "columnName": "styleJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "aiArtifactId",
+            "columnName": "aiArtifactId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceContentHash",
+            "columnName": "sourceContentHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_content_processes_bookUrl_chapterIndex_enabled_sortOrder",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "enabled",
+              "sortOrder"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_bookUrl_chapterIndex_enabled_sortOrder` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `enabled`, `sortOrder`)"
+          },
+          {
+            "name": "index_book_content_processes_bookUrl_kind",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "kind"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_bookUrl_kind` ON `${TABLE_NAME}` (`bookUrl`, `kind`)"
+          },
+          {
+            "name": "index_book_content_processes_aiArtifactId",
+            "unique": false,
+            "columnNames": [
+              "aiArtifactId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_aiArtifactId` ON `${TABLE_NAME}` (`aiArtifactId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_prompt_presets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `instruction` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `builtIn` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "instruction",
+            "columnName": "instruction",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "builtIn",
+            "columnName": "builtIn",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_prompt_presets_taskType_enabled_sortNumber",
+            "unique": false,
+            "columnNames": [
+              "taskType",
+              "enabled",
+              "sortNumber"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_prompt_presets_taskType_enabled_sortNumber` ON `${TABLE_NAME}` (`taskType`, `enabled`, `sortNumber`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `aliasesJson` TEXT NOT NULL, `avatarUri` TEXT, `tagsJson` TEXT NOT NULL, `role` TEXT NOT NULL, `voiceGender` TEXT NOT NULL DEFAULT 'unknown', `voiceAgeBand` TEXT NOT NULL DEFAULT 'unknown', `personality` TEXT NOT NULL, `summary` TEXT NOT NULL, `status` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "aliasesJson",
+            "columnName": "aliasesJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "avatarUri",
+            "columnName": "avatarUri",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tagsJson",
+            "columnName": "tagsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "role",
+            "columnName": "role",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "voiceGender",
+            "columnName": "voiceGender",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'unknown'"
+          },
+          {
+            "fieldPath": "voiceAgeBand",
+            "columnName": "voiceAgeBand",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'unknown'"
+          },
+          {
+            "fieldPath": "personality",
+            "columnName": "personality",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_profiles_bookUrl_name",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "name"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_book_character_profiles_bookUrl_name` ON `${TABLE_NAME}` (`bookUrl`, `name`)"
+          },
+          {
+            "name": "index_book_character_profiles_bookUrl_updatedAt",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "updatedAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_profiles_bookUrl_updatedAt` ON `${TABLE_NAME}` (`bookUrl`, `updatedAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_events",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `characterId` TEXT NOT NULL, `chapterIndex` INTEGER, `chapterTitle` TEXT NOT NULL, `eventTimeText` TEXT NOT NULL, `content` TEXT NOT NULL, `importance` INTEGER NOT NULL, `sourceTextHash` TEXT, `evidenceJson` TEXT NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterId",
+            "columnName": "characterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "chapterTitle",
+            "columnName": "chapterTitle",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "eventTimeText",
+            "columnName": "eventTimeText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "importance",
+            "columnName": "importance",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceTextHash",
+            "columnName": "sourceTextHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_events_bookUrl_characterId_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "characterId",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_events_bookUrl_characterId_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `characterId`, `chapterIndex`)"
+          },
+          {
+            "name": "index_book_character_events_bookUrl_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_events_bookUrl_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_relations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `fromCharacterId` TEXT NOT NULL, `toCharacterId` TEXT NOT NULL, `relationType` TEXT NOT NULL, `summary` TEXT NOT NULL, `attitude` TEXT NOT NULL, `evidenceJson` TEXT NOT NULL, `chapterIndex` INTEGER, `status` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "fromCharacterId",
+            "columnName": "fromCharacterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "toCharacterId",
+            "columnName": "toCharacterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "relationType",
+            "columnName": "relationType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "attitude",
+            "columnName": "attitude",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_relations_bookUrl_fromCharacterId_toCharacterId",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "fromCharacterId",
+              "toCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_fromCharacterId_toCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `fromCharacterId`, `toCharacterId`)"
+          },
+          {
+            "name": "index_book_character_relations_bookUrl_fromCharacterId",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "fromCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_fromCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `fromCharacterId`)"
+          },
+          {
+            "name": "index_book_character_relations_bookUrl_toCharacterId",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "toCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_toCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `toCharacterId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_knowledge_entries",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `keywordsJson` TEXT NOT NULL, `content` TEXT NOT NULL, `scopeStartChapter` INTEGER, `scopeEndChapter` INTEGER, `priority` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `evidenceJson` TEXT NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "keywordsJson",
+            "columnName": "keywordsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "scopeStartChapter",
+            "columnName": "scopeStartChapter",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "scopeEndChapter",
+            "columnName": "scopeEndChapter",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "priority",
+            "columnName": "priority",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_knowledge_entries_bookUrl_type_priority",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "type",
+              "priority"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_knowledge_entries_bookUrl_type_priority` ON `${TABLE_NAME}` (`bookUrl`, `type`, `priority`)"
+          },
+          {
+            "name": "index_book_knowledge_entries_bookUrl_title",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "title"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_knowledge_entries_bookUrl_title` ON `${TABLE_NAME}` (`bookUrl`, `title`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_outline_nodes",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `parentId` TEXT, `nodeType` TEXT NOT NULL, `title` TEXT NOT NULL, `summary` TEXT NOT NULL, `startChapterIndex` INTEGER, `endChapterIndex` INTEGER, `order` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentId",
+            "columnName": "parentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "nodeType",
+            "columnName": "nodeType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "startChapterIndex",
+            "columnName": "startChapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "endChapterIndex",
+            "columnName": "endChapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_outline_nodes_bookUrl_nodeType_startChapterIndex_endChapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "nodeType",
+              "startChapterIndex",
+              "endChapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_outline_nodes_bookUrl_nodeType_startChapterIndex_endChapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `nodeType`, `startChapterIndex`, `endChapterIndex`)"
+          },
+          {
+            "name": "index_book_outline_nodes_bookUrl_parentId_order",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "parentId",
+              "order"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_outline_nodes_bookUrl_parentId_order` ON `${TABLE_NAME}` (`bookUrl`, `parentId`, `order`)"
+          }
+        ]
+      },
+      {
+        "tableName": "read_aloud_voices",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `engineType` TEXT NOT NULL, `engineId` TEXT NOT NULL DEFAULT '', `speakerId` TEXT NOT NULL DEFAULT '', `displayName` TEXT NOT NULL, `traitsJson` TEXT NOT NULL DEFAULT '[]', `emotionCatalogJson` TEXT NOT NULL DEFAULT '[]', `managedBy` TEXT NOT NULL DEFAULT 'user', `enabled` INTEGER NOT NULL DEFAULT 1, `available` INTEGER NOT NULL DEFAULT 1, `revision` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineType",
+            "columnName": "engineType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineId",
+            "columnName": "engineId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "speakerId",
+            "columnName": "speakerId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "traitsJson",
+            "columnName": "traitsJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'[]'"
+          },
+          {
+            "fieldPath": "emotionCatalogJson",
+            "columnName": "emotionCatalogJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'[]'"
+          },
+          {
+            "fieldPath": "managedBy",
+            "columnName": "managedBy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'user'"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "available",
+            "columnName": "available",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "revision",
+            "columnName": "revision",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_read_aloud_voices_engineType_engineId_speakerId",
+            "unique": true,
+            "columnNames": [
+              "engineType",
+              "engineId",
+              "speakerId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_read_aloud_voices_engineType_engineId_speakerId` ON `${TABLE_NAME}` (`engineType`, `engineId`, `speakerId`)"
+          },
+          {
+            "name": "index_read_aloud_voices_enabled_displayName",
+            "unique": false,
+            "columnNames": [
+              "enabled",
+              "displayName"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_read_aloud_voices_enabled_displayName` ON `${TABLE_NAME}` (`enabled`, `displayName`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_voice_bindings",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `subjectType` TEXT NOT NULL, `subjectId` TEXT NOT NULL, `voiceId` TEXT NOT NULL, `locked` INTEGER NOT NULL DEFAULT 0, `source` TEXT NOT NULL DEFAULT 'user', `confidence` REAL NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`, `subjectType`, `subjectId`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "subjectType",
+            "columnName": "subjectType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "subjectId",
+            "columnName": "subjectId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "voiceId",
+            "columnName": "voiceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "locked",
+            "columnName": "locked",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'user'"
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl",
+            "subjectType",
+            "subjectId"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_voice_bindings_bookUrl_subjectType",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "subjectType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_voice_bindings_bookUrl_subjectType` ON `${TABLE_NAME}` (`bookUrl`, `subjectType`)"
+          },
+          {
+            "name": "index_book_voice_bindings_voiceId",
+            "unique": false,
+            "columnNames": [
+              "voiceId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_voice_bindings_voiceId` ON `${TABLE_NAME}` (`voiceId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapter_speech_analysis",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `contentHash` TEXT NOT NULL, `resolverVersion` TEXT NOT NULL, `characterRevision` TEXT NOT NULL DEFAULT '', `status` TEXT NOT NULL DEFAULT 'pending', `error` TEXT NOT NULL DEFAULT '', `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resolverVersion",
+            "columnName": "resolverVersion",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterRevision",
+            "columnName": "characterRevision",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'pending'"
+          },
+          {
+            "fieldPath": "error",
+            "columnName": "error",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapter_speech_analysis_bookUrl_chapterIndex_contentHash_resolverVersion",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "contentHash",
+              "resolverVersion"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapter_speech_analysis_bookUrl_chapterIndex_contentHash_resolverVersion` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `contentHash`, `resolverVersion`)"
+          },
+          {
+            "name": "index_chapter_speech_analysis_bookUrl_chapterIndex_updatedAt",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "updatedAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_analysis_bookUrl_chapterIndex_updatedAt` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `updatedAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapter_speech_segments",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `analysisId` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `paragraphIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `chapterPosition` INTEGER NOT NULL, `text` TEXT NOT NULL, `roleType` TEXT NOT NULL, `characterId` TEXT, `characterName` TEXT NOT NULL DEFAULT '', `emotion` TEXT NOT NULL DEFAULT '', `confidence` REAL NOT NULL DEFAULT 0, `source` TEXT NOT NULL, `userLocked` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "analysisId",
+            "columnName": "analysisId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paragraphIndex",
+            "columnName": "paragraphIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPosition",
+            "columnName": "chapterPosition",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "text",
+            "columnName": "text",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "roleType",
+            "columnName": "roleType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterId",
+            "columnName": "characterId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "characterName",
+            "columnName": "characterName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "emotion",
+            "columnName": "emotion",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "userLocked",
+            "columnName": "userLocked",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapter_speech_segments_analysisId_paragraphIndex_start_end",
+            "unique": true,
+            "columnNames": [
+              "analysisId",
+              "paragraphIndex",
+              "start",
+              "end"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapter_speech_segments_analysisId_paragraphIndex_start_end` ON `${TABLE_NAME}` (`analysisId`, `paragraphIndex`, `start`, `end`)"
+          },
+          {
+            "name": "index_chapter_speech_segments_bookUrl_chapterIndex_paragraphIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "paragraphIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_segments_bookUrl_chapterIndex_paragraphIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `paragraphIndex`)"
+          },
+          {
+            "name": "index_chapter_speech_segments_characterId",
+            "unique": false,
+            "columnNames": [
+              "characterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_segments_characterId` ON `${TABLE_NAME}` (`characterId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cloud_tts_engines",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `provider` TEXT NOT NULL, `baseUrl` TEXT NOT NULL DEFAULT '', `apiKey` TEXT NOT NULL DEFAULT '', `secretKey` TEXT NOT NULL DEFAULT '', `region` TEXT NOT NULL DEFAULT '', `appId` TEXT NOT NULL DEFAULT '', `model` TEXT NOT NULL DEFAULT '', `optionsJson` TEXT NOT NULL DEFAULT '{}', `enabled` INTEGER NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "provider",
+            "columnName": "provider",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "apiKey",
+            "columnName": "apiKey",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "secretKey",
+            "columnName": "secretKey",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "region",
+            "columnName": "region",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "appId",
+            "columnName": "appId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "model",
+            "columnName": "model",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "optionsJson",
+            "columnName": "optionsJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'{}'"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cloud_tts_engines_provider_enabled",
+            "unique": false,
+            "columnNames": [
+              "provider",
+              "enabled"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_cloud_tts_engines_provider_enabled` ON `${TABLE_NAME}` (`provider`, `enabled`)"
+          }
+        ]
+      },
+      {
+        "tableName": "exact_chapter_page_counts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookId` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `contentHash` INTEGER NOT NULL, `layoutSignature` INTEGER NOT NULL, `engineVersion` INTEGER NOT NULL, `pageCount` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`bookId`, `chapterId`, `layoutSignature`), FOREIGN KEY(`bookId`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookId",
+            "columnName": "bookId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterId",
+            "columnName": "chapterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "layoutSignature",
+            "columnName": "layoutSignature",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineVersion",
+            "columnName": "engineVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pageCount",
+            "columnName": "pageCount",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookId",
+            "chapterId",
+            "layoutSignature"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_exact_chapter_page_counts_bookId_layoutSignature",
+            "unique": false,
+            "columnNames": [
+              "bookId",
+              "layoutSignature"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_exact_chapter_page_counts_bookId_layoutSignature` ON `${TABLE_NAME}` (`bookId`, `layoutSignature`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookId"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "book_marks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `bookName` TEXT NOT NULL DEFAULT '', `bookAuthor` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER, `anchorJson` TEXT NOT NULL, `styleJson` TEXT, `note` TEXT NOT NULL, `chapterName` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "anchorJson",
+            "columnName": "anchorJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "styleJson",
+            "columnName": "styleJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "note",
+            "columnName": "note",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterName",
+            "columnName": "chapterName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_marks_bookUrl_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_marks_bookUrl_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`)"
+          }
+        ]
+      }
+    ],
+    "views": [
+      {
+        "viewName": "book_sources_part",
+        "createSql": "CREATE VIEW `${VIEW_NAME}` AS select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore, \n    (loginUrl is not null and trim(loginUrl) <> '') hasLoginUrl, lastUpdateTime, respondTime, weight, \n    (exploreUrl is not null and trim(exploreUrl) <> '') hasExploreUrl \n    from book_sources"
+      }
+    ],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4e649102fe7bd88d9a8ba9f9206d44ea')"
+    ]
+  }
+}

+ 4 - 2
app/src/main/java/io/legado/app/data/AppDatabase.kt

@@ -112,7 +112,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 106,
+    version = 107,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -194,7 +194,9 @@ val appDb by lazy {
         AutoMigration(from = 103, to = 104),
         AutoMigration(from = 104, to = 105),
         // readRecordSession 新增 bookUrl 归属列:同名作者作品共存时按书籍副本分别计时
-        AutoMigration(from = 105, to = 106)
+        AutoMigration(from = 105, to = 106),
+        // books 新增 isPrivate 列:单本私密标记,与所属私密分组共同决定书籍是否私密
+        AutoMigration(from = 106, to = 107)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {

+ 29 - 0
app/src/main/java/io/legado/app/data/dao/BookDao.kt

@@ -13,6 +13,7 @@ import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.ShelfBookSummary
 import io.legado.app.domain.model.CacheableBook
+import io.legado.app.domain.model.PrivateBookFacts
 import io.legado.app.help.book.isNotShelf
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import kotlinx.coroutines.flow.Flow
@@ -1163,4 +1164,32 @@ interface BookDao {
         """
     )
     fun flowBookShelfPreviewByUserGroup(groupId: Long): Flow<List<BookShelfItem>>
+
+    // ---------- 私密书籍标记 ----------
+    // 私密判定取并集:books.isPrivate = 1(单本标记)∪ 所属私密分组。
+    // 分组那一半由 PRIVATE_GROUP_MASK 在内存侧按位与得出,避免改动上面十几处投影。
+
+    @Query("SELECT bookUrl FROM books WHERE isPrivate = 1")
+    fun flowPrivateBookUrls(): Flow<List<String>>
+
+    /**
+     * 私密判定取并集:单本标记 ∪ 所属私密分组;顺带取回分组掩码供分组授权复用。
+     *
+     * 刻意**不挂起**:AI 工具是同步拼字符串返回的,等不了挂起调用;调用方负责不在主线程调用。
+     */
+    @Query(
+        """
+        SELECT
+            CASE WHEN
+                COALESCE((SELECT isPrivate FROM books WHERE bookUrl = :bookUrl), 0) = 1
+                OR ($PRIVATE_GROUP_MASK
+                    & COALESCE((SELECT `group` FROM books WHERE bookUrl = :bookUrl), 0)) <> 0
+            THEN 1 ELSE 0 END AS isPrivate,
+            COALESCE((SELECT `group` FROM books WHERE bookUrl = :bookUrl), 0) AS groupMask
+        """
+    )
+    fun privateFacts(bookUrl: String): PrivateBookFacts
+
+    @Query("UPDATE books SET isPrivate = :isPrivate WHERE bookUrl IN (:bookUrls)")
+    suspend fun setBooksPrivate(bookUrls: Set<String>, isPrivate: Boolean)
 }

+ 8 - 0
app/src/main/java/io/legado/app/data/entities/Book.kt

@@ -79,6 +79,14 @@ data class Book(
     // 自定义分组索引号
     @ColumnInfo(defaultValue = "0")
     var group: Long = 0,
+    // 单独标记为私密书籍(与所属分组无关;私密判定 = 本标记 ∪ 所属私密分组)
+    //
+    // 关于备份:这个字段会随 bookshelf.json 明文进本地/云备份(Gson 不认 @Transient)。
+    // 这是刻意的——书架备份本来就含书名、作者、简介,私密标记只是书架结构的一部分,
+    // 单独把它剔出去会导致恢复后"哪些书是私密"丢失、反而把内容暴露在公开书架上。
+    // 真正的敏感信息(本地密码及其派生凭据)另有排除规则,见 BackupConfig。
+    @ColumnInfo(defaultValue = "0")
+    var isPrivate: Boolean = false,
     // 最新章节标题
     var latestChapterTitle: String? = null,
     // 最新章节标题更新时间

+ 20 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -24,6 +24,26 @@ object LocalPreferencesKeys {
     val READ_URL_IN_BROWSER = booleanPreferencesKey("read_url_in_browser")
     val LAST_BACKUP = longPreferencesKey("last_backup")
     val PASSWORD = stringPreferencesKey("password")
+
+    // 私密内容解锁:password 仍是唯一权威凭据,下面几项只是它的校验值与生物快捷解锁信封。
+    // salt/verifier 由密码派生,绝不存放明文密码;envelope 是 Keystore 中
+    // 「每次使用都需生物验证」密钥加密后的密码,只有生物验证成功才能解开。
+    val PRIVATE_PASSWORD_SALT = stringPreferencesKey("private_password_salt")
+    val PRIVATE_PASSWORD_VERIFIER = stringPreferencesKey("private_password_verifier")
+    val PRIVATE_BIOMETRIC_ENABLED = booleanPreferencesKey("private_biometric_enabled")
+    val PRIVATE_BIOMETRIC_ENVELOPE = stringPreferencesKey("private_biometric_envelope")
+    val PRIVATE_BIOMETRIC_IV = stringPreferencesKey("private_biometric_iv")
+    val PRIVATE_VERIFY_ON_ENTER_GROUP = booleanPreferencesKey("private_verify_on_enter_group")
+    val PRIVATE_VERIFY_ON_OPEN_BOOK = booleanPreferencesKey("private_verify_on_open_book")
+    val PRIVATE_VERIFY_ON_APP_START = booleanPreferencesKey("private_verify_on_app_start")
+    val PRIVATE_UNLOCK_SCOPE = stringPreferencesKey("private_unlock_scope")
+
+    // 私密功能总开关:关掉即整体休眠(不验证、不脱敏、不守卫),密码与标记都留着
+    val PRIVATE_ENABLED = booleanPreferencesKey("private_enabled")
+
+    // "离开应用前有效"频率下的宽限期(秒):0 = 离开即失效
+    val PRIVATE_BACKGROUND_GRACE_SECONDS = intPreferencesKey("private_background_grace_seconds")
+
     val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
     val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
     val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes")

+ 38 - 1
app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt

@@ -17,6 +17,7 @@ import io.legado.app.data.entities.BookOutlineNode
 import io.legado.app.domain.gateway.AiMemoryGateway
 import io.legado.app.domain.gateway.AiToolGateway
 import io.legado.app.domain.gateway.BookKnowledgeGateway
+import io.legado.app.domain.gateway.PrivateAccessGateway
 import io.legado.app.domain.model.AiToolCall
 import io.legado.app.domain.model.AiToolDefinition
 import io.legado.app.domain.model.AiToolResult
@@ -36,8 +37,31 @@ class AiToolRepository(
     private val aiArtifactDao: AiArtifactDao,
     private val aiMemoryGateway: AiMemoryGateway,
     private val bookKnowledgeGateway: BookKnowledgeGateway,
+    private val privateAccessGateway: PrivateAccessGateway,
 ) : AiToolGateway {
 
+    /**
+     * 私密书在未获准时一律当作**不存在**:书名、简介、目录、正文都不交给模型。
+     *
+     * 判定复用与书架/详情页同一套规则(单本标记 ∪ 所属私密分组 + 授权),工具全是同步拼接,
+     * 所以走同步读——调用方已经在 Dispatchers.IO 上(见 [execute])。
+     * 返回的错误刻意与"书不存在"完全一致,不泄露"这本书是私密的"。
+     */
+    private fun isReadable(bookUrl: String): Boolean {
+        val facts = bookDao.privateFacts(bookUrl)
+        return !facts.isPrivate ||
+                privateAccessGateway.isGrantedNow(bookUrl, facts.groupMask)
+    }
+
+    private fun Book.isReadable(): Boolean = isReadable(bookUrl)
+
+    /** 只有书名+作者时(书签、阅读统计)按名作者反查一次再判定;查不到就当与私密无关 */
+    private fun isReadable(name: String, author: String): Boolean {
+        if (name.isBlank() || author.isBlank()) return true
+        val book = bookDao.getBook(name, author) ?: return true
+        return book.isReadable()
+    }
+
     override fun availableTools(): List<AiToolDefinition> = tools
 
     override fun requiresConfirmation(toolName: String): Boolean {
@@ -83,6 +107,7 @@ class AiToolRepository(
         val limit = args.int("limit", 8).coerceIn(1, 20)
         val books = bookDao.all
             .asSequence()
+            .filter { it.isReadable() }
             .filter { book ->
                 query.isBlank() ||
                     book.name.contains(query, ignoreCase = true) ||
@@ -246,6 +271,8 @@ class AiToolRepository(
         val bookAuthor = args.string("bookAuthor")?.trim().orEmpty()
         val bookmarks = bookmarkDao.all
             .asSequence()
+            // 私密书未获准时连笔记与正文片段都不给
+            .filter { isReadable(it.bookName, it.bookAuthor) }
             .filter { bookmark ->
                 (bookName.isBlank() || bookmark.bookName.equals(bookName, ignoreCase = true)) &&
                     (bookAuthor.isBlank() || bookmark.bookAuthor.equals(bookAuthor, ignoreCase = true)) &&
@@ -280,6 +307,7 @@ class AiToolRepository(
         val limit = args.int("limit", 10).coerceIn(1, 30)
         val records = readRecordDao.all
             .asSequence()
+            .filter { isReadable(it.bookName, it.bookAuthor) }
             .filter {
                 query.isBlank() ||
                     it.bookName.contains(query, ignoreCase = true) ||
@@ -709,7 +737,16 @@ class AiToolRepository(
         return GSON.toJson(mapOf("deleted" to true, "key" to key))
     }
 
-    private fun resolveBook(args: JsonObject): Book? {
+    /**
+     * 唯一能吐正文的入口:解析出来的书必须仍然可读,否则一律按"找不到"处理。
+     *
+     * 这道守卫必须在这里而不是各个工具里——工具是十来处,漏一个就漏一次正文。
+     */
+    private fun resolveBook(args: JsonObject): Book? = resolveBookIgnoringPrivacy(args)?.takeIf {
+        it.isReadable()
+    }
+
+    private fun resolveBookIgnoringPrivacy(args: JsonObject): Book? {
         args.string("bookUrl")?.takeIf { it.isNotBlank() }?.let { url ->
             bookDao.getBook(url)?.let { return it }
         }

+ 42 - 2
app/src/main/java/io/legado/app/data/repository/OtherConfigAuxiliaryRepositories.kt

@@ -10,6 +10,7 @@ import io.legado.app.domain.gateway.LocalPasswordGateway
 import io.legado.app.domain.gateway.OtherConfigSystemGateway
 import io.legado.app.help.DirectLinkUpload
 import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.security.PrivatePasswordCipher
 import io.legado.app.help.webView.WebViewDataCleaner
 import io.legado.app.receiver.SharedReceiverActivity
 import kotlinx.coroutines.Dispatchers
@@ -51,12 +52,51 @@ class DirectLinkSettingsRepository : DirectLinkSettingsGateway {
     )
 }
 
+/**
+ * 本地密码是备份加密与私密内容的共同凭据。
+ *
+ * salt / 校验值 / 生物信封必须与密码在同一次落盘里更新,否则会出现"密码改了、但生物快捷
+ * 还在解旧信封"的半更新状态。信封本身无法在这里重建——它必须由一次真实的生物认证产出,
+ * 所以密码一旦变化就如实关掉生物快捷,交给用户重新开启。
+ */
 class LocalPasswordRepository : LocalPasswordGateway {
 
     override suspend fun setPassword(password: String?) {
-        AppConfigStore.putAllAndAwait(
-            mapOf(LocalPreferencesKeys.PASSWORD.name to password.orEmpty())
+        val normalized = password.orEmpty()
+        val previous = AppConfigStore.getString(LocalPreferencesKeys.PASSWORD.name).orEmpty()
+        val values = mutableMapOf<String, Any?>(
+            LocalPreferencesKeys.PASSWORD.name to normalized
         )
+        when {
+            normalized.isEmpty() -> {
+                // 清掉密码就把派生凭据一起清掉,不留半套状态
+                values[LocalPreferencesKeys.PRIVATE_PASSWORD_SALT.name] = ""
+                values[LocalPreferencesKeys.PRIVATE_PASSWORD_VERIFIER.name] = ""
+                values[LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENVELOPE.name] = ""
+                values[LocalPreferencesKeys.PRIVATE_BIOMETRIC_IV.name] = ""
+                values[LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENABLED.name] = false
+            }
+
+            previous == normalized -> {
+                // 密码没变:刷新校验值即可,信封里装的还是同一个密码,仍然有效
+                putVerifier(values, normalized)
+            }
+
+            else -> {
+                putVerifier(values, normalized)
+                values[LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENVELOPE.name] = ""
+                values[LocalPreferencesKeys.PRIVATE_BIOMETRIC_IV.name] = ""
+                values[LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENABLED.name] = false
+            }
+        }
+        AppConfigStore.putAllAndAwait(values)
+    }
+
+    private fun putVerifier(values: MutableMap<String, Any?>, password: String) {
+        val salt = PrivatePasswordCipher.generateSalt()
+        values[LocalPreferencesKeys.PRIVATE_PASSWORD_SALT.name] = salt
+        values[LocalPreferencesKeys.PRIVATE_PASSWORD_VERIFIER.name] =
+            PrivatePasswordCipher.deriveVerifier(password, salt).orEmpty()
     }
 }
 

+ 320 - 0
app/src/main/java/io/legado/app/data/repository/PrivateAccessRepository.kt

@@ -0,0 +1,320 @@
+package io.legado.app.data.repository
+
+import android.content.Context
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.domain.gateway.PrivateAccessGateway
+import io.legado.app.domain.model.PrivateAccessState
+import io.legado.app.domain.model.PrivateBiometricStatus
+import io.legado.app.domain.model.PrivateUnlockScope
+import io.legado.app.domain.model.PrivateUnlockTarget
+import io.legado.app.domain.model.settings.PrivateAccessSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.security.BiometricUnlockLauncher
+import io.legado.app.help.security.PrivatePasswordCipher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+/**
+ * 私密内容解锁状态宿主。
+ *
+ * 解锁状态只放在进程内存里([unlockedFlow] 与按目标的授权集合):进程重启即回到锁定态,
+ * 符合"一次启动验证一次"的约定,不做超时、不做切后台重锁。
+ *
+ * 两种读写路径并存是有意的:设置项走 [SettingsRepository] 的 flow(需要把变化推给界面),
+ * 凭据(salt / 校验值 / 信封)走 [AppConfigStore] 直读写——它们是"校验时读一次"的短值,
+ * 不需要被观察,也不该在 flow 里反复解码。两条路径落在同一份 SharedPreferences 上,
+ * 所以不存在"界面看到的和读到的不一致"。
+ */
+class PrivateAccessRepository(
+    context: Context,
+    private val settingsRepository: SettingsRepository,
+) : PrivateAccessGateway {
+
+    private val appContext = context.applicationContext
+
+    private val unlockedFlow = MutableStateFlow(false)
+
+    /** 「启动应用时验证」这道门槛是否已过。与进程级解锁分开:它不授权任何内容 */
+    private val appStartVerifiedFlow = MutableStateFlow(false)
+
+    /** 仅"每次验证"频率下使用:刚验证过的那一个书籍 / 分组 */
+    private val grantedBookUrlsFlow = MutableStateFlow<Set<String>>(emptySet())
+    private val grantedGroupIdsFlow = MutableStateFlow<Set<Long>>(emptySet())
+
+    private val biometricStatusFlow = MutableStateFlow(
+        BiometricUnlockLauncher.detectStatus(appContext)
+    )
+
+    private val hasPasswordFlow = settingsRepository
+        .getString(LocalPreferencesKeys.PASSWORD.name, "")
+        .map { it.isNotEmpty() }
+        .distinctUntilChanged()
+
+    private val biometricEnabledFlow = settingsRepository
+        .getBoolean(LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENABLED.name, false)
+        .distinctUntilChanged()
+
+    private val enabledFlow = settingsRepository
+        .getBoolean(LocalPreferencesKeys.PRIVATE_ENABLED.name, true)
+        .distinctUntilChanged()
+
+    private val backgroundGraceFlow = settingsRepository
+        .getInt(LocalPreferencesKeys.PRIVATE_BACKGROUND_GRACE_SECONDS.name, 0)
+        .distinctUntilChanged()
+
+    private val verifyOnEnterGroupFlow = settingsRepository
+        .getBoolean(LocalPreferencesKeys.PRIVATE_VERIFY_ON_ENTER_GROUP.name, true)
+        .distinctUntilChanged()
+
+    private val verifyOnOpenBookFlow = settingsRepository
+        .getBoolean(LocalPreferencesKeys.PRIVATE_VERIFY_ON_OPEN_BOOK.name, true)
+        .distinctUntilChanged()
+
+    private val verifyOnAppStartFlow = settingsRepository
+        .getBoolean(LocalPreferencesKeys.PRIVATE_VERIFY_ON_APP_START.name, false)
+        .distinctUntilChanged()
+
+    private val unlockScopeFlow = settingsRepository
+        .getString(
+            LocalPreferencesKeys.PRIVATE_UNLOCK_SCOPE.name,
+            PrivateUnlockScope.AppSession.name
+        )
+        .map { stored ->
+            PrivateUnlockScope.entries.firstOrNull { it.name == stored }
+                ?: PrivateUnlockScope.AppSession
+        }
+        .distinctUntilChanged()
+
+    override val settings: Flow<PrivateAccessSettings> = combine(
+        verifyOnEnterGroupFlow,
+        verifyOnOpenBookFlow,
+        verifyOnAppStartFlow,
+        unlockScopeFlow,
+        enabledFlow
+    ) { verifyOnEnterGroup, verifyOnOpenBook, verifyOnAppStart, unlockScope, enabled ->
+        PrivateAccessSettings(
+            verifyOnEnterGroup = verifyOnEnterGroup,
+            verifyOnOpenBook = verifyOnOpenBook,
+            verifyOnAppStart = verifyOnAppStart,
+            unlockScope = unlockScope,
+            enabled = enabled
+        )
+    }.combine(backgroundGraceFlow) { settings, graceSeconds ->
+        settings.copy(backgroundGraceSeconds = graceSeconds)
+    }.distinctUntilChanged().flowOn(Dispatchers.Default)
+
+    override suspend fun updateSettings(transform: (PrivateAccessSettings) -> PrivateAccessSettings) {
+        val current = readSettings()
+        val next = transform(current)
+        if (next == current) return
+        AppConfigStore.putAllAndAwait(
+            mapOf(
+                LocalPreferencesKeys.PRIVATE_VERIFY_ON_ENTER_GROUP.name to next.verifyOnEnterGroup,
+                LocalPreferencesKeys.PRIVATE_VERIFY_ON_OPEN_BOOK.name to next.verifyOnOpenBook,
+                LocalPreferencesKeys.PRIVATE_VERIFY_ON_APP_START.name to next.verifyOnAppStart,
+                LocalPreferencesKeys.PRIVATE_UNLOCK_SCOPE.name to next.unlockScope.name,
+                LocalPreferencesKeys.PRIVATE_ENABLED.name to next.enabled,
+                LocalPreferencesKeys.PRIVATE_BACKGROUND_GRACE_SECONDS.name to
+                        next.backgroundGraceSeconds
+            )
+        )
+    }
+
+    override val state: Flow<PrivateAccessState> = combine(
+        unlockedFlow,
+        hasPasswordFlow,
+        biometricStatusFlow,
+        biometricEnabledFlow,
+        grantedBookUrlsFlow
+    ) { unlocked, hasPassword, status, biometricEnabled, grantedBookUrls ->
+        AccessSnapshot(unlocked, hasPassword, status, biometricEnabled, grantedBookUrls)
+    }.combine(grantedGroupIdsFlow) { snapshot, grantedGroupIds ->
+        PrivateAccessState(
+            isUnlocked = snapshot.isUnlocked,
+            hasPassword = snapshot.hasPassword,
+            biometricStatus = snapshot.biometricStatus,
+            biometricEnabled = snapshot.biometricEnabled,
+            grantedBookUrls = snapshot.grantedBookUrls,
+            grantedGroupIds = grantedGroupIds
+        )
+    }.combine(appStartVerifiedFlow) { state, appStartVerified ->
+        state.copy(isAppStartVerified = appStartVerified)
+    }.combine(enabledFlow) { state, enabled ->
+        // 总开关必须接进 state:所有"要不要验证/要不要脱敏"的判定都读 state.isTargetGranted,
+        // 只把它放进 settings 的话,开关对验证与脱敏完全不起作用
+        //(只有走同步入口 isGrantedNow 的 AI 守卫会生效——那正是这个 bug 之前的表现)。
+        state.copy(isEnabled = enabled)
+    }.distinctUntilChanged().flowOn(Dispatchers.Default)
+
+    private data class AccessSnapshot(
+        val isUnlocked: Boolean,
+        val hasPassword: Boolean,
+        val biometricStatus: PrivateBiometricStatus,
+        val biometricEnabled: Boolean,
+        val grantedBookUrls: Set<String>,
+    )
+
+    /** 校验 + 授权必须串行:并发两次验证不能出现"其中一次白验" */
+    private val unlockMutex = Mutex()
+
+    override suspend fun verifyPasswordOnly(password: String): Boolean =
+        withContext(Dispatchers.Default) { checkPassword(password) }
+
+    /** 校验密码本身。不碰任何授权状态——授权由 [verifyPassword] 决定 */
+    private suspend fun checkPassword(password: String): Boolean {
+        if (password.isEmpty()) return false
+        if (!ensurePasswordVerifier()) return false
+        val salt =
+            AppConfigStore.getString(LocalPreferencesKeys.PRIVATE_PASSWORD_SALT.name).orEmpty()
+        val verifier =
+            AppConfigStore.getString(LocalPreferencesKeys.PRIVATE_PASSWORD_VERIFIER.name).orEmpty()
+        return PrivatePasswordCipher.verify(password, salt, verifier)
+    }
+
+    override suspend fun verifyPassword(
+        password: String,
+        target: PrivateUnlockTarget?,
+    ): Boolean = withContext(Dispatchers.Default) {
+        if (!checkPassword(password)) return@withContext false
+
+        unlockMutex.withLock {
+            when {
+                // 进程级 / 到后台为止:任意一次验证通过即放行全部私密内容
+                // (两者的差别只在"什么时候失效",见 onAppBackground)
+                readSettings().unlockScope != PrivateUnlockScope.EveryTime ->
+                    unlockedFlow.value = true
+
+                // 启动验证(target == null)只是"进入应用"的门槛,不是对内容的授权:
+                // 记一笔"过了门槛"即可。否则"每次都要验证"的用户过一次启动验证
+                // 就再也不被问,分组与单书验证会形同虚设。
+                target == null -> appStartVerifiedFlow.value = true
+
+                else -> when (target) {
+                    is PrivateUnlockTarget.Book ->
+                        grantedBookUrlsFlow.value = grantedBookUrlsFlow.value + target.bookUrl
+
+                    is PrivateUnlockTarget.Group ->
+                        grantedGroupIdsFlow.value = grantedGroupIdsFlow.value + target.groupId
+                }
+            }
+        }
+        true
+    }
+
+    override fun isGrantedNow(bookUrl: String, group: Long): Boolean = PrivateAccessState(
+        isUnlocked = unlockedFlow.value,
+        grantedBookUrls = grantedBookUrlsFlow.value,
+        grantedGroupIds = grantedGroupIdsFlow.value,
+        // 读内存里的当前值而不是 flow:这个函数是同步的(AI 工具等同步路径要用)
+        isEnabled = AppConfigStore.getBoolean(LocalPreferencesKeys.PRIVATE_ENABLED.name) ?: true,
+    ).isTargetGranted(bookUrl, group)
+
+    override fun revoke(target: PrivateUnlockTarget) {
+        // 只有"每次验证"才需要撤销;进程级解锁时撤销没有意义
+        if (readSettings().unlockScope != PrivateUnlockScope.EveryTime) return
+        when (target) {
+            is PrivateUnlockTarget.Book ->
+                grantedBookUrlsFlow.value = grantedBookUrlsFlow.value - target.bookUrl
+
+            is PrivateUnlockTarget.Group ->
+                grantedGroupIdsFlow.value = grantedGroupIdsFlow.value - target.groupId
+        }
+    }
+
+    override suspend fun disableBiometricShortcut() = withContext(Dispatchers.Default) {
+        AppConfigStore.putAllAndAwait(
+            mapOf(
+                LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENABLED.name to false,
+                LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENVELOPE.name to "",
+                LocalPreferencesKeys.PRIVATE_BIOMETRIC_IV.name to ""
+            )
+        )
+    }
+
+    override suspend fun storeBiometricEnvelope(ciphertext: String, iv: String): Boolean =
+        withContext(Dispatchers.Default) {
+            if (ciphertext.isBlank() || iv.isBlank()) return@withContext false
+            val status = BiometricUnlockLauncher.detectStatus(appContext)
+            biometricStatusFlow.value = status
+            if (status != PrivateBiometricStatus.Available) return@withContext false
+            AppConfigStore.putAllAndAwait(
+                mapOf(
+                    LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENVELOPE.name to ciphertext,
+                    LocalPreferencesKeys.PRIVATE_BIOMETRIC_IV.name to iv,
+                    LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENABLED.name to true
+                )
+            )
+            true
+        }
+
+    override fun refreshBiometricStatus() {
+        biometricStatusFlow.value = BiometricUnlockLauncher.detectStatus(appContext)
+    }
+
+    override fun onAppBackground() {
+        // 只有"到后台为止"这种频率需要在这里失效;其它频率下切后台不该有任何影响
+        if (readSettings().unlockScope != PrivateUnlockScope.UntilBackground) return
+        lock()
+    }
+
+    override fun lock() {
+        unlockedFlow.value = false
+        // 主动回到锁定态同理要重新过启动门槛,否则"锁了却还留在应用里"
+        appStartVerifiedFlow.value = false
+        grantedBookUrlsFlow.value = emptySet()
+        grantedGroupIdsFlow.value = emptySet()
+    }
+
+    /**
+     * 老用户兜底:升级前设过本地密码的人只有 PASSWORD,没有 salt / 校验值
+     * (这两个键是随私密功能一起引入的)。先补建一次,否则他们输入正确密码也永远校验失败,
+     * 界面上还会一直挂着"验证"入口。
+     *
+     * PASSWORD 被刻意排除在备份之外,所以不能指望从恢复流程里带出这套值,只能就地生成。
+     */
+    private suspend fun ensurePasswordVerifier(): Boolean {
+        val existingSalt =
+            AppConfigStore.getString(LocalPreferencesKeys.PRIVATE_PASSWORD_SALT.name).orEmpty()
+        val existingVerifier =
+            AppConfigStore.getString(LocalPreferencesKeys.PRIVATE_PASSWORD_VERIFIER.name).orEmpty()
+        if (existingSalt.isNotEmpty() && existingVerifier.isNotEmpty()) return true
+        val legacyPassword = AppConfigStore.getString(LocalPreferencesKeys.PASSWORD.name).orEmpty()
+        if (legacyPassword.isEmpty()) return false
+        val salt = PrivatePasswordCipher.generateSalt()
+        val verifier = PrivatePasswordCipher.deriveVerifier(legacyPassword, salt) ?: return false
+        AppConfigStore.putAllAndAwait(
+            mapOf(
+                LocalPreferencesKeys.PRIVATE_PASSWORD_SALT.name to salt,
+                LocalPreferencesKeys.PRIVATE_PASSWORD_VERIFIER.name to verifier
+            )
+        )
+        return true
+    }
+
+    private fun readSettings(): PrivateAccessSettings = PrivateAccessSettings(
+        verifyOnEnterGroup = AppConfigStore.getBoolean(
+            LocalPreferencesKeys.PRIVATE_VERIFY_ON_ENTER_GROUP.name
+        ) ?: true,
+        verifyOnOpenBook = AppConfigStore.getBoolean(
+            LocalPreferencesKeys.PRIVATE_VERIFY_ON_OPEN_BOOK.name
+        ) ?: true,
+        verifyOnAppStart = AppConfigStore.getBoolean(
+            LocalPreferencesKeys.PRIVATE_VERIFY_ON_APP_START.name
+        ) ?: false,
+        unlockScope = AppConfigStore.getString(LocalPreferencesKeys.PRIVATE_UNLOCK_SCOPE.name)
+            ?.let { stored -> PrivateUnlockScope.entries.firstOrNull { it.name == stored } }
+            ?: PrivateUnlockScope.AppSession,
+        enabled = AppConfigStore.getBoolean(LocalPreferencesKeys.PRIVATE_ENABLED.name) ?: true,
+        backgroundGraceSeconds =
+            AppConfigStore.getInt(LocalPreferencesKeys.PRIVATE_BACKGROUND_GRACE_SECONDS.name) ?: 0
+    )
+
+}

+ 41 - 0
app/src/main/java/io/legado/app/data/repository/PrivateContentRepository.kt

@@ -0,0 +1,41 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.BookDao
+import io.legado.app.domain.gateway.PrivateContentGateway
+import io.legado.app.domain.model.PrivateBookFacts
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+
+/**
+ * 私密书籍标记的数据实现。
+ *
+ * 只负责"单本标记"这一半;"所属分组私密"那一半由书架按 group 掩码在内存侧求并集,
+ * 这样无需改动 BookDao 里十几处 BookShelfItem 投影。
+ */
+class PrivateContentRepository(
+    private val bookDao: BookDao,
+) : PrivateContentGateway {
+
+    override fun flowPrivateBookUrls(): Flow<Set<String>> = bookDao.flowPrivateBookUrls()
+        .map { it.toSet() }
+        .distinctUntilChanged()
+        .flowOn(Dispatchers.Default)
+
+    override suspend fun factsOf(bookUrl: String): PrivateBookFacts =
+        withContext(Dispatchers.IO) { bookDao.privateFacts(bookUrl) }
+
+    override suspend fun factsOfByName(name: String, author: String): PrivateBookFacts? =
+        withContext(Dispatchers.IO) {
+            val book = bookDao.getBook(name, author) ?: return@withContext null
+            bookDao.privateFacts(book.bookUrl)
+        }
+
+    override suspend fun setBooksPrivate(bookUrls: Set<String>, isPrivate: Boolean) {
+        if (bookUrls.isEmpty()) return
+        withContext(Dispatchers.IO) { bookDao.setBooksPrivate(bookUrls, isPrivate) }
+    }
+}

+ 24 - 2
app/src/main/java/io/legado/app/di/appModule.kt

@@ -68,6 +68,8 @@ import io.legado.app.data.repository.LocalPasswordRepository
 import io.legado.app.data.repository.MangaSettingsRepository
 import io.legado.app.data.repository.OtherConfigSystemRepository
 import io.legado.app.data.repository.OtherSettingsRepository
+import io.legado.app.data.repository.PrivateAccessRepository
+import io.legado.app.data.repository.PrivateContentRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadAloudVoiceRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
@@ -150,6 +152,8 @@ import io.legado.app.domain.gateway.MangaReaderSessionFactory
 import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherConfigSystemGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.PrivateAccessGateway
+import io.legado.app.domain.gateway.PrivateContentGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudVoiceGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
@@ -284,6 +288,7 @@ import io.legado.app.ui.config.customTheme.CustomThemeViewModel
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
 import io.legado.app.ui.config.labConfig.LabConfigViewModel
 import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
+import io.legado.app.ui.config.privateConfig.PrivateConfigViewModel
 import io.legado.app.ui.config.readConfig.ApplyReadSettingUseCase
 import io.legado.app.ui.config.readConfig.ReadConfigViewModel
 import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
@@ -315,6 +320,7 @@ import io.legado.app.ui.rss.source.edit.RssSourceEditViewModel
 import io.legado.app.ui.rss.source.manage.RssSourceViewModel
 import io.legado.app.ui.rss.subscription.RuleSubViewModel
 import io.legado.app.ui.tagGroupRule.TagGroupRuleViewModel
+import io.legado.app.ui.widget.components.privacy.PrivateReadGateViewModel
 import io.legado.app.utils.isNightMode
 import io.legado.app.utils.sysConfiguration
 import kotlinx.coroutines.Dispatchers
@@ -378,6 +384,9 @@ val appModule = module {
     singleOf(::StartBookSourceCheckUseCase)
     single<DirectLinkSettingsGateway> { DirectLinkSettingsRepository() }
     single<LocalPasswordGateway> { LocalPasswordRepository() }
+    // 私密内容:解锁凭据与书籍标记分开绑定,接口归属保持显式
+    single<PrivateAccessGateway> { PrivateAccessRepository(get(), get()) }
+    single<PrivateContentGateway> { PrivateContentRepository(get()) }
     single<OtherConfigSystemGateway> { OtherConfigSystemRepository(get()) }
     single<DownloadCacheSettingsGateway> { DownloadCacheSettingsRepository() }
     single<CoverSettingsGateway> { CoverSettingsRepository() }
@@ -463,7 +472,18 @@ val appModule = module {
     single<AiMemoryGateway> { AiMemoryRepository(get()) }
     single<AiPromptPresetGateway> { AiPromptPresetRepository(get()) }
     single<AiTextGateway> { AiTextRepositoryImpl() }
-    single<AiToolGateway> { AiToolRepository(get(), get(), get(), get(), get(), get(), get()) }
+    single<AiToolGateway> {
+        AiToolRepository(
+            get(),
+            get(),
+            get(),
+            get(),
+            get(),
+            get(),
+            get(),
+            get()
+        )
+    }
     single<AppStartupGateway> { AppStartupRepository(get()) }
     single<BackupRestoreGateway> { BackupRestoreRepository() }
     single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
@@ -584,10 +604,12 @@ val appModule = module {
             otherSettingsGateway = get(),
             downloadCacheSettingsGateway = get(),
             directLinkSettingsGateway = get(),
-            localPasswordGateway = get(),
             systemGateway = get(),
         )
     }
+    viewModelOf(::PrivateConfigViewModel)
+    // 阅读器入口的私密闸门:阅读记录 / 通知栏 / 深链等直开阅读器的路径都收在这里
+    viewModelOf(::PrivateReadGateViewModel)
     viewModelOf(::CustomThemeViewModel)
     viewModelOf(::ReadConfigViewModel)
     viewModelOf(::CoverConfigViewModel)

+ 90 - 0
app/src/main/java/io/legado/app/domain/gateway/PrivateAccessGateway.kt

@@ -0,0 +1,90 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.PrivateAccessState
+import io.legado.app.domain.model.PrivateUnlockTarget
+import io.legado.app.domain.model.settings.PrivateAccessSettings
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 私密内容解锁凭据。
+ *
+ * 本地密码是唯一权威凭据;生物识别只是"免手输密码"的快捷方式,生物验证成功后
+ * 仍然是用本地密码完成解锁(见 help/security/PrivatePasswordCipher)。
+ *
+ * 契约里不出现 Keystore / Cipher / Activity 等平台类型:平台侧的弹框与解密由
+ * help/security 下的组件完成,成功后仍回到 [verifyPassword] 这条唯一入口。
+ */
+interface PrivateAccessGateway {
+
+    val state: Flow<PrivateAccessState>
+
+    /** 验证时机设置 */
+    val settings: Flow<PrivateAccessSettings>
+
+    /** 与普通设置网关一致:一次 copy 原子提交相关字段 */
+    suspend fun updateSettings(transform: (PrivateAccessSettings) -> PrivateAccessSettings)
+
+    /**
+     * 用本地密码解锁;成功返回 true,失败返回 false(调用方负责提示)。
+     *
+     * [target] 指明这次解锁是为了看什么:在 [PrivateUnlockScope.EveryTime] 下只授予该目标;
+     * 传 null(如启动应用时验证)或频率为 [PrivateUnlockScope.AppSession] 时授予本次进程。
+     */
+    suspend fun verifyPassword(
+        password: String,
+        target: PrivateUnlockTarget? = null,
+    ): Boolean
+
+    /**
+     * 只校验密码,**不授予任何访问**。
+     *
+     * 给"验证后才能进入"的场景用(隐私设置页):这类场景要的是"证明你是本人",
+     * 而不是"解锁私密内容"。用 [verifyPassword] 会顺带把内容解锁——在 AppSession 频率下
+     * 尤其明显:只是进设置看一眼,整个保险箱就打开了。
+     */
+    suspend fun verifyPasswordOnly(password: String): Boolean
+
+    /** 离开目标时撤销其授权;仅在"每次验证"频率下有效 */
+    fun revoke(target: PrivateUnlockTarget)
+
+    /**
+     * 同步判定"这个目标此刻是否已获准"。
+     *
+     * 给必须同步返回的路径用(AI 工具同步拼字符串、等不了挂起调用)。只读内存里的授权集合,
+     * 不查盘、不做 IO;判定复用 [PrivateAccessState.isTargetGranted],不另立一套规则。
+     */
+    fun isGrantedNow(bookUrl: String, group: Long): Boolean
+
+    /**
+     * 关闭生物快捷:清空信封并把开关落成关闭。
+     *
+     * 开启**不**在这里做——信封必须由一次真实的生物认证产出
+     * (见 help/security/BiometricEnvelopeWriter),认证成功后经 [storeBiometricEnvelope] 落盘。
+     * 把"开启"拆成两步,是为了让"信封究竟有没有写成"有唯一的事实来源。
+     */
+    suspend fun disableBiometricShortcut()
+
+    /**
+     * 保存生物认证后产出的信封。
+     *
+     * 只有真正写入成功才置位开关并返回 true;传入空信封或设备能力已不可用时返回 false。
+     * 绝不留下"开关亮着、信封却是空的"这种假支持——那会让生物路径每次都在启动时静默回退。
+     */
+    suspend fun storeBiometricEnvelope(ciphertext: String, iv: String): Boolean
+
+    /**
+     * 重新探测设备生物识别能力(从设置页返回、录入新指纹后调用)。
+     */
+    fun refreshBiometricStatus()
+
+    /** 主动回到锁定态 */
+    fun lock()
+
+    /**
+     * 应用离开前台。
+     *
+     * 只在 [PrivateUnlockScope.UntilBackground] 下真正回到锁定态,其它频率下是空操作——
+     * 调用方不需要自己判断频率,免得每个宿主各写一遍。
+     */
+    fun onAppBackground()
+}

+ 39 - 0
app/src/main/java/io/legado/app/domain/gateway/PrivateContentGateway.kt

@@ -0,0 +1,39 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.PrivateBookFacts
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 私密内容标记。
+ *
+ * 私密判定取并集:书籍自身被标记私密,或书籍属于任一私密分组。
+ * 判定所需的数据源(books.isPrivate 列、私密分组掩码)都在数据层组装,UI 只消费结果。
+ */
+interface PrivateContentGateway {
+
+    /** 被单独标记为私密的书籍 url,供书架在内存侧与分组掩码求并集 */
+    fun flowPrivateBookUrls(): Flow<Set<String>>
+
+    /**
+     * 私密判定所需的全部事实。
+     *
+     * 中心 gate 只有 bookUrl,判定"能不能看"还需要分组掩码才能复用
+     * [io.legado.app.domain.model.PrivateAccessState.isTargetGranted] 的分组授权,
+     * 所以两项一次取回,而不是分两次查同一行。
+     */
+    suspend fun factsOf(bookUrl: String): PrivateBookFacts
+
+    /**
+     * 只有书名 + 作者时的判定入口。
+     *
+     * 阅读记录这类表里根本没存 bookUrl(书签也是),只能按名+作者反查一次;
+     * 反查不到返回 null,调用方按"与私密无关"处理——书都没了,也就没有可泄露的内容。
+     */
+    suspend fun factsOfByName(name: String, author: String): PrivateBookFacts?
+
+    /** 单本判定;实现方只需实现 [factsOf] */
+    suspend fun isBookPrivate(bookUrl: String): Boolean = factsOf(bookUrl).isPrivate
+
+    /** 批量设置/取消书籍的私密标记 */
+    suspend fun setBooksPrivate(bookUrls: Set<String>, isPrivate: Boolean)
+}

+ 121 - 0
app/src/main/java/io/legado/app/domain/model/PrivateAccess.kt

@@ -0,0 +1,121 @@
+package io.legado.app.domain.model
+
+/**
+ * 生物识别能力的显式建模。
+ *
+ * 平台能力不可用时必须如实上报,不得用"可用"伪造支持:书架与设置页据此决定
+ * 是弹出系统验证框、还是直接回退到应用内密码输入。
+ */
+enum class PrivateBiometricStatus {
+    /** 设备有可用生物识别且已录入凭据 */
+    Available,
+
+    /** 有硬件但尚未录入指纹/人脸 */
+    NotEnrolled,
+
+    /** 设备没有生物识别硬件 */
+    NoHardware,
+
+    /** 硬件暂时不可用(系统策略、维修模式等) */
+    Unavailable,
+
+    /** 系统版本过低,无法做加密绑定的生物解锁 */
+    UnsupportedSystem
+}
+
+/** 验证通过后保持多久 */
+enum class PrivateUnlockScope {
+    /** 每次进入私密分组/打开私密书籍都要重新验证 */
+    EveryTime,
+
+    /** 进入应用验证一次,本次进程内不再重复 */
+    AppSession,
+
+    /**
+     * 验证一次,直到应用离开前台为止。
+     *
+     * 介于上面两者之间:不像 [EveryTime] 那样每本书都要问一次,也不像 [AppSession]
+     * 那样进程活着就一直免验证——切到后台再回来就要重新验证。
+     */
+    UntilBackground,
+}
+
+/**
+ * 私密内容访问状态。
+ *
+ * 解锁状态只存活于当前进程:进程重启后回到锁定态。
+ * [grantedBookUrls] / [grantedGroupIds] 只在 [PrivateUnlockScope.EveryTime] 下使用,
+ * 记录"刚刚验证过的那一个目标",离开它即失效。
+ */
+data class PrivateAccessState(
+    val isUnlocked: Boolean = false,
+    val hasPassword: Boolean = false,
+    /**
+     * 是否已通过"启动应用时验证"这道门槛。
+     *
+     * 与 [isUnlocked] 是两件事:启动验证只证明"进得来",不代表对任何分组/书籍的授权。
+     * "每次都要验证"频率下过一次启动验证就把全部私密内容解锁,会让分组与单书验证形同虚设。
+     */
+    val isAppStartVerified: Boolean = false,
+    val biometricStatus: PrivateBiometricStatus = PrivateBiometricStatus.Unavailable,
+    val biometricEnabled: Boolean = false,
+    val grantedBookUrls: Set<String> = emptySet(),
+    val grantedGroupIds: Set<Long> = emptySet(),
+    /**
+     * 私密功能总开关。关掉时 [isTargetGranted] 恒真——不验证、不脱敏、不守卫;
+     * 密码与标记都留着,重新打开即刻恢复。判定集中在这里,所以各处不用各判一次。
+     */
+    val isEnabled: Boolean = true,
+) {
+    /** 是否可以直接弹系统生物验证框(而不是密码输入) */
+    val canUseBiometricShortcut: Boolean
+        get() = biometricEnabled && biometricStatus == PrivateBiometricStatus.Available
+
+    /** 私密功能是否可用:没有本地密码时一律不可用 */
+    val isAvailable: Boolean get() = hasPassword
+
+    /**
+     * 某个目标当前是否已获准查看。
+     *
+     * [group] 是书籍的分组位掩码:所属分组已获准时,组内书籍一并视为已获准,
+     * 否则"进了私密分组还要逐本再验证"会非常吵。
+     */
+    fun isTargetGranted(bookUrl: String?, group: Long): Boolean =
+        !isEnabled ||
+                isUnlocked ||
+                (bookUrl != null && bookUrl in grantedBookUrls) ||
+                (group != 0L && grantedGroupIds.any { (group and it) != 0L })
+}
+
+/**
+ * 私密判定的唯一实现:单本标记 ∪ 所属私密分组。
+ *
+ * 纯函数,便于单测;数据层与 UI 都只复用它,避免长出第二套判定。
+ */
+fun isPrivateBook(
+    bookUrl: String,
+    group: Long,
+    privateBookUrls: Set<String>,
+    privateGroupMask: Long,
+): Boolean = bookUrl in privateBookUrls ||
+        (privateGroupMask != 0L && (group and privateGroupMask) != 0L)
+
+/**
+ * 一本书的私密事实:是否私密 + 所属分组位掩码。
+ *
+ * 中心 gate(阅读器路由入口)只拿得到一个 bookUrl,需要这两项才能复用
+ * [PrivateAccessState.isTargetGranted] 的分组授权语义——否则"刚在书架解锁了私密分组"
+ * 的用户,从组内点开一本书会被再问一次。
+ */
+data class PrivateBookFacts(
+    val isPrivate: Boolean,
+    val groupMask: Long,
+)
+
+sealed interface PrivateUnlockTarget {
+    /** 打开某本私密书籍(书架点击 / 详情页进入) */
+    data class Book(val bookUrl: String) : PrivateUnlockTarget
+
+    /** 查看某个私密分组内容 */
+    data class Group(val groupId: Long) : PrivateUnlockTarget
+}

+ 34 - 0
app/src/main/java/io/legado/app/domain/model/settings/PrivateAccessSettings.kt

@@ -0,0 +1,34 @@
+package io.legado.app.domain.model.settings
+
+import androidx.compose.runtime.Stable
+import io.legado.app.domain.model.PrivateUnlockScope
+
+/**
+ * 私密内容的验证时机与频率。
+ *
+ * 只描述"什么时候要求验证、验证后保持多久";本地密码是否存在、生物识别是否可用属于
+ * 能力状态,由 PrivateAccessState 承载,不混进设置项。
+ */
+@Stable
+data class PrivateAccessSettings(
+    /** 切到私密分组时验证 */
+    val verifyOnEnterGroup: Boolean = true,
+    /** 打开私密书籍时验证 */
+    val verifyOnOpenBook: Boolean = true,
+    /** 启动应用后主动验证一次(否则等到首次访问私密内容才验证) */
+    val verifyOnAppStart: Boolean = false,
+    /** 验证通过后保持多久:每次都要验证 / 本次进程验证一次 */
+    val unlockScope: PrivateUnlockScope = PrivateUnlockScope.AppSession,
+    /**
+     * 总开关。关掉即整体休眠:不做任何验证、不脱敏、不放守卫;本地密码与书籍/分组标记
+     * 全部留着,重新打开即刻恢复原样。
+     *
+     * 放在末尾是为了不破坏按位置构造的调用方。
+     */
+    val enabled: Boolean = true,
+    /**
+     * 离开前台后多久才要求重新验证(秒)。只在 [PrivateUnlockScope.UntilBackground] 下有意义:
+     * 0 表示"离开即失效",>0 表示这段时间内回来不重验。
+     */
+    val backgroundGraceSeconds: Int = 0,
+)

+ 91 - 0
app/src/main/java/io/legado/app/help/security/BiometricEnvelopeWriter.kt

@@ -0,0 +1,91 @@
+package io.legado.app.help.security
+
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentActivity
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.help.config.AppConfigStore
+
+/**
+ * 「开启生物快捷」的写入侧:用 auth-per-use 密钥加密本地密码,产出信封。
+ *
+ * 加密同样需要一次认证——官方 CryptoObject 流程里,ENCRYPT_MODE 的 cipher 也要交给
+ * authenticate(),认证通过后 doFinal 才成立。所以开启开关是一次真实的生物验证动作,
+ * 而不是"写个空信封把开关点亮"。
+ *
+ * 职责边界:本组件只做「认证 + 产出信封」,不落盘、不改开关。
+ * 落盘与开关状态由 [io.legado.app.domain.gateway.PrivateAccessGateway] 决定,
+ * 这样"信封是否真的写成功"才有唯一的事实来源。
+ */
+object BiometricEnvelopeWriter {
+
+    /**
+     * @param onSealed 认证通过且信封封装成功
+     * @param onError  能力不可用、没有本地密码、密钥不可用或封装失败——一律不要改开关
+     * @param onCancel 用户主动放弃(返回键 / 点弹框外部 / 取消按钮)
+     */
+    fun launch(
+        activity: FragmentActivity,
+        title: CharSequence,
+        subtitle: CharSequence?,
+        negativeButtonText: CharSequence,
+        onSealed: (ciphertext: String, iv: String) -> Unit,
+        onError: () -> Unit,
+        onCancel: () -> Unit,
+    ) {
+        if (!PrivatePasswordCipher.isBiometricCryptoSupported()) {
+            onError()
+            return
+        }
+        val password = AppConfigStore.getString(LocalPreferencesKeys.PASSWORD.name).orEmpty()
+        if (password.isEmpty()) {
+            // 没有本地密码就无从封装:生物快捷本来就只是"免手输密码"
+            onError()
+            return
+        }
+        val cipher = PrivatePasswordCipher.createEncryptCipher()
+        if (cipher == null) {
+            onError()
+            return
+        }
+
+        val prompt = BiometricPrompt(
+            activity,
+            ContextCompat.getMainExecutor(activity),
+            object : BiometricPrompt.AuthenticationCallback() {
+                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+                    val authenticated = result.cryptoObject?.cipher
+                    val envelope = authenticated?.let {
+                        PrivatePasswordCipher.sealPassword(it, password)
+                    }
+                    if (envelope == null) onError() else onSealed(envelope.ciphertext, envelope.iv)
+                }
+
+                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+                    when (errorCode) {
+                        BiometricPrompt.ERROR_NEGATIVE_BUTTON,
+                        BiometricPrompt.ERROR_USER_CANCELED,
+                        BiometricPrompt.ERROR_CANCELED -> onCancel()
+
+                        else -> onError()
+                    }
+                }
+
+                override fun onAuthenticationFailed() {
+                    // 单次识别失败:系统弹框自己会提示重试,无需额外处理
+                }
+            }
+        )
+
+        val promptInfo = BiometricPrompt.PromptInfo.Builder()
+            .setTitle(title)
+            .setSubtitle(subtitle)
+            .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
+            .setNegativeButtonText(negativeButtonText)
+            .build()
+
+        runCatching { prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) }
+            .onFailure { onError() }
+    }
+}

+ 147 - 0
app/src/main/java/io/legado/app/help/security/BiometricUnlockLauncher.kt

@@ -0,0 +1,147 @@
+package io.legado.app.help.security
+
+import android.content.Context
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentActivity
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.domain.model.PrivateBiometricStatus
+import io.legado.app.help.config.AppConfigStore
+
+/**
+ * 系统生物验证弹框的宿主封装。
+ *
+ * 只做两件事:探测设备能力,以及在拿到 CryptoObject 的前提下发起验证并把解出的密码交回去。
+ * 校验与解锁仍然由 [PrivateAccessGateway] 用密码完成,生物路径不会绕过密码。
+ *
+ * 之所以只接受 BIOMETRIC_STRONG:官方明确「允许锁屏凭据回退时不能传 CryptoObject」,
+ * 而锁屏凭据回退在本设计里由应用内密码承担,不需要 DEVICE_CREDENTIAL。
+ *
+ * 这里直接读 [AppConfigStore] 而不走 PrivateAccessGateway:信封是平台侧凭据
+ * (Keystore 加密的产物),domain 契约不该把它暴露出去——gateway 只接收
+ * "认证后产出的信封"这个结果。
+ */
+object BiometricUnlockLauncher {
+
+    fun detectStatus(context: Context): PrivateBiometricStatus {
+        if (!PrivatePasswordCipher.isBiometricCryptoSupported()) {
+            return PrivateBiometricStatus.UnsupportedSystem
+        }
+        val manager = BiometricManager.from(context)
+        return when (manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
+            BiometricManager.BIOMETRIC_SUCCESS -> PrivateBiometricStatus.Available
+            BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> PrivateBiometricStatus.NotEnrolled
+            BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> PrivateBiometricStatus.NoHardware
+            BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> PrivateBiometricStatus.Unavailable
+            else -> PrivateBiometricStatus.Unavailable
+        }
+    }
+
+    /**
+     * 密钥作废后的凭据清理。
+     *
+     * 信封是用旧密钥封的,密钥一旦被作废,它就永远解不开了——留着只会让开关一直
+     * 显示"已开启",而每次解锁都静默回退到手输密码。这里直接落盘而不是走 gateway:
+     * 调用点都在系统验证回调里,等不了挂起函数;写的是同一份 AppConfigStore,
+     * 开关对应的 flow 会立刻跟上。
+     */
+    private fun clearInvalidatedEnvelope() {
+        AppConfigStore.putAll(
+            mapOf(
+                LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENABLED.name to false,
+                LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENVELOPE.name to "",
+                LocalPreferencesKeys.PRIVATE_BIOMETRIC_IV.name to "",
+            )
+        )
+    }
+
+    /**
+     * 发起验证。
+     *
+     * @param onPassword 生物验证成功且信封解出密码(正常路径)
+     * @param onFallback 需要改用应用内密码:设备不可用、密钥失效或用户点了"使用密码"。
+     *                   密钥被指纹变更作废时,本组件会先清掉已失效的信封与开关再回调
+     * @param onCancel   用户主动取消验证(返回键、点弹框外部、系统取消);默认与 [onFallback]
+     *                   一致,只有"启动验证"这类必须闭环的场景才需要区分并另行处理
+     * @param onError    真正的错误(次数锁定等),调用方应提示用户
+     */
+    fun launch(
+        activity: FragmentActivity,
+        title: CharSequence,
+        subtitle: CharSequence?,
+        negativeButtonText: CharSequence,
+        onPassword: (String) -> Unit,
+        onFallback: () -> Unit,
+        onCancel: () -> Unit = onFallback,
+        onError: (String) -> Unit,
+    ) {
+        val ciphertext =
+            AppConfigStore.getString(LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENVELOPE.name)
+        val iv = AppConfigStore.getString(LocalPreferencesKeys.PRIVATE_BIOMETRIC_IV.name)
+        if (ciphertext.isNullOrBlank() || iv.isNullOrBlank()) {
+            onFallback()
+            return
+        }
+        val cipher = when (val result = PrivatePasswordCipher.createDecryptCipher(iv)) {
+            is PrivatePasswordCipher.DecryptResult.Ready -> result.cipher
+
+            PrivatePasswordCipher.DecryptResult.KeyInvalidated -> {
+                // 密钥被"新增/变更指纹"永久作废:信封已经解不开,重建密钥也救不回来。
+                // 不清掉的话开关会一直显示"已开启",而每次解锁都在这里静默回退。
+                clearInvalidatedEnvelope()
+                onFallback()
+                return
+            }
+
+            PrivatePasswordCipher.DecryptResult.Unavailable -> {
+                onFallback()
+                return
+            }
+        }
+
+        val prompt = BiometricPrompt(
+            activity,
+            ContextCompat.getMainExecutor(activity),
+            object : BiometricPrompt.AuthenticationCallback() {
+                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+                    val cryptoCipher = result.cryptoObject?.cipher
+                    val password = cryptoCipher?.let {
+                        PrivatePasswordCipher.decryptPassword(it, ciphertext)
+                    }
+                    if (password.isNullOrEmpty()) onFallback() else onPassword(password)
+                }
+
+                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+                    when (errorCode) {
+                        // 点了"使用密码":这是换一条凭据,不是放弃验证
+                        BiometricPrompt.ERROR_NEGATIVE_BUTTON -> onFallback()
+
+                        // 用户按返回/取消:这是放弃与否的明确表态
+                        BiometricPrompt.ERROR_USER_CANCELED -> onCancel()
+
+                        // 系统取消(旋转、切后台、熄屏…):不是用户放弃。
+                        // 当成拒绝会让"转个屏就退出应用"这种荒唐行为出现在启动门槛上
+                        BiometricPrompt.ERROR_CANCELED -> onFallback()
+
+                        else -> onError(errString.toString())
+                    }
+                }
+
+                override fun onAuthenticationFailed() {
+                    // 单次识别失败,系统弹框自己会提示重试,无需额外处理
+                }
+            }
+        )
+
+        val promptInfo = BiometricPrompt.PromptInfo.Builder()
+            .setTitle(title)
+            .setSubtitle(subtitle)
+            .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
+            .setNegativeButtonText(negativeButtonText)
+            .build()
+
+        runCatching { prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) }
+            .onFailure { onFallback() }
+    }
+}

+ 239 - 0
app/src/main/java/io/legado/app/help/security/PrivatePasswordCipher.kt

@@ -0,0 +1,239 @@
+package io.legado.app.help.security
+
+import android.os.Build
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyPermanentlyInvalidatedException
+import android.security.keystore.KeyProperties
+import androidx.annotation.RequiresApi
+import java.nio.charset.StandardCharsets
+import java.security.KeyStore
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.util.Base64
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.SecretKeyFactory
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.PBEKeySpec
+
+/**
+ * 私密内容解锁的密码学工具。
+ *
+ * 设计要点:
+ * - 本地密码是唯一权威凭据,这里只额外保存「校验值」而非密码本身;
+ * - 校验值走 PBKDF2WithHmacSHA256 + 随机 salt,杜绝明文比对;
+ * - 生物快捷解锁不是"回调返回成功就解锁",而是用 Android Keystore 中
+ *   「每次使用都需生物验证」的密钥把密码封装成信封;只有 BiometricPrompt 拿着
+ *   CryptoObject 验证通过,信封才解得出密码。回调被伪造也拿不到密钥。
+ *
+ * 官方约束(developer.android.com/identity/sign-in/biometric-auth):
+ * 时间有效期密钥不能与 CryptoObject 同时使用,允许锁屏凭据回退时也不能用 CryptoObject。
+ * 因此这里只接受 BIOMETRIC_STRONG 的「每次使用验证」密钥,锁屏凭据回退由应用内密码承担。
+ */
+object PrivatePasswordCipher {
+
+    private const val ANDROID_KEY_STORE = "AndroidKeyStore"
+    private const val KEY_ALIAS = "legado_private_vault"
+    private const val TRANSFORMATION = "AES/GCM/NoPadding"
+    private const val GCM_TAG_BITS = 128
+
+    private const val PBKDF2_ALGORITHM = "PBKDF2WithHmacSHA256"
+    private const val PBKDF2_ITERATIONS = 210_000
+    private const val PBKDF2_KEY_BITS = 256
+    private const val SALT_LENGTH_BYTES = 16
+
+    /**
+     * 「每次使用都需验证」密钥要求 API 30+;低于 30 只能用手输密码,
+     * 这里如实返回 false,由调用方把能力标成 UnsupportedSystem。
+     */
+    fun isBiometricCryptoSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+
+    fun generateSalt(): String {
+        val salt = ByteArray(SALT_LENGTH_BYTES)
+        SecureRandom().nextBytes(salt)
+        return encode(salt)
+    }
+
+    /** 派生校验值;salt 非法或算法缺失时返回 null,由调用方按失败处理 */
+    fun deriveVerifier(password: String, salt: String): String? {
+        val saltBytes = decode(salt) ?: return null
+        val spec = PBEKeySpec(password.toCharArray(), saltBytes, PBKDF2_ITERATIONS, PBKDF2_KEY_BITS)
+        return try {
+            val derived =
+                SecretKeyFactory.getInstance(PBKDF2_ALGORITHM).generateSecret(spec).encoded
+            encode(derived)
+        } catch (_: Exception) {
+            null
+        } finally {
+            spec.clearPassword()
+        }
+    }
+
+    /** 定长比较,避免按字节提前 return 造成的时间差 */
+    fun verify(password: String, salt: String, verifier: String): Boolean {
+        if (salt.isBlank() || verifier.isBlank()) return false
+        val expected = decode(verifier) ?: return false
+        val actual = deriveVerifier(password, salt)?.let { decode(it) } ?: return false
+        return MessageDigest.isEqual(expected, actual)
+    }
+
+    /** 读取或创建 Keystore 中受生物验证保护的密钥 */
+    fun getOrCreateSecretKey(): SecretKey? {
+        if (!isBiometricCryptoSupported()) return null
+        val keyStore = openKeyStore() ?: return null
+        val existing = runCatching { keyStore.getKey(KEY_ALIAS, null) as? SecretKey }.getOrNull()
+        if (existing != null) return existing
+        // getKey 抛 UnrecoverableKeyException(部分机型在密钥被指纹变更作废时直接在这一步暴露)
+        // 时别名仍占着位置,必须先删掉才重建得出来
+        runCatching { keyStore.deleteEntry(KEY_ALIAS) }
+        return generateSecretKey()
+    }
+
+    private fun openKeyStore(): KeyStore? = runCatching {
+        KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
+    }.getOrNull()
+
+    @RequiresApi(Build.VERSION_CODES.R)
+    private fun generateSecretKey(): SecretKey? = runCatching {
+        val spec = KeyGenParameterSpec.Builder(
+            KEY_ALIAS,
+            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+        )
+            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+            // 0 表示"每次使用都要验证",只允许 Class 3 生物识别
+            .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
+            .setInvalidatedByBiometricEnrollment(true)
+            .build()
+        KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE)
+            .apply { init(spec) }
+            .generateKey()
+    }.getOrNull()
+
+    /** 新增/变更指纹会让密钥永久失效,此时只能删掉重建,并要求用户重新输一次密码重建信封 */
+    fun resetSecretKey() {
+        runCatching { openKeyStore()?.deleteEntry(KEY_ALIAS) }
+    }
+
+    fun isKeyPermanentlyInvalidated(error: Throwable?): Boolean {
+        var cause: Throwable? = error
+        while (cause != null) {
+            if (cause is KeyPermanentlyInvalidatedException) return true
+            cause = cause.cause
+        }
+        return false
+    }
+
+    /**
+     * 一次 Cipher 初始化的结果。
+     *
+     * 把"密钥已被指纹变更作废"和"环境不可用"分开很重要:前者必须清理凭据,后者不能。
+     */
+    private data class CipherAttempt(val cipher: Cipher?, val invalidated: Boolean)
+
+    private fun attemptCipher(block: () -> Cipher): CipherAttempt = runCatching(block).fold(
+        onSuccess = { CipherAttempt(cipher = it, invalidated = false) },
+        onFailure = { CipherAttempt(cipher = null, invalidated = isKeyPermanentlyInvalidated(it)) },
+    )
+
+    private fun createEncryptCipherOnce(): CipherAttempt {
+        if (!isBiometricCryptoSupported()) return CipherAttempt(null, false)
+        val key = getOrCreateSecretKey() ?: return CipherAttempt(null, false)
+        return attemptCipher {
+            Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key) }
+        }
+    }
+
+    private fun createDecryptCipherOnce(ivBytes: ByteArray): CipherAttempt {
+        if (!isBiometricCryptoSupported()) return CipherAttempt(null, false)
+        val key = getOrCreateSecretKey() ?: return CipherAttempt(null, false)
+        return attemptCipher {
+            Cipher.getInstance(TRANSFORMATION).apply {
+                init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, ivBytes))
+            }
+        }
+    }
+
+    /**
+     * 用于加密密码的 Cipher(写入信封时调用)。
+     *
+     * 密钥被"新增/变更指纹"作废后有两副面孔:有的机型 `getKey` 直接抛
+     * UnrecoverableKeyException(上面已自愈),有的机型取键成功、**只有 init 才抛**
+     * [KeyPermanentlyInvalidatedException]。后者如果不处理,开关就会永远点不开——
+     * 每次开启都拿到同一个失效密钥、都在这里失败。
+     *
+     * 这里失败即判定为作废,删掉重建再试一次:加密的目的是产出**新的**信封,
+     * 重建不会让任何已有数据变得不可读,所以自愈是安全的。
+     */
+    fun createEncryptCipher(): Cipher? {
+        val first = createEncryptCipherOnce()
+        if (first.cipher != null || !first.invalidated) return first.cipher
+        resetSecretKey()
+        return createEncryptCipherOnce().cipher
+    }
+
+    /** 解密侧的结果:区分"该清凭据"与"环境不可用" */
+    sealed interface DecryptResult {
+        /** 信封与密钥都可用,可以交给 BiometricPrompt 的 CryptoObject */
+        data class Ready(val cipher: Cipher) : DecryptResult
+
+        /** 密钥被新增/变更指纹永久作废:信封已经解不开,调用方应清掉它并关闭开关 */
+        data object KeyInvalidated : DecryptResult
+
+        /** 设备能力或密钥环境不可用(无硬件、IV 非法、算法缺失) */
+        data object Unavailable : DecryptResult
+    }
+
+    /**
+     * 用于解密密码的 Cipher(交给 BiometricPrompt 的 CryptoObject)。
+     *
+     * 与加密侧不同,这里**不能**靠重建密钥自愈:已落盘的信封是用旧密钥封的,
+     * 新密钥一样解不开。所以只如实报告结果,由调用方清掉信封、关闭开关、回退到手输密码。
+     */
+    fun createDecryptCipher(iv: String): DecryptResult {
+        val ivBytes = decode(iv) ?: return DecryptResult.Unavailable
+        val attempt = createDecryptCipherOnce(ivBytes)
+        val cipher = attempt.cipher
+        return when {
+            cipher != null -> DecryptResult.Ready(cipher)
+            attempt.invalidated -> DecryptResult.KeyInvalidated
+            else -> DecryptResult.Unavailable
+        }
+    }
+
+    data class Envelope(val ciphertext: String, val iv: String)
+
+    /**
+     * 用**已通过生物认证**的 cipher 把密码封进信封。
+     *
+     * 密钥是 auth-per-use(`setUserAuthenticationParameters(0, ...)`),加密与解密一样受认证约束:
+     * 未认证时 doFinal 会抛 UserNotAuthenticatedException。所以信封只能在 BiometricPrompt 的
+     * CryptoObject 回调里产出(见 [BiometricEnvelopeWriter]),不能"在后台悄悄加密"——
+     * 那样必然失败,而且很容易被误当成"功能已开启"。
+     */
+    fun sealPassword(cipher: Cipher, password: String): Envelope? {
+        if (password.isEmpty()) return null
+        return runCatching {
+            val bytes = cipher.doFinal(password.toByteArray(StandardCharsets.UTF_8))
+            Envelope(ciphertext = encode(bytes), iv = encode(cipher.iv))
+        }.getOrNull()
+    }
+
+    /** 生物验证通过后,用同一个 Cipher 解出密码 */
+    fun decryptPassword(cipher: Cipher, ciphertext: String): String? {
+        val bytes = decode(ciphertext) ?: return null
+        return runCatching {
+            cipher.doFinal(bytes).toString(StandardCharsets.UTF_8)
+        }.getOrNull()
+    }
+
+    // 用 java.util.Base64 而不是 android.util.Base64:前者在 JVM 单测里可直接跑,
+    // 不依赖 Robolectric,密码学逻辑因此可以做纯单元测试。
+    private fun encode(bytes: ByteArray): String =
+        Base64.getEncoder().encodeToString(bytes)
+
+    private fun decode(value: String): ByteArray? = runCatching {
+        Base64.getDecoder().decode(value)
+    }.getOrNull()
+}

+ 9 - 0
app/src/main/java/io/legado/app/help/storage/BackupConfig.kt

@@ -21,6 +21,15 @@ internal val alwaysIgnoredPreferenceKeys = setOf(
     PreferKey.audioPlayWakeLock,
     LocalPreferencesKeys.PASSWORD.name,
     LocalPreferencesKeys.MIGRATED_TO_SETTINGS.name,
+    // 私密内容解锁的派生凭据。salt + verifier 是"可离线爆破的产物":密码往往很短,
+    // PBKDF2 的迭代次数挡不住 GPU,进云备份等于把口令强度降了一个数量级。
+    // 生物信封(含 iv)与开关同样排除:换了设备 Keystore 密钥并不通用,导出只会得到一个
+    // "开关亮着、信封解不开"的假状态,不如让用户在新设备上重新开启一次。
+    LocalPreferencesKeys.PRIVATE_PASSWORD_SALT.name,
+    LocalPreferencesKeys.PRIVATE_PASSWORD_VERIFIER.name,
+    LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENABLED.name,
+    LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENVELOPE.name,
+    LocalPreferencesKeys.PRIVATE_BIOMETRIC_IV.name,
 )
 
 /**

+ 52 - 30
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt

@@ -16,6 +16,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
@@ -58,6 +59,8 @@ import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.privacy.PrivateLockedCover
+import io.legado.app.ui.widget.components.privacy.rememberPrivateLockedBookUrls
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
@@ -337,6 +340,8 @@ private fun BookOverview(
     topPadding: androidx.compose.ui.unit.Dp,
     onBookClick: (BookmarkBookUi) -> Unit,
 ) {
+    // 私密书在这里不会有任何文字,只保留模糊封面
+    val lockedUrls = rememberPrivateLockedBookUrls(books.mapNotNull { it.bookUrl })
     LazyColumn(
         state = listState,
         modifier = Modifier.fillMaxSize(),
@@ -344,6 +349,7 @@ private fun BookOverview(
         verticalArrangement = Arrangement.spacedBy(8.dp),
     ) {
         items(books, key = { "${it.key.name}|${it.key.author}" }) { book ->
+            val locked = book.bookUrl?.let { it in lockedUrls } == true
             GlassCard(
                 modifier = Modifier.fillMaxWidth(),
                 cornerRadius = 16.dp,
@@ -357,40 +363,56 @@ private fun BookOverview(
                     horizontalArrangement = Arrangement.spacedBy(16.dp),
                     verticalAlignment = Alignment.CenterVertically,
                 ) {
+                    // 私密且未获准:这一段文字整块不渲染(不铺占位条,
+                    // 也不显示"已隐藏"字样),只留模糊封面说明状态
                     Column(modifier = Modifier.weight(1f)) {
-                        AppText(
-                            text = book.key.name,
-                            style = LegadoTheme.typography.titleMediumEmphasized,
-                            maxLines = 2,
-                            overflow = TextOverflow.Ellipsis,
-                        )
-                        if (book.key.author.isNotBlank()) AppText(
-                            text = book.key.author,
-                            style = LegadoTheme.typography.labelMediumEmphasized,
-                            color = LegadoTheme.colorScheme.onSurfaceVariant,
-                        )
-                        AppText(
-                            text = stringResource(
-                                R.string.feature_bookmarks_counts,
-                                book.bookmarkCount,
-                                book.markingCount,
-                            ),
-                            style = LegadoTheme.typography.labelSmall,
-                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        if (!locked) {
+                            AppText(
+                                text = book.key.name,
+                                style = LegadoTheme.typography.titleMediumEmphasized,
+                                maxLines = 2,
+                                overflow = TextOverflow.Ellipsis,
+                            )
+                            if (book.key.author.isNotBlank()) AppText(
+                                text = book.key.author,
+                                style = LegadoTheme.typography.labelMediumEmphasized,
+                                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            )
+                            AppText(
+                                text = stringResource(
+                                    R.string.feature_bookmarks_counts,
+                                    book.bookmarkCount,
+                                    book.markingCount,
+                                ),
+                                style = LegadoTheme.typography.labelSmall,
+                                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            )
+                            if (book.matchedMarkingId != null) AppText(
+                                text = stringResource(R.string.feature_bookmarks_match_hint),
+                                style = LegadoTheme.typography.labelSmall,
+                                color = LegadoTheme.colorScheme.primary,
+                            )
+                        }
+                    }
+                    if (locked) {
+                        PrivateLockedCover(
+                            name = book.key.name,
+                            author = book.key.author,
+                            path = book.coverPath,
+                            sourceOrigin = book.sourceOrigin,
+                            modifier = Modifier
+                                .width(52.dp)
+                                .aspectRatio(5f / 7f),
                         )
-                        if (book.matchedMarkingId != null) AppText(
-                            text = stringResource(R.string.feature_bookmarks_match_hint),
-                            style = LegadoTheme.typography.labelSmall,
-                            color = LegadoTheme.colorScheme.primary,
+                    } else {
+                        CoilBookCover(
+                            name = book.key.name,
+                            author = book.key.author,
+                            path = book.coverPath,
+                            sourceOrigin = book.sourceOrigin,
+                            modifier = Modifier.width(52.dp),
                         )
                     }
-                    CoilBookCover(
-                        name = book.key.name,
-                        author = book.key.author,
-                        path = book.coverPath,
-                        sourceOrigin = book.sourceOrigin,
-                        modifier = Modifier.width(52.dp),
-                    )
                 }
             }
         }

+ 20 - 2
app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt

@@ -57,6 +57,7 @@ import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.privacy.rememberPrivateLockedBookUrls
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
 import io.legado.app.ui.widget.components.text.AppText
@@ -230,6 +231,10 @@ private fun BookCacheManageScreen(
                 AppCircularProgressIndicator()
             }
         } else {
+            // 私密且未获准的书:缓存条目保留(缓存管理要能清缓存),但不显示书名与作者
+            val lockedBookUrls = rememberPrivateLockedBookUrls(
+                (filteredShelfBooks + filteredNotShelfBooks).map { it.bookUrl }
+            )
             FastScrollLazyColumn(
                 modifier = Modifier.fillMaxSize(),
                 contentPadding = adaptiveContentPadding(
@@ -241,7 +246,7 @@ private fun BookCacheManageScreen(
                 cacheSection(
                     title = bookshelfSectionTitle,
                     emptyText = bookshelfSectionEmptyText,
-                    books = filteredShelfBooks,
+                    books = filteredShelfBooks.redactLocked(lockedBookUrls),
                     expandedBookUrls = state.expandedBookUrls,
                     chaptersByBookUrl = state.chaptersByBookUrl,
                     onToggleExpanded = { bookUrl ->
@@ -255,7 +260,7 @@ private fun BookCacheManageScreen(
                     cacheSection(
                         title = notBookshelfSectionTitle,
                         emptyText = notBookshelfSectionEmptyText,
-                        books = filteredNotShelfBooks,
+                        books = filteredNotShelfBooks.redactLocked(lockedBookUrls),
                         expandedBookUrls = state.expandedBookUrls,
                         chaptersByBookUrl = state.chaptersByBookUrl,
                         onToggleExpanded = { bookUrl ->
@@ -297,6 +302,19 @@ private fun BookCacheManageScreen(
     )
 }
 
+/**
+ * 私密且未获准的书:**书名与作者置空**。
+ *
+ * 不留占位条,也不显示"已隐藏"字样;条目本身保留,否则用户没法清掉这本书的缓存。
+ */
+private fun List<BookCacheBookItem>.redactLocked(
+    lockedBookUrls: Set<String>,
+): List<BookCacheBookItem> = if (lockedBookUrls.isEmpty()) {
+    this
+} else {
+    map { if (it.bookUrl in lockedBookUrls) it.copy(name = "", author = "") else it }
+}
+
 private fun LazyListScope.cacheSection(
     title: String,
     emptyText: String,

+ 23 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -9,6 +9,7 @@ import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
 import io.legado.app.domain.model.BookshelfConflict
 import io.legado.app.domain.model.ConflictBookSummary
+import io.legado.app.domain.model.PrivateAccessState
 import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.ui.widget.components.variable.VariableEditorUiState
 import kotlinx.collections.immutable.ImmutableList
@@ -57,6 +58,15 @@ data class BookInfoUiState(
     /** 加入书架时发现的疑似重复;非空时由冲突 Sheet 决定共存还是迁移。 */
     val shelfConflict: BookshelfConflict? = null,
     val isResolvingShelfConflict: Boolean = false,
+    /** 本书是否私密(单本标记 ∪ 所属私密分组);菜单里"标记/取消私密"读它 */
+    val bookPrivate: Boolean = false,
+    /** 进入本页时这本书是否需要验证(页面内刚标记私密不会立刻脱敏) */
+    val privateLockedByEntry: Boolean = false,
+    /** 本书为私密书籍且尚未解锁:详情页只显示锁定态 */
+    val privateLocked: Boolean = false,
+    val privateAccess: PrivateAccessState = PrivateAccessState(),
+    /** 应用内密码解锁弹窗(生物不可用或用户选择密码时) */
+    val showPrivatePasswordDialog: Boolean = false,
 )
 
 @Stable
@@ -242,11 +252,23 @@ sealed interface BookInfoIntent {
 
     /** 简介 HTML 图片长按。 */
     data class IntroImageLongClick(val source: String) : BookInfoIntent
+
+    /** 私密书籍未解锁时的验证入口 */
+    data object RequestPrivateUnlock : BookInfoIntent
+    data object ShowPrivatePassword : BookInfoIntent
+    data class SubmitPrivatePassword(val password: String) : BookInfoIntent
+    data object DismissPrivatePassword : BookInfoIntent
 }
 
 sealed interface BookInfoEffect {
     data class ShowMessage(val message: String) : BookInfoEffect
 
+    /** 交给宿主弹出系统生物验证框(需要 FragmentActivity) */
+    data object RequestBiometricUnlock : BookInfoEffect
+
+    /** 私密功能需要本地密码:引导去设置页 */
+    data object NavigateToLocalPasswordSettings : BookInfoEffect
+
     data class Finish(
         val resultCode: Int? = null,
         val afterTransition: Boolean = false,
@@ -339,4 +361,5 @@ enum class BookInfoMenuAction {
     ToggleDeleteAlert,
     ClearCache,
     ShowLog,
+    TogglePrivate,
 }

+ 30 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -30,6 +30,7 @@ import io.legado.app.data.entities.BookGroup
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isLocal
+import io.legado.app.help.security.BiometricUnlockLauncher
 import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.info.edit.BookInfoEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
@@ -73,6 +74,8 @@ fun BookInfoRouteScreen(
     onOpenCharacterList: (bookUrl: String) -> Unit = {},
     onOpenKnowledgeList: (bookUrl: String) -> Unit = {},
     onOpenEventList: (bookUrl: String) -> Unit = {},
+    /** 私密功能需要本地密码,未设置时引导去设置页(与书架同一条路径) */
+    onOpenSettings: () -> Unit = {},
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
@@ -82,6 +85,11 @@ fun BookInfoRouteScreen(
     val lifecycleOwner = LocalLifecycleOwner.current
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val showMangaUi by rememberUpdatedState(uiState.showMangaUi)
+    // 资源串在 composable 作用域内解析,避免 LocalContext.current.getString 拿到过期值
+    val unlockTitle = stringResource(R.string.private_unlock_title)
+    val unlockSubtitle = stringResource(R.string.private_unlock_subtitle)
+    val unlockUsePassword = stringResource(R.string.private_unlock_use_password)
+    val noPasswordHint = stringResource(R.string.private_content_no_password)
     var showSelectBooksDirSheet by remember { mutableStateOf(false) }
 
     val tocActivityResult = rememberLauncherForActivityResult(TocActivityResult()) {
@@ -215,6 +223,28 @@ fun BookInfoRouteScreen(
                 is BookInfoEffect.OpenEventList -> {
                     onOpenEventList(effect.bookUrl)
                 }
+
+                BookInfoEffect.RequestBiometricUnlock -> {
+                    BiometricUnlockLauncher.launch(
+                        activity = activity,
+                        title = unlockTitle,
+                        subtitle = unlockSubtitle,
+                        negativeButtonText = unlockUsePassword,
+                        onPassword = { password ->
+                            viewModel.onIntent(BookInfoIntent.SubmitPrivatePassword(password))
+                        },
+                        onFallback = {
+                            viewModel.onIntent(BookInfoIntent.ShowPrivatePassword)
+                        },
+                        onError = { message -> context.toastOnUi(message) },
+                    )
+                }
+
+                BookInfoEffect.NavigateToLocalPasswordSettings -> {
+                    // 只弹提示用户进不去设置页,这里与书架保持一致:说明原因后直接跳过去
+                    context.toastOnUi(noPasswordHint)
+                    onOpenSettings()
+                }
             }
         }
     }

+ 383 - 180
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -143,6 +143,9 @@ import io.legado.app.ui.widget.components.image.cover.usesDefaultBookCover
 import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.privacy.PrivateLockedCover
+import io.legado.app.ui.widget.components.privacy.PrivateLockedHint
+import io.legado.app.ui.widget.components.privacy.PrivateMaskLine
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.text.AnimatedTextLine
 import io.legado.app.ui.widget.components.text.AppText
@@ -248,6 +251,27 @@ fun BookInfoScreen(
             },
         )
     }
+
+    if (state.showPrivatePasswordDialog) {
+        var password by remember { mutableStateOf("") }
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { onIntent(BookInfoIntent.DismissPrivatePassword) },
+            title = stringResource(R.string.private_unlock_password_title),
+            content = {
+                AppTextField(
+                    value = password,
+                    onValueChange = { password = it },
+                    label = stringResource(R.string.private_unlock_password_title),
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            },
+            confirmText = stringResource(R.string.ok),
+            onConfirm = { onIntent(BookInfoIntent.SubmitPrivatePassword(password)) },
+            dismissText = stringResource(R.string.cancel),
+            onDismiss = { onIntent(BookInfoIntent.DismissPrivatePassword) },
+        )
+    }
 }
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class,
@@ -306,13 +330,16 @@ private fun BookInfoScreenContent(
         },
         snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
         floatingActionButton = {
-            ExtendedFloatingActionButton(
-                onClick = { onIntent(BookInfoIntent.ReadClick) },
-                containerColor = LegadoTheme.colorScheme.primaryContainer,
-                contentColor = LegadoTheme.colorScheme.onPrimaryContainer,
-                icon = { Icon(Icons.Default.Book, null) },
-                text = { Text(stringResource(R.string.reading)) },
-            )
+            // 未验证时不提供"直接开始阅读"的入口,避免绕过验证
+            if (!state.privateLocked) {
+                ExtendedFloatingActionButton(
+                    onClick = { onIntent(BookInfoIntent.ReadClick) },
+                    containerColor = LegadoTheme.colorScheme.primaryContainer,
+                    contentColor = LegadoTheme.colorScheme.onPrimaryContainer,
+                    icon = { Icon(Icons.Default.Book, null) },
+                    text = { Text(stringResource(R.string.reading)) },
+                )
+            }
         },
         alwaysDrawBehindBars = true,
     ) { paddingValues ->
@@ -320,119 +347,154 @@ private fun BookInfoScreenContent(
         if (book == null) {
             Box(modifier = Modifier.fillMaxSize())
         } else {
-            val resolvedBackdropStyle = requireNotNull(backdropStyle)
-            Box(modifier = Modifier.fillMaxSize()) {
-                BookInfoBackdrop(
-                    book = book,
-                    style = resolvedBackdropStyle,
-                    usesDefaultCover = usesDefaultCover,
-                    onNetworkCoverLoadError = onNetworkCoverLoadError,
-                )
-                AppPullToRefresh(
-                    modifier = Modifier.fillMaxSize(),
-                    isRefreshing = state.isTocLoading,
-                    onRefresh = { onIntent(BookInfoIntent.MenuAction(BookInfoMenuAction.Refresh)) },
-                    topPadding = paddingValues.calculateTopPadding(),
-                    scrollBehavior = scrollBehavior
-                ) {
-                    LazyColumn(
-                        state = listState,
-                        modifier = Modifier.fillMaxSize(),
-                        contentPadding = PaddingValues(
-                            top = paddingValues.calculateTopPadding() + 8.dp,
-                            bottom = paddingValues.calculateBottomPadding() + 88.dp,
-                        ),
-                    ) {
-                        item {
-                            BookInfoHeader(
-                                book = book,
-                                highlightedTags = state.highlightedTags,
-                                kindLabels = state.kindLabels,
-                                groupNames = state.groupNames,
-                                onCoverClick = { onIntent(BookInfoIntent.CoverClick) },
-                                onCoverLongClick = { onIntent(BookInfoIntent.CoverLongClick) },
-                                onAuthorClick = { onIntent(BookInfoIntent.AuthorClick(it)) },
-                                onBookNameClick = { onIntent(BookInfoIntent.BookNameClick(it)) },
-                                onOriginClick = { onIntent(BookInfoIntent.OriginClick) },
-                                onNetworkCoverLoadError = {
-                                    onNetworkCoverLoadError(book.coverPath)
-                                },
-                                usesDefaultCover = usesDefaultCover,
-                                applySeedOverlay = resolvedBackdropStyle.applySeedOverlay,
-                                sharedTransitionScope = sharedTransitionScope,
-                                animatedVisibilityScope = animatedVisibilityScope,
-                                sharedCoverKey = sharedCoverKey,
-                            )
-                        }
-                        item {
-                            Column(
-                                modifier = Modifier
-                                    .fillMaxWidth()
-                                    .background(
-                                        color = LegadoTheme.colorScheme.surface
-                                    )
-                                    .padding(bottom = 24.dp)
+            // 脱敏 ⇄ 解锁用交叉淡化过渡。
+            // 刻意不在整页上加模糊:那会把脱敏封面的圆角与阴影一起糊掉,
+            // 而且整页级别的大区域模糊在低端机上开销明显。
+            Crossfade(
+                targetState = state.privateLocked,
+                modifier = Modifier.fillMaxSize(),
+                animationSpec = tween(320),
+            ) { locked ->
+                // 过渡期间新旧两份内容会同时组合:共享元素 key 只交给与目标一致的那一份,
+                // 否则同一个 sharedBounds key 会短暂出现两个持有者
+                if (locked) {
+                    BookInfoLockedContent(
+                        book = book,
+                        hasLocalPassword = state.privateAccess.hasPassword,
+                        paddingValues = paddingValues,
+                        usesDefaultCover = usesDefaultCover,
+                        applySeedOverlay = backdropStyle?.applySeedOverlay == true,
+                        sharedTransitionScope = sharedTransitionScope,
+                        animatedVisibilityScope = animatedVisibilityScope,
+                        sharedCoverKey = sharedCoverKey.takeIf { state.privateLocked },
+                        onVerify = { onIntent(BookInfoIntent.RequestPrivateUnlock) },
+                    )
+                } else {
+                    val resolvedBackdropStyle = requireNotNull(backdropStyle)
+                    Box(modifier = Modifier.fillMaxSize()) {
+                        BookInfoBackdrop(
+                            book = book,
+                            style = resolvedBackdropStyle,
+                            usesDefaultCover = usesDefaultCover,
+                            onNetworkCoverLoadError = onNetworkCoverLoadError,
+                        )
+                        AppPullToRefresh(
+                            modifier = Modifier.fillMaxSize(),
+                            isRefreshing = state.isTocLoading,
+                            onRefresh = { onIntent(BookInfoIntent.MenuAction(BookInfoMenuAction.Refresh)) },
+                            topPadding = paddingValues.calculateTopPadding(),
+                            scrollBehavior = scrollBehavior
+                        ) {
+                            LazyColumn(
+                                state = listState,
+                                modifier = Modifier.fillMaxSize(),
+                                contentPadding = PaddingValues(
+                                    top = paddingValues.calculateTopPadding() + 8.dp,
+                                    bottom = paddingValues.calculateBottomPadding() + 88.dp,
+                                ),
                             ) {
-                                BookInfoActions(
-                                    inBookshelf = state.inBookshelf,
-                                    onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
-                                    onTocClick = { onIntent(BookInfoIntent.TocClick) },
-                                    onGroupClick = { onIntent(BookInfoIntent.GroupClick) },
-                                    onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) },
-                                    onReadRecordClick = { onIntent(BookInfoIntent.ReadRecordClick) },
-                                )
-                                if (
-                                    state.characters.isNotEmpty() ||
-                                    state.knowledgeEntries.isNotEmpty() ||
-                                    state.recentEvents.isNotEmpty()
-                                ) {
-                                    BookInfoCharacters(
-                                        characters = state.characters,
-                                        onCharacterClick = {
-                                            onIntent(BookInfoIntent.CharacterClick(it))
-                                        },
-                                        onNetworkClick = {
-                                            onIntent(BookInfoIntent.CharacterNetworkClick)
-                                        },
-                                        onViewAllClick = {
-                                            onIntent(BookInfoIntent.CharacterListClick)
-                                        },
-                                        onKnowledgeClick = {
-                                            onIntent(BookInfoIntent.KnowledgeListClick)
-                                        },
-                                        onEventsClick = {
-                                            onIntent(BookInfoIntent.EventListClick)
+                                item {
+                                    BookInfoHeader(
+                                        book = book,
+                                        highlightedTags = state.highlightedTags,
+                                        kindLabels = state.kindLabels,
+                                        groupNames = state.groupNames,
+                                        onCoverClick = { onIntent(BookInfoIntent.CoverClick) },
+                                        onCoverLongClick = { onIntent(BookInfoIntent.CoverLongClick) },
+                                        onAuthorClick = { onIntent(BookInfoIntent.AuthorClick(it)) },
+                                        onBookNameClick = { onIntent(BookInfoIntent.BookNameClick(it)) },
+                                        onOriginClick = { onIntent(BookInfoIntent.OriginClick) },
+                                        onNetworkCoverLoadError = {
+                                            onNetworkCoverLoadError(book.coverPath)
                                         },
+                                        usesDefaultCover = usesDefaultCover,
+                                        applySeedOverlay = resolvedBackdropStyle.applySeedOverlay,
+                                        sharedTransitionScope = sharedTransitionScope,
+                                        animatedVisibilityScope = animatedVisibilityScope,
+                                        sharedCoverKey = sharedCoverKey.takeIf { !state.privateLocked },
                                     )
                                 }
-                                state.relatedBooks.forEach { module ->
-                                    RelatedBooksBanner(
-                                        title = module.title,
-                                        books = module.books,
-                                        onBookClick = { book, _ ->
-                                            onIntent(BookInfoIntent.RelatedBookClick(book))
-                                        },
-                                        onMoreClick = {
-                                            onIntent(BookInfoIntent.RelatedBooksMore(module.title, module.resolvedUrl))
-                                        },
-                                    )
+                                item {
+                                    Column(
+                                        modifier = Modifier
+                                            .fillMaxWidth()
+                                            .background(
+                                                color = LegadoTheme.colorScheme.surface
+                                            )
+                                            .padding(bottom = 24.dp)
+                                    ) {
+                                        BookInfoActions(
+                                            inBookshelf = state.inBookshelf,
+                                            onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
+                                            onTocClick = { onIntent(BookInfoIntent.TocClick) },
+                                            onGroupClick = { onIntent(BookInfoIntent.GroupClick) },
+                                            onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) },
+                                            onReadRecordClick = { onIntent(BookInfoIntent.ReadRecordClick) },
+                                        )
+                                        if (
+                                            state.characters.isNotEmpty() ||
+                                            state.knowledgeEntries.isNotEmpty() ||
+                                            state.recentEvents.isNotEmpty()
+                                        ) {
+                                            BookInfoCharacters(
+                                                characters = state.characters,
+                                                onCharacterClick = {
+                                                    onIntent(BookInfoIntent.CharacterClick(it))
+                                                },
+                                                onNetworkClick = {
+                                                    onIntent(BookInfoIntent.CharacterNetworkClick)
+                                                },
+                                                onViewAllClick = {
+                                                    onIntent(BookInfoIntent.CharacterListClick)
+                                                },
+                                                onKnowledgeClick = {
+                                                    onIntent(BookInfoIntent.KnowledgeListClick)
+                                                },
+                                                onEventsClick = {
+                                                    onIntent(BookInfoIntent.EventListClick)
+                                                },
+                                            )
+                                        }
+                                        state.relatedBooks.forEach { module ->
+                                            RelatedBooksBanner(
+                                                title = module.title,
+                                                books = module.books,
+                                                onBookClick = { book, _ ->
+                                                    onIntent(BookInfoIntent.RelatedBookClick(book))
+                                                },
+                                                onMoreClick = {
+                                                    onIntent(
+                                                        BookInfoIntent.RelatedBooksMore(
+                                                            module.title,
+                                                            module.resolvedUrl
+                                                        )
+                                                    )
+                                                },
+                                            )
+                                        }
+                                        BookInfoSummary(
+                                            book = book,
+                                            tocLoadFailed = state.tocLoadFailed,
+                                            onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) },
+                                            bookSource = state.bookSource,
+                                            onJumpToAnotherApp = jumpToAnotherApp,
+                                            onIntroButtonClick = { name, click ->
+                                                onIntent(
+                                                    BookInfoIntent.IntroButtonClick(
+                                                        name,
+                                                        click
+                                                    )
+                                                )
+                                            },
+                                            onIntroImageClick = { click ->
+                                                onIntent(BookInfoIntent.IntroImageClick(click))
+                                            },
+                                            onIntroImageLongClick = { source ->
+                                                onIntent(BookInfoIntent.IntroImageLongClick(source))
+                                            },
+                                        )
+                                    }
                                 }
-                                BookInfoSummary(
-                                    book = book,
-                                    tocLoadFailed = state.tocLoadFailed,
-                                    onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) },
-                                    bookSource = state.bookSource,
-                                    onJumpToAnotherApp = jumpToAnotherApp,
-                                    onIntroButtonClick = { name, click ->
-                                        onIntent(BookInfoIntent.IntroButtonClick(name, click))
-                                    },
-                                    onIntroImageClick = { click ->
-                                        onIntent(BookInfoIntent.IntroImageClick(click))
-                                    },
-                                    onIntroImageLongClick = { source ->
-                                        onIntent(BookInfoIntent.IntroImageLongClick(source))
-                                    },
-                                )
                             }
                         }
                     }
@@ -574,6 +636,85 @@ private fun BookInfoColorTheme(
     )
 }
 
+/**
+ * 未验证时的详情页:**沿用正常详情页的同一套布局**,只是封面模糊、文字全部换成占位条。
+ *
+ * 复用 `BookInfoHeader`(而不是另写一份骨架)有两个直接好处:
+ * 1. 共享元素动画能接上——封面用的还是同一个 sharedCoverKey 与同一个 scope;
+ * 2. 脱敏态就是最终形态,不存在"进去时先是一样、再变成另一样"的闪烁。
+ */
+@Composable
+private fun BookInfoLockedContent(
+    book: BookInfoBookUi,
+    hasLocalPassword: Boolean,
+    paddingValues: PaddingValues,
+    usesDefaultCover: Boolean,
+    applySeedOverlay: Boolean,
+    sharedTransitionScope: SharedTransitionScope?,
+    animatedVisibilityScope: AnimatedVisibilityScope?,
+    sharedCoverKey: String?,
+    onVerify: () -> Unit,
+) {
+    // 刻意不渲染 BookInfoBackdrop:脱敏态连背景大图都不出现
+    LazyColumn(
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = PaddingValues(
+            top = paddingValues.calculateTopPadding() + 8.dp,
+            bottom = paddingValues.calculateBottomPadding() + 96.dp,
+        ),
+    ) {
+        item {
+            BookInfoHeader(
+                book = book,
+                highlightedTags = emptyList(),
+                kindLabels = emptyList(),
+                groupNames = null,
+                onCoverClick = {},
+                onCoverLongClick = {},
+                onAuthorClick = {},
+                onBookNameClick = {},
+                onOriginClick = {},
+                onNetworkCoverLoadError = {},
+                usesDefaultCover = usesDefaultCover,
+                applySeedOverlay = applySeedOverlay,
+                sharedTransitionScope = sharedTransitionScope,
+                animatedVisibilityScope = animatedVisibilityScope,
+                sharedCoverKey = sharedCoverKey,
+                locked = true,
+            )
+        }
+        item {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(horizontal = 24.dp, vertical = 28.dp),
+                horizontalAlignment = Alignment.CenterHorizontally,
+                verticalArrangement = Arrangement.spacedBy(24.dp),
+            ) {
+                // 只保留书名/作者/书源的占位(在 header 内),正文区直接给提示,不再铺占位条
+                PrivateLockedHint(
+                    title = stringResource(R.string.private_locked_book_title),
+                    description = stringResource(
+                        if (hasLocalPassword) {
+                            R.string.private_locked_book_desc
+                        } else {
+                            R.string.private_content_no_password
+                        }
+                    ),
+                    actionText = stringResource(
+                        if (hasLocalPassword) {
+                            R.string.private_verify_and_open
+                        } else {
+                            R.string.set_local_password
+                        }
+                    ),
+                    onAction = onVerify,
+                )
+            }
+        }
+    }
+}
+
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 private fun BookInfoTransparentTopAppBar(
@@ -720,6 +861,8 @@ private fun BookInfoTopBarActions(
     state: BookInfoUiState,
     onMenuAction: (BookInfoMenuAction) -> Unit,
 ) {
+    // 未验证时只留返回:编辑/分享/更多都会导出真实书信息
+    if (state.privateLocked) return
     if (state.inBookshelf) {
         TopBarActionButton(
             onClick = { onMenuAction(BookInfoMenuAction.Edit) },
@@ -963,6 +1106,13 @@ private fun BookInfoOverflowMenu(
             text = stringResource(R.string.log),
             onClick = { onMenuAction(BookInfoMenuAction.ShowLog) }
         )
+        RoundDropdownMenuItem(
+            text = stringResource(
+                if (state.bookPrivate) R.string.private_unmark_book else R.string.private_mark_book
+            ),
+            onClick = { onMenuAction(BookInfoMenuAction.TogglePrivate) },
+            isSelected = state.bookPrivate
+        )
     }
 }
 
@@ -983,8 +1133,11 @@ private fun BookInfoHeader(
     sharedTransitionScope: SharedTransitionScope?,
     animatedVisibilityScope: AnimatedVisibilityScope?,
     sharedCoverKey: String?,
+    // 未验证时就地脱敏:保留同一套布局与共享元素,只把封面模糊、文字换成占位条
+    locked: Boolean = false,
 ) {
     val coverDescription = stringResource(R.string.a11y_book_cover_actions, book.name)
+    val hiddenDescription = stringResource(R.string.private_hidden_label)
     Column(
         modifier = Modifier
             .fillMaxWidth()
@@ -1017,30 +1170,57 @@ private fun BookInfoHeader(
                 Box(
                     modifier = Modifier
                         .width(112.dp)
-                        .combinedClickable(onClick = onCoverClick, onLongClick = onCoverLongClick)
+                        .then(
+                            // 脱敏时封面不可点:点开大图等于直接泄漏
+                            if (locked) {
+                                Modifier
+                            } else {
+                                Modifier.combinedClickable(
+                                    onClick = onCoverClick,
+                                    onLongClick = onCoverLongClick
+                                )
+                            }
+                        )
                         .semantics {
                             role = Role.Button
-                            contentDescription = coverDescription
+                            contentDescription = if (locked) hiddenDescription else coverDescription
                         }
                 ) {
-                    CoilBookCover(
-                        name = book.name,
-                        author = book.author,
-                        path = if (usesDefaultCover) null else book.coverPath,
-                        sourceOrigin = if (usesDefaultCover) null else book.origin,
-                        // 传 bookUrl 供别名缓存键。详情页故意不设 preferCache:
-                        // 在线时仍走完整链路拉新链接并刷新别名,保证封面换图后书架也能更新;
-                        // 精确命中时同样不跑脚本。
-                        bookUrl = book.bookUrl,
-                        onError = onNetworkCoverLoadError,
-                        modifier = Modifier
-                            .width(112.dp)
-                            .aspectRatio(5f / 7f),
-                        showLoadingPlaceholder = sharedCoverKey == null,
-                        sharedTransitionScope = sharedTransitionScope,
-                        animatedVisibilityScope = animatedVisibilityScope,
-                        sharedCoverKey = sharedCoverKey
-                    )
+                    if (locked) {
+                        PrivateLockedCover(
+                            name = null,
+                            author = null,
+                            path = if (usesDefaultCover) null else book.coverPath,
+                            sourceOrigin = if (usesDefaultCover) null else book.origin,
+                            bookUrl = book.bookUrl,
+                            modifier = Modifier
+                                .width(112.dp)
+                                .aspectRatio(5f / 7f),
+                            // 同一个 key + 同一个 scope:共享元素动画在脱敏态下依然连续
+                            sharedCoverKey = sharedCoverKey,
+                            sharedTransitionScope = sharedTransitionScope,
+                            animatedVisibilityScope = animatedVisibilityScope,
+                        )
+                    } else {
+                        CoilBookCover(
+                            name = book.name,
+                            author = book.author,
+                            path = if (usesDefaultCover) null else book.coverPath,
+                            sourceOrigin = if (usesDefaultCover) null else book.origin,
+                            // 传 bookUrl 供别名缓存键。详情页故意不设 preferCache:
+                            // 在线时仍走完整链路拉新链接并刷新别名,保证封面换图后书架也能更新;
+                            // 精确命中时同样不跑脚本。
+                            bookUrl = book.bookUrl,
+                            onError = onNetworkCoverLoadError,
+                            modifier = Modifier
+                                .width(112.dp)
+                                .aspectRatio(5f / 7f),
+                            showLoadingPlaceholder = sharedCoverKey == null,
+                            sharedTransitionScope = sharedTransitionScope,
+                            animatedVisibilityScope = animatedVisibilityScope,
+                            sharedCoverKey = sharedCoverKey
+                        )
+                    }
                 }
                 Column(
                     modifier = Modifier
@@ -1049,60 +1229,83 @@ private fun BookInfoHeader(
                         .padding(top = 8.dp, bottom = 8.dp),
                     verticalArrangement = Arrangement.spacedBy(4.dp)
                 ) {
-                    var showTitleMenu by remember { mutableStateOf(false) }
-                    var isTitleExpanded by rememberSaveable { mutableStateOf(false) }
-                    Box {
+                    if (locked) {
+                        // 书名 / 作者 / 来源全部换成占位条:不渲染任何真实字符串,也不响应点击
+                        PrivateMaskLine(widthFraction = 0.72f, thickness = 20.dp)
+                        PrivateMaskLine(
+                            modifier = Modifier.padding(top = 6.dp),
+                            widthFraction = 0.42f,
+                            thickness = 12.dp
+                        )
+                        PrivateMaskLine(
+                            modifier = Modifier.padding(top = 6.dp),
+                            widthFraction = 0.3f,
+                            thickness = 9.dp
+                        )
+                    } else {
+                        var showTitleMenu by remember { mutableStateOf(false) }
+                        var isTitleExpanded by rememberSaveable { mutableStateOf(false) }
+                        Box {
+                            AnimatedTextLine(
+                                text = book.name,
+                                style = LegadoTheme.typography.headlineSmall,
+                                fontWeight = FontWeight.Bold,
+                                maxLines = if (isTitleExpanded) Int.MAX_VALUE else 2,
+                                modifier = Modifier.combinedClickable(
+                                    onClick = { onBookNameClick(false) },
+                                    onLongClick = { showTitleMenu = true }
+                                )
+                            )
+                            RoundDropdownMenu(
+                                expanded = showTitleMenu,
+                                onDismissRequest = { showTitleMenu = false }
+                            ) {
+                                RoundDropdownMenuItem(
+                                    text = stringResource(R.string.search),
+                                    onClick = {
+                                        showTitleMenu = false
+                                        onBookNameClick(true)
+                                    }
+                                )
+                                RoundDropdownMenuItem(
+                                    text = stringResource(if (isTitleExpanded) R.string.collapse else R.string.expand),
+                                    onClick = {
+                                        showTitleMenu = false
+                                        isTitleExpanded = !isTitleExpanded
+                                    }
+                                )
+                            }
+                        }
                         AnimatedTextLine(
-                            text = book.name,
-                            style = LegadoTheme.typography.headlineSmall,
-                            fontWeight = FontWeight.Bold,
-                            maxLines = if (isTitleExpanded) Int.MAX_VALUE else 2,
+                            text = stringResource(R.string.author_show, book.realAuthor),
+                            style = LegadoTheme.typography.bodyLarge,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
                             modifier = Modifier.combinedClickable(
-                                onClick = { onBookNameClick(false) },
-                                onLongClick = { showTitleMenu = true }
+                                onClick = { onAuthorClick(false) },
+                                onLongClick = { onAuthorClick(true) }
                             )
                         )
-                        RoundDropdownMenu(
-                            expanded = showTitleMenu,
-                            onDismissRequest = { showTitleMenu = false }
-                        ) {
-                            RoundDropdownMenuItem(
-                                text = stringResource(R.string.search),
-                                onClick = {
-                                    showTitleMenu = false
-                                    onBookNameClick(true)
-                                }
-                            )
-                            RoundDropdownMenuItem(
-                                text = stringResource(if (isTitleExpanded) R.string.collapse else R.string.expand),
-                                onClick = {
-                                    showTitleMenu = false
-                                    isTitleExpanded = !isTitleExpanded
-                                }
-                            )
-                        }
-                    }
-                    AnimatedTextLine(
-                        text = stringResource(R.string.author_show, book.realAuthor),
-                        style = LegadoTheme.typography.bodyLarge,
-                        color = LegadoTheme.colorScheme.onSurfaceVariant,
-                        modifier = Modifier.combinedClickable(
-                            onClick = { onAuthorClick(false) },
-                            onLongClick = { onAuthorClick(true) }
+                        AnimatedTextLine(
+                            text = stringResource(R.string.origin_show, book.originName),
+                            style = LegadoTheme.typography.labelMedium,
+                            color = LegadoTheme.colorScheme.primary,
+                            modifier = Modifier.clickable(onClick = onOriginClick)
                         )
-                    )
-                    AnimatedTextLine(
-                        text = stringResource(R.string.origin_show, book.originName),
-                        style = LegadoTheme.typography.labelMedium,
-                        color = LegadoTheme.colorScheme.primary,
-                        modifier = Modifier.clickable(onClick = onOriginClick)
-                    )
+                    }
                 }
             }
-            if (highlightedTags.isNotEmpty()) {
+            if (locked) {
+                PrivateMaskLine(widthFraction = 0.5f, thickness = 24.dp)
+            } else if (highlightedTags.isNotEmpty()) {
                 HighlightTagRow(tags = highlightedTags)
             }
-            if (kindLabels.isNotEmpty() || !groupNames.isNullOrBlank()) {
+            if (locked) {
+                PrivateMaskLine(
+                    modifier = Modifier.padding(top = 2.dp),
+                    widthFraction = 0.34f,
+                    thickness = 20.dp
+                )
+            } else if (kindLabels.isNotEmpty() || !groupNames.isNullOrBlank()) {
                 val kindListState = rememberLazyListState()
                 LazyRow(
                     state = kindListState,

+ 150 - 25
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -30,10 +30,15 @@ import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.domain.gateway.CoverSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.PrivateAccessGateway
+import io.legado.app.domain.gateway.PrivateContentGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.model.BookshelfConflict
+import io.legado.app.domain.model.PrivateAccessState
+import io.legado.app.domain.model.PrivateUnlockTarget
 import io.legado.app.domain.model.settings.CoverSettings
 import io.legado.app.domain.model.settings.OtherSettings
+import io.legado.app.domain.model.settings.PrivateAccessSettings
 import io.legado.app.domain.model.settings.ThemeSettings
 import io.legado.app.domain.usecase.BookTocUnavailableException
 import io.legado.app.domain.usecase.ChangeBookSourceUseCase
@@ -92,6 +97,7 @@ import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
@@ -117,6 +123,8 @@ class BookInfoViewModel(
     private val themeSettingsGateway: ThemeSettingsGateway,
     private val coverSettingsGateway: CoverSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
+    private val privateAccessGateway: PrivateAccessGateway,
+    private val privateContentGateway: PrivateContentGateway,
 ) : BaseViewModel(application) {
 
     val allGroups = bookGroupRepository.flowSelect().map { it.toImmutableList() }
@@ -124,6 +132,25 @@ class BookInfoViewModel(
     // 仅保存“每本书/屏幕”状态;外观与其他设置不在此存储,避免整体重置时被抹掉。
     private val _screenState = MutableStateFlow(BookInfoUiState())
 
+    /** 当前书籍是否私密(单本标记 ∪ 所属私密分组);菜单里"标记/取消私密"读它 */
+    private val bookPrivateFlow = MutableStateFlow(false)
+
+    /**
+     * 进入本页时这本书是否需要验证。
+     *
+     * 与 [bookPrivateFlow] 分开:用户在页面内刚把这本书标成私密时,页面不应该立刻变成
+     * 脱敏态——是否要验证只在进入本页时定一次。
+     */
+    private val privateLockedByEntryFlow = MutableStateFlow(false)
+
+    private val privateAccessState: StateFlow<PrivateAccessState> =
+        privateAccessGateway.state
+            .stateIn(viewModelScope, SharingStarted.Eagerly, PrivateAccessState())
+
+    private val privateAccessSettings: StateFlow<PrivateAccessSettings> =
+        privateAccessGateway.settings
+            .stateIn(viewModelScope, SharingStarted.Eagerly, PrivateAccessSettings())
+
     // 设置类字段始终从各自 gateway(唯一 SSOT)派生叠加,重置屏幕状态无法影响它们。
     val uiState: StateFlow<BookInfoUiState> = combine(
         _screenState,
@@ -132,6 +159,19 @@ class BookInfoViewModel(
         otherSettingsGateway.settings,
     ) { screen, theme, cover, other ->
         screen.withSettings(theme, cover, other)
+    }.combine(bookPrivateFlow) { screen, bookPrivate ->
+        screen.copy(bookPrivate = bookPrivate)
+    }.combine(privateLockedByEntryFlow) { screen, lockedByEntry ->
+        screen.copy(privateLockedByEntry = lockedByEntry)
+    }.combine(privateAccessState) { screen, privateAccess ->
+        screen.copy(privateAccess = privateAccess)
+    }.combine(privateAccessSettings) { screen, privateSettings ->
+        val group = screen.book?.group ?: 0L
+        screen.copy(
+            privateLocked = privateSettings.verifyOnOpenBook &&
+                    screen.privateLockedByEntry &&
+                    !screen.privateAccess.isTargetGranted(screen.book?.bookUrl, group)
+        )
     }.stateIn(
         scope = viewModelScope,
         started = SharingStarted.WhileSubscribed(5_000),
@@ -179,6 +219,60 @@ class BookInfoViewModel(
             field = value
             observeReadRecordIfNeeded(value)
         }
+
+    override fun onCleared() {
+        // "每次验证"频率下离开详情页即撤销授权,下次进来要重新验证
+        currentBook?.bookUrl?.let {
+            privateAccessGateway.revoke(PrivateUnlockTarget.Book(it))
+        }
+        super.onCleared()
+    }
+
+    /** 详情页溢出菜单里的"标记/取消私密" */
+    private fun toggleBookPrivate() {
+        val bookUrl = currentBook?.bookUrl ?: return
+        val target = !bookPrivateFlow.value
+        execute {
+            privateContentGateway.setBooksPrivate(setOf(bookUrl), target)
+        }.onSuccess {
+            bookPrivateFlow.value = target
+            showMessage(
+                context.getString(
+                    if (target) R.string.private_mark_book else R.string.private_unmark_book
+                )
+            )
+        }.onError {
+            showMessage(context.getString(R.string.save_failed))
+        }
+    }
+
+    /** 与书架同一套降级顺序:生物快捷 → 应用内密码 → 引导设密码 */
+    private fun requestPrivateUnlock() {
+        val access = privateAccessState.value
+        // 已经获准查看(进程解锁或本目标已授权)就不必再打扰
+        if (!uiState.value.privateLocked) return
+        if (!access.hasPassword) {
+            emitEffect(BookInfoEffect.NavigateToLocalPasswordSettings)
+            return
+        }
+        if (access.canUseBiometricShortcut) {
+            emitEffect(BookInfoEffect.RequestBiometricUnlock)
+        } else {
+            _screenState.update { it.copy(showPrivatePasswordDialog = true) }
+        }
+    }
+
+    private fun submitPrivatePassword(password: String) {
+        if (password.isEmpty()) return
+        val target = currentBook?.bookUrl?.let { PrivateUnlockTarget.Book(it) }
+        viewModelScope.launch {
+            if (privateAccessGateway.verifyPassword(password, target)) {
+                _screenState.update { it.copy(showPrivatePasswordDialog = false) }
+            } else {
+                showMessage(context.getString(R.string.private_unlock_password_error))
+            }
+        }
+    }
     private var currentChapterList: List<BookChapter> = emptyList()
     private var tocLoadFailed = false
     private var currentWebFiles: List<BookInfoWebFile> = emptyList()
@@ -225,38 +319,58 @@ class BookInfoViewModel(
     ) {
         val current = currentBook
         if (current != null) return
-        currentBook = if (!name.isNullOrBlank() && !author.isNullOrBlank()) {
-            Book(
-                bookUrl = bookUrl,
-                name = name,
-                author = author,
-                origin = origin ?: BookType.localTag,
-                coverUrl = coverPath
-            ).apply {
-                addType(BookType.notShelf)
-            }
-        } else {
-            null
-        }
         clearReadRecordObserve()
         relatedBooksLoadJob?.cancel()
         characterLoadJob?.cancel()
-        syncUiState()
         execute {
-            val dbBook = bookRepository.getBook(bookUrl)
-            if (dbBook != null) {
-                inBookshelf = !dbBook.isNotShelf
-                dbBook
+            // 私密标记与书籍信息一次取回:先确定是否私密,再发布书籍。
+            // 分开查会先渲染真实书名/封面、再切成锁定态,既闪烁又泄漏。
+            val isPrivate = privateContentGateway.isBookPrivate(bookUrl)
+            val fallback = if (!name.isNullOrBlank() && !author.isNullOrBlank()) {
+                Book(
+                    bookUrl = bookUrl,
+                    name = name,
+                    author = author,
+                    origin = origin ?: BookType.localTag,
+                    coverUrl = coverPath
+                ).apply {
+                    addType(BookType.notShelf)
+                }
             } else {
-                val searchBook = searchRepository.getSearchBook(bookUrl)?.toBook()
-                if (searchBook != null) {
-                    inBookshelf = false
-                    searchBook
-                } else {
-                    currentBook ?: throw NoStackTraceException("未找到书籍")
+                null
+            }
+            val dbBook = bookRepository.getBook(bookUrl)
+            val book = when {
+                dbBook != null -> {
+                    inBookshelf = !dbBook.isNotShelf
+                    dbBook
+                }
+
+                else -> {
+                    val searchBook = searchRepository.getSearchBook(bookUrl)?.toBook()
+                    if (searchBook != null) {
+                        inBookshelf = false
+                        searchBook
+                    } else {
+                        fallback ?: throw NoStackTraceException("未找到书籍")
+                    }
+                }
+            }
+            book to isPrivate
+        }.onSuccess { (book, isPrivate) ->
+            // 先落私密状态再发布书籍:第一帧就已经是最终形态,没有中间态
+            bookPrivateFlow.value = isPrivate
+            privateLockedByEntryFlow.value = isPrivate
+            if (isPrivate) {
+                // 私密书籍的详情页直接弹验证,不用用户再点一次;取消后停在脱敏页,
+                // 那里仍有"验证"入口可以重试。
+                // privateLocked 是组合流,等它真正算出 true 再申请——否则会被
+                // requestPrivateUnlock 的前置判断挡回去,弹不出来。first 保证只弹一次。
+                viewModelScope.launch {
+                    uiState.first { it.privateLocked }
+                    requestPrivateUnlock()
                 }
             }
-        }.onSuccess { book ->
             // 如果从数据库/搜索中拿到的书没有封面,但我们有传入的封面,则保留传入的封面
             if (book.coverUrl.isNullOrBlank() && !coverPath.isNullOrBlank()) {
                 book.coverUrl = coverPath
@@ -287,6 +401,16 @@ class BookInfoViewModel(
                 _screenState.update { it.copy(showAppLogSheet = false) }
             }
 
+            BookInfoIntent.RequestPrivateUnlock -> requestPrivateUnlock()
+            BookInfoIntent.ShowPrivatePassword -> {
+                _screenState.update { it.copy(showPrivatePasswordDialog = true) }
+            }
+
+            is BookInfoIntent.SubmitPrivatePassword -> submitPrivatePassword(intent.password)
+            BookInfoIntent.DismissPrivatePassword -> {
+                _screenState.update { it.copy(showPrivatePasswordDialog = false) }
+            }
+
             BookInfoIntent.ReadClick -> onReadClick()
             BookInfoIntent.ShelfClick -> onShelfClick()
             BookInfoIntent.TocClick -> onTocClick()
@@ -1400,6 +1524,7 @@ class BookInfoViewModel(
             )
 
             BookInfoMenuAction.ShowLog -> showAppLog()
+            BookInfoMenuAction.TogglePrivate -> toggleBookPrivate()
         }
     }
 

+ 31 - 10
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt

@@ -64,6 +64,9 @@ import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.heatmap.HeatmapMode
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import io.legado.app.ui.widget.components.privacy.PrivateLockedCover
+import io.legado.app.ui.widget.components.privacy.PrivateRecordKey
+import io.legado.app.ui.widget.components.privacy.rememberPrivateLockedRecords
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
@@ -343,6 +346,10 @@ fun TopReadingListCard(
                 LaunchedEffect(book.bookName, book.bookAuthor) {
                     coverPath = loadBookCover(book.bookName, book.bookAuthor)
                 }
+                // 私密且未获准:封面走模糊、文字一律不渲染,读屏描述同样不给
+                val overviewKey = PrivateRecordKey(book.bookName, book.bookAuthor)
+                val overviewLocked =
+                    overviewKey in rememberPrivateLockedRecords(listOf(overviewKey))
                 val rankingDescription = stringResource(
                     R.string.a11y_reading_ranking_item,
                     index + 1,
@@ -356,7 +363,8 @@ fun TopReadingListCard(
                         .fillMaxWidth()
                         .clickable { onBookClick(book.bookName, book.bookAuthor) }
                         .semantics(mergeDescendants = true) {
-                            contentDescription = rankingDescription
+                            contentDescription =
+                                if (overviewLocked) "" else rankingDescription
                             role = Role.Button
                         }
                         .padding(vertical = 8.dp, horizontal = 8.dp),
@@ -369,29 +377,42 @@ fun TopReadingListCard(
                         textAlign = TextAlign.Center,
                         color = if (index < 3) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurfaceVariant
                     )
-                    CoilBookCover(
-                        name = book.bookName,
-                        author = book.bookAuthor,
-                        path = coverPath,
-                        modifier = Modifier.width(40.dp)
-                    )
+                    if (overviewLocked) {
+                        PrivateLockedCover(
+                            name = book.bookName,
+                            author = book.bookAuthor,
+                            path = coverPath,
+                            modifier = Modifier
+                                .width(40.dp)
+                                .aspectRatio(5f / 7f),
+                        )
+                    } else {
+                        CoilBookCover(
+                            name = book.bookName,
+                            author = book.bookAuthor,
+                            path = coverPath,
+                            modifier = Modifier.width(40.dp)
+                        )
+                    }
                     Spacer(modifier = Modifier.width(12.dp))
                     Column(modifier = Modifier.weight(1f)) {
                         AppText(
                             modifier = Modifier.padding(end = 8.dp),
-                            text = ReadRecordFormatter.formatDuration(book.readTime),
+                            text = if (overviewLocked) "" else {
+                                ReadRecordFormatter.formatDuration(book.readTime)
+                            },
                             style = LegadoTheme.typography.bodySmall,
                             color = LegadoTheme.colorScheme.primary
                         )
                         AppText(
-                            text = book.bookName,
+                            text = if (overviewLocked) "" else book.bookName,
                             style = LegadoTheme.typography.bodyMedium,
                             fontWeight = FontWeight.Bold,
                             maxLines = 1,
                             overflow = TextOverflow.Ellipsis
                         )
                         AppText(
-                            text = book.bookAuthor,
+                            text = if (overviewLocked) "" else book.bookAuthor,
                             style = LegadoTheme.typography.labelSmall,
                             color = LegadoTheme.colorScheme.onSurfaceVariant
                         )

+ 97 - 32
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
@@ -58,6 +59,7 @@ import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.contentDescription
 import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.SpanStyle
 import androidx.compose.ui.text.buildAnnotatedString
 import androidx.compose.ui.text.font.FontWeight
@@ -100,6 +102,9 @@ import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.privacy.PrivateLockedCover
+import io.legado.app.ui.widget.components.privacy.PrivateRecordKey
+import io.legado.app.ui.widget.components.privacy.rememberPrivateLockedRecords
 import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
 import io.legado.app.ui.widget.components.swipe.SwipeAction
@@ -1114,6 +1119,11 @@ fun LatestReadItem(
         lastReadText
     )
 
+    // 私密且未获准:书名、作者、封面全部脱敏(只留模糊封面,不留占位条与"已隐藏"字样)。
+    // 记录表里没有 bookUrl,所以身份只能取"书名+作者"。
+    val recordKey = PrivateRecordKey(record.bookName, record.bookAuthor)
+    val locked = recordKey in rememberPrivateLockedRecords(listOf(recordKey))
+
     Row(
         modifier = modifier
             .fillMaxWidth()
@@ -1123,18 +1133,30 @@ fun LatestReadItem(
                 onLongClick = onLongClick
             )
             .semantics(mergeDescendants = true) {
-                contentDescription = itemDescription
+                // 脱敏时连读屏描述也不给:它拼的正是书名与作者
+                contentDescription = if (locked) "" else itemDescription
                 role = Role.Button
             }
             .adaptiveHorizontalPadding(vertical = 8.dp),
         verticalAlignment = Alignment.CenterVertically
     ) {
-        CoilBookCover(
-            name = record.bookName,
-            author = record.bookAuthor,
-            path = coverPath,
-            modifier = Modifier.width(44.dp)
-        )
+        if (locked) {
+            PrivateLockedCover(
+                name = record.bookName,
+                author = record.bookAuthor,
+                path = coverPath,
+                modifier = Modifier
+                    .width(44.dp)
+                    .aspectRatio(5f / 7f),
+            )
+        } else {
+            CoilBookCover(
+                name = record.bookName,
+                author = record.bookAuthor,
+                path = coverPath,
+                modifier = Modifier.width(44.dp)
+            )
+        }
 
         SelectionCheckmark(inSelectionMode, isSelected)
 
@@ -1142,13 +1164,13 @@ fun LatestReadItem(
 
         Column(modifier = Modifier.weight(1f)) {
             AppText(
-                text = record.bookName,
+                text = if (locked) "" else record.bookName,
                 style = LegadoTheme.typography.titleMedium,
                 maxLines = 1,
                 overflow = TextOverflow.Ellipsis
             )
             AppText(
-                text = author,
+                text = if (locked) "" else author,
                 style = LegadoTheme.typography.bodySmall,
                 color = LegadoTheme.colorScheme.outline,
                 maxLines = 1,
@@ -1162,7 +1184,8 @@ fun LatestReadItem(
                         repeatDelayMillis = 2000,
                         initialDelayMillis = 1000
                     ),
-                text = buildAnnotatedString {
+                // 脱敏时整行不给:这里拼的是阅读时长 + 最后读到的章节(内容级信息)
+                text = if (locked) AnnotatedString("") else buildAnnotatedString {
                     withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.outline)) {
                         append(formatDuring(record.readTime))
                         append(" • ")
@@ -1216,6 +1239,10 @@ fun TimelineSessionItem(
         duration,
         endTimeText
     )
+    // 私密且未获准:封面走模糊、文字一律不渲染,读屏描述同样不给。
+    // 时间(endTime)不是身份信息,保留
+    val lockedKey = PrivateRecordKey(session.bookName, session.bookAuthor)
+    val locked = lockedKey in rememberPrivateLockedRecords(listOf(lockedKey))
 
     val nodeRadius = 4.dp
     val lineWidth = 2.dp
@@ -1230,11 +1257,18 @@ fun TimelineSessionItem(
             .fillMaxWidth()
             .selectionBackground(isSelected)
             .combinedClickable(
+                // 锁定态:点击与长按都只走解锁(目的是阅读器入口,那里进入即弹验证);
+                // 解锁之后这一条就是普通条目,长按恢复"选择模式"的原语义
                 onClick = { onBookClick(session.bookName, session.bookAuthor) },
-                onLongClick = onLongClick
+                onLongClick = if (locked) {
+                    { onBookClick(session.bookName, session.bookAuthor) }
+                } else {
+                    onLongClick
+                }
             )
             .semantics(mergeDescendants = true) {
-                contentDescription = itemDescription
+                // 脱敏时连读屏描述也不给:它拼的正是书名与章节名
+                contentDescription = if (locked) "" else itemDescription
                 role = Role.Button
             }
             .drawBehind {
@@ -1275,23 +1309,34 @@ fun TimelineSessionItem(
             Row(
                 verticalAlignment = Alignment.CenterVertically
             ) {
-                CoilBookCover(
-                    name = session.bookName,
-                    author = session.bookAuthor,
-                    path = coverPath,
-                    modifier = Modifier.width(44.dp)
-                )
+                if (locked) {
+                    PrivateLockedCover(
+                        name = session.bookName,
+                        author = session.bookAuthor,
+                        path = coverPath,
+                        modifier = Modifier
+                            .width(44.dp)
+                            .aspectRatio(5f / 7f),
+                    )
+                } else {
+                    CoilBookCover(
+                        name = session.bookName,
+                        author = session.bookAuthor,
+                        path = coverPath,
+                        modifier = Modifier.width(44.dp)
+                    )
+                }
                 SelectionCheckmark(inSelectionMode, isSelected)
                 Spacer(modifier = Modifier.width(8.dp))
                 Column {
                     AppText(
-                        text = session.bookName,
+                        text = if (locked) "" else session.bookName,
                         style = LegadoTheme.typography.titleMedium,
                         maxLines = 1,
                         overflow = TextOverflow.Ellipsis
                     )
                     AppText(
-                        text = author,
+                        text = if (locked) "" else author,
                         style = LegadoTheme.typography.bodySmall,
                         color = MaterialTheme.colorScheme.outline,
                         maxLines = 1,
@@ -1299,7 +1344,7 @@ fun TimelineSessionItem(
                     )
                     Spacer(modifier = Modifier.height(8.dp))
                     AppText(
-                        text = chapterTitle.orEmpty(),
+                        text = if (locked) "" else chapterTitle.orEmpty(),
                         style = LegadoTheme.typography.labelSmall,
                         color = MaterialTheme.colorScheme.outline,
                         maxLines = 1,
@@ -1334,28 +1379,45 @@ fun ReadRecordItem(
         author,
         formatDuring(detail.readTime)
     )
+    // 私密且未获准:封面走模糊、文字一律不渲染,读屏描述同样不给
+    val lockedKey = PrivateRecordKey(detail.bookName, detail.bookAuthor)
+    val locked = lockedKey in rememberPrivateLockedRecords(listOf(lockedKey))
 
     Row(
         modifier = modifier
             .fillMaxWidth()
             .selectionBackground(isSelected)
             .combinedClickable(
+                // 锁定态:点击与长按都只走解锁(目的是阅读器入口,那里进入即弹验证);
+                // 解锁后这一条就是普通条目,长按恢复"选择模式"的原语义
                 onClick = onClick,
-                onLongClick = onLongClick
+                onLongClick = if (locked) onClick else onLongClick
             )
             .semantics(mergeDescendants = true) {
-                contentDescription = itemDescription
+                // 脱敏时连读屏描述也不给:它拼的正是书名与作者
+                contentDescription = if (locked) "" else itemDescription
                 role = Role.Button
             }
             .adaptiveHorizontalPadding(vertical = 8.dp),
         verticalAlignment = Alignment.CenterVertically
     ) {
-        CoilBookCover(
-            name = detail.bookName,
-            author = detail.bookAuthor,
-            path = coverPath,
-            modifier = Modifier.width(44.dp)
-        )
+        if (locked) {
+            PrivateLockedCover(
+                name = detail.bookName,
+                author = detail.bookAuthor,
+                path = coverPath,
+                modifier = Modifier
+                    .width(44.dp)
+                    .aspectRatio(5f / 7f),
+            )
+        } else {
+            CoilBookCover(
+                name = detail.bookName,
+                author = detail.bookAuthor,
+                path = coverPath,
+                modifier = Modifier.width(44.dp)
+            )
+        }
 
         SelectionCheckmark(inSelectionMode, isSelected)
 
@@ -1363,18 +1425,21 @@ fun ReadRecordItem(
 
         Column(modifier = Modifier.weight(1f)) {
             AppText(
-                text = detail.bookName,
+                text = if (locked) "" else detail.bookName,
                 style = LegadoTheme.typography.titleMedium,
                 maxLines = 1
             )
             AppText(
-                text = author,
+                text = if (locked) "" else author,
                 style = LegadoTheme.typography.bodySmall,
                 color = MaterialTheme.colorScheme.outline,
             )
             Spacer(modifier = Modifier.height(8.dp))
             AppText(
-                text = stringResource(R.string.reading_time_with_value, formatDuring(detail.readTime)),
+                text = if (locked) "" else stringResource(
+                    R.string.reading_time_with_value,
+                    formatDuring(detail.readTime)
+                ),
                 color = MaterialTheme.colorScheme.outline,
                 style = LegadoTheme.typography.labelSmall
             )

+ 7 - 2
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -83,6 +83,7 @@ import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.privacy.rememberPrivateLockedBookUrls
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
@@ -960,6 +961,10 @@ private fun SearchSuggestionPanel(
     onClearHistory: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
+    // 私密且未获准的书不进搜索提示:这一块没有封面可供模糊,"空文本"会留下一个没有意义的
+    // 空行,所以直接不出现——顺带也就不会泄露书名/作者
+    val lockedUrls = rememberPrivateLockedBookUrls(state.bookshelfHints.map { it.bookUrl })
+    val visibleHints = state.bookshelfHints.filterNot { it.bookUrl in lockedUrls }
     LazyColumn(
         modifier = modifier,
         verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -968,7 +973,7 @@ private fun SearchSuggestionPanel(
             bottom = 8.dp
         )
     ) {
-        if (state.bookshelfHints.isNotEmpty()) {
+        if (visibleHints.isNotEmpty()) {
             item {
                 Row(verticalAlignment = Alignment.CenterVertically) {
                     AppIcon(Icons.Default.Book, contentDescription = null)
@@ -980,7 +985,7 @@ private fun SearchSuggestionPanel(
                 }
             }
 
-            items(state.bookshelfHints, key = { it.bookUrl }) { book ->
+            items(visibleHints, key = { it.bookUrl }) { book ->
                 SelectionItemCard(
                     modifier = Modifier.animateItem(),
                     title = book.name,

+ 6 - 1
app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt

@@ -29,7 +29,8 @@ fun ConfigNavScreen(
     onNavigateToAi: () -> Unit,
     onNavigateToDownloadCache: () -> Unit,
     onNavigateToTranslation: () -> Unit,
-    onNavigateToLab: () -> Unit
+    onNavigateToLab: () -> Unit,
+    onNavigateToPrivate: () -> Unit
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
 
@@ -86,6 +87,10 @@ fun ConfigNavScreen(
                         title = stringResource(R.string.translation_config),
                         onClick = onNavigateToTranslation
                     )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.privacy),
+                        onClick = onNavigateToPrivate
+                    )
                     ClickableSettingItem(
                         title = stringResource(R.string.lab_setting),
                         onClick = onNavigateToLab

+ 3 - 8
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigContract.kt

@@ -13,7 +13,7 @@ data class OtherConfigUiState(
     val webServiceAutoStart: Boolean = false,
     val autoRefresh: Boolean = false,
     val defaultToRead: Boolean = false,
-    val firebaseEnable: Boolean = true,
+
     val defaultBookTreeUri: String? = null,
     val antiAlias: Boolean = false,
     val replaceEnableDefault: Boolean = true,
@@ -72,7 +72,6 @@ sealed interface OtherConfigOverlay {
     data object FilePicker : OtherConfigOverlay
     data object DirectLinkUpload : OtherConfigOverlay
     data object ClearWebViewConfirmation : OtherConfigOverlay
-    data object Password : OtherConfigOverlay
 }
 
 sealed interface OtherConfigIntent {
@@ -82,7 +81,7 @@ sealed interface OtherConfigIntent {
     data class WebServiceAutoStartChanged(val value: Boolean) : OtherConfigIntent
     data class AutoRefreshChanged(val value: Boolean) : OtherConfigIntent
     data class DefaultToReadChanged(val value: Boolean) : OtherConfigIntent
-    data class FirebaseEnableChanged(val value: Boolean) : OtherConfigIntent
+
     data class DefaultBookTreeUriChanged(val value: String?) : OtherConfigIntent
     data class AntiAliasChanged(val value: Boolean) : OtherConfigIntent
     data class ReplaceEnableDefaultChanged(val value: Boolean) : OtherConfigIntent
@@ -113,17 +112,13 @@ sealed interface OtherConfigIntent {
     data object DismissDirectTestResult : OtherConfigIntent
     data class ShowOverlay(val overlay: OtherConfigOverlay) : OtherConfigIntent
     data object DismissOverlay : OtherConfigIntent
-    data object RequestNotificationPermission : OtherConfigIntent
-    data object RequestBatteryPermission : OtherConfigIntent
     data object RequestSystemDirectory : OtherConfigIntent
     data object ConfirmClearWebViewData : OtherConfigIntent
-    data class SaveLocalPassword(val password: String) : OtherConfigIntent
     data class MessageShown(val id: Long) : OtherConfigIntent
 }
 
 sealed interface OtherConfigEffect {
-    data object RequestNotificationPermission : OtherConfigEffect
-    data object RequestBatteryPermission : OtherConfigEffect
+
     data object OpenSystemDirectory : OtherConfigEffect
     data object RestartWebService : OtherConfigEffect
     data object RestartApp : OtherConfigEffect

+ 0 - 46
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigRouteScreen.kt

@@ -1,29 +1,17 @@
 package io.legado.app.ui.config.otherConfig
 
-import android.Manifest
-import android.app.Activity
-import android.os.Build
 import android.widget.Toast
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.service.WebService
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
-import io.legado.app.utils.SystemUtils
 import io.legado.app.utils.restart
 import io.legado.app.utils.takePersistablePermissionSafely
 import kotlinx.coroutines.delay
@@ -37,11 +25,7 @@ fun OtherConfigRouteScreen(
 ) {
     val context = LocalContext.current
     val state = viewModel.uiState.collectAsStateWithLifecycle().value
-    var password by remember { mutableStateOf("") }
 
-    val notificationPermissionLauncher = rememberLauncherForActivityResult(
-        ActivityResultContracts.RequestPermission()
-    ) { }
     val selectDocTree = rememberLauncherForActivityResult(
         ActivityResultContracts.OpenDocumentTree()
     ) { uri ->
@@ -54,16 +38,6 @@ fun OtherConfigRouteScreen(
     LaunchedEffect(viewModel, context) {
         viewModel.effects.collectLatest { effect ->
             when (effect) {
-                OtherConfigEffect.RequestNotificationPermission -> {
-                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-                        notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
-                    } else {
-                        Toast.makeText(context, R.string.permission_not_required, Toast.LENGTH_SHORT).show()
-                    }
-                }
-                OtherConfigEffect.RequestBatteryPermission -> {
-                    (context as? Activity)?.let(SystemUtils::ignoreBatteryOptimization)
-                }
                 OtherConfigEffect.OpenSystemDirectory -> selectDocTree.launch(null)
                 OtherConfigEffect.RestartWebService -> {
                     if (WebService.isRun) {
@@ -121,26 +95,6 @@ fun OtherConfigRouteScreen(
         onDismiss = { viewModel.onIntent(OtherConfigIntent.DismissOverlay) },
     )
 
-    AppAlertDialog(
-        show = state.activeOverlay == OtherConfigOverlay.Password,
-        onDismissRequest = { viewModel.onIntent(OtherConfigIntent.DismissOverlay) },
-        title = stringResource(R.string.set_local_password),
-        content = {
-            AppTextField(
-                value = password,
-                onValueChange = { password = it },
-                label = "Password",
-                backgroundColor = LegadoTheme.colorScheme.surface,
-                modifier = Modifier.fillMaxWidth(),
-            )
-        },
-        confirmText = stringResource(R.string.ok),
-        onConfirm = {
-            viewModel.onIntent(OtherConfigIntent.SaveLocalPassword(password))
-        },
-        dismissText = stringResource(R.string.cancel),
-        onDismiss = { viewModel.onIntent(OtherConfigIntent.DismissOverlay) },
-    )
 }
 
 private const val RESTART_DELAY_MILLIS = 3_000L

+ 0 - 29
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt

@@ -99,35 +99,6 @@ fun OtherConfigScreen(
                 )
             }
 
-            SplicedColumnGroup(title = stringResource(R.string.privacy)) {
-
-                ClickableSettingItem(
-                    title = stringResource(R.string.notification_permission),
-                    description = stringResource(R.string.notification_permission_rationale),
-                    onClick = { onIntent(OtherConfigIntent.RequestNotificationPermission) }
-                )
-
-                ClickableSettingItem(
-                    title = stringResource(R.string.background_permission),
-                    description = stringResource(R.string.ignore_battery_permission_rationale),
-                    onClick = { onIntent(OtherConfigIntent.RequestBatteryPermission) }
-                )
-
-                SwitchSettingItem(
-                    title = stringResource(R.string.firebase_enable_title),
-                    description = stringResource(R.string.firebase_enable_summary),
-                    checked = state.firebaseEnable,
-                    onCheckedChange = { onIntent(OtherConfigIntent.FirebaseEnableChanged(it)) }
-                )
-
-                ClickableSettingItem(
-                    title = stringResource(R.string.set_local_password),
-                    description = stringResource(R.string.set_local_password_summary),
-                    onClick = { onIntent(OtherConfigIntent.ShowOverlay(OtherConfigOverlay.Password)) }
-                )
-
-            }
-
             SplicedColumnGroup(title = stringResource(R.string.read)) {
 
                 ClickableSettingItem(

+ 3 - 18
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt

@@ -9,7 +9,6 @@ import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.DirectLinkRule
 import io.legado.app.domain.gateway.DirectLinkSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
-import io.legado.app.domain.gateway.LocalPasswordGateway
 import io.legado.app.domain.gateway.OtherConfigSystemGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
@@ -30,7 +29,6 @@ class OtherConfigViewModel(
     private val otherSettingsGateway: OtherSettingsGateway,
     private val downloadCacheSettingsGateway: DownloadCacheSettingsGateway,
     private val directLinkSettingsGateway: DirectLinkSettingsGateway,
-    private val localPasswordGateway: LocalPasswordGateway,
     private val systemGateway: OtherConfigSystemGateway,
     initialState: OtherConfigUiState = OtherConfigUiState(),
 ) : ViewModel() {
@@ -91,8 +89,6 @@ class OtherConfigViewModel(
                 updateOtherSetting { it.copy(autoRefresh = intent.value) }
             is OtherConfigIntent.DefaultToReadChanged ->
                 updateOtherSetting { it.copy(defaultToRead = intent.value) }
-            is OtherConfigIntent.FirebaseEnableChanged ->
-                updateOtherSetting { it.copy(firebaseEnable = intent.value) }
             is OtherConfigIntent.DefaultBookTreeUriChanged ->
                 updateOtherSetting { it.copy(defaultBookTreeUri = intent.value) }
             is OtherConfigIntent.AntiAliasChanged ->
@@ -157,17 +153,14 @@ class OtherConfigViewModel(
             }
             OtherConfigIntent.DismissOverlay ->
                 _uiState.update { it.copy(activeOverlay = null) }
-            OtherConfigIntent.RequestNotificationPermission ->
-                _effects.tryEmit(OtherConfigEffect.RequestNotificationPermission)
-            OtherConfigIntent.RequestBatteryPermission ->
-                _effects.tryEmit(OtherConfigEffect.RequestBatteryPermission)
+
             OtherConfigIntent.RequestSystemDirectory ->
                 _effects.tryEmit(OtherConfigEffect.OpenSystemDirectory)
             OtherConfigIntent.ConfirmClearWebViewData -> {
                 _uiState.update { it.copy(activeOverlay = null) }
                 clearWebViewData()
             }
-            is OtherConfigIntent.SaveLocalPassword -> saveLocalPassword(intent.password)
+
             is OtherConfigIntent.MessageShown -> {
                 _uiState.update { state ->
                     state.copy(
@@ -233,14 +226,6 @@ class OtherConfigViewModel(
         }
     }
 
-    private fun saveLocalPassword(password: String) {
-        viewModelScope.launch {
-            runCatching { localPasswordGateway.setPassword(password) }
-                .onSuccess { _uiState.update { it.copy(activeOverlay = null) } }
-                .onFailure { showMessage(it.localizedMessage ?: "设置失败") }
-        }
-    }
-
     fun saveUserAgent(input: String) {
         viewModelScope.launch {
             runCatching {
@@ -358,7 +343,7 @@ private fun OtherSettings.toUiState(current: OtherConfigUiState): OtherConfigUiS
         webServiceAutoStart = webServiceAutoStart,
         autoRefresh = autoRefresh,
         defaultToRead = defaultToRead,
-        firebaseEnable = firebaseEnable,
+
         defaultBookTreeUri = defaultBookTreeUri,
         antiAlias = antiAlias,
         replaceEnableDefault = replaceEnableDefault,

+ 68 - 0
app/src/main/java/io/legado/app/ui/config/privateConfig/PrivateConfigContract.kt

@@ -0,0 +1,68 @@
+package io.legado.app.ui.config.privateConfig
+
+import androidx.compose.runtime.Stable
+import io.legado.app.domain.model.PrivateBiometricStatus
+import io.legado.app.domain.model.PrivateUnlockScope
+import io.legado.app.domain.model.settings.PrivateAccessSettings
+
+@Stable
+data class PrivateConfigUiState(
+    val hasLocalPassword: Boolean = false,
+    val biometricEnabled: Boolean = false,
+    val biometricStatus: PrivateBiometricStatus = PrivateBiometricStatus.Unavailable,
+    val settings: PrivateAccessSettings = PrivateAccessSettings(),
+    val firebaseEnable: Boolean = true,
+    val showPasswordDialog: Boolean = false,
+) {
+    /** 设备具备加密绑定的生物识别能力时才允许开启快捷解锁 */
+    val biometricSupported: Boolean
+        get() = biometricStatus == PrivateBiometricStatus.Available
+}
+
+sealed interface PrivateConfigIntent {
+    data object ShowPasswordDialog : PrivateConfigIntent
+    data object DismissPasswordDialog : PrivateConfigIntent
+
+    /**
+     * [currentPassword] 只在"已经有本地密码"时参与校验。
+     *
+     * 改密码必须先证明知道旧密码:本地密码是私密内容的唯一凭据,拿到一台已解锁手机的人
+     * 若能直接把它改成自己的,私密保险箱就等于没锁。
+     */
+    data class SavePassword(
+        val password: String,
+        val currentPassword: String = "",
+    ) : PrivateConfigIntent
+
+    data class SetBiometricShortcutEnabled(val enabled: Boolean) : PrivateConfigIntent
+
+    /** 生物认证通过、信封封装完成后回传;由 gateway 决定落盘与开关状态 */
+    data class StoreBiometricEnvelope(val ciphertext: String, val iv: String) : PrivateConfigIntent
+
+    /** 私密功能总开关:关掉即整体休眠(不验证、不脱敏),密码与标记都留着 */
+    data class SetPrivateEnabled(val enabled: Boolean) : PrivateConfigIntent
+
+    data class SetVerifyOnEnterGroup(val enabled: Boolean) : PrivateConfigIntent
+    data class SetVerifyOnOpenBook(val enabled: Boolean) : PrivateConfigIntent
+    data class SetVerifyOnAppStart(val enabled: Boolean) : PrivateConfigIntent
+    data class SetUnlockScope(val scope: PrivateUnlockScope) : PrivateConfigIntent
+
+    /** "离开应用前有效"频率下的宽限期(秒),0 = 离开即失效 */
+    data class SetBackgroundGraceSeconds(val seconds: Int) : PrivateConfigIntent
+
+    data object RequestNotificationPermission : PrivateConfigIntent
+    data object RequestBatteryPermission : PrivateConfigIntent
+    data class SetFirebaseEnabled(val enabled: Boolean) : PrivateConfigIntent
+}
+
+sealed interface PrivateConfigEffect {
+    data class ShowMessage(val message: String) : PrivateConfigEffect
+    data object RequestNotificationPermission : PrivateConfigEffect
+    data object RequestBatteryPermission : PrivateConfigEffect
+
+    /**
+     * 开启生物快捷需要先过一次真实认证(auth-per-use 密钥加密也要认证),
+     * 由宿主弹 BiometricPrompt 并回传信封。
+     */
+    data object RequestBiometricEnvelope : PrivateConfigEffect
+}

+ 100 - 0
app/src/main/java/io/legado/app/ui/config/privateConfig/PrivateConfigRouteScreen.kt

@@ -0,0 +1,100 @@
+package io.legado.app.ui.config.privateConfig
+
+import android.Manifest
+import android.app.Activity
+import android.os.Build
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.help.security.BiometricEnvelopeWriter
+import io.legado.app.ui.widget.components.privacy.findFragmentActivity
+import io.legado.app.utils.SystemUtils
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.compose.viewmodel.koinViewModel
+
+@Composable
+fun PrivateConfigRouteScreen(
+    onBackClick: () -> Unit,
+    viewModel: PrivateConfigViewModel = koinViewModel(),
+) {
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
+    // 资源串在 composable 作用域内解析:LocalContext.current.getString 不感知配置变化
+    val envelopeTitle = stringResource(R.string.private_biometric_enable_title)
+    val envelopeSubtitle = stringResource(R.string.private_biometric_enable_subtitle)
+    val cancelLabel = stringResource(R.string.cancel)
+
+    val notificationPermissionLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.RequestPermission()
+    ) { }
+
+    LaunchedEffect(viewModel, context) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is PrivateConfigEffect.ShowMessage -> context.toastOnUi(effect.message)
+
+                PrivateConfigEffect.RequestNotificationPermission -> {
+                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+                        notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+                    } else {
+                        Toast.makeText(
+                            context,
+                            R.string.permission_not_required,
+                            Toast.LENGTH_SHORT
+                        ).show()
+                    }
+                }
+
+                PrivateConfigEffect.RequestBatteryPermission -> {
+                    (context as? Activity)?.let(SystemUtils::ignoreBatteryOptimization)
+                }
+
+                PrivateConfigEffect.RequestBiometricEnvelope -> {
+                    val activity = context.findFragmentActivity()
+                    if (activity == null) {
+                        Toast.makeText(
+                            context,
+                            R.string.private_biometric_unavailable,
+                            Toast.LENGTH_SHORT
+                        ).show()
+                    } else {
+                        BiometricEnvelopeWriter.launch(
+                            activity = activity,
+                            title = envelopeTitle,
+                            subtitle = envelopeSubtitle,
+                            negativeButtonText = cancelLabel,
+                            onSealed = { ciphertext, iv ->
+                                viewModel.onIntent(
+                                    PrivateConfigIntent.StoreBiometricEnvelope(ciphertext, iv)
+                                )
+                            },
+                            onError = {
+                                Toast.makeText(
+                                    context,
+                                    R.string.private_biometric_unavailable,
+                                    Toast.LENGTH_SHORT
+                                ).show()
+                            },
+                            // 用户主动放弃:开关保持关闭,不需要额外提示
+                            onCancel = {},
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    PrivateConfigScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+    )
+}

+ 261 - 0
app/src/main/java/io/legado/app/ui/config/privateConfig/PrivateConfigScreen.kt

@@ -0,0 +1,261 @@
+package io.legado.app.ui.config.privateConfig
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.domain.model.PrivateBiometricStatus
+import io.legado.app.domain.model.PrivateUnlockScope
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.SplicedColumnGroup
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
+import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
+import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PrivateConfigScreen(
+    state: PrivateConfigUiState,
+    onIntent: (PrivateConfigIntent) -> Unit,
+    onBackClick: () -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.privacy),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = 120.dp
+            )
+        ) {
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.private_credential_title)) {
+                    SwitchSettingItem(
+                        title = stringResource(R.string.private_feature_enabled),
+                        description = stringResource(R.string.private_feature_enabled_summary),
+                        checked = state.settings.enabled,
+                        onCheckedChange = {
+                            onIntent(PrivateConfigIntent.SetPrivateEnabled(it))
+                        }
+                    )
+
+                    ClickableSettingItem(
+                        title = stringResource(R.string.set_local_password),
+                        description = stringResource(
+                            if (state.hasLocalPassword) {
+                                R.string.private_content_summary
+                            } else {
+                                R.string.private_content_no_password
+                            }
+                        ),
+                        onClick = { onIntent(PrivateConfigIntent.ShowPasswordDialog) }
+                    )
+
+                    SwitchSettingItem(
+                        title = stringResource(R.string.private_content_biometric),
+                        description = stringResource(
+                            when {
+                                !state.hasLocalPassword -> R.string.private_content_no_password
+                                !state.biometricSupported ->
+                                    biometricStatusHint(state.biometricStatus)
+
+                                else -> R.string.private_content_biometric_summary
+                            }
+                        ),
+                        checked = state.biometricEnabled,
+                        enabled = state.hasLocalPassword && state.biometricSupported,
+                        onCheckedChange = {
+                            onIntent(PrivateConfigIntent.SetBiometricShortcutEnabled(it))
+                        }
+                    )
+                }
+            }
+
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.private_verify_timing_title)) {
+                    SwitchSettingItem(
+                        title = stringResource(R.string.private_verify_on_enter_group),
+                        description = stringResource(R.string.private_verify_on_enter_group_summary),
+                        checked = state.settings.verifyOnEnterGroup,
+                        enabled = state.hasLocalPassword,
+                        onCheckedChange = {
+                            onIntent(PrivateConfigIntent.SetVerifyOnEnterGroup(it))
+                        }
+                    )
+                    SwitchSettingItem(
+                        title = stringResource(R.string.private_verify_on_open_book),
+                        description = stringResource(R.string.private_verify_on_open_book_summary),
+                        checked = state.settings.verifyOnOpenBook,
+                        enabled = state.hasLocalPassword,
+                        onCheckedChange = {
+                            onIntent(PrivateConfigIntent.SetVerifyOnOpenBook(it))
+                        }
+                    )
+                    SwitchSettingItem(
+                        title = stringResource(R.string.private_verify_on_app_start),
+                        description = stringResource(R.string.private_verify_on_app_start_summary),
+                        checked = state.settings.verifyOnAppStart,
+                        enabled = state.hasLocalPassword,
+                        onCheckedChange = {
+                            onIntent(PrivateConfigIntent.SetVerifyOnAppStart(it))
+                        }
+                    )
+                    DropdownListSettingItem(
+                        title = stringResource(R.string.private_unlock_scope_title),
+                        description = stringResource(R.string.private_unlock_scope_summary),
+                        selectedValue = state.settings.unlockScope.name,
+                        displayEntries = arrayOf(
+                            stringResource(R.string.private_unlock_scope_every_time),
+                            stringResource(R.string.private_unlock_scope_app_session),
+                            stringResource(R.string.private_unlock_scope_until_background)
+                        ),
+                        entryValues = arrayOf(
+                            PrivateUnlockScope.EveryTime.name,
+                            PrivateUnlockScope.AppSession.name,
+                            PrivateUnlockScope.UntilBackground.name
+                        ),
+                        onValueChange = { value ->
+                            PrivateUnlockScope.entries.firstOrNull { it.name == value }
+                                ?.let { onIntent(PrivateConfigIntent.SetUnlockScope(it)) }
+                        }
+                    )
+                    // 只在"离开应用前有效"下才出现:其它频率下这个值没有意义
+                    if (state.settings.unlockScope == PrivateUnlockScope.UntilBackground) {
+                        DropdownListSettingItem(
+                            title = stringResource(R.string.private_background_grace_title),
+                            description = stringResource(R.string.private_background_grace_summary),
+                            selectedValue = state.settings.backgroundGraceSeconds.toString(),
+                            displayEntries = arrayOf(
+                                stringResource(R.string.private_background_grace_immediately),
+                                stringResource(R.string.private_background_grace_seconds, 30),
+                                stringResource(R.string.private_background_grace_minutes, 2),
+                                stringResource(R.string.private_background_grace_minutes, 3),
+                                stringResource(R.string.private_background_grace_minutes, 5),
+                            ),
+                            // 值统一按秒存;秒/分钟只是给人看的
+                            entryValues = arrayOf("0", "30", "120", "180", "300"),
+                            onValueChange = { value ->
+                                value.toIntOrNull()?.let {
+                                    onIntent(PrivateConfigIntent.SetBackgroundGraceSeconds(it))
+                                }
+                            }
+                        )
+                    }
+                }
+            }
+
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.private_permission_title)) {
+                    ClickableSettingItem(
+                        title = stringResource(R.string.notification_permission),
+                        description = stringResource(R.string.notification_permission_rationale),
+                        onClick = { onIntent(PrivateConfigIntent.RequestNotificationPermission) }
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.background_permission),
+                        description = stringResource(R.string.ignore_battery_permission_rationale),
+                        onClick = { onIntent(PrivateConfigIntent.RequestBatteryPermission) }
+                    )
+                    SwitchSettingItem(
+                        title = stringResource(R.string.firebase_enable_title),
+                        description = stringResource(R.string.firebase_enable_summary),
+                        checked = state.firebaseEnable,
+                        onCheckedChange = {
+                            onIntent(PrivateConfigIntent.SetFirebaseEnabled(it))
+                        }
+                    )
+                }
+            }
+        }
+    }
+
+    if (state.showPasswordDialog) {
+        var currentPassword by remember { mutableStateOf("") }
+        var password by remember { mutableStateOf("") }
+        val passwordKeyboard = KeyboardOptions(keyboardType = KeyboardType.Password)
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { onIntent(PrivateConfigIntent.DismissPasswordDialog) },
+            title = stringResource(
+                if (state.hasLocalPassword) {
+                    R.string.private_change_password
+                } else {
+                    R.string.set_local_password
+                }
+            ),
+            content = {
+                Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+                    // 已有密码时先要旧密码:本地密码是私密内容的唯一凭据,
+                    // 不验旧密码就等于"谁拿到这台手机,谁就能把锁换成自己的"
+                    if (state.hasLocalPassword) {
+                        AppTextField(
+                            value = currentPassword,
+                            onValueChange = { currentPassword = it },
+                            label = stringResource(R.string.private_current_password_title),
+                            backgroundColor = LegadoTheme.colorScheme.surface,
+                            visualTransformation = PasswordVisualTransformation(),
+                            keyboardOptions = passwordKeyboard,
+                            modifier = Modifier.fillMaxWidth(),
+                        )
+                    }
+                    AppTextField(
+                        value = password,
+                        onValueChange = { password = it },
+                        label = stringResource(R.string.private_unlock_password_title),
+                        backgroundColor = LegadoTheme.colorScheme.surface,
+                        visualTransformation = PasswordVisualTransformation(),
+                        keyboardOptions = passwordKeyboard,
+                        modifier = Modifier.fillMaxWidth(),
+                    )
+                }
+            },
+            confirmText = stringResource(R.string.ok),
+            onConfirm = {
+                onIntent(PrivateConfigIntent.SavePassword(password, currentPassword))
+            },
+            dismissText = stringResource(R.string.cancel),
+            onDismiss = { onIntent(PrivateConfigIntent.DismissPasswordDialog) },
+        )
+    }
+}
+
+private fun biometricStatusHint(status: PrivateBiometricStatus): Int = when (status) {
+    PrivateBiometricStatus.NotEnrolled -> R.string.private_biometric_not_enrolled
+    PrivateBiometricStatus.NoHardware -> R.string.private_biometric_no_hardware
+    PrivateBiometricStatus.UnsupportedSystem -> R.string.private_biometric_unsupported_system
+    else -> R.string.private_biometric_unavailable
+}

+ 185 - 0
app/src/main/java/io/legado/app/ui/config/privateConfig/PrivateConfigViewModel.kt

@@ -0,0 +1,185 @@
+package io.legado.app.ui.config.privateConfig
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.R
+import io.legado.app.domain.gateway.LocalPasswordGateway
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.PrivateAccessGateway
+import io.legado.app.domain.model.PrivateBiometricStatus
+import io.legado.app.domain.model.settings.OtherSettings
+import io.legado.app.domain.model.settings.PrivateAccessSettings
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import splitties.init.appCtx
+
+class PrivateConfigViewModel(
+    private val privateAccessGateway: PrivateAccessGateway,
+    private val localPasswordGateway: LocalPasswordGateway,
+    private val otherSettingsGateway: OtherSettingsGateway,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(PrivateConfigUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<PrivateConfigEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        viewModelScope.launch {
+            privateAccessGateway.state.collect { access ->
+                _uiState.update {
+                    it.copy(
+                        hasLocalPassword = access.hasPassword,
+                        biometricEnabled = access.biometricEnabled,
+                        biometricStatus = access.biometricStatus,
+                    )
+                }
+            }
+        }
+        viewModelScope.launch {
+            privateAccessGateway.settings.collect { settings ->
+                _uiState.update { it.copy(settings = settings) }
+            }
+        }
+        viewModelScope.launch {
+            otherSettingsGateway.settings.collect { settings ->
+                _uiState.update { it.copy(firebaseEnable = settings.firebaseEnable) }
+            }
+        }
+        privateAccessGateway.refreshBiometricStatus()
+    }
+
+    fun onIntent(intent: PrivateConfigIntent) {
+        when (intent) {
+            PrivateConfigIntent.ShowPasswordDialog ->
+                _uiState.update { it.copy(showPasswordDialog = true) }
+
+            PrivateConfigIntent.DismissPasswordDialog ->
+                _uiState.update { it.copy(showPasswordDialog = false) }
+
+            is PrivateConfigIntent.SavePassword ->
+                savePassword(intent.password, intent.currentPassword)
+
+            is PrivateConfigIntent.SetBiometricShortcutEnabled ->
+                setBiometricShortcutEnabled(intent.enabled)
+
+            is PrivateConfigIntent.StoreBiometricEnvelope ->
+                storeBiometricEnvelope(intent.ciphertext, intent.iv)
+
+            is PrivateConfigIntent.SetPrivateEnabled ->
+                updateSettings { it.copy(enabled = intent.enabled) }
+
+            is PrivateConfigIntent.SetVerifyOnEnterGroup ->
+                updateSettings { it.copy(verifyOnEnterGroup = intent.enabled) }
+
+            is PrivateConfigIntent.SetVerifyOnOpenBook ->
+                updateSettings { it.copy(verifyOnOpenBook = intent.enabled) }
+
+            is PrivateConfigIntent.SetVerifyOnAppStart ->
+                updateSettings { it.copy(verifyOnAppStart = intent.enabled) }
+
+            is PrivateConfigIntent.SetUnlockScope ->
+                updateSettings { it.copy(unlockScope = intent.scope) }
+
+            is PrivateConfigIntent.SetBackgroundGraceSeconds ->
+                updateSettings { it.copy(backgroundGraceSeconds = intent.seconds) }
+
+            PrivateConfigIntent.RequestNotificationPermission ->
+                _effects.tryEmit(PrivateConfigEffect.RequestNotificationPermission)
+
+            PrivateConfigIntent.RequestBatteryPermission ->
+                _effects.tryEmit(PrivateConfigEffect.RequestBatteryPermission)
+
+            is PrivateConfigIntent.SetFirebaseEnabled -> updateOtherSettings {
+                it.copy(firebaseEnable = intent.enabled)
+            }
+        }
+    }
+
+    private fun savePassword(password: String, currentPassword: String) {
+        viewModelScope.launch {
+            if (_uiState.value.hasLocalPassword) {
+                // 已有密码时,改密码 / 清密码都必须先过一次现有凭据。
+                // 走 gateway 的同一个校验入口,不另开旁路,免得两条校验逻辑漂移。
+                val verified = runCatching {
+                    privateAccessGateway.verifyPassword(currentPassword)
+                }.getOrDefault(false)
+                if (!verified) {
+                    showMessageRes(R.string.private_unlock_password_error)
+                    return@launch
+                }
+            }
+            runCatching { localPasswordGateway.setPassword(password) }
+                .onSuccess {
+                    _uiState.update { it.copy(showPasswordDialog = false) }
+                    privateAccessGateway.refreshBiometricStatus()
+                }
+                .onFailure { showMessageRes(R.string.save_failed) }
+        }
+    }
+
+    /**
+     * 生物快捷解锁只是免手输密码的入口,而且开启必须先过一次真实认证:
+     * auth-per-use 密钥连加密都要认证,信封只能在 BiometricPrompt 的回调里产出。
+     * 所以这里只发一个"去认证"的 Effect,开关等认证结果回来才动,不做"看起来开了"的假开关。
+     */
+    private fun setBiometricShortcutEnabled(enabled: Boolean) {
+        if (enabled) {
+            if (!_uiState.value.biometricSupported) {
+                showMessageRes(biometricUnavailableMessage(_uiState.value.biometricStatus))
+                return
+            }
+            _effects.tryEmit(PrivateConfigEffect.RequestBiometricEnvelope)
+            return
+        }
+        // 关闭是纯本地操作:清掉信封即可
+        viewModelScope.launch {
+            runCatching { privateAccessGateway.disableBiometricShortcut() }
+                .onFailure { showMessageRes(R.string.save_failed) }
+        }
+    }
+
+    /** 认证产出的信封交给 gateway 落盘;写失败就保持关闭并如实提示 */
+    private fun storeBiometricEnvelope(ciphertext: String, iv: String) {
+        viewModelScope.launch {
+            val stored = runCatching {
+                privateAccessGateway.storeBiometricEnvelope(ciphertext, iv)
+            }.getOrDefault(false)
+            if (stored) {
+                privateAccessGateway.refreshBiometricStatus()
+            } else {
+                showMessageRes(R.string.private_biometric_unavailable)
+            }
+        }
+    }
+
+    private fun updateSettings(transform: (PrivateAccessSettings) -> PrivateAccessSettings) {
+        viewModelScope.launch {
+            runCatching { privateAccessGateway.updateSettings(transform) }
+                .onFailure { showMessageRes(R.string.save_failed) }
+        }
+    }
+
+    private fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
+        viewModelScope.launch {
+            runCatching { otherSettingsGateway.update(transform) }
+                .onFailure { showMessageRes(R.string.save_failed) }
+        }
+    }
+
+    private fun biometricUnavailableMessage(status: PrivateBiometricStatus): Int = when (status) {
+        PrivateBiometricStatus.NotEnrolled -> R.string.private_biometric_not_enrolled
+        PrivateBiometricStatus.NoHardware -> R.string.private_biometric_no_hardware
+        PrivateBiometricStatus.UnsupportedSystem -> R.string.private_biometric_unsupported_system
+        else -> R.string.private_biometric_unavailable
+    }
+
+    private fun showMessageRes(resId: Int) {
+        _effects.tryEmit(PrivateConfigEffect.ShowMessage(appCtx.getString(resId)))
+    }
+}

+ 121 - 100
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -66,6 +66,7 @@ import io.legado.app.ui.book.readaloud.ReadAloudShellHost
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.welcome.WelcomeActivity
+import io.legado.app.ui.widget.components.privacy.PrivateAppStartGate
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.startActivity
@@ -470,107 +471,127 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
         val currentRoute = navBackStack.lastOrNull()
 
         SharedTransitionLayout {
-            Box(modifier = Modifier.fillMaxSize()) {
-            NavDisplay(
-                backStack = backStack,
-                entryDecorators = listOf(
-                    rememberSaveableStateHolderNavEntryDecorator(),
-                    rememberViewModelStoreNavEntryDecorator(),
-                ),
-                sceneStrategies = listOf(
-                    ModalOverlaySceneStrategy(),
-                    SinglePaneSceneStrategy(),
-                ),
-                transitionSpec = {
-                    (slideIntoContainer(
-                        towards = AnimatedContentTransitionScope.SlideDirection.Start,
-                        animationSpec = tween(durationMillis = 480, easing = FastOutSlowInEasing),
-                        initialOffset = { fullWidth -> fullWidth }
-                    ) + fadeIn(
-                        animationSpec = tween(
-                            durationMillis = 360,
-                            easing = LinearOutSlowInEasing
+            // 启动验证做成单独的 screen:门槛未过时完全不组合应用界面,
+            // 因此验证页背后看不到书架/阅读界面,也没有可交互的入口
+            PrivateAppStartGate {
+                Box(modifier = Modifier.fillMaxSize()) {
+                    NavDisplay(
+                        backStack = backStack,
+                        entryDecorators = listOf(
+                            rememberSaveableStateHolderNavEntryDecorator(),
+                            rememberViewModelStoreNavEntryDecorator(),
+                        ),
+                        sceneStrategies = listOf(
+                            ModalOverlaySceneStrategy(),
+                            SinglePaneSceneStrategy(),
+                        ),
+                        transitionSpec = {
+                            (slideIntoContainer(
+                                towards = AnimatedContentTransitionScope.SlideDirection.Start,
+                                animationSpec = tween(
+                                    durationMillis = 480,
+                                    easing = FastOutSlowInEasing
+                                ),
+                                initialOffset = { fullWidth -> fullWidth }
+                            ) + fadeIn(
+                                animationSpec = tween(
+                                    durationMillis = 360,
+                                    easing = LinearOutSlowInEasing
+                                )
+                            )) togetherWith (slideOutOfContainer(
+                                towards = AnimatedContentTransitionScope.SlideDirection.Start,
+                                animationSpec = tween(
+                                    durationMillis = 480,
+                                    easing = FastOutSlowInEasing
+                                ),
+                                targetOffset = { fullWidth -> fullWidth / 4 }
+                            ) + fadeOut(
+                                animationSpec = tween(
+                                    durationMillis = 360,
+                                    easing = LinearOutSlowInEasing
+                                )
+                            ))
+                        },
+                        popTransitionSpec = {
+                            (slideIntoContainer(
+                                towards = AnimatedContentTransitionScope.SlideDirection.Start,
+                                animationSpec = tween(
+                                    durationMillis = 480,
+                                    easing = FastOutSlowInEasing
+                                ),
+                                initialOffset = { fullWidth -> -fullWidth / 4 }
+                            ) + fadeIn(
+                                animationSpec = tween(
+                                    durationMillis = 360,
+                                    easing = LinearOutSlowInEasing
+                                )
+                            )) togetherWith (scaleOut(
+                                targetScale = 0.8f,
+                                animationSpec = tween(
+                                    durationMillis = 480,
+                                    easing = FastOutSlowInEasing
+                                )
+                            ) + fadeOut(animationSpec = tween(durationMillis = 360)))
+                        },
+                        predictivePopTransitionSpec = { _ ->
+                            (slideIntoContainer(
+                                towards = AnimatedContentTransitionScope.SlideDirection.Start,
+                                animationSpec = tween(easing = FastOutSlowInEasing),
+                                initialOffset = { fullWidth -> -fullWidth / 4 }
+                            ) + fadeIn(animationSpec = tween(easing = LinearOutSlowInEasing))) togetherWith (scaleOut(
+                                targetScale = 0.8f,
+                                animationSpec = tween(easing = FastOutSlowInEasing)
+                            ) + fadeOut(animationSpec = tween()))
+                        },
+                        onBack = { MainNavigator.navigateBack(this@MainActivity, backStack) },
+                        entryProvider = mainEntryProvider(
+                            backStack = backStack,
+                            configuration = configuration,
+                            showMangaUi = mangaSettings.showMangaUi,
+                            useRail = useRail,
+                            sharedTransitionScope = this@SharedTransitionLayout,
+                            onNavigateToRoute = { route ->
+                                MainNavigator.navigateToRoute(
+                                    backStack,
+                                    route,
+                                    navRouteTracker,
+                                )
+                            },
+                            onNavigateBack = {
+                                MainNavigator.navigateBack(
+                                    this@MainActivity,
+                                    backStack,
+                                    navRouteTracker
+                                )
+                            },
                         )
-                    )) togetherWith (slideOutOfContainer(
-                        towards = AnimatedContentTransitionScope.SlideDirection.Start,
-                        animationSpec = tween(durationMillis = 480, easing = FastOutSlowInEasing),
-                        targetOffset = { fullWidth -> fullWidth / 4 }
-                    ) + fadeOut(
-                        animationSpec = tween(
-                            durationMillis = 360,
-                            easing = LinearOutSlowInEasing
-                        )
-                    ))
-                },
-                popTransitionSpec = {
-                    (slideIntoContainer(
-                        towards = AnimatedContentTransitionScope.SlideDirection.Start,
-                        animationSpec = tween(durationMillis = 480, easing = FastOutSlowInEasing),
-                        initialOffset = { fullWidth -> -fullWidth / 4 }
-                    ) + fadeIn(
-                        animationSpec = tween(
-                            durationMillis = 360,
-                            easing = LinearOutSlowInEasing
-                        )
-                    )) togetherWith (scaleOut(
-                        targetScale = 0.8f,
-                        animationSpec = tween(durationMillis = 480, easing = FastOutSlowInEasing)
-                    ) + fadeOut(animationSpec = tween(durationMillis = 360)))
-                },
-                predictivePopTransitionSpec = { _ ->
-                    (slideIntoContainer(
-                        towards = AnimatedContentTransitionScope.SlideDirection.Start,
-                        animationSpec = tween(easing = FastOutSlowInEasing),
-                        initialOffset = { fullWidth -> -fullWidth / 4 }
-                    ) + fadeIn(animationSpec = tween(easing = LinearOutSlowInEasing))) togetherWith (scaleOut(
-                        targetScale = 0.8f,
-                        animationSpec = tween(easing = FastOutSlowInEasing)
-                    ) + fadeOut(animationSpec = tween()))
-                },
-                onBack = { MainNavigator.navigateBack(this@MainActivity, backStack) },
-                entryProvider = mainEntryProvider(
-                    backStack = backStack,
-                    configuration = configuration,
-                    showMangaUi = mangaSettings.showMangaUi,
-                    useRail = useRail,
-                    sharedTransitionScope = this@SharedTransitionLayout,
-                    onNavigateToRoute = { route ->
-                        MainNavigator.navigateToRoute(
-                            backStack,
-                            route,
-                            navRouteTracker,
-                        )
-                    },
-                    onNavigateBack = {
-                        MainNavigator.navigateBack(this@MainActivity, backStack, navRouteTracker)
-                    },
-                )
-            )
-                // 朗读悬浮胶囊叠在整个导航之上:阅读器只是其中一个目的地,
-                // 挂在阅读器里会导致离开阅读界面后胶囊消失。
-                ReadAloudShellHost(
-                    playerState = pageShellPlayerState,
-                    showCapsule = pageShellShowCapsule,
-                    hidden = currentRoute is MainRouteReadAloudPlayer,
-                    onIntent = pageShellPlayerViewModel::onIntent,
-                    onCapsulePositionChanged = { x, y ->
-                        pageShellCapsuleScope.launch {
-                            readAloudSettingsRepository.putCapsulePosition(x, y)
-                        }
-                    },
-                    onOpenPlayer = {
-                        MainNavigator.navigateToRoute(
-                            backStack,
-                            MainRouteReadAloudPlayer,
-                            navRouteTracker
-                        )
-                    },
-            )
-            }
-            BackHandler(
-                enabled = !configuration.appShell.predictiveBackEnabled
-            ) {
-                MainNavigator.navigateBack(this@MainActivity, backStack)
+                    )
+                    // 朗读悬浮胶囊叠在整个导航之上:阅读器只是其中一个目的地,
+                    // 挂在阅读器里会导致离开阅读界面后胶囊消失。
+                    ReadAloudShellHost(
+                        playerState = pageShellPlayerState,
+                        showCapsule = pageShellShowCapsule,
+                        hidden = currentRoute is MainRouteReadAloudPlayer,
+                        onIntent = pageShellPlayerViewModel::onIntent,
+                        onCapsulePositionChanged = { x, y ->
+                            pageShellCapsuleScope.launch {
+                                readAloudSettingsRepository.putCapsulePosition(x, y)
+                            }
+                        },
+                        onOpenPlayer = {
+                            MainNavigator.navigateToRoute(
+                                backStack,
+                                MainRouteReadAloudPlayer,
+                                navRouteTracker
+                            )
+                        },
+                    )
+                }
+                BackHandler(
+                    enabled = !configuration.appShell.predictiveBackEnabled
+                ) {
+                    MainNavigator.navigateBack(this@MainActivity, backStack)
+                }
             }
         }
         TextSheetHost()

+ 164 - 128
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -129,6 +129,7 @@ import io.legado.app.ui.config.customTheme.CustomThemeRouteScreen
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigRouteScreen
 import io.legado.app.ui.config.labConfig.LabConfigRouteScreen
 import io.legado.app.ui.config.otherConfig.OtherConfigRouteScreen
+import io.legado.app.ui.config.privateConfig.PrivateConfigRouteScreen
 import io.legado.app.ui.config.readConfig.ReadConfigRouteScreen
 import io.legado.app.ui.config.themeConfig.ThemeConfigRouteScreen
 import io.legado.app.ui.config.themeManage.ThemeManageRouteScreen
@@ -153,6 +154,8 @@ import io.legado.app.ui.theme.ProvideThemeOverride
 import io.legado.app.ui.theme.rememberImageSeedColor
 import io.legado.app.ui.theme.rememberThemeOverride
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
+import io.legado.app.ui.widget.components.privacy.PrivateReadGate
+import io.legado.app.ui.widget.components.privacy.PrivateVerifyGate
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.startActivityForBook
@@ -547,7 +550,8 @@ fun MainActivity.mainEntryProvider(
             onNavigateToAi = { backStack.add(MainRouteSettingsAi) },
             onNavigateToDownloadCache = { backStack.add(MainRouteSettingsDownloadCache) },
             onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) },
-            onNavigateToLab = { backStack.add(MainRouteSettingsLabConfig) }
+            onNavigateToLab = { backStack.add(MainRouteSettingsLabConfig) },
+            onNavigateToPrivate = { backStack.add(MainRouteSettingsPrivate) }
         )
     }
 
@@ -659,6 +663,14 @@ fun MainActivity.mainEntryProvider(
         LabConfigRouteScreen(onBackClick = { onNavigateBack() })
     }
 
+    entry<MainRouteSettingsPrivate> {
+        // 隐私设置页可以关掉三种验证时机,所以进入前要求确认身份(生物或密码)。
+        // 只校验、不授予:进来一次不该顺带解锁私密内容。
+        PrivateVerifyGate(onCancel = { onNavigateBack() }) {
+            PrivateConfigRouteScreen(onBackClick = { onNavigateBack() })
+        }
+    }
+
     entry<MainRouteSettingsCustomTheme> {
         CustomThemeRouteScreen(
             onBackClick = { onNavigateBack() }
@@ -721,147 +733,167 @@ fun MainActivity.mainEntryProvider(
             }
         }
     ) { route ->
-        val readBookViewModel = koinViewModel<ReadBookViewModel>(
-            key = "ReadBook:${route.bookUrl ?: "last-read"}"
-        )
-        val readerSessionViewModel = koinViewModel<ReaderSessionViewModel>(
-            key = "ReaderSession:${route.bookUrl ?: "last-read"}"
-        )
-        val controller = remember(readBookViewModel, readerSessionViewModel) {
-            ReadBookController(
-                this@mainEntryProvider,
-                readBookViewModel,
-                readerSessionViewModel,
+        // 私密闸门:阅读记录 / 通知栏 / 深链等直开阅读器的路径全都在这里被拦一次,
+        // 未授权时阅读器根本不会被组合(也就没有加载正文的机会)
+        PrivateReadGate(
+            bookUrl = route.bookUrl,
+            onExit = {
+                if (backStack.size > 1) {
+                    onNavigateBack()
+                } else {
+                    // 通知 / 深链可能让阅读器成为栈里唯一一项:没有"当前页"可退,
+                    // 按约定落到书架,而不是把应用关掉
+                    backStack.clear()
+                    backStack.add(MainRouteHome)
+                }
+            },
+            onOpenLocalPasswordSettings = {
+                onNavigateToRoute(MainRouteSettingsPrivate)
+            },
+        ) {
+            val readBookViewModel = koinViewModel<ReadBookViewModel>(
+                key = "ReadBook:${route.bookUrl ?: "last-read"}"
             )
-        }
-        // Canvas 阅读面在首次组合时就会请求分页,必须先告诉 ViewModel 本路由要打开哪本书。
-        // 刻意用 remember 而非 LaunchedEffect:后者在组合之后才跑,赶不上首帧。
-        @Suppress("RememberReturnType")
-        remember(readBookViewModel, route) {
-        }
-        val lifecycleOwner = LocalLifecycleOwner.current
-        val initRequest = remember(route) {
-            ReadBookInitRequest(
-                bookUrl = route.bookUrl,
-                inBookshelf = route.inBookshelf,
-                chapterChanged = route.chapterChanged,
+            val readerSessionViewModel = koinViewModel<ReaderSessionViewModel>(
+                key = "ReaderSession:${route.bookUrl ?: "last-read"}"
             )
-        }
-        val effectsReady = remember(readBookViewModel) { CompletableDeferred<Unit>() }
-        val readerResumeState = remember(controller, lifecycleOwner) { booleanArrayOf(false) }
-        val collectorReady = remember(readBookViewModel) { booleanArrayOf(false) }
-        // 是否跟随朗读位置:用户手动翻页/跳章后为 false,此时回阅读界面不回拉可见页。
-        val readAloudSessionStore: ReadAloudSessionStore = org.koin.compose.koinInject()
-        val readAloudFollow = remember(readBookViewModel) { booleanArrayOf(true) }
-        LaunchedEffect(readAloudSessionStore) {
-            readAloudSessionStore.state
-                .map { it.followReadAloudPosition }
-                .distinctUntilChanged()
-                .collect { readAloudFollow[0] = it }
-        }
-        fun resumeReader() {
-            if (readerResumeState[0]) return
-            readerResumeState[0] = true
-            controller.onResume()
-            readBookViewModel.onIntent(ReadBookIntent.OnResume)
-            // 回到阅读界面时把可见页对齐到当前朗读位置:朗读期间用户可能进听书页跳段/跳章,
-            // 服务驱动的是内部页游标,可见页需要显式回位,否则停在离开时那一页。
-            // 只在跟随状态下做(用户手动翻页脱离后不回拉),语义与「回到朗读位置」一致。
-            if (BaseReadAloudService.isRun && readAloudFollow[0]) {
-                readBookViewModel.onIntent(ReadBookIntent.BackToSpeakingPosition)
+            val controller = remember(readBookViewModel, readerSessionViewModel) {
+                ReadBookController(
+                    this@mainEntryProvider,
+                    readBookViewModel,
+                    readerSessionViewModel,
+                )
+            }
+            // Canvas 阅读面在首次组合时就会请求分页,必须先告诉 ViewModel 本路由要打开哪本书。
+            // 刻意用 remember 而非 LaunchedEffect:后者在组合之后才跑,赶不上首帧。
+            @Suppress("RememberReturnType")
+            remember(readBookViewModel, route) {
+            }
+            val lifecycleOwner = LocalLifecycleOwner.current
+            val initRequest = remember(route) {
+                ReadBookInitRequest(
+                    bookUrl = route.bookUrl,
+                    inBookshelf = route.inBookshelf,
+                    chapterChanged = route.chapterChanged,
+                )
+            }
+            val effectsReady = remember(readBookViewModel) { CompletableDeferred<Unit>() }
+            val readerResumeState = remember(controller, lifecycleOwner) { booleanArrayOf(false) }
+            val collectorReady = remember(readBookViewModel) { booleanArrayOf(false) }
+            // 是否跟随朗读位置:用户手动翻页/跳章后为 false,此时回阅读界面不回拉可见页。
+            val readAloudSessionStore: ReadAloudSessionStore = org.koin.compose.koinInject()
+            val readAloudFollow = remember(readBookViewModel) { booleanArrayOf(true) }
+            LaunchedEffect(readAloudSessionStore) {
+                readAloudSessionStore.state
+                    .map { it.followReadAloudPosition }
+                    .distinctUntilChanged()
+                    .collect { readAloudFollow[0] = it }
+            }
+            fun resumeReader() {
+                if (readerResumeState[0]) return
+                readerResumeState[0] = true
+                controller.onResume()
+                readBookViewModel.onIntent(ReadBookIntent.OnResume)
+                // 回到阅读界面时把可见页对齐到当前朗读位置:朗读期间用户可能进听书页跳段/跳章,
+                // 服务驱动的是内部页游标,可见页需要显式回位,否则停在离开时那一页。
+                // 只在跟随状态下做(用户手动翻页脱离后不回拉),语义与「回到朗读位置」一致。
+                if (BaseReadAloudService.isRun && readAloudFollow[0]) {
+                    readBookViewModel.onIntent(ReadBookIntent.BackToSpeakingPosition)
+                }
             }
-        }
 
-        fun pauseReader() {
-            if (!readerResumeState[0]) return
-            readerResumeState[0] = false
-            controller.onPause()
-            readBookViewModel.onIntent(ReadBookIntent.OnPause)
-        }
+            fun pauseReader() {
+                if (!readerResumeState[0]) return
+                readerResumeState[0] = false
+                controller.onPause()
+                readBookViewModel.onIntent(ReadBookIntent.OnPause)
+            }
 
-        ReadBookRouteScreen(
-            viewModel = readBookViewModel,
-            readerSessionViewModel = readerSessionViewModel,
-            host = controller,
-            controller = controller,
-            sharedTransitionScope = sharedTransitionScope,
-            animatedVisibilityScope = LocalNavAnimatedContentScope.current,
-            sharedCoverKey = route.sharedCoverKey,
-            onEffectsReady = { effectsReady.complete(Unit) },
-            onOpenSearch = { word, bookUrl, autoFocus ->
-                onNavigateToRoute(
-                    MainRouteSearchContent(
-                        bookUrl = bookUrl,
-                        searchWord = word,
-                        searchResultIndex = readBookViewModel.uiState.value.searchResultIndex,
-                        autoFocus = autoFocus,
+            ReadBookRouteScreen(
+                viewModel = readBookViewModel,
+                readerSessionViewModel = readerSessionViewModel,
+                host = controller,
+                controller = controller,
+                sharedTransitionScope = sharedTransitionScope,
+                animatedVisibilityScope = LocalNavAnimatedContentScope.current,
+                sharedCoverKey = route.sharedCoverKey,
+                onEffectsReady = { effectsReady.complete(Unit) },
+                onOpenSearch = { word, bookUrl, autoFocus ->
+                    onNavigateToRoute(
+                        MainRouteSearchContent(
+                            bookUrl = bookUrl,
+                            searchWord = word,
+                            searchResultIndex = readBookViewModel.uiState.value.searchResultIndex,
+                            autoFocus = autoFocus,
+                        )
                     )
-                )
-            },
-            onOpenVoiceCasting = { bookUrl ->
-                onNavigateToRoute(MainRouteBookVoiceCasting(bookUrl))
-            },
-            onOpenTtsEnginesAndVoices = {
-                onNavigateToRoute(MainRouteCloudTtsEngines(route.bookUrl))
-            },
-            onOpenTtsCache = {
-                onNavigateToRoute(MainRouteTtsCache)
-            },
-            // 经典控制面板的「切换到听书播放器」:目的地是 nav3 路由,
-            // 必须在这里接上,否则意图只发到效果层、没人导航。
-            onOpenReadAloudPlayer = {
-                onNavigateToRoute(MainRouteReadAloudPlayer)
-            },
-        )
+                },
+                onOpenVoiceCasting = { bookUrl ->
+                    onNavigateToRoute(MainRouteBookVoiceCasting(bookUrl))
+                },
+                onOpenTtsEnginesAndVoices = {
+                    onNavigateToRoute(MainRouteCloudTtsEngines(route.bookUrl))
+                },
+                onOpenTtsCache = {
+                    onNavigateToRoute(MainRouteTtsCache)
+                },
+                // 经典控制面板的「切换到听书播放器」:目的地是 nav3 路由,
+                // 必须在这里接上,否则意图只发到效果层、没人导航。
+                onOpenReadAloudPlayer = {
+                    onNavigateToRoute(MainRouteReadAloudPlayer)
+                },
+            )
 
-        DisposableEffect(controller, lifecycleOwner, route.readAloud) {
-            activeReadBookInputHandler = controller
-            activeReadBookRoute = route
-            MainActivity.hasActiveReadBookRoute = true
-            controller.onClose = { onNavigateBack() }
-            controller.onStartContentLoadFinish = {
-                if (route.readAloud) {
-                    io.legado.app.model.ReadBook.readAloud()
+            DisposableEffect(controller, lifecycleOwner, route.readAloud) {
+                activeReadBookInputHandler = controller
+                activeReadBookRoute = route
+                MainActivity.hasActiveReadBookRoute = true
+                controller.onClose = { onNavigateBack() }
+                controller.onStartContentLoadFinish = {
+                    if (route.readAloud) {
+                        io.legado.app.model.ReadBook.readAloud()
+                    }
                 }
-            }
 
-            val lifecycleObserver = LifecycleEventObserver { _, event ->
-                when (event) {
-                    Lifecycle.Event.ON_RESUME -> {
-                        if (collectorReady[0]) resumeReader()
+                val lifecycleObserver = LifecycleEventObserver { _, event ->
+                    when (event) {
+                        Lifecycle.Event.ON_RESUME -> {
+                            if (collectorReady[0]) resumeReader()
+                        }
+
+                        Lifecycle.Event.ON_PAUSE -> pauseReader()
+                        else -> Unit
                     }
-                    Lifecycle.Event.ON_PAUSE -> pauseReader()
-                    else -> Unit
                 }
-            }
-            lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
-            onDispose {
-                pauseReader()
-                readBookViewModel.onIntent(ReadBookIntent.OnDispose)
-                lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
-                if (activeReadBookInputHandler === controller) {
-                    activeReadBookInputHandler = null
-                }
-                if (activeReadBookRoute == route) {
-                    activeReadBookRoute = null
+                lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
+                onDispose {
+                    pauseReader()
+                    readBookViewModel.onIntent(ReadBookIntent.OnDispose)
+                    lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
+                    if (activeReadBookInputHandler === controller) {
+                        activeReadBookInputHandler = null
+                    }
+                    if (activeReadBookRoute == route) {
+                        activeReadBookRoute = null
+                    }
+                    MainActivity.hasActiveReadBookRoute = false
+                    controller.clearTts()
+                    this@mainEntryProvider.toggleSystemBar(configuration.appShell.showStatusBar)
                 }
-                MainActivity.hasActiveReadBookRoute = false
-                controller.clearTts()
-                this@mainEntryProvider.toggleSystemBar(configuration.appShell.showStatusBar)
             }
-        }
 
-        LaunchedEffect(route, readBookViewModel, lifecycleOwner) {
-            // Resolving the book and applying its read style do not depend on launcher effects.
-            // Start that I/O immediately; initData still waits below because it can emit effects.
-            val initialBook = readBookViewModel.initReadBookConfig(initRequest)
-            effectsReady.await()
-            collectorReady[0] = true
-            readBookViewModel.initData(initRequest, initialBook) {
-                readBookViewModel.markJustInitData()
-                controller.onRouteInitialized()
-                if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
-                    resumeReader()
+            LaunchedEffect(route, readBookViewModel, lifecycleOwner) {
+                // Resolving the book and applying its read style do not depend on launcher effects.
+                // Start that I/O immediately; initData still waits below because it can emit effects.
+                val initialBook = readBookViewModel.initReadBookConfig(initRequest)
+                effectsReady.await()
+                collectorReady[0] = true
+                readBookViewModel.initData(initRequest, initialBook) {
+                    readBookViewModel.markJustInitData()
+                    controller.onRouteInitialized()
+                    if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
+                        resumeReader()
+                    }
                 }
             }
         }
@@ -1241,6 +1273,10 @@ fun MainActivity.mainEntryProvider(
             onOpenSourceLogin = { sourceUrl ->
                 onNavigateToRoute(MainRouteSourceLogin(SourceLoginType.BookSource, sourceUrl))
             },
+            // 私密内容需要本地密码:直接把人送到私密设置页,而不是只弹一句提示
+            onOpenSettings = {
+                onNavigateToRoute(MainRouteSettingsPrivate)
+            },
             onOpenReader = { bookUrl, inBookshelf, chapterChanged ->
                 onNavigateToRoute(
                     MainRouteReadBook(

+ 4 - 0
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -99,6 +99,9 @@ data object MainRouteSettingsCustomTheme : MainRoute
 @Serializable
 data object MainRouteSettingsThemeManage : MainRoute
 
+@Serializable
+data object MainRouteSettingsPrivate : MainRoute
+
 @Serializable
 data object MainRouteSettingsLabConfig : MainRoute
 
@@ -271,6 +274,7 @@ object MainRouteConst {
     const val ROUTE_SETTINGS_AI_PROMPT = "settings/ai/prompt"
     const val ROUTE_AI_CHAT = "ai/chat"
     const val ROUTE_SETTINGS_CUSTOM_THEME = "settings/custom_theme"
+    const val ROUTE_SETTINGS_PRIVATE = "settings/private"
     const val ROUTE_SETTINGS_LAB_CONFIG = "settings/lab_config"
     const val ROUTE_SETTINGS_DOWNLOAD_CACHE = "settings/download_cache"
     const val ROUTE_SETTINGS_TRANSLATION = "settings/translation"

+ 3 - 1
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -126,7 +126,8 @@ object MainNavigator {
             MainRouteSettingsCustomTheme,
             MainRouteSettingsThemeManage,
             MainRouteSettingsDownloadCache,
-            MainRouteSettingsTranslation -> {
+            MainRouteSettingsTranslation,
+            MainRouteSettingsPrivate -> {
                 backStack.clear()
                 backStack.add(MainRouteHome)
                 backStack.add(MainRouteSettings)
@@ -475,6 +476,7 @@ object MainNavigator {
             MainRouteConst.ROUTE_SETTINGS_AI -> MainRouteSettingsAi
             MainRouteConst.ROUTE_AI_CHAT -> MainRouteAiChat
             MainRouteConst.ROUTE_SETTINGS_CUSTOM_THEME -> MainRouteSettingsCustomTheme
+            MainRouteConst.ROUTE_SETTINGS_PRIVATE -> MainRouteSettingsPrivate
             MainRouteConst.ROUTE_SETTINGS_LAB_CONFIG -> MainRouteSettingsLabConfig
             MainRouteConst.ROUTE_SETTINGS_DOWNLOAD_CACHE -> MainRouteSettingsDownloadCache
             MainRouteConst.ROUTE_SETTINGS_TRANSLATION -> MainRouteSettingsTranslation

+ 1 - 0
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -552,6 +552,7 @@ fun MainScreen(
                                 onNavigateToRemoteImport = onNavigateToRemoteImport,
                                 onNavigateToLocalImport = onNavigateToLocalImport,
                                 onNavigateToCache = onNavigateToCache,
+                                onNavigateToSettings = onOpenSettings,
                                 sharedTransitionScope = sharedTransitionScope,
                                 animatedVisibilityScope = animatedVisibilityScope,
                             )

+ 179 - 31
app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt

@@ -3,6 +3,9 @@ package io.legado.app.ui.main.bookshelf
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
@@ -28,9 +31,11 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
 import androidx.compose.material.icons.filled.Update
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.shadow
 import androidx.compose.ui.graphics.Brush
@@ -61,6 +66,10 @@ import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.image.cover.BookshelfCover
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import io.legado.app.ui.widget.components.privacy.PrivateLockedCover
+import io.legado.app.ui.widget.components.privacy.PrivateLockedCoverBlurRadius
+import io.legado.app.ui.widget.components.privacy.PrivateLockedCoverOverlay
+import io.legado.app.ui.widget.components.privacy.RuntimeBlurSupported
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.toTimeAgo
@@ -678,12 +687,53 @@ fun BookItem(
     coverShadow: Boolean = false,
     isSearchMode: Boolean = false,
     searchKey: String = "",
+    /** 私密书籍且尚未解锁:只渲染模糊封面与占位内容,不渲染任何真实文字 */
+    locked: Boolean = false,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
     onClick: () -> Unit,
     onLongClick: (() -> Unit)?
 ) {
+    val lockedHiddenLabel = stringResource(R.string.private_hidden_label)
+    // 锁定 ⇄ 解锁:封面走模糊深度、其余文字直接隐藏。
+    // 之所以不整体模糊卡片,是因为整体 blur 会把封面的圆角与阴影一起糊掉。
+    // 低版本运行时模糊不可用,这条动画自然退化成瞬间切换(此时脱敏走 Coil 静态模糊)。
+    val coverBlur by animateDpAsState(
+        targetValue = if (locked && RuntimeBlurSupported) PrivateLockedCoverBlurRadius else 0.dp,
+        animationSpec = tween(320),
+        label = "private-cover-blur",
+    )
+    val lockOverlayAlpha by animateFloatAsState(
+        targetValue = if (locked) 1f else 0f,
+        animationSpec = tween(260),
+        label = "private-lock-overlay",
+    )
+    // 低版本 Modifier.blur 是 no-op,脱敏只能靠 Coil 变换出的模糊封面兜底;
+    // 这条路径没有模糊动画,但同样不糊整卡,圆角完好。
+    if (locked && !RuntimeBlurSupported) {
+        PrivateLockedBookItem(
+            settings = settings,
+            bookUi = bookUi,
+            layoutMode = layoutMode,
+            modifier = modifier,
+            isSelected = isSelected,
+            gridStyle = gridStyle,
+            isCompact = isCompact,
+            titleSmallFont = titleSmallFont,
+            titleCenter = titleCenter,
+            titleMaxLines = titleMaxLines,
+            coverShadow = coverShadow,
+            // 脱敏封面也要带上与正常封面相同的共享元素参数,
+            // 否则源端没有 sharedBounds,进详情页的封面转场动画就无从接起
+            sharedTransitionScope = sharedTransitionScope,
+            animatedVisibilityScope = animatedVisibilityScope,
+            sharedCoverKey = sharedCoverKey,
+            onClick = onClick,
+            onLongClick = onLongClick
+        )
+        return
+    }
     val book = bookUi.book
     val showListDetails = layoutMode == 0 && !isCompact && settings.showBookIntro
     val showIntroText = showListDetails && settings.bookshelfShowIntro
@@ -723,27 +773,43 @@ fun BookItem(
     }
 
     val cover: @Composable (Modifier) -> Unit = { coverModifier ->
-        BookshelfCover(
-            name = book.name,
-            author = book.author,
-            path = book.getDisplayCover(),
-            isUpdating = isUpdating,
-            modifier = coverModifier,
-            coverModifier = Modifier
-                .fillMaxWidth()
-                .aspectRatio(5f / 7f),
-            sourceOrigin = book.origin,
-            // 传本书 bookUrl:封面命中本地(含别名)缓存时
-            // 不解析书源规则、不弹登录提示、不重新下载
-            bookUrl = book.bookUrl,
-            badgeText = if (layoutMode != 0) unreadText else null,
-            showBadgeDot = showUpdateBadge,
-            leftBottomText = matchedSourceLabel ?: bookTypeLabel,
-            showLoadingPlaceholder = true,
-            sharedTransitionScope = sharedTransitionScope,
-            animatedVisibilityScope = animatedVisibilityScope,
-            sharedCoverKey = sharedCoverKey,
-        )
+        Box(modifier = coverModifier) {
+            BookshelfCover(
+                name = book.name,
+                author = book.author,
+                path = book.getDisplayCover(),
+                // 锁定态不露更新进度指示:它同样是在透露"这本书有更新"
+                isUpdating = isUpdating && !locked,
+                modifier = Modifier.fillMaxSize(),
+                coverModifier = Modifier
+                    .fillMaxWidth()
+                    .aspectRatio(5f / 7f),
+                sourceOrigin = book.origin,
+                // 传本书 bookUrl:封面命中本地(含别名)缓存时
+                // 不解析书源规则、不弹登录提示、不重新下载
+                bookUrl = book.bookUrl,
+                // 锁定态不露未读角标与来源标签:它们都是真实信息
+                badgeText = if (locked) null else if (layoutMode != 0) unreadText else null,
+                showBadgeDot = !locked && showUpdateBadge,
+                leftBottomText = if (locked) null else matchedSourceLabel ?: bookTypeLabel,
+                showLoadingPlaceholder = true,
+                // 模糊与"已隐藏"叠加层都交给封面组件在**共享元素节点内部**渲染。
+                // 这是关键:转场时 overlay 只搬运 sharedBounds 节点自己的内容,
+                // 加在祖先上的 blur、放在外面的兄弟节点都会被落下,
+                // 表现就是"动画一开始模糊和点阵就没了"。
+                contentBlur = coverBlur,
+                // 与详情页脱敏态共用同一份"已隐藏"叠层,只是整体做淡入淡出,
+                // 避免"糊一下、锁一下"的跳变
+                overlayContent = if (lockOverlayAlpha > 0f) {
+                    { PrivateLockedCoverOverlay(modifier = Modifier.alpha(lockOverlayAlpha)) }
+                } else {
+                    null
+                },
+                sharedTransitionScope = sharedTransitionScope,
+                animatedVisibilityScope = animatedVisibilityScope,
+                sharedCoverKey = sharedCoverKey,
+            )
+        }
     }
     val accessibilityLabel = bookAccessibilityLabel(
         name = book.name,
@@ -759,7 +825,9 @@ fun BookItem(
     if (layoutMode != 0) {
         BookshelfGridItem(
             cover = cover,
-            title = book.name,
+            // 锁定态不渲染书名:占位条与"已隐藏"字样都不再需要,封面那套
+            // (模糊 + 遮罩 + 点阵 + 锁标)已经说明了状态
+            title = if (locked) "" else book.name,
             gridStyle = gridStyle,
             modifier = modifier,
             isSelected = isSelected,
@@ -767,7 +835,7 @@ fun BookItem(
             titleCenter = titleCenter,
             titleMaxLines = titleMaxLines,
             coverShadow = coverShadow,
-            accessibilityLabel = accessibilityLabel,
+            accessibilityLabel = if (locked) lockedHiddenLabel else accessibilityLabel,
             coverWidth = settings.bookshelfGridCoverWidth,
             onClick = onClick,
             onLongClick = onLongClick,
@@ -779,10 +847,10 @@ fun BookItem(
         settings = settings,
         isCompact = isCompact,
         cover = cover,
-        title = book.name,
+        title = if (locked) "" else book.name,
         modifier = modifier,
         isSelected = isSelected,
-        titleEnd = if (unreadText != null) {
+        titleEnd = if (locked) null else if (unreadText != null) {
             {
                 TextCard(
                     text = unreadText,
@@ -794,13 +862,16 @@ fun BookItem(
                 )
             }
         } else null,
-        subTitle = if (isCompact) {
+        subTitle = if (locked) null else if (isCompact) {
             stringResource(R.string.author_read, book.author, unreadCount)
         } else {
             book.author
         },
-        desc = book.durChapterTitle ?: "",
-        columnContent = if (showListDetails) {
+        desc = if (locked) "" else book.durChapterTitle ?: "",
+        // 锁定态什么都不铺:不显示比"假装有内容"的占位条更干净
+        columnContent = if (locked) {
+            null
+        } else if (showListDetails) {
             {
                 val kindList = bookUi.displayTags
                 if (settings.bookshelfShowTag && kindList.isNotEmpty()) {
@@ -842,7 +913,7 @@ fun BookItem(
                 }
             }
         } else null,
-        bottomContent = if (showIntroBelowContent) {
+        bottomContent = if (locked) null else if (showIntroBelowContent) {
             {
                 GlassCard(
                     modifier = Modifier.padding(all = 4.dp),
@@ -857,7 +928,7 @@ fun BookItem(
                 }
             }
         } else null,
-        extra = if (showListDetails && settings.bookshelfShowLatestChapter) {
+        extra = if (locked) null else if (showListDetails && settings.bookshelfShowLatestChapter) {
             {
                 if (settings.showLastUpdateTime && !book.isLocal) {
                     AppText(
@@ -879,8 +950,85 @@ fun BookItem(
         } else null,
         titleMaxLines = titleMaxLines,
         coverShadow = coverShadow,
-        accessibilityLabel = accessibilityLabel,
+        accessibilityLabel = if (locked) lockedHiddenLabel else accessibilityLabel,
+        coverWidth = settings.bookshelfListCoverWidth,
+        onClick = onClick,
+        onLongClick = onLongClick,
+    )
+}
+
+/**
+ * 私密书籍的脱敏卡片。
+ *
+ * 刻意复用 BookshelfGridItem / BookshelfListItem 的骨架,只替换三处内容:
+ * 封面换成强模糊版、标题换成"已隐藏"、正文区域换成占位条。
+ * 未读角标、最新章节、标签、简介一律不渲染——它们都是真实信息。
+ */
+@Composable
+private fun PrivateLockedBookItem(
+    settings: BookshelfSettings,
+    bookUi: BookUiItem,
+    layoutMode: Int,
+    modifier: Modifier = Modifier,
+    isSelected: Boolean = false,
+    gridStyle: Int = 0,
+    isCompact: Boolean = false,
+    titleSmallFont: Boolean = false,
+    titleCenter: Boolean = true,
+    titleMaxLines: Int = 2,
+    coverShadow: Boolean = false,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKey: String? = null,
+    onClick: () -> Unit,
+    onLongClick: (() -> Unit)?
+) {
+    val book = bookUi.book
+    val hiddenLabel = stringResource(R.string.private_hidden_label)
+    val cover: @Composable (Modifier) -> Unit = { coverModifier ->
+        PrivateLockedCover(
+            name = book.name,
+            author = book.author,
+            path = book.getDisplayCover(),
+            modifier = coverModifier
+                .fillMaxWidth()
+                .aspectRatio(5f / 7f),
+            sharedCoverKey = sharedCoverKey,
+            sharedTransitionScope = sharedTransitionScope,
+            animatedVisibilityScope = animatedVisibilityScope,
+        )
+    }
+    if (layoutMode != 0) {
+        BookshelfGridItem(
+            cover = cover,
+            // 与正常态锁定卡片一致:不渲染书名文字,只留封面
+            title = "",
+            gridStyle = gridStyle,
+            modifier = modifier,
+            isSelected = isSelected,
+            titleSmallFont = titleSmallFont,
+            titleCenter = titleCenter,
+            titleMaxLines = titleMaxLines,
+            coverShadow = coverShadow,
+            accessibilityLabel = hiddenLabel,
+            coverWidth = settings.bookshelfGridCoverWidth,
+            onClick = onClick,
+            onLongClick = onLongClick,
+        )
+        return
+    }
+    BookshelfListItem(
+        settings = settings,
+        isCompact = isCompact,
+        cover = cover,
+        title = "",
+        modifier = modifier,
+        isSelected = isSelected,
+        titleMaxLines = titleMaxLines,
+        coverShadow = coverShadow,
+        accessibilityLabel = hiddenLabel,
         coverWidth = settings.bookshelfListCoverWidth,
+        columnContent = null,
         onClick = onClick,
         onLongClick = onLongClick,
     )

+ 18 - 3
app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.main.bookshelf
 import androidx.compose.runtime.Stable
 import io.legado.app.constant.BookType
 import io.legado.app.data.entities.Book
+import io.legado.app.domain.model.PrivateAccessState
 import io.legado.app.utils.splitNotBlank
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.toImmutableList
@@ -50,8 +51,11 @@ data class BookShelfItem(
 
     /**
      * 将 DTO 转换为专为 Compose 设计的 UI 状态
+     *
+     * [isPrivate] 由书架在内存侧算出并集(单本标记 ∪ 所属私密分组),
+     * 这里只负责带上结果,不做判定。
      */
-    fun toUiItem(): BookUiItem {
+    fun toUiItem(isPrivate: Boolean = false): BookUiItem {
         val tagList = mutableListOf<String>()
         customTag?.splitNotBlank(",", "\n")?.filter { it.isNotBlank() }?.let {
             tagList.addAll(it)
@@ -65,7 +69,8 @@ data class BookShelfItem(
 
         return BookUiItem(
             book = this,
-            displayTags = tagList.toImmutableList()
+            displayTags = tagList.toImmutableList(),
+            isPrivate = isPrivate
         )
     }
 }
@@ -74,9 +79,19 @@ data class BookShelfItem(
  * 理想实现:专为 UI 设计的状态类
  */
 @Stable
+        /**
+         * 这本书此刻是否处于锁定态(需要脱敏)。
+         *
+         * 唯一实现:凡是判断"某本书要不要遮"的地方都走这里,
+         * 避免 UiState 与 ViewModel 各自复制一份谓词后漂移。
+         */
+fun BookUiItem.isLocked(access: PrivateAccessState, verifyOnOpenBook: Boolean): Boolean =
+    isPrivate && verifyOnOpenBook && !access.isTargetGranted(book.bookUrl, book.group)
+
 data class BookUiItem(
     val book: BookShelfItem,
-    val displayTags: ImmutableList<String>
+    val displayTags: ImmutableList<String>,
+    val isPrivate: Boolean = false
 ) {
     fun matches(key: String): Boolean {
         return book.name.contains(key, true) ||

+ 299 - 71
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -12,6 +12,7 @@ import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.Crossfade
 import androidx.compose.animation.ExperimentalAnimationApi
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
@@ -59,6 +60,7 @@ import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.GridView
 import androidx.compose.material.icons.filled.History
 import androidx.compose.material.icons.filled.Link
+import androidx.compose.material.icons.filled.Lock
 import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Save
 import androidx.compose.material.icons.filled.SelectAll
@@ -90,6 +92,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.ClipEntry
 import androidx.compose.ui.platform.LocalClipboard
 import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalHapticFeedback
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.Role
@@ -101,6 +104,8 @@ import androidx.compose.ui.window.Dialog
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.data.entities.BookGroup
+import io.legado.app.domain.model.PrivateUnlockTarget
+import io.legado.app.help.security.BiometricUnlockLauncher
 import io.legado.app.ui.book.group.GroupEditSheet
 import io.legado.app.ui.book.info.GroupSelectSheet
 import io.legado.app.ui.main.bookCoverSharedElementKey
@@ -112,6 +117,7 @@ import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
 import io.legado.app.ui.widget.components.AppPullToRefresh
 import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
@@ -129,6 +135,8 @@ import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.privacy.PrivateLockedPage
+import io.legado.app.ui.widget.components.privacy.findFragmentActivity
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.reorderAccessibility
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
@@ -164,6 +172,7 @@ fun BookshelfRouteScreen(
     onNavigateToRemoteImport: () -> Unit,
     onNavigateToLocalImport: () -> Unit,
     onNavigateToCache: (Long) -> Unit,
+    onNavigateToSettings: () -> Unit = {},
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
 ) {
@@ -182,6 +191,7 @@ fun BookshelfRouteScreen(
         onNavigateToRemoteImport = onNavigateToRemoteImport,
         onNavigateToLocalImport = onNavigateToLocalImport,
         onNavigateToCache = onNavigateToCache,
+        onNavigateToSettings = onNavigateToSettings,
         sharedTransitionScope = sharedTransitionScope,
         animatedVisibilityScope = animatedVisibilityScope,
     )
@@ -206,6 +216,8 @@ fun BookshelfScreen(
     onNavigateToRemoteImport: () -> Unit,
     onNavigateToLocalImport: () -> Unit,
     onNavigateToCache: (Long) -> Unit,
+    // 私密功能需要本地密码,未设置时引导去设置页
+    onNavigateToSettings: () -> Unit = {},
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
 ) {
@@ -215,6 +227,57 @@ fun BookshelfScreen(
 
     val clipboardManager = LocalClipboard.current
     val snackbarHostState = remember { SnackbarHostState() }
+    val context = LocalContext.current
+    // 资源串在 composable 作用域内解析:LocalContext.current.getString 不感知配置变化,
+    // 语言/字体缩放切换后可能拿到过期值(LocalContextGetResourceValueCall)。
+    val unlockTitle = stringResource(R.string.private_unlock_title)
+    val unlockSubtitle = stringResource(R.string.private_unlock_subtitle)
+    val unlockUsePassword = stringResource(R.string.private_unlock_use_password)
+
+    /** 系统生物验证框必须由 FragmentActivity 承载;拿不到宿主就直接用应用内密码 */
+    fun launchBiometricUnlock(target: PrivateUnlockTarget) {
+        val activity = context.findFragmentActivity()
+        if (activity == null) {
+            onIntent(BookshelfIntent.ShowOverlay(BookshelfOverlay.PrivatePassword(target)))
+            return
+        }
+        BiometricUnlockLauncher.launch(
+            activity = activity,
+            title = unlockTitle,
+            subtitle = unlockSubtitle,
+            negativeButtonText = unlockUsePassword,
+            onPassword = { password ->
+                onIntent(BookshelfIntent.UnlockPrivateWithBiometricPassword(target, password))
+            },
+            onFallback = {
+                onIntent(BookshelfIntent.ShowOverlay(BookshelfOverlay.PrivatePassword(target)))
+            },
+            onError = { message ->
+                scope.launch { snackbarHostState.showSnackbar(message) }
+            },
+        )
+    }
+
+    /**
+     * 点击脱敏的私密书籍:等同于点了一次"验证"。
+     *
+     * 验证通过后由 ViewModel 打开阅读,而不是在这里直接调 onBookClick——解锁是异步的,
+     * 要等列表重新包含这本书再打开,否则事件会早于状态更新而丢失。
+     */
+    val requestBookUnlock: (String) -> Unit = { bookUrl ->
+        onIntent(BookshelfIntent.RequestPrivateUnlock(PrivateUnlockTarget.Book(bookUrl)))
+    }
+
+    /**
+     * 打开书籍的统一出口。
+     *
+     * 不在这里撤销授权:撤销的所有权已经移到阅读器入口的私密闸门——
+     * 它是"授权存活到离开阅读器为止"的唯一事实来源。在这里提前撤销会让紧随其后的
+     * 闸门把刚验证过的用户再拦一次。
+     */
+    val openBook: (BookShelfItem, String?) -> Unit = { book, sharedCoverKey ->
+        onBookClick(book, sharedCoverKey)
+    }
 
     LaunchedEffect(Unit) {
         effects.collect { effect ->
@@ -236,10 +299,38 @@ fun BookshelfScreen(
                         )
                     }
                 }
+
+                is BookshelfEffect.RequestBiometricUnlock -> launchBiometricUnlock(effect.target)
+
+                BookshelfEffect.NavigateToLocalPasswordSettings -> onNavigateToSettings()
             }
         }
     }
 
+    // 解锁是"一致性关键"的动作:等这本书不再是锁定态、且重新出现在列表里之后再打开,
+    // 否则事件会早于状态更新而丢失。
+    //
+    // 两个坑都在 key 与判定上:
+    // 1. privateAccess 必须进 key。"每次都要验证"频率下,验证成功只把目标写进
+    //    grantedBookUrls,列表内容一眼不变——只拿列表当 key 的话这次授权压根不会触发重启;
+    // 2. 判定用"这本书还锁不锁"而不是 privateAccess.isUnlocked,后者的只在
+    //    "本次进程验证一次"频率下才置位,用它当门槛会让"每次都要验证"的用户永远打不开。
+    LaunchedEffect(
+        uiState.pendingOpenBookUrl,
+        uiState.privateAccess,
+        uiState.items,
+        uiState.visibleGroupBooks
+    ) {
+        val url = uiState.pendingOpenBookUrl ?: return@LaunchedEffect
+        val bookUi = uiState.items.firstOrNull { it.book.bookUrl == url }
+            ?: uiState.visibleGroupBooks[uiState.selectedGroupId]
+                ?.firstOrNull { it.book.bookUrl == url }
+            ?: return@LaunchedEffect
+        if (uiState.isBookLocked(bookUi)) return@LaunchedEffect
+        onIntent(BookshelfIntent.ConsumePendingOpenBook)
+        openBook(bookUi.book, null)
+    }
+
     LaunchedEffect(uiState.pendingUploadUrl) {
         val url = uiState.pendingUploadUrl ?: return@LaunchedEffect
         val result = snackbarHostState.showSnackbar(
@@ -520,6 +611,30 @@ fun BookshelfScreen(
                             contentDescription = stringResource(R.string.move_to_group)
                         )
                     }
+                    AnimatedVisibility(visible = isEditMode && uiState.privateAccess.hasPassword) {
+                        val selected = uiState.items.filter { it.book.bookUrl in selectedBookUrls }
+                        val targetPrivate = selected.none { it.isPrivate }
+                        TopBarActionButton(
+                            onClick = {
+                                if (selectedBookUrls.isNotEmpty()) {
+                                    onIntent(
+                                        BookshelfIntent.SetBooksPrivate(
+                                            selectedBookUrls,
+                                            targetPrivate
+                                        )
+                                    )
+                                }
+                            },
+                            imageVector = Icons.Default.Lock,
+                            contentDescription = stringResource(
+                                if (targetPrivate) {
+                                    R.string.private_mark_book
+                                } else {
+                                    R.string.private_unmark_book
+                                }
+                            )
+                        )
+                    }
 
                     if (!isEditMode) {
                         Box {
@@ -850,19 +965,25 @@ fun BookshelfScreen(
                         itemsIndexed(
                             uiState.groups,
                             key = { _, it -> it.groupId }) { index, group ->
-                            val countText = if (uiState.settings.showBookCount) {
+                            // 私密分组在文件夹根目录也不露出预览封面与书籍数量
+                            val groupLocked = uiState.isGroupLocked(group)
+                            val countText = if (uiState.settings.showBookCount && !groupLocked) {
                                 uiState.groupBookCounts[group.groupId]?.let {
                                     stringResource(R.string.book_count, it)
                                 }
                             } else {
                                 null
                             }
+                            val previewBooks = if (groupLocked) {
+                                emptyList()
+                            } else {
+                                uiState.groupPreviews[group.groupId] ?: emptyList()
+                            }
                             if (bookshelfFolderLayoutMode == 0) {
                                 BookGroupItemList(
                                     settings = uiState.settings,
                                     group = group,
-                                    previewBooks = uiState.groupPreviews[group.groupId]
-                                        ?: emptyList(),
+                                    previewBooks = previewBooks,
                                     countText = countText,
                                     isCompact = uiState.settings.bookshelfLayoutCompact,
                                     titleMaxLines = uiState.settings.bookshelfTitleMaxLines,
@@ -874,14 +995,13 @@ fun BookshelfScreen(
                                     onLongClick = {
                                         onIntent(BookshelfIntent.ShowOverlay(BookshelfOverlay.GroupEditSheet(group.groupId)))
                                     },
-                                    onBookClick = { book -> onBookClick(book, null) }
+                                    onBookClick = { book -> openBook(book, null) }
                                 )
                             } else {
                                 BookGroupItemGrid(
                                     settings = uiState.settings,
                                     group = group,
-                                    previewBooks = uiState.groupPreviews[group.groupId]
-                                        ?: emptyList(),
+                                    previewBooks = previewBooks,
                                     countText = countText,
                                     gridStyle = uiState.settings.bookshelfGridLayout,
                                     titleSmallFont = uiState.settings.bookshelfTitleSmallFont,
@@ -900,28 +1020,39 @@ fun BookshelfScreen(
                         }
                     }
                 } else {
+                    // 注意:锁定态只替换"某个分组自己的内容区",不能替换整个分页容器,
+                    // 否则用户会被卡在锁定页里、连相邻分组都切不过去。
                     if (isUsingStandaloneSearchGroup) {
-                        BookshelfPage(
-                            gridState = standaloneSearchGridState,
-                            paddingValues = paddingValues,
-                            books = uiState.items,
-                            uiState = uiState,
-                            selectedBookUrls = selectedBookUrls,
-                            canReorderBooks = false,
-                            onToggleBookSelection = { toggleBookSelection(it.book.bookUrl) },
-                            draggingBooks = null,
-                            pendingSavedBooks = null,
-                            onDragStarted = {},
-                            onMoveBook = { _, _, _ -> },
-                            onDragFinished = {},
-                            onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
-                            onBookClick = onBookClick,
-                            onBookLongClick = onBookLongClick,
-                            isCurrentPage = true,
-                            sharedCoverGroupId = currentGroupId,
-                            sharedTransitionScope = sharedTransitionScope,
-                            animatedVisibilityScope = animatedVisibilityScope,
-                        )
+                        if (uiState.selectedGroupLocked) {
+                            PrivateGroupLockedPage(
+                                groupId = uiState.selectedGroupId,
+                                uiState = uiState,
+                                onIntent = onIntent
+                            )
+                        } else {
+                            BookshelfPage(
+                                gridState = standaloneSearchGridState,
+                                paddingValues = paddingValues,
+                                books = uiState.items,
+                                uiState = uiState,
+                                selectedBookUrls = selectedBookUrls,
+                                canReorderBooks = false,
+                                onToggleBookSelection = { toggleBookSelection(it.book.bookUrl) },
+                                draggingBooks = null,
+                                pendingSavedBooks = null,
+                                onDragStarted = {},
+                                onMoveBook = { _, _, _ -> },
+                                onDragFinished = {},
+                                onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
+                                onBookClick = openBook,
+                                onBookLongClick = onBookLongClick,
+                                onLockedBookClick = requestBookUnlock,
+                                isCurrentPage = true,
+                                sharedCoverGroupId = currentGroupId,
+                                sharedTransitionScope = sharedTransitionScope,
+                                animatedVisibilityScope = animatedVisibilityScope,
+                            )
+                        }
                     } else {
                         HorizontalPager(
                             state = pagerState,
@@ -937,51 +1068,78 @@ fun BookshelfScreen(
                         ) { pageIndex ->
                             val group = uiState.groups.getOrNull(pageIndex)
                             if (group != null) {
-                                val isSelectedGroup = group.groupId == uiState.selectedGroupId
-                                val books = uiState.visibleGroupBooks[group.groupId]
-                                    ?: persistentListOf()
-                                val canReorderBooks = isEditMode &&
-                                        !uiState.isSearch &&
-                                        (group.bookSort.takeIf { it >= 0 }
-                                            ?: uiState.bookshelfSort) == 3 &&
-                                        isSelectedGroup
-                                BookshelfPage(
-                                    gridState = groupGridStates.getValue(group.groupId),
-                                    paddingValues = paddingValues,
-                                    books = books,
-                                    uiState = uiState,
-                                    selectedBookUrls = selectedBookUrls,
-                                    canReorderBooks = canReorderBooks,
-                                    onToggleBookSelection = { toggleBookSelection(it.book.bookUrl) },
-                                    draggingBooks = if (isSelectedGroup) {
-                                        uiState.draggingBooks
-                                    } else {
-                                        null
-                                    },
-                                    pendingSavedBooks = if (isSelectedGroup) {
-                                        uiState.pendingSavedBooks
+                                // 锁定 ↔ 解锁之间交叉淡化。只锁这个分组的内容区,
+                                // 左右相邻分组仍可正常滑动切换。
+                                Crossfade(
+                                    targetState = uiState.isGroupLocked(group),
+                                    animationSpec = tween(220),
+                                ) { locked ->
+                                    if (locked) {
+                                        PrivateGroupLockedPage(
+                                            groupId = group.groupId,
+                                            uiState = uiState,
+                                            onIntent = onIntent
+                                        )
                                     } else {
-                                        null
-                                    },
-                                    onDragStarted = {
-                                        if (isSelectedGroup) onIntent(BookshelfIntent.StartDragging(it))
-                                    },
-                                    onMoveBook = { from, to, currentBooks ->
-                                        if (isSelectedGroup) {
-                                            onIntent(BookshelfIntent.MoveDragging(from, to, currentBooks))
-                                        }
-                                    },
-                                    onDragFinished = {
-                                        if (isSelectedGroup) onIntent(BookshelfIntent.FinishDragging)
-                                    },
-                                    onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
-                                    onBookClick = onBookClick,
-                                    onBookLongClick = onBookLongClick,
-                                    isCurrentPage = isSelectedGroup,
-                                    sharedCoverGroupId = group.groupId,
-                                    sharedTransitionScope = sharedTransitionScope,
-                                    animatedVisibilityScope = animatedVisibilityScope,
-                                )
+                                        val isSelectedGroup =
+                                            group.groupId == uiState.selectedGroupId
+                                        val books = uiState.visibleGroupBooks[group.groupId]
+                                            ?: persistentListOf()
+                                        val canReorderBooks = isEditMode &&
+                                                !uiState.isSearch &&
+                                                (group.bookSort.takeIf { it >= 0 }
+                                                    ?: uiState.bookshelfSort) == 3 &&
+                                                isSelectedGroup
+                                        BookshelfPage(
+                                            gridState = groupGridStates.getValue(group.groupId),
+                                            paddingValues = paddingValues,
+                                            books = books,
+                                            uiState = uiState,
+                                            selectedBookUrls = selectedBookUrls,
+                                            canReorderBooks = canReorderBooks,
+                                            onToggleBookSelection = { toggleBookSelection(it.book.bookUrl) },
+                                            draggingBooks = if (isSelectedGroup) {
+                                                uiState.draggingBooks
+                                            } else {
+                                                null
+                                            },
+                                            pendingSavedBooks = if (isSelectedGroup) {
+                                                uiState.pendingSavedBooks
+                                            } else {
+                                                null
+                                            },
+                                            onDragStarted = {
+                                                if (isSelectedGroup) onIntent(
+                                                    BookshelfIntent.StartDragging(
+                                                        it
+                                                    )
+                                                )
+                                            },
+                                            onMoveBook = { from, to, currentBooks ->
+                                                if (isSelectedGroup) {
+                                                    onIntent(
+                                                        BookshelfIntent.MoveDragging(
+                                                            from,
+                                                            to,
+                                                            currentBooks
+                                                        )
+                                                    )
+                                                }
+                                            },
+                                            onDragFinished = {
+                                                if (isSelectedGroup) onIntent(BookshelfIntent.FinishDragging)
+                                            },
+                                            onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
+                                            onBookClick = openBook,
+                                            onBookLongClick = onBookLongClick,
+                                            onLockedBookClick = requestBookUnlock,
+                                            isCurrentPage = isSelectedGroup,
+                                            sharedCoverGroupId = group.groupId,
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                        )
+                                    }
+                                }
                             }
                         }
                     }
@@ -1214,6 +1372,32 @@ private fun BookshelfOverlays(
         onDismissRequest = { onIntent(BookshelfIntent.DismissOverlay) }
     )
 
+    // 应用内密码解锁:生物不可用、用户点了"使用密码",或设备不支持时的统一兜底
+    if (activeOverlay is BookshelfOverlay.PrivatePassword) {
+        val target = activeOverlay.target
+        var password by remember(target) { mutableStateOf("") }
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { onIntent(BookshelfIntent.DismissOverlay) },
+            title = stringResource(R.string.private_unlock_password_title),
+            content = {
+                AppTextField(
+                    value = password,
+                    onValueChange = { password = it },
+                    label = stringResource(R.string.private_unlock_password_title),
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            },
+            confirmText = stringResource(R.string.ok),
+            onConfirm = {
+                onIntent(BookshelfIntent.SubmitPrivatePassword(target, password))
+            },
+            dismissText = stringResource(R.string.cancel),
+            onDismiss = { onIntent(BookshelfIntent.DismissOverlay) },
+        )
+    }
+
     if (activeOverlay is BookshelfOverlay.GroupEditSheet) {
         val editGroup = allGroups.firstOrNull { it.groupId == activeOverlay.groupId }
         if (editGroup != null) {
@@ -1331,6 +1515,40 @@ private data class BookshelfEditStickySummary(
     val showGroupName: Boolean,
 )
 
+/**
+ * 私密分组的锁定态内容区。
+ *
+ * 只替换"这个分组自己的内容",不动分页容器——否则用户会被卡在锁定页里,
+ * 连相邻分组都切不过去。提示块复用 [PrivateLockedPage],与详情页脱敏态是同一套呈现。
+ */
+@Composable
+private fun PrivateGroupLockedPage(
+    groupId: Long,
+    uiState: BookshelfUiState,
+    onIntent: (BookshelfIntent) -> Unit,
+) {
+    PrivateLockedPage(
+        title = stringResource(R.string.private_locked_group_title),
+        description = stringResource(
+            if (uiState.privateAccess.hasPassword) {
+                R.string.private_locked_group_desc
+            } else {
+                R.string.private_content_no_password
+            }
+        ),
+        actionText = stringResource(
+            if (uiState.privateAccess.hasPassword) {
+                R.string.private_verify_and_view
+            } else {
+                R.string.set_local_password
+            }
+        ),
+        onAction = {
+            onIntent(BookshelfIntent.RequestPrivateUnlock(PrivateUnlockTarget.Group(groupId)))
+        }
+    )
+}
+
 @Composable
 fun BookshelfPage(
     gridState: LazyGridState,
@@ -1348,6 +1566,8 @@ fun BookshelfPage(
     onGlobalSearch: () -> Unit,
     onBookClick: (BookShelfItem, String?) -> Unit,
     onBookLongClick: (BookShelfItem, String?) -> Unit,
+    /** 点击处于脱敏状态的私密书籍:请求验证,验证通过后自动打开阅读 */
+    onLockedBookClick: (String) -> Unit = {},
     isCurrentPage: Boolean = true,
     sharedCoverGroupId: Long,
     sharedTransitionScope: SharedTransitionScope? = null,
@@ -1466,6 +1686,9 @@ fun BookshelfPage(
                     key = bookUi.book.bookUrl,
                     enabled = canReorderBooks
                 ) { isDragging ->
+                    val bookLocked = uiState.isBookLocked(bookUi)
+                    // 锁定 ⇄ 解锁的过渡在 BookItem 内部完成:封面走模糊深度、文字走占位替换。
+                    // 不在这一层整体模糊,是因为整体 blur 会把封面圆角与阴影一起糊掉。
                     BookItem(
                         settings = uiState.settings,
                         customTagColors = if (uiState.enableCustomTagColors) {
@@ -1511,6 +1734,7 @@ fun BookshelfPage(
                         gridStyle = bookItemGridStyle,
                         isCompact = bookItemIsCompact,
                         isUpdating = uiState.updatingBooks.contains(bookUi.book.bookUrl),
+                        locked = bookLocked,
                         titleSmallFont = bookItemTitleSmallFont,
                         titleCenter = bookItemTitleCenter,
                         titleMaxLines = bookItemTitleMaxLines,
@@ -1523,6 +1747,8 @@ fun BookshelfPage(
                         onClick = {
                             if (uiState.isEditMode) {
                                 onToggleBookSelection(bookUi)
+                            } else if (bookLocked) {
+                                onLockedBookClick(bookUi.book.bookUrl)
                             } else {
                                 onBookClick(bookUi.book, sharedCoverKey)
                             }
@@ -1534,6 +1760,8 @@ fun BookshelfPage(
                                 if (uiState.isEditMode) {
                                     onToggleBookSelection(bookUi)
                                 } else {
+                                    // 长按一律进详情页:脱敏渲染在详情页内完成,
+                                    // 不给"长按只弹验证、进不去详情"的分叉
                                     onBookLongClick(bookUi.book, sharedCoverKey)
                                 }
                             }

+ 66 - 2
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt

@@ -1,9 +1,12 @@
 package io.legado.app.ui.main.bookshelf
 
-import androidx.compose.runtime.Stable
 import android.net.Uri
+import androidx.compose.runtime.Stable
 import io.legado.app.data.entities.BookGroup
+import io.legado.app.domain.model.PrivateAccessState
+import io.legado.app.domain.model.PrivateUnlockTarget
 import io.legado.app.domain.model.settings.BookshelfSettings
+import io.legado.app.domain.model.settings.PrivateAccessSettings
 import io.legado.app.ui.config.themeConfig.TagColorPair
 import io.legado.app.ui.widget.components.list.ListUiState
 import kotlinx.collections.immutable.ImmutableList
@@ -23,6 +26,9 @@ data class BookshelfGroupSelectorState(
 
 sealed interface BookshelfOverlay {
     data object AddUrlDialog : BookshelfOverlay
+
+    /** 应用内密码解锁(生物不可用、用户点了"使用密码",或设备不支持时的兜底) */
+    data class PrivatePassword(val target: PrivateUnlockTarget) : BookshelfOverlay
     data object ImportSheet : BookshelfOverlay
     data object ExportSheet : BookshelfOverlay
     data object ConfigSheet : BookshelfOverlay
@@ -66,6 +72,29 @@ sealed interface BookshelfIntent {
     data class SetCustomTagColorsEnabled(val enabled: Boolean) : BookshelfIntent
     data class SetCustomTagColors(val colors: List<TagColorPair>) : BookshelfIntent
     data object UploadResultConsumed : BookshelfIntent
+
+    /** 编辑态批量标记/取消书籍私密 */
+    data class SetBooksPrivate(val bookUrls: Set<String>, val isPrivate: Boolean) :
+        BookshelfIntent
+
+    /** 点击私密书籍 / 进入私密分组时请求解锁;已解锁则直接执行目标动作 */
+    data class RequestPrivateUnlock(val target: PrivateUnlockTarget) : BookshelfIntent
+
+    /** 密码覆盖层提交;由宿主在生物不可用或用户选择密码时打开 */
+    data class SubmitPrivatePassword(
+        val target: PrivateUnlockTarget,
+        val password: String,
+    ) : BookshelfIntent
+
+    /** 生物验证成功并解出密码后回传给 ViewModel,仍走同一条密码校验 */
+    data class UnlockPrivateWithBiometricPassword(
+        val target: PrivateUnlockTarget,
+        val password: String,
+    ) : BookshelfIntent
+
+    /** 待打开的私密书已经处理完,清空挂起目标 */
+    data object ConsumePendingOpenBook : BookshelfIntent
+
 }
 
 sealed interface BookshelfEffect {
@@ -74,6 +103,12 @@ sealed interface BookshelfEffect {
         val actionLabel: String? = null,
         val url: String? = null,
     ) : BookshelfEffect
+
+    /** 交给宿主弹出系统生物验证框(需要 FragmentActivity) */
+    data class RequestBiometricUnlock(val target: PrivateUnlockTarget) : BookshelfEffect
+
+    /** 私密功能需要本地密码,引导去设置页 */
+    data object NavigateToLocalPasswordSettings : BookshelfEffect
 }
 
 @Stable
@@ -115,4 +150,33 @@ data class BookshelfUiState(
     val customTagColors: ImmutableList<TagColorPair> = persistentListOf(),
     val themeColor: Int = 0,
     val pendingUploadUrl: String? = null,
-) : ListUiState<BookUiItem>
+    /** 私密内容解锁状态;未设置本地密码时 hasPassword=false,私密功能不可用 */
+    val privateAccess: PrivateAccessState = PrivateAccessState(),
+    /** 验证时机与频率设置;决定哪些私密内容此刻处于锁定态 */
+    val privateSettings: PrivateAccessSettings = PrivateAccessSettings(),
+
+    /**
+     * 等待解锁后打开的书:解锁是一件"一致性关键"的事,打开书籍因此落成状态而不是
+     * 一次性 Effect——否则列表尚未重新过滤出这本书时事件就丢了。
+     */
+    val pendingOpenBookUrl: String? = null,
+) : ListUiState<BookUiItem> {
+
+    fun isBookLocked(bookUi: BookUiItem): Boolean =
+        bookUi.isLocked(privateAccess, privateSettings.verifyOnOpenBook)
+
+    /**
+     * 某个分组当前是否处于锁定态。
+     *
+     * 判定放在这里而不是 ViewModel:只有锁定的是**这个分组自己的内容区**,
+     * 分页容器与相邻分组保持可交互,否则用户会被卡在锁定页里无法切走。
+     */
+    fun isGroupLocked(group: BookGroupUi): Boolean =
+        group.isPrivate &&
+                privateSettings.verifyOnEnterGroup &&
+                !privateAccess.isTargetGranted(null, group.groupId)
+
+    /** 单独搜索分组模式下的当前分组是否锁定 */
+    val selectedGroupLocked: Boolean
+        get() = groups.firstOrNull { it.groupId == selectedGroupId }?.let(::isGroupLocked) == true
+}

+ 207 - 28
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt

@@ -19,7 +19,13 @@ import io.legado.app.data.repository.UploadRepository
 import io.legado.app.domain.gateway.AppShellSettingsGateway
 import io.legado.app.domain.gateway.BookshelfSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
+import io.legado.app.domain.gateway.PrivateAccessGateway
+import io.legado.app.domain.gateway.PrivateContentGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.model.PrivateAccessState
+import io.legado.app.domain.model.PrivateUnlockTarget
+import io.legado.app.domain.model.isPrivateBook
+import io.legado.app.domain.model.settings.PrivateAccessSettings
 import io.legado.app.domain.usecase.AddBookUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.ExportBookshelfUseCase
@@ -93,6 +99,8 @@ class BookshelfViewModel(
     private val appShellSettingsGateway: AppShellSettingsGateway,
     private val themeSettingsGateway: ThemeSettingsGateway,
     private val downloadCacheSettingsGateway: DownloadCacheSettingsGateway,
+    private val privateAccessGateway: PrivateAccessGateway,
+    private val privateContentGateway: PrivateContentGateway,
 ) : BaseViewModel(application) {
     private var addBookJob: Coroutine<*>? = null
 
@@ -112,6 +120,9 @@ class BookshelfViewModel(
     private val isInitialLoadingFlow = MutableStateFlow(true)
     private val pendingUploadUrlFlow = MutableStateFlow<String?>(null)
 
+    /** 点击脱敏的私密书籍后挂起、等解锁成功再打开的目标书 */
+    private val pendingOpenBookUrlFlow = MutableStateFlow<String?>(null)
+
     private data class BookshelfSortConfig(
         val sort: Int,
         val sortOrder: Int
@@ -166,6 +177,57 @@ class BookshelfViewModel(
     val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll()
         .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
 
+    /**
+     * 解锁态:进程内有效,重启应用即回到锁定。Eagerly 是为了点击时能同步读到当前值,
+     * 决定"直接放行 / 弹生物框 / 弹密码框 / 引导设密码"。
+     */
+    private val privateAccessStateFlow: StateFlow<PrivateAccessState> =
+        privateAccessGateway.state
+            .stateIn(viewModelScope, SharingStarted.Eagerly, PrivateAccessState())
+
+    private val privateAccessSettingsFlow: StateFlow<PrivateAccessSettings> =
+        privateAccessGateway.settings
+            .stateIn(viewModelScope, SharingStarted.Eagerly, PrivateAccessSettings())
+
+    /** 解锁态与验证时机一起参与渲染,避免"关了验证却仍然显示锁定态"的分叉 */
+    private val privateUiStateFlow: StateFlow<Pair<PrivateAccessState, PrivateAccessSettings>> =
+        combine(privateAccessStateFlow, privateAccessSettingsFlow) { access, settings ->
+            access to settings
+        }.stateIn(
+            viewModelScope,
+            SharingStarted.Eagerly,
+            PrivateAccessState() to PrivateAccessSettings()
+        )
+
+    /**
+     * 私密判定所需的两个来源:私密分组掩码 + 被单独标记的书籍 url。
+     * 判定在内存侧求并集,避免改动 BookDao 里十几处 BookShelfItem 投影。
+     */
+    private data class PrivateMarkers(
+        val groupMask: Long,
+        val bookUrls: Set<String>
+    )
+
+    private fun PrivateMarkers.isPrivate(item: BookShelfItem): Boolean =
+        isPrivateBook(
+            bookUrl = item.bookUrl,
+            group = item.group,
+            privateBookUrls = bookUrls,
+            privateGroupMask = groupMask
+        )
+
+    private val privateMarkersFlow: StateFlow<PrivateMarkers> = combine(
+        allGroupsFlow,
+        privateContentGateway.flowPrivateBookUrls()
+    ) { groups, privateBookUrls ->
+        PrivateMarkers(
+            groupMask = groups.fold(0L) { acc, group ->
+                if (group.groupId > 0 && group.isPrivate) acc or group.groupId else acc
+            },
+            bookUrls = privateBookUrls
+        )
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PrivateMarkers(0L, emptySet()))
+
     private val hideEmptyGroupsFlow: StateFlow<Boolean> = bookshelfSettings
         .map { it.hideEmptyGroups }
         .distinctUntilChanged()
@@ -235,7 +297,8 @@ class BookshelfViewModel(
         val groups: List<BookGroup>,
         val bookGroupStyle: Int,
         val systemCountsMap: Map<Long, Int>,
-        val allBookCount: Int
+        val allBookCount: Int,
+        val markers: PrivateMarkers
     )
 
     val groupSelectorState: StateFlow<BookshelfGroupSelectorState> = combine(
@@ -274,8 +337,9 @@ class BookshelfViewModel(
             combine(
                 bookRepository.flowBookShelfByGroup(groupId),
                 groupsFlow,
-                sortConfigFlow
-            ) { list, groups, sortConfig ->
+                sortConfigFlow,
+                privateMarkersFlow
+            ) { list, groups, sortConfig, markers ->
                 SelectedGroupBooksState(
                     groupId = groupId,
                     books = bookshelfRepository.sortBooks(
@@ -283,7 +347,7 @@ class BookshelfViewModel(
                         groups.find { it.groupId == groupId },
                         sortConfig.sort,
                         sortConfig.sortOrder
-                    ).map { it.toUiItem() },
+                    ).map { it.toUiItem(markers.isPrivate(it)) },
                     sortConfig = sortConfig
                 )
             }
@@ -297,9 +361,9 @@ class BookshelfViewModel(
 
     @OptIn(ExperimentalCoroutinesApi::class)
     private val allGroupBooksImmutableFlow: Flow<ImmutableMap<Long, ImmutableList<BookUiItem>>> =
-        combine(groupsFlow, sortConfigFlow) { groups, sortConfig ->
-            groups to sortConfig
-        }.flatMapLatest { (groups, sortConfig) ->
+        combine(groupsFlow, sortConfigFlow, privateMarkersFlow) { groups, sortConfig, markers ->
+            Triple(groups, sortConfig, markers)
+        }.flatMapLatest { (groups, sortConfig, markers) ->
             if (groups.isEmpty()) {
                 flowOf(persistentMapOf())
             } else {
@@ -310,7 +374,7 @@ class BookshelfViewModel(
                             group,
                             sortConfig.sort,
                             sortConfig.sortOrder
-                        ).map { it.toUiItem() }.toImmutableList()
+                        ).map { it.toUiItem(markers.isPrivate(it)) }.toImmutableList()
                     }
                 }
                 combine(flows) { results ->
@@ -325,12 +389,22 @@ class BookshelfViewModel(
     private val selectedBooksStateFlow: Flow<SelectedBooksState> = combine(
         selectedGroupBooksFlow,
         searchKeyFlow,
-        searchModeFlow
-    ) { selectedGroup, searchKey, isSearchMode ->
+        searchModeFlow,
+        privateUiStateFlow
+    ) { selectedGroup, searchKey, isSearchMode, privateUi ->
+        // 未解锁时私密书籍不参与搜索匹配:否则"搜得到/搜不到"本身就泄漏了书名
+        val (privateAccess, privateSettings) = privateUi
         SelectedBooksState(
             groupId = selectedGroup.groupId,
             books = selectedGroup.books,
-            visibleBooks = filterBooks(selectedGroup.books, searchKey, isSearchMode),
+            visibleBooks = filterBooks(
+                books = selectedGroup.books,
+                searchKey = searchKey,
+                isSearchMode = isSearchMode,
+                hidePrivate = { item ->
+                    item.isLocked(privateAccess, privateSettings.verifyOnOpenBook)
+                }
+            ),
             searchKey = searchKey,
             isSearchMode = isSearchMode,
             sortConfig = selectedGroup.sortConfig
@@ -366,19 +440,22 @@ class BookshelfViewModel(
         groupsFlow,
         bookGroupStyleFlow,
         bookRepository.flowSystemGroupCounts(),
-        bookRepository.flowAllBookShelfCount()
-    ) { groups, bookGroupStyle, systemCounts, totalCount ->
+        bookRepository.flowAllBookShelfCount(),
+        privateMarkersFlow
+    ) { groups, bookGroupStyle, systemCounts, totalCount, markers ->
         DataForPreviews(
             groups,
             bookGroupStyle,
             systemCounts.associate { it.groupId to it.count },
-            totalCount
+            totalCount,
+            markers
         )
     }.flatMapLatest { data ->
         val groups = data.groups
         val bookGroupStyle = data.bookGroupStyle
         val systemCountsMap = data.systemCountsMap
         val allBookCount = data.allBookCount
+        val markers = data.markers
 
         if (bookGroupStyle !in 2..3) {
             flowOf(GroupPreviewState(persistentMapOf(), persistentMapOf(), allBookCount))
@@ -393,7 +470,11 @@ class BookshelfViewModel(
                 }
                 val previewFlow = bookRepository.flowGroupPreview(group.groupId)
                 combine(countFlow, previewFlow) { count, preview ->
-                    Triple(group.groupId, count, preview.map { it.toUiItem() })
+                    Triple(
+                        group.groupId,
+                        count,
+                        preview.map { it.toUiItem(markers.isPrivate(it)) }
+                    )
                 }
             }
             combine(groupFlows) { results ->
@@ -412,13 +493,15 @@ class BookshelfViewModel(
         groupIdFlow,
         loadingTextFlow,
         updatingBooksFlow,
-        upBooksCountFlow
-    ) { groupId, loadingText, updatingBooks, upBooksCount ->
+        upBooksCountFlow,
+        pendingOpenBookUrlFlow
+    ) { groupId, loadingText, updatingBooks, upBooksCount, pendingOpenBookUrl ->
         InternalState(
             groupId = groupId,
             loadingText = loadingText,
             updatingBooks = updatingBooks,
-            upBooksCount = upBooksCount
+            upBooksCount = upBooksCount,
+            pendingOpenBookUrl = pendingOpenBookUrl
         )
     }
 
@@ -426,7 +509,8 @@ class BookshelfViewModel(
         val groupId: Long,
         val loadingText: String?,
         val updatingBooks: Set<String>,
-        val upBooksCount: Int
+        val upBooksCount: Int,
+        val pendingOpenBookUrl: String?
     )
 
     data class BookshelfInteractionState(
@@ -513,8 +597,13 @@ class BookshelfViewModel(
         dataStateFlow,
         interactionStateFlow,
         isInitialLoadingFlow,
-        hiddenGroupIdsFlow
-    ) { data, interaction, isInitialLoading, hiddenIds ->
+        hiddenGroupIdsFlow,
+        privateUiStateFlow
+    ) { data, interaction, isInitialLoading, hiddenIds, privateUi ->
+        val (privateAccess, privateSettings) = privateUi
+        val hidePrivateFromSearch: (BookUiItem) -> Boolean = { item ->
+            item.isLocked(privateAccess, privateSettings.verifyOnOpenBook)
+        }
         val selectedBooks = data.selectedBooks
         val groups = data.groups.filter { it.groupId !in hiddenIds }
         val allGroups = data.allGroups
@@ -525,7 +614,12 @@ class BookshelfViewModel(
                 data.allGroupBooks
             } else {
                 data.allGroupBooks.mapValues { (_, books) ->
-                    filterBooks(books, selectedBooks.searchKey, true).toImmutableList()
+                    filterBooks(
+                        books = books,
+                        searchKey = selectedBooks.searchKey,
+                        isSearchMode = true,
+                        hidePrivate = hidePrivateFromSearch
+                    ).toImmutableList()
                 }.toImmutableMap()
             }
         val books = data.allGroupBooks[internal.groupId]
@@ -536,8 +630,9 @@ class BookshelfViewModel(
             ?: emptyList()
         val selectedGroupIndex = groups.indexOfFirst { it.groupId == internal.groupId }
             .coerceAtLeast(0)
-        val currentGroupName = allGroups.firstOrNull { it.groupId == internal.groupId }?.groupName
-            ?: groups.getOrNull(selectedGroupIndex)?.groupName
+        val currentGroup = allGroups.firstOrNull { it.groupId == internal.groupId }
+            ?: groups.getOrNull(selectedGroupIndex)
+        val currentGroupName = currentGroup?.groupName
         val selectedIds = interaction.selectedBookUrls.mapTo(linkedSetOf<Any>()) { it }
         val title = buildTitle(
             bookGroupStyle = interaction.bookGroupStyle,
@@ -601,7 +696,10 @@ class BookshelfViewModel(
             currentGroupName = currentGroupName,
             draggingBooks = interaction.draggingBooks?.toImmutableList(),
             pendingSavedBooks = interaction.pendingSavedBooks?.toImmutableList(),
-            visibleGroupBooks = visibleGroupBooks
+            visibleGroupBooks = visibleGroupBooks,
+            privateAccess = privateAccess,
+            privateSettings = privateSettings,
+            pendingOpenBookUrl = internal.pendingOpenBookUrl,
         )
     }
 
@@ -725,18 +823,89 @@ class BookshelfViewModel(
                 }
             }
             BookshelfIntent.UploadResultConsumed -> pendingUploadUrlFlow.value = null
+            BookshelfIntent.ConsumePendingOpenBook -> pendingOpenBookUrlFlow.value = null
+            is BookshelfIntent.RequestPrivateUnlock -> requestPrivateUnlock(intent.target)
+            is BookshelfIntent.SubmitPrivatePassword ->
+                submitPrivatePassword(intent.target, intent.password)
+
+            is BookshelfIntent.UnlockPrivateWithBiometricPassword ->
+                submitPrivatePassword(intent.target, intent.password)
+
+            is BookshelfIntent.SetBooksPrivate ->
+                setBooksPrivate(intent.bookUrls, intent.isPrivate)
+        }
+    }
+
+    /**
+     * 私密内容的统一入口:已解锁直接放行,否则按"生物快捷 → 应用内密码 → 引导设密码"降级。
+     */
+    private fun requestPrivateUnlock(target: PrivateUnlockTarget) {
+        val access = privateAccessStateFlow.value
+        if (target is PrivateUnlockTarget.Book) {
+            // 打开书籍落成状态:解锁后列表才会重新包含这本书,此时再打开才不会丢事件
+            pendingOpenBookUrlFlow.value = target.bookUrl
+        }
+        if (access.isUnlocked) return
+        if (!access.hasPassword) {
+            // 没有密码就没有解锁路径:把刚挂上的目标一起撤掉,
+            // 否则以后任意一次授权(进分组、启动验证)都会让这本旧书被自动打开
+            pendingOpenBookUrlFlow.value = null
+            _effects.tryEmit(BookshelfEffect.NavigateToLocalPasswordSettings)
+            return
+        }
+        if (access.canUseBiometricShortcut) {
+            _effects.tryEmit(BookshelfEffect.RequestBiometricUnlock(target))
+        } else {
+            activeOverlayFlow.value = BookshelfOverlay.PrivatePassword(target)
+        }
+    }
+
+    private fun submitPrivatePassword(target: PrivateUnlockTarget, password: String) {
+        if (password.isEmpty()) return
+        viewModelScope.launch {
+            if (privateAccessGateway.verifyPassword(password, target)) {
+                activeOverlayFlow.value = null
+            } else {
+                // 校验失败就把挂起动作一起清掉,避免之后某次成功解锁误打开旧目标
+                pendingOpenBookUrlFlow.value = null
+                _effects.tryEmit(
+                    BookshelfEffect.ShowSnackbar(
+                        context.getString(R.string.private_unlock_password_error)
+                    )
+                )
+            }
+        }
+    }
+
+    private fun setBooksPrivate(bookUrls: Set<String>, isPrivate: Boolean) {
+        if (bookUrls.isEmpty()) return
+        execute {
+            privateContentGateway.setBooksPrivate(bookUrls, isPrivate)
+        }.onSuccess {
+            showMessage(context.getString(R.string.private_marked_books, bookUrls.size))
+        }.onError {
+            showMessage(
+                context.getString(R.string.private_mark_failed, it.localizedMessage.orEmpty())
+            )
         }
     }
 
     private fun filterBooks(
         books: List<BookUiItem>,
         searchKey: String,
-        isSearchMode: Boolean
+        isSearchMode: Boolean,
+        hidePrivate: (BookUiItem) -> Boolean = { false }
     ): List<BookUiItem> {
-        return if (!isSearchMode || searchKey.isBlank()) {
+        // 仍处于锁定态的私密书籍先剔掉,再谈匹配:否则"能不能搜到"本身就是泄漏
+        val candidates = if (isSearchMode && searchKey.isNotBlank()) {
+            books.filterNot(hidePrivate)
+        } else {
             books
+        }
+        return if (!isSearchMode || searchKey.isBlank()) {
+            candidates
         } else {
-            books.filter { it.matches(searchKey) }
+            candidates.filter { it.matches(searchKey) }
         }
     }
 
@@ -770,13 +939,20 @@ class BookshelfViewModel(
 
     fun changeGroup(groupId: Long) {
         if (groupIdFlow.value != groupId) {
+            // "每次验证"频率下离开分组即撤销授权,下次进来要重新验证
+            privateAccessGateway.revoke(PrivateUnlockTarget.Group(groupIdFlow.value))
             groupIdFlow.value = groupId
             viewModelScope.launch {
                 bookshelfSettingsGateway.update { it.copy(saveTabPosition = groupId) }
             }
             clearSelection()
             clearDragState()
+            // 切分组即放弃"解锁后打开"的挂起目标:这本书已经不在当前分组里了,
+            // 留着它会在以后某次回到该分组时莫名把书弹开
+            pendingOpenBookUrlFlow.value = null
         }
+        // 进入私密分组不在这里申请权限:先呈现锁定页,由用户点"验证并查看"时再申请。
+        // 否则左右滑动浏览分组会被连续弹窗打断,而用户此刻可能只是想路过这个分组。
     }
 
     fun setSearchKey(key: String) {
@@ -796,6 +972,9 @@ class BookshelfViewModel(
     }
 
     fun dismissOverlay() {
+        if (activeOverlayFlow.value is BookshelfOverlay.PrivatePassword) {
+            pendingOpenBookUrlFlow.value = null
+        }
         activeOverlayFlow.value = null
     }
 

+ 8 - 0
app/src/main/java/io/legado/app/ui/widget/components/image/cover/BookshelfCover.kt

@@ -6,6 +6,7 @@ import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
@@ -14,6 +15,7 @@ import androidx.compose.material.icons.filled.Update
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.card.TextCard
@@ -40,6 +42,10 @@ fun BookshelfCover(
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
+    /** 内容模糊:透传给封面组件,作用点在共享元素节点内部,转场时才不会被落下 */
+    contentBlur: Dp = 0.dp,
+    /** 盖在封面上的叠加层(遮罩/点阵/锁标),必须渲染在共享节点内部 */
+    overlayContent: (@Composable BoxScope.() -> Unit)? = null,
 ) {
     Box(modifier = modifier) {
         CoilBookCover(
@@ -55,6 +61,8 @@ fun BookshelfCover(
             sharedTransitionScope = sharedTransitionScope,
             animatedVisibilityScope = animatedVisibilityScope,
             sharedCoverKey = sharedCoverKey,
+            contentBlur = contentBlur,
+            overlayContent = overlayContent,
         )
 
         // 使用 animatedVisibilityScope 的 animateEnterExit 为叠加层添加同步动画

+ 46 - 7
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt

@@ -13,6 +13,7 @@ import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.animation.core.animateFloat
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxSize
@@ -31,6 +32,8 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.BlurredEdgeTreatment
+import androidx.compose.ui.draw.blur
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.drawWithCache
 import androidx.compose.ui.draw.shadow
@@ -238,6 +241,20 @@ fun CoilBookCover(
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
+    /**
+     * 内容模糊半径,作用于封面图与占位文字这些**共享元素内部的子节点**。
+     *
+     * 之所以要传进来而不是让调用方在外面套 `Modifier.blur`:共享元素转场时,
+     * overlay 只会搬运 sharedBounds 节点自己的内容,加在祖先上的模糊会被落下,
+     * 表现就是"动画一开始模糊突然没了"。
+     */
+    contentBlur: Dp = 0.dp,
+    /**
+     * 盖在封面之上的叠加层(遮罩、点阵、锁标…),渲染在共享节点**内部**。
+     *
+     * 放成兄弟节点的话转场时不会被 overlay 带走,会出现"装饰停在原地、只有封面在飞"。
+     */
+    overlayContent: (@Composable BoxScope.() -> Unit)? = null,
 ) {
     val coverSettings = LocalAppUiConfiguration.current.cover
     val isNight = LegadoTheme.isDark
@@ -273,6 +290,11 @@ fun CoilBookCover(
         animatedVisibilityScope = animatedVisibilityScope
     )
     val shape = remember(transitionRadius) { RoundedCornerShape(transitionRadius) }
+    val contentBlurModifier = if (contentBlur > 0.dp) {
+        Modifier.blur(contentBlur, BlurredEdgeTreatment.Unbounded)
+    } else {
+        Modifier
+    }
 
     Box(
         modifier = modifier
@@ -305,7 +327,9 @@ fun CoilBookCover(
             name = name,
             author = author,
             path = path,
-            modifier = Modifier.fillMaxSize(),
+            modifier = Modifier
+                .fillMaxSize()
+                .then(contentBlurModifier),
             sourceOrigin = sourceOrigin,
             bookUrl = bookUrl,
             preferCache = preferCache,
@@ -334,19 +358,34 @@ fun CoilBookCover(
                     !isOnlineCoverLoaded
                 )
         ) {
-            CoverTextOverlay(
-                name = name,
-                author = author,
-                isNight = isNight
-            )
+            // 占位文字(默认封面上的书名/作者)也一起模糊:
+            // 它露的是真实字符串,锁定态不能比正常态更清晰
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .then(contentBlurModifier)
+            ) {
+                CoverTextOverlay(
+                    name = name,
+                    author = author,
+                    isNight = isNight
+                )
+            }
         }
+
+        // 遮罩/点阵/锁标等叠加层渲染在共享节点内部,转场时会随封面一起移动
+        overlayContent?.invoke(this)
     }
 }
 
 
+/**
+ * 转场两端的圆角:起点用源页面缓存下来的圆角,终点用本节点的 [radius],
+ * 期间随转场进度插值,避免两端圆角不一致时跳变。脱敏封面复用同一实现。
+ */
 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
-private fun rememberSharedCoverTransitionRadius(
+internal fun rememberSharedCoverTransitionRadius(
     sharedCoverKey: String?,
     radius: Dp,
     animatedVisibilityScope: AnimatedVisibilityScope?

+ 226 - 0
app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateAppStartGate.kt

@@ -0,0 +1,226 @@
+package io.legado.app.ui.widget.components.privacy
+
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.domain.gateway.PrivateAccessGateway
+import io.legado.app.domain.model.PrivateAccessState
+import io.legado.app.domain.model.settings.PrivateAccessSettings
+import io.legado.app.help.security.BiometricUnlockLauncher
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.coroutines.launch
+import org.koin.compose.koinInject
+import kotlin.system.exitProcess
+
+/** 从 Compose 的 Context 链里找回承载系统生物验证框的 FragmentActivity */
+fun Context.findFragmentActivity(): FragmentActivity? {
+    var current: Context? = this
+    while (current is ContextWrapper) {
+        if (current is FragmentActivity) return current
+        current = current.baseContext
+    }
+    return null
+}
+
+/**
+ * 启动验证未通过:直接结束应用。
+ *
+ * 与"打开私密内容时验证失败"不同——那时可以退回锁定态继续使用应用;启动验证是进入应用的
+ * 门槛,被拒绝就应该离开,而不是留在应用里。
+ */
+private fun exitApp(context: Context) {
+    context.findFragmentActivity()?.finishAffinity()
+    // 只走 exitProcess:killProcess 之后这行永远不可达,两者留一个就够,
+    // 而 exitProcess 会正常走 JVM 退出,比直接信号杀进程更干净。
+    exitProcess(0)
+}
+
+/**
+ * 「启动应用时验证」的门槛。
+ *
+ * 它是**单独的 screen**而不是叠在应用界面上的对话框:门槛未过时 [content] 完全不组合,
+ * 所以验证页背后既看不到书架/阅读界面,也没有任何可以点进去的入口。做成独立 Activity
+ * 也能达到同样效果,但要多操心启动流程、任务栈与生物验证的宿主,收益不抵风险。
+ *
+ * 判定依据是 [PrivateAccessState.isAppStartVerified],**不是** [PrivateAccessState.isUnlocked]:
+ * 启动验证只证明"进得来",不是对任何分组/书籍的授权(见 gateway 的 verifyPassword)。
+ *
+ * 这里是进入应用的门槛而不是内容开关,所以不通过就退出应用;密码输错不算"不通过",
+ * 留在页面里提示错误并允许重试。
+ */
+@Composable
+fun PrivateAppStartGate(
+    // content 放最后:调用点用尾随 lambda 表达"门槛内的应用界面"
+    gateway: PrivateAccessGateway = koinInject(),
+    content: @Composable () -> Unit,
+) {
+    val access by gateway.state.collectAsStateWithLifecycle(PrivateAccessState())
+    val settings by gateway.settings.collectAsStateWithLifecycle(PrivateAccessSettings())
+
+    // 应用离开前台:只有"离开应用前有效"这种频率会真正回到锁定态——频率判断在 gateway 里。
+    // 宽限期(秒)在这里落地:离开时只记时间,回来时超过宽限期才失效;
+    // 宽限期为 0 就是"离开即失效",不必等回来再判断。
+    val lifecycleOwner = LocalLifecycleOwner.current
+    val graceSeconds = settings.backgroundGraceSeconds
+    val leftAt = remember { mutableStateOf(0L) }
+    DisposableEffect(lifecycleOwner, gateway, graceSeconds) {
+        val observer = LifecycleEventObserver { _, event ->
+            when (event) {
+                Lifecycle.Event.ON_STOP -> {
+                    leftAt.value = System.currentTimeMillis()
+                    if (graceSeconds == 0) gateway.onAppBackground()
+                }
+
+                Lifecycle.Event.ON_START -> {
+                    val since = leftAt.value
+                    if (since != 0L &&
+                        System.currentTimeMillis() - since > graceSeconds * 1000L
+                    ) {
+                        gateway.onAppBackground()
+                    }
+                    leftAt.value = 0L
+                }
+
+                else -> Unit
+            }
+        }
+        lifecycleOwner.lifecycle.addObserver(observer)
+        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+    }
+
+    val locked = settings.enabled && access.hasPassword && settings.verifyOnAppStart &&
+            !access.isAppStartVerified && !access.isUnlocked
+
+    if (locked) {
+        PrivateAppStartVerifyScreen(gateway)
+    } else {
+        content()
+    }
+}
+
+/** 门槛页:全屏,不透明,背后不组合任何应用内容 */
+@Composable
+private fun PrivateAppStartVerifyScreen(gateway: PrivateAccessGateway) {
+    val context = LocalContext.current
+    val scope = rememberCoroutineScope()
+    val access by gateway.state.collectAsStateWithLifecycle(PrivateAccessState())
+
+    // 每次进程启动只主动打扰一次;旋转屏幕后不再重新弹系统验证框
+    var autoLaunched by rememberSaveable { mutableStateOf(false) }
+    var showPasswordDialog by rememberSaveable { mutableStateOf(false) }
+    var passwordError by remember { mutableStateOf(false) }
+    var password by remember { mutableStateOf("") }
+
+    val title = stringResource(R.string.private_unlock_title)
+    val subtitle = stringResource(R.string.private_unlock_subtitle)
+    val usePassword = stringResource(R.string.private_unlock_use_password)
+    val passwordTitle = stringResource(R.string.private_unlock_password_title)
+    val passwordErrorText = stringResource(R.string.private_unlock_password_error)
+
+    fun launchUnlock() {
+        val activity = context.findFragmentActivity()
+        if (activity != null && access.canUseBiometricShortcut) {
+            BiometricUnlockLauncher.launch(
+                activity = activity,
+                title = title,
+                subtitle = subtitle,
+                negativeButtonText = usePassword,
+                onPassword = { decrypted ->
+                    scope.launch { gateway.verifyPassword(decrypted) }
+                },
+                onFallback = { showPasswordDialog = true },
+                // 用户明确放弃:门槛没通过,离开应用
+                onCancel = { exitApp(context) },
+                onError = { showPasswordDialog = true },
+            )
+        } else {
+            showPasswordDialog = true
+        }
+    }
+
+    LaunchedEffect(Unit) {
+        if (autoLaunched) return@LaunchedEffect
+        autoLaunched = true
+        launchUnlock()
+    }
+
+    BackHandler { exitApp(context) }
+
+    PrivateLockedPage(
+        title = title,
+        description = subtitle,
+        actionText = stringResource(R.string.private_verify_and_open),
+        onAction = { launchUnlock() },
+        modifier = Modifier.fillMaxSize(),
+    )
+
+    if (showPasswordDialog) {
+        AppAlertDialog(
+            show = true,
+            // 点对话框外部或返回键都视为放弃验证
+            onDismissRequest = { exitApp(context) },
+            title = passwordTitle,
+            content = {
+                Column(modifier = Modifier.fillMaxWidth()) {
+                    AppTextField(
+                        value = password,
+                        onValueChange = {
+                            password = it
+                            passwordError = false
+                        },
+                        label = passwordTitle,
+                        modifier = Modifier.fillMaxWidth(),
+                    )
+                    if (passwordError) {
+                        AppText(
+                            text = passwordErrorText,
+                            color = LegadoTheme.colorScheme.error,
+                            modifier = Modifier.padding(top = 8.dp),
+                        )
+                    }
+                }
+            },
+            confirmText = stringResource(R.string.ok),
+            onConfirm = {
+                val input = password
+                scope.launch {
+                    if (gateway.verifyPassword(input)) {
+                        password = ""
+                        showPasswordDialog = false
+                    } else {
+                        // 输错属于"还没通过",不是"放弃":留在对话框里让用户重试
+                        passwordError = true
+                    }
+                }
+            },
+            dismissText = stringResource(R.string.cancel),
+            onDismiss = { exitApp(context) },
+        )
+    }
+}

+ 111 - 0
app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateCoverBlurTransformation.kt

@@ -0,0 +1,111 @@
+package io.legado.app.ui.widget.components.privacy
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import coil3.size.Size
+import coil3.transform.Transformation
+
+/**
+ * 私密内容的封面脱敏。
+ *
+ * 为什么不用 Modifier.blur:项目 minSdk 26,而 androidx.compose.ui.draw.blur 只在
+ * API 31+ 生效,26~30 上是一个 no-op——单独依赖它等于在低版本设备上直接泄漏真实封面。
+ *
+ * 这里走 Coil Transformation:先强降采样(顺带让模糊更彻底、更便宜),再做两轮
+ * 可分离 box blur,最后放大回原尺寸。全 API 行为一致,且模糊结果只存在于内存。
+ */
+class PrivateCoverBlurTransformation : Transformation() {
+
+    override val cacheKey: String = "private-cover-blur-v1"
+
+    override suspend fun transform(input: Bitmap, size: Size): Bitmap {
+        val width = input.width
+        val height = input.height
+        if (width <= 0 || height <= 0) return input
+
+        val smallWidth = (width / DOWNSCALE_FACTOR).coerceAtLeast(MIN_DOWNSCALED_EDGE)
+        val smallHeight = (height / DOWNSCALE_FACTOR).coerceAtLeast(MIN_DOWNSCALED_EDGE)
+        val small = Bitmap.createScaledBitmap(input, smallWidth, smallHeight, true)
+
+        var blurred = small
+        repeat(BLUR_PASSES) {
+            blurred = boxBlur(blurred, BLUR_RADIUS)
+        }
+        if (blurred === small) return input
+
+        return Bitmap.createScaledBitmap(blurred, width, height, true)
+    }
+
+    companion object {
+        private const val DOWNSCALE_FACTOR = 6
+        private const val MIN_DOWNSCALED_EDGE = 6
+        private const val BLUR_RADIUS = 3
+        private const val BLUR_PASSES = 2
+        private const val CHANNEL_MAX = 255
+
+        /** 可分离 box blur:先横向再纵向,窗口越界按边缘像素钳制 */
+        private fun boxBlur(source: Bitmap, radius: Int): Bitmap {
+            val width = source.width
+            val height = source.height
+            if (width <= 0 || height <= 0) return source
+
+            val sourcePixels = IntArray(width * height)
+            source.getPixels(sourcePixels, 0, width, 0, 0, width, height)
+            val horizontal = IntArray(width * height)
+            val vertical = IntArray(width * height)
+            val window = radius * 2 + 1
+
+            for (y in 0 until height) {
+                val rowOffset = y * width
+                for (x in 0 until width) {
+                    var alpha = 0
+                    var red = 0
+                    var green = 0
+                    var blue = 0
+                    for (dx in -radius..radius) {
+                        val sx = (x + dx).coerceIn(0, width - 1)
+                        val pixel = sourcePixels[rowOffset + sx]
+                        alpha += Color.alpha(pixel)
+                        red += Color.red(pixel)
+                        green += Color.green(pixel)
+                        blue += Color.blue(pixel)
+                    }
+                    horizontal[rowOffset + x] = argb(
+                        alpha / window, red / window, green / window, blue / window
+                    )
+                }
+            }
+
+            for (x in 0 until width) {
+                for (y in 0 until height) {
+                    var alpha = 0
+                    var red = 0
+                    var green = 0
+                    var blue = 0
+                    for (dy in -radius..radius) {
+                        val sy = (y + dy).coerceIn(0, height - 1)
+                        val pixel = horizontal[sy * width + x]
+                        alpha += Color.alpha(pixel)
+                        red += Color.red(pixel)
+                        green += Color.green(pixel)
+                        blue += Color.blue(pixel)
+                    }
+                    vertical[y * width + x] = argb(
+                        alpha / window, red / window, green / window, blue / window
+                    )
+                }
+            }
+
+            val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+            output.setPixels(vertical, 0, width, 0, 0, width, height)
+            return output
+        }
+
+        private fun argb(alpha: Int, red: Int, green: Int, blue: Int): Int = Color.argb(
+            alpha.coerceIn(0, CHANNEL_MAX),
+            red.coerceIn(0, CHANNEL_MAX),
+            green.coerceIn(0, CHANNEL_MAX),
+            blue.coerceIn(0, CHANNEL_MAX)
+        )
+    }
+}

+ 75 - 0
app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateDotMatrix.kt

@@ -0,0 +1,75 @@
+package io.legado.app.ui.widget.components.privacy
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlin.math.PI
+import kotlin.math.ceil
+import kotlin.math.sin
+
+/**
+ * 锁定封面上的「动态点阵」遮罩。
+ *
+ * 一片随时间起伏的点阵,比纯色遮罩更能表达"这里是被刻意遮住的",
+ * 也让人一眼看出不是封面加载失败。
+ *
+ * 实现全走 Compose 标准能力:[rememberInfiniteTransition] 驱动相位、
+ * [Canvas] 逐点绘制——没有自定义 Shader,也不依赖平台特效。
+ *
+ * 开销:点数与 [cellSize] 的平方成反比(10dp 格子下,一张卡片一两百个点),
+ * 且只在锁定态渲染;点阵画在模糊层之外,所以它本身是清晰的,不会被封面的模糊一起糊掉。
+ */
+@Composable
+fun PrivateDotMatrix(
+    color: Color,
+    modifier: Modifier = Modifier,
+    cellSize: Dp = 10.dp,
+    dotRadius: Dp = 1.6.dp,
+    baseAlpha: Float = 0.10f,
+    pulseAlpha: Float = 0.16f,
+) {
+    val transition = rememberInfiniteTransition(label = "private-dot-matrix")
+    val phase by transition.animateFloat(
+        initialValue = 0f,
+        targetValue = (2 * PI).toFloat(),
+        animationSpec = infiniteRepeatable(
+            animation = tween(durationMillis = 2600, easing = LinearEasing),
+            repeatMode = RepeatMode.Restart,
+        ),
+        label = "private-dot-phase",
+    )
+    Canvas(modifier = modifier) {
+        val cell = cellSize.toPx()
+        if (cell <= 0f) return@Canvas
+        val radius = dotRadius.toPx()
+        val rows = ceil(size.height / cell).toInt()
+        val columns = ceil(size.width / cell).toInt()
+        for (row in 0..rows) {
+            val y = row * cell
+            // 奇数行错开半格:更像织出来的网,而不是规整的网格
+            val rowOffset = if (row % 2 == 0) 0f else cell / 2f
+            for (column in 0..columns) {
+                val x = column * cell + rowOffset
+                // 相邻点之间留相位差,形成一道斜向缓缓流动的波
+                val wave = sin(phase + (row + column) * 0.55f)
+                val alpha = baseAlpha + pulseAlpha * ((wave + 1f) / 2f)
+                drawCircle(
+                    color = color.copy(alpha = alpha.coerceIn(0f, 1f)),
+                    radius = radius,
+                    center = Offset(x, y),
+                )
+            }
+        }
+    }
+}

+ 97 - 0
app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateListMasking.kt

@@ -0,0 +1,97 @@
+package io.legado.app.ui.widget.components.privacy
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.domain.gateway.PrivateAccessGateway
+import io.legado.app.domain.gateway.PrivateContentGateway
+import io.legado.app.domain.model.PrivateAccessState
+import io.legado.app.domain.model.PrivateBookFacts
+import io.legado.app.domain.model.settings.PrivateAccessSettings
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.koin.compose.koinInject
+
+/**
+ * 列表页共用:这批书里**此刻需要脱敏**的是哪些。
+ *
+ * 只服务"列表里的条目":命中集合里的 bookUrl 应换成模糊封面 + 不显示任何文本
+ * (见 [PrivateLockedCover] 与书架卡片的做法)。判定与书架、详情页、阅读器闸门共用同一套
+ * 规则(私密 ∪ 分组 + 授权 + 打开书籍时验证),不另立第二份。
+ *
+ * 放在 composable 里而不是各页 ViewModel:列表页只需要一个"要不要脱敏"的布尔,
+ * 让四处列表各写一遍 VM 管线反而更容易漂移。网关访问收在这一个组件内,调用方不碰数据层。
+ */
+@Composable
+fun rememberPrivateLockedBookUrls(bookUrls: List<String>): Set<String> {
+    val privateContentGateway: PrivateContentGateway = koinInject()
+    val privateAccessGateway: PrivateAccessGateway = koinInject()
+    val access by privateAccessGateway.state.collectAsStateWithLifecycle(PrivateAccessState())
+    val settings by privateAccessGateway.settings.collectAsStateWithLifecycle(PrivateAccessSettings())
+
+    val facts by produceState<Map<String, PrivateBookFacts>>(
+        initialValue = emptyMap(),
+        key1 = bookUrls,
+    ) {
+        // 一次查回整批:列表页按条查会变成 N 次 IO
+        value = withContext(Dispatchers.IO) {
+            bookUrls.distinct().associateWith { privateContentGateway.factsOf(it) }
+        }
+    }
+
+    return remember(bookUrls, facts, access, settings) {
+        if (!settings.verifyOnOpenBook) {
+            emptySet()
+        } else {
+            bookUrls.filterTo(mutableSetOf()) { url ->
+                val itemFacts = facts[url] ?: return@filterTo false
+                itemFacts.isPrivate && !access.isTargetGranted(url, itemFacts.groupMask)
+            }
+        }
+    }
+}
+
+/**
+ * 只有书名 + 作者的列表条目(阅读记录)的标识。
+ *
+ * 这类表里没存 bookUrl,所以只能拿书名+作者当身份。
+ */
+data class PrivateRecordKey(val name: String, val author: String)
+
+/**
+ * 与 [rememberPrivateLockedBookUrls] 同义,只是换成"按名+作者"的入口。
+ *
+ * 阅读记录、书签这类表里没有 bookUrl,只能反查 books;反查不到按"与私密无关"处理。
+ */
+@Composable
+fun rememberPrivateLockedRecords(records: List<PrivateRecordKey>): Set<PrivateRecordKey> {
+    val privateContentGateway: PrivateContentGateway = koinInject()
+    val privateAccessGateway: PrivateAccessGateway = koinInject()
+    val access by privateAccessGateway.state.collectAsStateWithLifecycle(PrivateAccessState())
+    val settings by privateAccessGateway.settings.collectAsStateWithLifecycle(PrivateAccessSettings())
+
+    val facts by produceState<Map<PrivateRecordKey, PrivateBookFacts>>(
+        initialValue = emptyMap(),
+        key1 = records,
+    ) {
+        value = withContext(Dispatchers.IO) {
+            records.distinct().mapNotNull { key ->
+                privateContentGateway.factsOfByName(key.name, key.author)?.let { key to it }
+            }.toMap()
+        }
+    }
+
+    return remember(records, facts, access, settings) {
+        if (!settings.verifyOnOpenBook) {
+            emptySet()
+        } else {
+            records.filterTo(mutableSetOf()) { key ->
+                val itemFacts = facts[key] ?: return@filterTo false
+                itemFacts.isPrivate && !access.isTargetGranted(null, itemFacts.groupMask)
+            }
+        }
+    }
+}
+

+ 320 - 0
app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateLockedContent.kt

@@ -0,0 +1,320 @@
+package io.legado.app.ui.widget.components.privacy
+
+import android.os.Build
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.BlurredEdgeTreatment
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil3.request.allowHardware
+import coil3.request.transformations
+import coil3.size.Size
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.button.PrimaryButton
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.image.cover.BookCoverImage
+import io.legado.app.ui.widget.components.image.cover.rememberSharedCoverTransitionRadius
+import io.legado.app.ui.widget.components.text.AppText
+
+/** 运行时模糊(`Modifier.blur`)从 API 31 起才生效,低版本是 no-op */
+val RuntimeBlurSupported: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+
+/**
+ * 脱敏封面的模糊深度。
+ *
+ * 书架卡片锁定态与详情页脱敏态共用这一个值:两端糊得不一样的话,
+ * 共享元素转场飞到一半会看到一次"糊度跳变"。
+ */
+val PrivateLockedCoverBlurRadius: Dp = 20.dp
+
+private val LOCKED_COVER_RADIUS = 4.dp
+
+/**
+ * 锁定封面上的"已隐藏"叠层:遮罩 + 动态点阵 + 锁标。
+ *
+ * **必须渲染在共享元素节点内部**:转场时 overlay 只搬运 sharedBounds 节点自己的内容,
+ * 放成兄弟节点就会出现"装饰留在原地、只有封面在飞"。所以它由 [PrivateLockedCover]
+ * 与书架卡片共用同一份实现,而不是各自拼装三层。
+ *
+ * [modifier] 供调用方控制整体淡入淡出(卡片解锁时的 alpha、详情页转场的 enter/exit)。
+ */
+@Composable
+fun PrivateLockedCoverOverlay(
+    modifier: Modifier = Modifier,
+) {
+    Box(
+        modifier = modifier.fillMaxSize(),
+        contentAlignment = Alignment.Center,
+    ) {
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .background(LegadoTheme.colorScheme.surface.copy(alpha = 0.45f))
+        )
+        // 点阵画在模糊层之外,所以它本身是清晰的,不会被封面的模糊一起糊掉
+        PrivateDotMatrix(
+            color = LegadoTheme.colorScheme.onSurface,
+            modifier = Modifier.fillMaxSize(),
+        )
+        PrivateLockIcon(modifier = Modifier.size(20.dp))
+    }
+}
+
+/**
+ * 锁定态的封面:真实封面只以强模糊形式出现,并叠一层遮罩与锁标。
+ *
+ * 模糊有两条互斥路径,选择权在这里而不是调用方:
+ * - API 31+ 走运行时 `Modifier.blur(blurRadius)`,作用在封面图这个**共享元素内部的子节点**上,
+ *   所以转场期间它带着模糊一起飞,并且半径是连续值、可以被调用方动画(书架卡片的聚焦效果);
+ * - 低版本 `Modifier.blur` 是 no-op,退回 Coil 变换烘出的静态模糊图(放弃动画)。
+ *   此时缓存键必须与清晰封面区分,否则模糊结果会污染正常封面的缓存。
+ *
+ * 共享元素按 [CoilBookCover] 的写法挂在这一层:
+ * 1. 带 `clipInOverlayDuringTransition`,动画期间 overlay 里也有圆角;
+ * 2. 圆角用 `rememberSharedCoverTransitionRadius` 与源端插值,两端衔接不跳变。
+ * 叠加层(遮罩/点阵/锁标)也必须留在 sharedBounds **内部**,否则飞起来会掉队。
+ */
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun PrivateLockedCover(
+    name: String?,
+    author: String?,
+    path: String?,
+    modifier: Modifier = Modifier,
+    sourceOrigin: String? = null,
+    bookUrl: String? = null,
+    coverSize: Size = Size(96, 134),
+    radius: Dp = LOCKED_COVER_RADIUS,
+    blurRadius: Dp = PrivateLockedCoverBlurRadius,
+    sharedCoverKey: String? = null,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    val transitionRadius = rememberSharedCoverTransitionRadius(
+        sharedCoverKey = sharedCoverKey,
+        radius = radius,
+        animatedVisibilityScope = animatedVisibilityScope
+    )
+    val shape = remember(transitionRadius) { RoundedCornerShape(transitionRadius) }
+    val blurEnabled = blurRadius > 0.dp
+    val useRuntimeBlur = blurEnabled && RuntimeBlurSupported
+    val useCoilBlur = blurEnabled && !RuntimeBlurSupported
+    Box(
+        modifier = modifier
+            .then(
+                with(sharedTransitionScope) {
+                    if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {
+                        Modifier.sharedBounds(
+                            sharedContentState = rememberSharedContentState(sharedCoverKey),
+                            animatedVisibilityScope = animatedVisibilityScope,
+                            clipInOverlayDuringTransition = OverlayClip(shape)
+                        )
+                    } else {
+                        Modifier
+                    }
+                }
+            )
+            .clip(shape)
+    ) {
+        // sharedCoverKey 这里只当缓存键用;sharedBounds 由上面的 Box 承担,
+        // 避免同一 key 出现两个持有者。
+        //
+        // preferCache = true 是有意的权衡:锁定态还需要显示"真实封面的模糊版"(否则用户
+        // 连自己有没有这本书都认不出),但**不应该**因此联网拉新链接或执行书源规则。
+        // 代价是锁定期间不会刷新封面,等解锁后由正常封面路径补上。
+        BookCoverImage(
+            name = name,
+            author = author,
+            path = path,
+            sourceOrigin = sourceOrigin,
+            bookUrl = bookUrl,
+            preferCache = true,
+            modifier = Modifier
+                .fillMaxSize()
+                .then(
+                    if (useRuntimeBlur) {
+                        Modifier.blur(blurRadius, BlurredEdgeTreatment.Unbounded)
+                    } else {
+                        Modifier
+                    }
+                ),
+            // 独立缓存键:运行时模糊用的是清晰图,但请求尺寸(见下)与书架卡片不同,
+            // 共用键会让卡片拿到小图而发虚
+            memoryCacheKey = path?.let { "$it#private-locked" },
+            showLoadingPlaceholder = false,
+            sharedCoverKey = sharedCoverKey,
+            requestBuilder = {
+                allowHardware(false)
+                size(coverSize)
+                if (useCoilBlur) {
+                    transformations(PrivateCoverBlurTransformation())
+                }
+            },
+        )
+        // 叠加层走 enter/exit 动画跟随转场;渲染位置在共享节点内部,由组件统一保证
+        val overlayModifier = Modifier.then(
+            if (animatedVisibilityScope != null) {
+                with(animatedVisibilityScope) {
+                    Modifier.animateEnterExit(enter = fadeIn(), exit = fadeOut())
+                }
+            } else {
+                Modifier
+            }
+        )
+        PrivateLockedCoverOverlay(modifier = overlayModifier)
+    }
+}
+
+@Composable
+fun PrivateLockIcon(
+    modifier: Modifier = Modifier,
+    tint: Color = LegadoTheme.colorScheme.onSurfaceVariant,
+) {
+    AppIcon(
+        modifier = modifier,
+        imageVector = Icons.Default.Lock,
+        contentDescription = null,
+        tint = tint
+    )
+}
+
+/**
+ * 文本脱敏占位条。
+ *
+ * 锁定态绝不渲染真实书名/作者/简介,一律用这种圆角条代替——
+ * 文字无法像图片那样可靠地"模糊"(低版本 blur 会失效),所以直接不渲染。
+ */
+@Composable
+fun PrivateMaskLine(
+    modifier: Modifier = Modifier,
+    widthFraction: Float = 0.6f,
+    thickness: Dp = 10.dp,
+) {
+    Box(
+        modifier = modifier
+            .fillMaxWidth(widthFraction)
+            .height(thickness)
+            .clip(RoundedCornerShape(thickness / 2))
+            .background(LegadoTheme.colorScheme.onSurface.copy(alpha = 0.12f))
+    )
+}
+
+/** 列表模式下替代书名/作者/简介的一组占位条 */
+@Composable
+fun PrivateMaskTextLines(
+    modifier: Modifier = Modifier,
+    lineWidths: List<Float> = listOf(0.5f, 0.32f),
+) {
+    Column(
+        modifier = modifier.fillMaxWidth(),
+        verticalArrangement = Arrangement.spacedBy(6.dp)
+    ) {
+        lineWidths.forEachIndexed { index, width ->
+            PrivateMaskLine(
+                widthFraction = width,
+                thickness = if (index == 0) 12.dp else 9.dp
+            )
+        }
+    }
+}
+
+/**
+ * 锁定提示块:锁标 + 标题 + 说明 + 验证按钮。
+ *
+ * 抽出来是为了让"私密分组整页隐藏"与"详情页就地脱敏"用同一套提示,不再各写一份。
+ */
+@Composable
+fun PrivateLockedHint(
+    title: String,
+    description: String?,
+    actionText: String,
+    onAction: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Column(
+        modifier = modifier.fillMaxWidth(),
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.spacedBy(12.dp)
+    ) {
+        Box(
+            modifier = Modifier
+                .size(72.dp)
+                .clip(CircleShape)
+                .background(LegadoTheme.colorScheme.surfaceContainerHigh),
+            contentAlignment = Alignment.Center
+        ) {
+            PrivateLockIcon(
+                modifier = Modifier.size(32.dp),
+                tint = LegadoTheme.colorScheme.onSurfaceVariant
+            )
+        }
+        AppText(
+            text = title,
+            style = LegadoTheme.typography.titleMediumEmphasized,
+            textAlign = TextAlign.Center
+        )
+        if (!description.isNullOrBlank()) {
+            AppText(
+                text = description,
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                textAlign = TextAlign.Center
+            )
+        }
+        PrimaryButton(
+            onClick = onAction,
+            text = actionText,
+            modifier = Modifier.padding(top = 4.dp)
+        )
+    }
+}
+
+/** 整页锁定态:私密分组的内容区复用它 */
+@Composable
+fun PrivateLockedPage(
+    title: String,
+    description: String?,
+    actionText: String,
+    onAction: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Box(
+        modifier = modifier
+            .fillMaxSize()
+            .padding(horizontal = 24.dp, vertical = 32.dp),
+        contentAlignment = Alignment.Center
+    ) {
+        PrivateLockedHint(
+            title = title,
+            description = description,
+            actionText = actionText,
+            onAction = onAction,
+            modifier = Modifier.fillMaxWidth()
+        )
+    }
+}
+

+ 199 - 0
app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateReadGate.kt

@@ -0,0 +1,199 @@
+package io.legado.app.ui.widget.components.privacy
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.domain.model.PrivateAccessState
+import io.legado.app.domain.model.PrivateBookFacts
+import io.legado.app.domain.model.PrivateUnlockTarget
+import io.legado.app.domain.model.settings.PrivateAccessSettings
+import io.legado.app.help.security.BiometricUnlockLauncher
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.utils.toastOnUi
+import org.koin.compose.viewmodel.koinViewModel
+
+/**
+ * 中心 gate 的状态。
+ *
+ * [isLocked] 是**派生**的而不是一次判定的结果:解锁成功后 [access] 会变化,状态自然翻到
+ * 未锁定,"验证成功后自动进阅读器"因此不需要额外的回调管道。
+ */
+@Stable
+data class PrivateReadGateUiState(
+    val isChecking: Boolean = true,
+    val bookUrl: String? = null,
+    val facts: PrivateBookFacts = PrivateBookFacts(isPrivate = false, groupMask = 0L),
+    val access: PrivateAccessState = PrivateAccessState(),
+    val settings: PrivateAccessSettings = PrivateAccessSettings(),
+) {
+    val target: PrivateUnlockTarget? get() = bookUrl?.let(PrivateUnlockTarget::Book)
+
+    val isLocked: Boolean
+        get() = !isChecking && bookUrl != null && facts.isPrivate &&
+                settings.verifyOnOpenBook &&
+                !access.isTargetGranted(bookUrl, facts.groupMask)
+}
+
+sealed interface PrivateReadGateEffect {
+    data class ShowMessage(val message: String) : PrivateReadGateEffect
+    data object NavigateToLocalPasswordSettings : PrivateReadGateEffect
+}
+
+/**
+ * 阅读器路由的私密闸门。
+ *
+ * 未授权时用**全屏锁定页**取代阅读器内容——不是"先打开再遮住",阅读器在这个分支里
+ * 根本不会被组合,也就没有加载正文的机会。验证通过后内容分支自然接上,就是"自动进阅读器"。
+ *
+ * @param onExit 取消时退出的动作:调用方负责"退回当前页,没有当前页就退回书架"
+ */
+@Composable
+fun PrivateReadGate(
+    bookUrl: String?,
+    onExit: () -> Unit,
+    onOpenLocalPasswordSettings: () -> Unit,
+    content: @Composable () -> Unit,
+) {
+    val viewModel = koinViewModel<PrivateReadGateViewModel>(
+        key = "PrivateReadGate:${bookUrl ?: "last-read"}"
+    )
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
+    var showPasswordDialog by rememberSaveable { mutableStateOf(false) }
+
+    val unlockTitle = stringResource(R.string.private_unlock_title)
+    val unlockSubtitle = stringResource(R.string.private_unlock_subtitle)
+    val unlockUsePassword = stringResource(R.string.private_unlock_use_password)
+    val passwordTitle = stringResource(R.string.private_unlock_password_title)
+
+    LaunchedEffect(bookUrl) { viewModel.resolve(bookUrl) }
+    LaunchedEffect(Unit) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                is PrivateReadGateEffect.ShowMessage -> context.toastOnUi(effect.message)
+                PrivateReadGateEffect.NavigateToLocalPasswordSettings -> {
+                    showPasswordDialog = false
+                    onOpenLocalPasswordSettings()
+                }
+            }
+        }
+    }
+
+    // 离开阅读器即撤销本次授权:回到书架时卡片要重新脱敏。
+    // 放在这里而不是"打开前撤销",是因为打开前撤销会让紧接着的这道 gate 再次拦人。
+    DisposableEffect(state.bookUrl) {
+        val url = state.bookUrl
+        if (url == null) {
+            onDispose { }
+        } else {
+            onDispose { viewModel.revokeGrant(url) }
+        }
+    }
+
+    fun requestUnlock() {
+        if (!state.access.hasPassword) {
+            viewModel.openLocalPasswordSettings()
+            return
+        }
+        val activity = context.findFragmentActivity()
+        if (activity != null && state.access.canUseBiometricShortcut) {
+            BiometricUnlockLauncher.launch(
+                activity = activity,
+                title = unlockTitle,
+                subtitle = unlockSubtitle,
+                negativeButtonText = unlockUsePassword,
+                onPassword = { viewModel.submitPassword(it) },
+                onFallback = { showPasswordDialog = true },
+                // 取消 = 放弃打开:按约定退回当前页 / 书架
+                onCancel = onExit,
+                onError = { message -> context.toastOnUi(message) },
+            )
+        } else {
+            showPasswordDialog = true
+        }
+    }
+
+    // 锁定态**直接弹验证**(与详情页同一套做法):从书签 / 阅读记录点一条私密书进来,
+    // 这一次点击本身就该是解锁动作,而不是先看锁定页、再点一次"验证"。
+    // 只会自动弹一次;用户取消后停在锁定页,那里仍有验证入口。
+    var autoUnlockLaunched by rememberSaveable { mutableStateOf(false) }
+    LaunchedEffect(state.isLocked) {
+        if (state.isLocked && !autoUnlockLaunched) {
+            autoUnlockLaunched = true
+            requestUnlock()
+        }
+    }
+
+    when {
+        // 判定期间不渲染任何内容:这一帧本来就处在路由淡入里,看不到空白,
+        // 但绝不能先渲染阅读器再遮住
+        state.isChecking -> Box(modifier = Modifier.fillMaxSize())
+
+        state.isLocked -> PrivateLockedPage(
+            title = stringResource(R.string.private_locked_book_title),
+            description = stringResource(
+                if (state.access.hasPassword) {
+                    R.string.private_locked_book_desc
+                } else {
+                    R.string.private_content_no_password
+                }
+            ),
+            actionText = stringResource(
+                if (state.access.hasPassword) {
+                    R.string.private_verify_and_open
+                } else {
+                    R.string.set_local_password
+                }
+            ),
+            onAction = { requestUnlock() },
+        )
+
+        else -> content()
+    }
+
+    if (showPasswordDialog) {
+        var password by remember { mutableStateOf("") }
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { onExit() },
+            title = passwordTitle,
+            content = {
+                AppTextField(
+                    value = password,
+                    onValueChange = { password = it },
+                    label = passwordTitle,
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    visualTransformation = PasswordVisualTransformation(),
+                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            },
+            confirmText = stringResource(R.string.ok),
+            onConfirm = {
+                showPasswordDialog = false
+                viewModel.submitPassword(password)
+            },
+            dismissText = stringResource(R.string.cancel),
+            onDismiss = { onExit() },
+        )
+    }
+}

+ 112 - 0
app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateReadGateViewModel.kt

@@ -0,0 +1,112 @@
+package io.legado.app.ui.widget.components.privacy
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.R
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.domain.gateway.PrivateAccessGateway
+import io.legado.app.domain.gateway.PrivateContentGateway
+import io.legado.app.domain.model.PrivateBookFacts
+import io.legado.app.domain.model.PrivateUnlockTarget
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import splitties.init.appCtx
+
+/**
+ * 阅读器入口的私密闸门。
+ *
+ * 私密判定原先只存在于书架卡片与书籍详情页两个 UI 点,于是任何**绕过这两个点**直接进
+ * 阅读器的路径(阅读记录、通知栏继续阅读、深链、文件关联、导入完成、音频播放页…)
+ * 都能在未授权状态下读到正文。这里把它收敛成阅读器路由入口这一处的唯一闸门,
+ * 而不是给每个入口各打一个补丁——后者必然漏。
+ */
+class PrivateReadGateViewModel(
+    private val privateContentGateway: PrivateContentGateway,
+    private val privateAccessGateway: PrivateAccessGateway,
+    private val bookRepository: BookRepository,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(PrivateReadGateUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<PrivateReadGateEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    /** 已判定过的入参,避免重组时重复查库 */
+    private var resolvedKey: String? = null
+    private var hasResolved = false
+
+    init {
+        viewModelScope.launch {
+            privateAccessGateway.state.collect { access ->
+                _uiState.update { it.copy(access = access) }
+            }
+        }
+        viewModelScope.launch {
+            privateAccessGateway.settings.collect { settings ->
+                _uiState.update { it.copy(settings = settings) }
+            }
+        }
+    }
+
+    /**
+     * 判定一次。
+     *
+     * bookUrl 为空时按**阅读器自己的回退规则**(`BookRepository.getLastReadBook`)解析目标:
+     * 闸门必须与阅读器看到同一本书,否则会出现"放行了 A、打开的却是 B"。
+     */
+    fun resolve(bookUrl: String?) {
+        if (hasResolved && resolvedKey == bookUrl) return
+        hasResolved = true
+        resolvedKey = bookUrl
+        viewModelScope.launch {
+            val url = bookUrl?.takeIf { it.isNotEmpty() }
+                ?: bookRepository.getLastReadBook()?.bookUrl
+            val facts = url?.let { privateContentGateway.factsOf(it) }
+                ?: PrivateBookFacts(isPrivate = false, groupMask = 0L)
+            _uiState.update { it.copy(isChecking = false, bookUrl = url, facts = facts) }
+        }
+    }
+
+    /**
+     * 应用内密码解锁。
+     *
+     * 成功后不需要任何"继续打开"的回调:授权集合变化会让 [PrivateReadGateUiState.isLocked]
+     * 自然翻假,内容分支自己就接上了。
+     */
+    fun submitPassword(password: String) {
+        val target = _uiState.value.target ?: return
+        if (password.isEmpty()) return
+        viewModelScope.launch {
+            val verified = runCatching {
+                privateAccessGateway.verifyPassword(password, target)
+            }.getOrDefault(false)
+            if (!verified) {
+                _effects.tryEmit(
+                    PrivateReadGateEffect.ShowMessage(
+                        appCtx.getString(R.string.private_unlock_password_error)
+                    )
+                )
+            }
+        }
+    }
+
+    /** 没有本地密码就没有解锁路径:只能去设置页设一个 */
+    fun openLocalPasswordSettings() {
+        _effects.tryEmit(PrivateReadGateEffect.NavigateToLocalPasswordSettings)
+    }
+
+    /**
+     * 离开阅读器即撤销这次的一次性授权。
+     *
+     * 授权范围是"这一次阅读"而不是"这个会话":不撤的话,回到书架卡片仍是解锁态,
+     * 与"每次打开都要验证"的语义不符。
+     */
+    fun revokeGrant(bookUrl: String) {
+        privateAccessGateway.revoke(PrivateUnlockTarget.Book(bookUrl))
+    }
+}

+ 158 - 0
app/src/main/java/io/legado/app/ui/widget/components/privacy/PrivateVerifyGate.kt

@@ -0,0 +1,158 @@
+package io.legado.app.ui.widget.components.privacy
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.domain.gateway.PrivateAccessGateway
+import io.legado.app.domain.model.PrivateAccessState
+import io.legado.app.help.security.BiometricUnlockLauncher
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.coroutines.launch
+import org.koin.compose.koinInject
+
+/**
+ * 「验证后才能进入」的门槛(隐私设置页)。
+ *
+ * 两点与启动门槛刻意不同:
+ * 1. **只校验、不授予**:走 [PrivateAccessGateway.verifyPasswordOnly]。用 `verifyPassword`
+ *    会顺带把私密内容解锁——AppSession 频率下尤其明显:只是进设置看一眼,保险箱就开了。
+ * 2. **取消只返回,不退出应用**:这只是"这一页不让进",不是"不许用应用"。
+ *
+ * 未设本地密码时不拦:那一页本身就是"设置密码"的入口。
+ */
+@Composable
+fun PrivateVerifyGate(
+    onCancel: () -> Unit,
+    gateway: PrivateAccessGateway = koinInject(),
+    content: @Composable () -> Unit,
+) {
+    val access by gateway.state.collectAsStateWithLifecycle(PrivateAccessState())
+    val context = LocalContext.current
+    val scope = rememberCoroutineScope()
+
+    var verified by rememberSaveable { mutableStateOf(false) }
+    var autoLaunched by rememberSaveable { mutableStateOf(false) }
+    var showPasswordDialog by rememberSaveable { mutableStateOf(false) }
+    var passwordError by remember { mutableStateOf(false) }
+    var password by remember { mutableStateOf("") }
+
+    val title = stringResource(R.string.private_unlock_title)
+    val description = stringResource(R.string.private_unlock_subtitle)
+    val usePassword = stringResource(R.string.private_unlock_use_password)
+    val passwordTitle = stringResource(R.string.private_unlock_password_title)
+    val passwordErrorText = stringResource(R.string.private_unlock_password_error)
+
+    fun submit(input: String) {
+        scope.launch {
+            if (gateway.verifyPasswordOnly(input)) {
+                verified = true
+                password = ""
+                showPasswordDialog = false
+            } else {
+                // 输错属于"还没通过",不是"放弃":留在页面上让用户重试
+                passwordError = true
+            }
+        }
+    }
+
+    fun launchVerify() {
+        val activity = context.findFragmentActivity()
+        if (activity != null && access.canUseBiometricShortcut) {
+            BiometricUnlockLauncher.launch(
+                activity = activity,
+                title = title,
+                subtitle = description,
+                negativeButtonText = usePassword,
+                onPassword = { submit(it) },
+                onFallback = { showPasswordDialog = true },
+                onCancel = onCancel,
+                onError = { showPasswordDialog = true },
+            )
+        } else {
+            showPasswordDialog = true
+        }
+    }
+
+    // 总开关关掉时这一页不拦——否则关掉之后就再也进不去把它打开了
+    val requiresVerification = access.isEnabled && access.hasPassword && !verified
+
+    if (!requiresVerification) {
+        content()
+    } else {
+        LaunchedEffect(Unit) {
+            if (autoLaunched) return@LaunchedEffect
+            autoLaunched = true
+            launchVerify()
+        }
+
+        BackHandler { onCancel() }
+
+        PrivateLockedPage(
+            title = title,
+            description = description,
+            actionText = stringResource(R.string.private_verify_and_open),
+            onAction = { launchVerify() },
+            modifier = Modifier.fillMaxSize(),
+        )
+
+        if (showPasswordDialog) {
+            AppAlertDialog(
+                show = true,
+                onDismissRequest = { onCancel() },
+                title = passwordTitle,
+                content = {
+                    Column(modifier = Modifier.fillMaxWidth()) {
+                        AppTextField(
+                            value = password,
+                            onValueChange = {
+                                password = it
+                                passwordError = false
+                            },
+                            label = passwordTitle,
+                            backgroundColor = LegadoTheme.colorScheme.surface,
+                            visualTransformation = PasswordVisualTransformation(),
+                            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+                            modifier = Modifier.fillMaxWidth(),
+                        )
+                        if (passwordError) {
+                            AppText(
+                                text = passwordErrorText,
+                                color = LegadoTheme.colorScheme.error,
+                                modifier = Modifier.padding(top = 8.dp),
+                            )
+                        }
+                    }
+                },
+                confirmText = stringResource(R.string.ok),
+                onConfirm = {
+                    val input = password
+                    submit(input)
+                },
+                dismissText = stringResource(R.string.cancel),
+                onDismiss = { onCancel() },
+            )
+        }
+    }
+}

+ 52 - 1
app/src/main/res/values-zh-rCN/strings.xml

@@ -1449,7 +1449,58 @@
     <string name="allow_drop_down_refresh">允许下拉刷新</string>
     <string name="private_group">设为隐私分组</string>
     <string name="disable_private_group">关闭隐私分组</string>
-    <string name="private_group_desc">开启后,本分组内的书籍将从公开书架隐藏</string>
+    <string name="private_group_desc">开启后,本分组内的书籍不会出现在公开书架,进入该分组需要验证。单本标记为私密的书不同:它仍留在书架上,封面与文字会被遮挡,验证后才显示。</string>
+
+    <!--私密内容-->
+    <string name="private_content_summary">私密分组与私密书籍在验证前保持隐藏。</string>
+    <string name="private_content_no_password">请先设置本地密码,才能使用私密分组与私密书籍。</string>
+    <string name="private_content_biometric">生物识别快捷解锁</string>
+    <string name="private_content_biometric_summary">用设备生物识别代替手输密码完成验证。</string>
+    <string name="private_credential_title">解锁凭据</string>
+    <string name="private_permission_title">权限与数据</string>
+    <string name="private_verify_timing_title">验证时机</string>
+    <string name="private_verify_on_enter_group">进入私密分组时验证</string>
+    <string name="private_verify_on_enter_group_summary">切换到私密分组后要求验证。</string>
+    <string name="private_verify_on_open_book">打开私密书籍时验证</string>
+    <string name="private_verify_on_open_book_summary">打开被标记为私密的书籍前要求验证。</string>
+    <string name="private_verify_on_app_start">启动应用时验证</string>
+    <string name="private_verify_on_app_start_summary">应用启动后立即要求验证,而不是等首次访问私密内容时再验证。</string>
+    <string name="private_unlock_scope_title">验证频率</string>
+    <string name="private_unlock_scope_summary">验证通过后保持有效的时间。</string>
+    <string name="private_unlock_scope_every_time">每次验证</string>
+    <string name="private_unlock_scope_app_session">进入应用时验证一次</string>
+    <string name="private_biometric_enable_title">启用生物快捷</string>
+    <string name="private_biometric_enable_subtitle">验证一次,用于封装本地密码</string>
+    <string name="private_biometric_not_enrolled">设备尚未录入指纹或人脸。</string>
+    <string name="private_biometric_no_hardware">设备没有生物识别硬件。</string>
+    <string name="private_biometric_unavailable">生物识别暂时不可用。</string>
+    <string name="private_biometric_unsupported_system">生物识别快捷解锁需要 Android 11 及以上。</string>
+    <string name="private_locked_group_title">该分组已隐藏</string>
+    <string name="private_locked_group_desc">验证后查看该分组内的书籍。</string>
+    <string name="private_locked_book_title">该书已隐藏</string>
+    <string name="private_locked_book_desc">验证后打开这本书。</string>
+    <string name="private_hidden_label">已隐藏</string>
+    <string name="private_verify_and_view">验证并查看</string>
+    <string name="private_verify_and_open">验证</string>
+    <string name="private_unlock_title">私密内容</string>
+    <string name="private_unlock_subtitle">验证后继续</string>
+    <string name="private_unlock_use_password">使用密码</string>
+    <string name="private_change_password">修改本地密码</string>
+    <string name="private_feature_enabled">启用私密功能</string>
+    <string name="private_feature_enabled_summary">关闭后不再验证、不再脱敏;本地密码与书籍/分组标记都保留。</string>
+    <string name="private_unlock_scope_until_background">离开应用前有效</string>
+    <string name="private_background_grace_title">离开后重新验证</string>
+    <string name="private_background_grace_summary">离开多久之后需要重新验证。</string>
+    <string name="private_background_grace_immediately">立即</string>
+    <string name="private_background_grace_minutes">%1$d 分钟后</string>
+    <string name="private_background_grace_seconds">%1$d 秒后</string>
+    <string name="private_current_password_title">当前密码</string>
+    <string name="private_unlock_password_title">输入本地密码</string>
+    <string name="private_unlock_password_error">密码错误</string>
+    <string name="private_mark_book">标记为私密</string>
+    <string name="private_unmark_book">取消私密标记</string>
+    <string name="private_marked_books">已更新 %1$d 本书的私密状态</string>
+    <string name="private_mark_failed">设置私密标记失败:%1$s</string>
     <string name="text_underline">文字下划线</string>
     <string name="select_new_source">选中新增源</string>
     <string name="select_update_source">选中更新源</string>

+ 1 - 1
app/src/main/res/values-zh-rHK/strings.xml

@@ -1277,7 +1277,7 @@
     <string name="allow_drop_down_refresh">允许下拉刷新</string>
     <string name="private_group">設為私隱分組</string>
     <string name="disable_private_group">關閉私隱分組</string>
-    <string name="private_group_desc">開啟後,本分組內嘅書籍將從公開書架隱藏</string>
+    <string name="private_group_desc">開啟後,本分組內嘅書籍唔會喺公開書架出現,入該分組需要驗證。單本標記為私密嘅書唔同:佢仲喺書架度,封面同文字會被遮住,驗證後先顯示。</string>
     <string name="text_underline">文字下划线</string>
     <string name="select_new_source">选中新增源</string>
     <string name="select_update_source">选中更新源</string>

+ 1 - 1
app/src/main/res/values-zh-rTW/strings.xml

@@ -1280,7 +1280,7 @@
     <string name="allow_drop_down_refresh">允许下拉刷新</string>
     <string name="private_group">設為隱私分組</string>
     <string name="disable_private_group">關閉隱私分組</string>
-    <string name="private_group_desc">開啟後,本分組內的書籍將從公開書架隱藏</string>
+    <string name="private_group_desc">開啟後,本分組內的書籍不會出現在公開書架,進入該分組需要驗證。單本標記為私密的書不同:它仍留在書架上,封面與文字會被遮擋,驗證後才顯示。</string>
     <string name="text_underline">文字下划线</string>
     <string name="select_new_source">选中新增源</string>
     <string name="select_update_source">选中更新源</string>

+ 52 - 1
app/src/main/res/values/strings.xml

@@ -1514,7 +1514,58 @@
     <string name="allow_drop_down_refresh">Allow drop-down refresh</string>
     <string name="private_group">Set as private group</string>
     <string name="disable_private_group">Disable private group</string>
-    <string name="private_group_desc">After enabling, books in this group are hidden from the public bookshelf</string>
+    <string name="private_group_desc">After enabling, books in this group stay out of the public bookshelf, and opening the group requires verification. Marking a single book as private is different: it stays on the shelf with its cover blurred until verified.</string>
+
+    <!--Private content-->
+    <string name="private_content_summary">Private groups and books stay hidden until you verify with the local password.</string>
+    <string name="private_content_no_password">Set the local password first to use private groups and books.</string>
+    <string name="private_content_biometric">Biometric shortcut</string>
+    <string name="private_content_biometric_summary">Verify with the device biometric instead of typing the password.</string>
+    <string name="private_credential_title">Unlock credential</string>
+    <string name="private_permission_title">Permissions and data</string>
+    <string name="private_verify_timing_title">When to verify</string>
+    <string name="private_verify_on_enter_group">Verify when entering a private group</string>
+    <string name="private_verify_on_enter_group_summary">Ask for verification after switching to a private group.</string>
+    <string name="private_verify_on_open_book">Verify when opening a private book</string>
+    <string name="private_verify_on_open_book_summary">Ask for verification before opening a book marked as private.</string>
+    <string name="private_verify_on_app_start">Verify on app start</string>
+    <string name="private_verify_on_app_start_summary">Ask for verification right after the app starts, instead of on first private access.</string>
+    <string name="private_unlock_scope_title">Verification frequency</string>
+    <string name="private_unlock_scope_summary">How long a successful verification stays valid.</string>
+    <string name="private_unlock_scope_every_time">Every time</string>
+    <string name="private_unlock_scope_app_session">Once per app session</string>
+    <string name="private_biometric_enable_title">Enable biometric shortcut</string>
+    <string name="private_biometric_enable_subtitle">Authenticate once to seal the local password</string>
+    <string name="private_biometric_not_enrolled">No biometric credential is enrolled on this device.</string>
+    <string name="private_biometric_no_hardware">This device has no biometric hardware.</string>
+    <string name="private_biometric_unavailable">Biometric authentication is temporarily unavailable.</string>
+    <string name="private_biometric_unsupported_system">Biometric shortcut requires Android 11 or newer.</string>
+    <string name="private_locked_group_title">This group is hidden</string>
+    <string name="private_locked_group_desc">Verify to show the books in this group.</string>
+    <string name="private_locked_book_title">This book is hidden</string>
+    <string name="private_locked_book_desc">Verify to open this book.</string>
+    <string name="private_hidden_label">Hidden</string>
+    <string name="private_verify_and_view">Verify and view</string>
+    <string name="private_verify_and_open">Verify</string>
+    <string name="private_unlock_title">Private content</string>
+    <string name="private_unlock_subtitle">Verify to continue</string>
+    <string name="private_unlock_use_password">Use password</string>
+    <string name="private_change_password">Change local password</string>
+    <string name="private_feature_enabled">Enable private content</string>
+    <string name="private_feature_enabled_summary">Turn off to skip all verification and masking. The local password and book/group marks are kept.</string>
+    <string name="private_unlock_scope_until_background">Until leaving the app</string>
+    <string name="private_background_grace_title">Re-verify after leaving</string>
+    <string name="private_background_grace_summary">How long you can be away before verification is required again.</string>
+    <string name="private_background_grace_immediately">Immediately</string>
+    <string name="private_background_grace_minutes">%1$d minutes</string>
+    <string name="private_background_grace_seconds">%1$d seconds</string>
+    <string name="private_current_password_title">Current password</string>
+    <string name="private_unlock_password_title">Enter the local password</string>
+    <string name="private_unlock_password_error">Wrong password</string>
+    <string name="private_mark_book">Mark as private</string>
+    <string name="private_unmark_book">Remove private mark</string>
+    <string name="private_marked_books">Updated the private flag of %1$d books</string>
+    <string name="private_mark_failed">Failed to update the private mark: %1$s</string>
     <string name="text_underline">Text underline</string>
     <string name="select_new_source">Select new source</string>
     <string name="select_update_source">Select update source</string>

+ 132 - 0
app/src/test/java/io/legado/app/data/dao/BookPrivateAccessQueryTest.kt

@@ -0,0 +1,132 @@
+package io.legado.app.data.dao
+
+import androidx.room.Room
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.SearchBook
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+/**
+ * 私密判定的 SQL 侧。
+ *
+ * 这些规则全部写在 SQL 里:单本标记 ∪ 所属私密分组(位掩码并集),
+ * 以及"整组隐藏"与"单本脱敏"两套语义的边界——换成内存实现就验证不到了。
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = android.app.Application::class, sdk = [35])
+class BookPrivateAccessQueryTest {
+
+    private lateinit var db: AppDatabase
+
+    // 分组 id 是位掩码,这里取两个不重叠的位
+    private val privateGroupId = 0b100L
+    private val publicGroupId = 0b1000L
+
+    private val privateGroupBook = "http://example.com/book/private-group"
+    private val publicGroupBook = "http://example.com/book/public-group"
+    private val markedBook = "http://example.com/book/marked"
+
+    @Before
+    fun setUp() {
+        db = Room.inMemoryDatabaseBuilder(
+            RuntimeEnvironment.getApplication(),
+            AppDatabase::class.java
+        ).allowMainThreadQueries().build()
+        runBlocking {
+            db.bookGroupDao.insert(
+                BookGroup(groupId = privateGroupId, groupName = "私密分组", isPrivate = true),
+                BookGroup(groupId = publicGroupId, groupName = "公开分组")
+            )
+            db.bookDao.insert(
+                newBook(privateGroupBook, privateGroupId),
+                newBook(publicGroupBook, publicGroupId),
+                newBook(markedBook, publicGroupId)
+            )
+            db.bookDao.setBooksPrivate(setOf(markedBook), isPrivate = true)
+        }
+    }
+
+    @After
+    fun tearDown() {
+        db.close()
+    }
+
+    @Test
+    fun singleBookMark_roundTripsThroughSetAndQuery() = runBlocking {
+        assertEquals(listOf(markedBook), db.bookDao.flowPrivateBookUrls().first())
+    }
+
+    @Test
+    fun singleBookMark_canBeRemovedAgain() = runBlocking {
+        db.bookDao.setBooksPrivate(setOf(markedBook), isPrivate = false)
+
+        assertTrue(db.bookDao.flowPrivateBookUrls().first().isEmpty())
+    }
+
+    @Test
+    fun singleBookMark_isPrivateEvenOutsidePrivateGroups() = runBlocking {
+        assertTrue(db.bookDao.privateFacts(markedBook).isPrivate)
+    }
+
+    @Test
+    fun bookInPrivateGroup_isPrivateByUnion() = runBlocking {
+        // 单本没标记,但所属分组私密——并集判定要认出来
+        assertTrue(db.bookDao.privateFacts(privateGroupBook).isPrivate)
+    }
+
+    @Test
+    fun ordinaryBook_isNotPrivate() = runBlocking {
+        assertFalse(db.bookDao.privateFacts(publicGroupBook).isPrivate)
+    }
+
+    @Test
+    fun groupMask_isReturnedAlongsideTheJudgement() = runBlocking {
+        // 阅读器入口的闸门要拿这个掩码复用"私密分组已解锁"的授权:
+        // 取错会让刚在书架解锁完分组的用户,点开组内一本书又被拦一次
+        assertEquals(privateGroupId, db.bookDao.privateFacts(privateGroupBook).groupMask)
+        assertEquals(publicGroupId, db.bookDao.privateFacts(publicGroupBook).groupMask)
+    }
+
+    @Test
+    fun privateGroupBooks_areHiddenFromPublicShelf() = runBlocking {
+        val urls = db.bookDao.flowBookShelf().first().map { it.bookUrl }
+
+        // 整组隐藏:私密分组的书不出现在公开书架
+        assertFalse(privateGroupBook in urls)
+        assertTrue(publicGroupBook in urls)
+        // 单本标记只做脱敏、不隐藏:它仍然留在公开书架上
+        assertTrue(markedBook in urls)
+    }
+
+    @Test
+    fun privateGroupBooks_areVisibleInsideTheirOwnGroup() = runBlocking {
+        val urls = db.bookDao.flowBookShelfByUserGroup(privateGroupId).first().map { it.bookUrl }
+
+        assertEquals(listOf(privateGroupBook), urls)
+    }
+
+    @Test
+    fun privateGroupBookCount_isVisibleToItsOwnGroupQuery() = runBlocking {
+        assertEquals(1, db.bookDao.flowUserGroupBookCount(privateGroupId).first())
+    }
+
+    private fun newBook(url: String, group: Long) = SearchBook(
+        bookUrl = url,
+        origin = "http://example.com",
+        name = "书-$url",
+        author = "作者"
+    ).toBook().apply {
+        this.group = group
+    }
+}

+ 101 - 0
app/src/test/java/io/legado/app/domain/model/PrivateAccessTest.kt

@@ -0,0 +1,101 @@
+package io.legado.app.domain.model
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PrivateAccessTest {
+
+    @Test
+    fun `book marked private alone is private`() {
+        assertTrue(
+            isPrivateBook(
+                bookUrl = "url-1",
+                group = 0L,
+                privateBookUrls = setOf("url-1"),
+                privateGroupMask = 0L
+            )
+        )
+    }
+
+    @Test
+    fun `book in private group is private`() {
+        // groupId 用位掩码存放:0b100 表示第 3 个分组
+        assertTrue(
+            isPrivateBook(
+                bookUrl = "url-1",
+                group = 0b100L,
+                privateBookUrls = emptySet(),
+                privateGroupMask = 0b010L or 0b100L
+            )
+        )
+    }
+
+    @Test
+    fun `book outside private groups is public`() {
+        assertFalse(
+            isPrivateBook(
+                bookUrl = "url-1",
+                group = 0b001L,
+                privateBookUrls = setOf("url-2"),
+                privateGroupMask = 0b100L
+            )
+        )
+    }
+
+    @Test
+    fun `no private source at all keeps everything public`() {
+        assertFalse(
+            isPrivateBook(
+                bookUrl = "url-1",
+                group = 0b111L,
+                privateBookUrls = emptySet(),
+                privateGroupMask = 0L
+            )
+        )
+    }
+
+    private fun state(
+        isUnlocked: Boolean = false,
+        grantedBookUrls: Set<String> = emptySet(),
+        grantedGroupIds: Set<Long> = emptySet(),
+    ) = PrivateAccessState(
+        isUnlocked = isUnlocked,
+        grantedBookUrls = grantedBookUrls,
+        grantedGroupIds = grantedGroupIds,
+    )
+
+    @Test
+    fun `app session unlock grants every target`() {
+        val state = state(isUnlocked = true)
+        assertTrue(state.isTargetGranted("url-1", 0b100L))
+        assertTrue(state.isTargetGranted(null, 0b001L))
+    }
+
+    @Test
+    fun `per target unlock grants only the granted book`() {
+        val state = state(grantedBookUrls = setOf("url-1"))
+        assertTrue(state.isTargetGranted("url-1", 0L))
+        assertFalse(state.isTargetGranted("url-2", 0L))
+    }
+
+    @Test
+    fun `granted group covers the books inside it`() {
+        // 进了已获准的私密分组后,组内书籍不应再逐本验证
+        val state = state(grantedGroupIds = setOf(0b010L))
+        assertTrue(state.isTargetGranted("url-in-group", 0b010L))
+        assertFalse(state.isTargetGranted("url-other-group", 0b100L))
+    }
+
+    @Test
+    fun `union covers both sources at once`() {
+        assertTrue(
+            isPrivateBook(
+                bookUrl = "url-1",
+                group = 0b010L,
+                privateBookUrls = setOf("url-1"),
+                privateGroupMask = 0b010L
+            )
+        )
+    }
+}

+ 49 - 0
app/src/test/java/io/legado/app/help/security/PrivatePasswordCipherTest.kt

@@ -0,0 +1,49 @@
+package io.legado.app.help.security
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PrivatePasswordCipherTest {
+
+    @Test
+    fun `verifier matches the password it was derived from`() {
+        val salt = PrivatePasswordCipher.generateSalt()
+        val verifier = requireNotNull(PrivatePasswordCipher.deriveVerifier("pwd-123", salt))
+        assertTrue(PrivatePasswordCipher.verify("pwd-123", salt, verifier))
+    }
+
+    @Test
+    fun `verifier rejects a wrong password`() {
+        val salt = PrivatePasswordCipher.generateSalt()
+        val verifier = requireNotNull(PrivatePasswordCipher.deriveVerifier("pwd-123", salt))
+        assertFalse(PrivatePasswordCipher.verify("pwd-124", salt, verifier))
+        assertFalse(PrivatePasswordCipher.verify("", salt, verifier))
+    }
+
+    @Test
+    fun `same password with different salts yields different verifiers`() {
+        val first = requireNotNull(
+            PrivatePasswordCipher.deriveVerifier(
+                "pwd",
+                PrivatePasswordCipher.generateSalt()
+            )
+        )
+        val second = requireNotNull(
+            PrivatePasswordCipher.deriveVerifier(
+                "pwd",
+                PrivatePasswordCipher.generateSalt()
+            )
+        )
+        assertNotEquals(first, second)
+    }
+
+    @Test
+    fun `missing salt or verifier is rejected instead of silently passing`() {
+        assertFalse(PrivatePasswordCipher.verify("pwd", "", "verifier"))
+        assertFalse(PrivatePasswordCipher.verify("pwd", PrivatePasswordCipher.generateSalt(), ""))
+        assertNull(PrivatePasswordCipher.deriveVerifier("pwd", "not-a-valid-base64-salt"))
+    }
+}

+ 14 - 0
app/src/test/java/io/legado/app/help/storage/BackupConfigSecurityTest.kt

@@ -11,4 +11,18 @@ class BackupConfigSecurityTest {
         assertTrue(LocalPreferencesKeys.PASSWORD.name in alwaysIgnoredPreferenceKeys)
         assertTrue(LocalPreferencesKeys.MIGRATED_TO_SETTINGS.name in alwaysIgnoredPreferenceKeys)
     }
+
+    /**
+     * 私密解锁的派生凭据一律不进备份:
+     * - salt + verifier 是可离线爆破的产物,密码通常很短,PBKDF2 挡不住 GPU;
+     * - 生物信封与开关绑在本机 Keystore 上,导出只会得到"开关亮着但解不开"的假状态。
+     */
+    @Test
+    fun `private access credentials are never exported`() {
+        assertTrue(LocalPreferencesKeys.PRIVATE_PASSWORD_SALT.name in alwaysIgnoredPreferenceKeys)
+        assertTrue(LocalPreferencesKeys.PRIVATE_PASSWORD_VERIFIER.name in alwaysIgnoredPreferenceKeys)
+        assertTrue(LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENABLED.name in alwaysIgnoredPreferenceKeys)
+        assertTrue(LocalPreferencesKeys.PRIVATE_BIOMETRIC_ENVELOPE.name in alwaysIgnoredPreferenceKeys)
+        assertTrue(LocalPreferencesKeys.PRIVATE_BIOMETRIC_IV.name in alwaysIgnoredPreferenceKeys)
+    }
 }

+ 6 - 24
app/src/test/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModelTest.kt

@@ -7,19 +7,18 @@ import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.DirectLinkRule
 import io.legado.app.domain.gateway.DirectLinkSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
-import io.legado.app.domain.gateway.LocalPasswordGateway
 import io.legado.app.domain.gateway.OtherConfigSystemGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.model.settings.DownloadCacheSettings
 import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.domain.model.settings.ReadAloudSettings
-import io.legado.app.domain.model.settings.DownloadCacheSettings
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.async
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.async
-import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
@@ -27,8 +26,8 @@ import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
-import org.robolectric.Shadows
 import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows
 import org.robolectric.annotation.Config
 import splitties.init.injectAsAppCtx
 
@@ -51,7 +50,6 @@ class OtherConfigViewModelTest {
             otherSettingsGateway = otherSettingsGateway,
             downloadCacheSettingsGateway = FakeDownloadCacheSettingsGateway(),
             directLinkSettingsGateway = FakeDirectLinkSettingsGateway(),
-            localPasswordGateway = FakeLocalPasswordGateway(),
             systemGateway = FakeOtherConfigSystemGateway(),
             initialState = OtherConfigUiState(),
         )
@@ -102,21 +100,15 @@ class OtherConfigViewModelTest {
     }
 
     @Test
-    fun directRuleAndPassword_writeThroughGateways() = runBlocking {
+    fun directRule_writeThroughGateway() = runBlocking {
         val directLinkGateway = FakeDirectLinkSettingsGateway()
-        val localPasswordGateway = FakeLocalPasswordGateway()
-        val viewModel = createViewModel(
-            directLinkSettingsGateway = directLinkGateway,
-            localPasswordGateway = localPasswordGateway,
-        )
+        val viewModel = createViewModel(directLinkSettingsGateway = directLinkGateway)
         Shadows.shadowOf(Looper.getMainLooper()).idle()
 
         viewModel.onIntent(OtherConfigIntent.ConfirmDirectLinkRule)
-        viewModel.onIntent(OtherConfigIntent.SaveLocalPassword("secret"))
         Shadows.shadowOf(Looper.getMainLooper()).idle()
 
         assertEquals("Example", directLinkGateway.savedRule?.summary)
-        assertEquals("secret", localPasswordGateway.savedPassword)
     }
 
     @Test
@@ -140,7 +132,6 @@ class OtherConfigViewModelTest {
         otherSettingsGateway: FakeOtherSettingsGateway = FakeOtherSettingsGateway(),
         directLinkSettingsGateway: FakeDirectLinkSettingsGateway =
             FakeDirectLinkSettingsGateway(),
-        localPasswordGateway: FakeLocalPasswordGateway = FakeLocalPasswordGateway(),
         systemGateway: FakeOtherConfigSystemGateway = FakeOtherConfigSystemGateway(),
     ) = OtherConfigViewModel(
         appLocaleGateway = FakeAppLocaleGateway(),
@@ -148,7 +139,6 @@ class OtherConfigViewModelTest {
         otherSettingsGateway = otherSettingsGateway,
         downloadCacheSettingsGateway = FakeDownloadCacheSettingsGateway(),
         directLinkSettingsGateway = directLinkSettingsGateway,
-        localPasswordGateway = localPasswordGateway,
         systemGateway = systemGateway,
         initialState = OtherConfigUiState(),
     )
@@ -228,14 +218,6 @@ class OtherConfigViewModelTest {
         override suspend fun testRule(rule: DirectLinkRule): String = "ok"
     }
 
-    private class FakeLocalPasswordGateway : LocalPasswordGateway {
-        var savedPassword: String? = null
-
-        override suspend fun setPassword(password: String?) {
-            savedPassword = password
-        }
-    }
-
     private class FakeOtherConfigSystemGateway : OtherConfigSystemGateway {
         var enabled = true
 

+ 254 - 0
app/src/test/java/io/legado/app/ui/config/privateConfig/PrivateConfigViewModelTest.kt

@@ -0,0 +1,254 @@
+package io.legado.app.ui.config.privateConfig
+
+import android.app.Application
+import android.os.Looper
+import io.legado.app.domain.gateway.LocalPasswordGateway
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.PrivateAccessGateway
+import io.legado.app.domain.model.PrivateAccessState
+import io.legado.app.domain.model.PrivateBiometricStatus
+import io.legado.app.domain.model.PrivateUnlockScope
+import io.legado.app.domain.model.PrivateUnlockTarget
+import io.legado.app.domain.model.settings.OtherSettings
+import io.legado.app.domain.model.settings.PrivateAccessSettings
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import splitties.init.injectAsAppCtx
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class PrivateConfigViewModelTest {
+
+    @Before
+    fun setUp() {
+        RuntimeEnvironment.getApplication().injectAsAppCtx()
+    }
+
+    @Test
+    fun savePassword_writesThroughLocalPasswordGateway() = runBlocking {
+        val passwordGateway = FakeLocalPasswordGateway()
+        val viewModel = createViewModel(localPasswordGateway = passwordGateway)
+        idleMainLooper()
+
+        viewModel.onIntent(PrivateConfigIntent.SavePassword("secret"))
+        idleMainLooper()
+
+        assertEquals("secret", passwordGateway.savedPassword)
+        assertFalse(viewModel.uiState.value.showPasswordDialog)
+    }
+
+    @Test
+    fun verifyTimingToggles_persistThroughPrivateAccessGateway() = runBlocking {
+        val accessGateway = FakePrivateAccessGateway()
+        val viewModel = createViewModel(privateAccessGateway = accessGateway)
+        idleMainLooper()
+
+        viewModel.onIntent(PrivateConfigIntent.SetVerifyOnEnterGroup(false))
+        idleMainLooper()
+
+        assertFalse(accessGateway.settingsValue.verifyOnEnterGroup)
+        assertFalse(viewModel.uiState.value.settings.verifyOnEnterGroup)
+    }
+
+    @Test
+    fun unlockScope_persistsEveryTime() = runBlocking {
+        val accessGateway = FakePrivateAccessGateway()
+        val viewModel = createViewModel(privateAccessGateway = accessGateway)
+        idleMainLooper()
+
+        viewModel.onIntent(PrivateConfigIntent.SetUnlockScope(PrivateUnlockScope.EveryTime))
+        idleMainLooper()
+
+        assertEquals(PrivateUnlockScope.EveryTime, accessGateway.settingsValue.unlockScope)
+        assertEquals(PrivateUnlockScope.EveryTime, viewModel.uiState.value.settings.unlockScope)
+    }
+
+    @Test
+    fun biometricShortcut_reportsUnavailableDeviceInsteadOfPretendingSuccess() = runBlocking {
+        val accessGateway = FakePrivateAccessGateway()
+        val viewModel = createViewModel(privateAccessGateway = accessGateway)
+        idleMainLooper()
+
+        val effect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.effects.first() }
+        viewModel.onIntent(PrivateConfigIntent.SetBiometricShortcutEnabled(true))
+        idleMainLooper()
+
+        assertFalse(accessGateway.biometricEnabled)
+        assertFalse(viewModel.uiState.value.biometricEnabled)
+        assertTrue(effect.await() is PrivateConfigEffect.ShowMessage)
+    }
+
+    @Test
+    fun enablingBiometricShortcut_asksForAuthenticationAndDoesNotFlipSwitchEarly() = runBlocking {
+        val accessGateway = FakePrivateAccessGateway(PrivateBiometricStatus.Available)
+        val viewModel = createViewModel(privateAccessGateway = accessGateway)
+        idleMainLooper()
+
+        val effect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.effects.first() }
+        viewModel.onIntent(PrivateConfigIntent.SetBiometricShortcutEnabled(true))
+        idleMainLooper()
+
+        // 信封要等一次真实认证才产出,所以这里只能请求认证,开关绝不能先亮起来
+        assertEquals(PrivateConfigEffect.RequestBiometricEnvelope, effect.await())
+        assertFalse(accessGateway.envelopeStored)
+        assertFalse(accessGateway.biometricEnabled)
+        assertFalse(viewModel.uiState.value.biometricEnabled)
+    }
+
+    @Test
+    fun storedEnvelope_flipsSwitchOnlyAfterGatewayAccepts() = runBlocking {
+        val accessGateway = FakePrivateAccessGateway(PrivateBiometricStatus.Available)
+        val viewModel = createViewModel(privateAccessGateway = accessGateway)
+        idleMainLooper()
+
+        viewModel.onIntent(PrivateConfigIntent.StoreBiometricEnvelope("ciphertext", "iv"))
+        idleMainLooper()
+
+        assertTrue(accessGateway.envelopeStored)
+        assertTrue(viewModel.uiState.value.biometricEnabled)
+    }
+
+    @Test
+    fun emptyEnvelope_neverFlipsSwitch() = runBlocking {
+        val accessGateway = FakePrivateAccessGateway(PrivateBiometricStatus.Available)
+        val viewModel = createViewModel(privateAccessGateway = accessGateway)
+        idleMainLooper()
+
+        viewModel.onIntent(PrivateConfigIntent.StoreBiometricEnvelope("", ""))
+        idleMainLooper()
+
+        assertFalse(accessGateway.envelopeStored)
+        assertFalse(viewModel.uiState.value.biometricEnabled)
+    }
+
+    @Test
+    fun disablingBiometricShortcut_clearsStoredEnvelope() = runBlocking {
+        val accessGateway = FakePrivateAccessGateway(PrivateBiometricStatus.Available)
+        val viewModel = createViewModel(privateAccessGateway = accessGateway)
+        idleMainLooper()
+
+        viewModel.onIntent(PrivateConfigIntent.StoreBiometricEnvelope("ciphertext", "iv"))
+        idleMainLooper()
+        assertTrue(viewModel.uiState.value.biometricEnabled)
+
+        viewModel.onIntent(PrivateConfigIntent.SetBiometricShortcutEnabled(false))
+        idleMainLooper()
+
+        assertTrue(accessGateway.disabled)
+        assertFalse(accessGateway.envelopeStored)
+        assertFalse(viewModel.uiState.value.biometricEnabled)
+    }
+
+    private fun idleMainLooper() {
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+    }
+
+    private fun createViewModel(
+        privateAccessGateway: FakePrivateAccessGateway = FakePrivateAccessGateway(),
+        localPasswordGateway: FakeLocalPasswordGateway = FakeLocalPasswordGateway(),
+        otherSettingsGateway: FakeOtherSettingsGateway = FakeOtherSettingsGateway(),
+    ) = PrivateConfigViewModel(
+        privateAccessGateway = privateAccessGateway,
+        localPasswordGateway = localPasswordGateway,
+        otherSettingsGateway = otherSettingsGateway,
+    )
+
+    private class FakePrivateAccessGateway(
+        biometricStatus: PrivateBiometricStatus = PrivateBiometricStatus.NoHardware,
+    ) : PrivateAccessGateway {
+        private val accessFlow = MutableStateFlow(
+            PrivateAccessState(
+                hasPassword = true,
+                biometricEnabled = false,
+                biometricStatus = biometricStatus,
+            )
+        )
+        private val settingsFlow = MutableStateFlow(PrivateAccessSettings())
+
+        var biometricEnabled = false
+            private set
+
+        /** 信封是否真的落过盘 */
+        var envelopeStored = false
+            private set
+
+        var disabled = false
+            private set
+
+        val settingsValue: PrivateAccessSettings get() = settingsFlow.value
+
+        override val state: Flow<PrivateAccessState> get() = accessFlow
+        override val settings: Flow<PrivateAccessSettings> get() = settingsFlow
+
+        override suspend fun updateSettings(transform: (PrivateAccessSettings) -> PrivateAccessSettings) {
+            settingsFlow.value = transform(settingsFlow.value)
+        }
+
+        override fun isGrantedNow(bookUrl: String, group: Long): Boolean =
+            accessFlow.value.isTargetGranted(bookUrl, group)
+
+        override suspend fun verifyPasswordOnly(password: String): Boolean =
+            password.isNotEmpty()
+
+        override fun onAppBackground() = Unit
+
+        override suspend fun verifyPassword(
+            password: String,
+            target: PrivateUnlockTarget?,
+        ) = true
+
+        override fun revoke(target: PrivateUnlockTarget) = Unit
+
+        override suspend fun disableBiometricShortcut() {
+            disabled = true
+            biometricEnabled = false
+            envelopeStored = false
+            accessFlow.value = accessFlow.value.copy(biometricEnabled = false)
+        }
+
+        /** 与真实实现一致:空信封或能力不可用时拒绝写入,不假装成功 */
+        override suspend fun storeBiometricEnvelope(ciphertext: String, iv: String): Boolean {
+            if (ciphertext.isBlank() || iv.isBlank()) return false
+            if (accessFlow.value.biometricStatus != PrivateBiometricStatus.Available) return false
+            envelopeStored = true
+            biometricEnabled = true
+            accessFlow.value = accessFlow.value.copy(biometricEnabled = true)
+            return true
+        }
+
+        override fun refreshBiometricStatus() = Unit
+        override fun lock() = Unit
+    }
+
+    private class FakeLocalPasswordGateway : LocalPasswordGateway {
+        var savedPassword: String? = null
+
+        override suspend fun setPassword(password: String?) {
+            savedPassword = password
+        }
+    }
+
+    private class FakeOtherSettingsGateway : OtherSettingsGateway {
+        private val settingsFlow = MutableStateFlow(OtherSettings())
+        override val currentSettings: OtherSettings get() = settingsFlow.value
+        override val settings: Flow<OtherSettings> get() = settingsFlow
+
+        override suspend fun update(transform: (OtherSettings) -> OtherSettings) {
+            settingsFlow.value = transform(settingsFlow.value)
+        }
+    }
+}

+ 51 - 0
app/src/test/java/io/legado/app/ui/widget/components/privacy/PrivateCoverBlurTransformationTest.kt

@@ -0,0 +1,51 @@
+package io.legado.app.ui.widget.components.privacy
+
+import android.app.Application
+import android.graphics.Bitmap
+import android.graphics.Color
+import coil3.size.Size
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * 锁定态封面必须真的被抹掉细节。
+ *
+ * 项目 minSdk 26,Compose 的 Modifier.blur 在 API 31 以下不生效,所以脱敏只能靠这个
+ * Transformation;这里验证它对任意 API 都真的产生了混合像素,而不是原样返回。
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class PrivateCoverBlurTransformationTest {
+
+    @Test
+    fun `sharp edge is smoothed out`() = runTest {
+        val source = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
+            for (y in 0 until height) {
+                for (x in 0 until width) {
+                    setPixel(x, y, if (x < width / 2) Color.BLACK else Color.WHITE)
+                }
+            }
+        }
+
+        val blurred = PrivateCoverBlurTransformation().transform(source, Size(64, 64))
+
+        assertEquals(64, blurred.width)
+        assertEquals(64, blurred.height)
+        val edge = Color.red(blurred.getPixel(32, 32))
+        assertNotEquals("边缘像素应被模糊成中间灰,而不是保留原始黑白", 0, edge)
+        assertNotEquals("边缘像素应被模糊成中间灰,而不是保留原始黑白", 255, edge)
+    }
+
+    @Test
+    fun `output keeps original size`() = runTest {
+        val source = Bitmap.createBitmap(40, 56, Bitmap.Config.ARGB_8888)
+        val blurred = PrivateCoverBlurTransformation().transform(source, Size(40, 56))
+        assertEquals(40, blurred.width)
+        assertEquals(56, blurred.height)
+    }
+}

+ 1 - 0
gradle/libs.versions.toml

@@ -107,6 +107,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref
 activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity" }
 
 androidx-annotation = { group = "androidx.annotation", name = "annotation", version = "1.10.0" }
+androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometricKtx" }
 androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" }
 androidx-compose-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
 androidx-compose-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptiveLayout" }