Просмотр исходного кода

使用Compose重构的阅读记录模块的初始版本。
现在阅读记录功能更加强大,能够获取某一天所有书籍统计、可视化的阅读时间线等。
目前仅实现基本功能,且尚不完善,仅供尝鲜使用。
由于原版并无精细化的数据结构,所以时间线与日期查看仅在更新此版本后开始记录。原版的查看最后阅读功能仍可使用。

HapeLee 11 месяцев назад
Родитель
Сommit
ba33e7cdd5
21 измененных файлов с 3061 добавлено и 43 удалено
  1. 15 7
      .github/workflows/test.yml
  2. 1925 0
      app/schemas/io.legado.app.data.AppDatabase/79.json
  3. 5 0
      app/src/main/AndroidManifest.xml
  4. 2 1
      app/src/main/java/io/legado/app/App.kt
  5. 8 4
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  6. 88 5
      app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt
  7. 1 1
      app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt
  8. 28 0
      app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt
  9. 20 0
      app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt
  10. 119 0
      app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt
  11. 15 0
      app/src/main/java/io/legado/app/di/readRecordModule.kt
  12. 1 1
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  13. 90 9
      app/src/main/java/io/legado/app/model/ReadBook.kt
  14. 92 9
      app/src/main/java/io/legado/app/model/ReadManga.kt
  15. 1 1
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  16. 3 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt
  17. 2 3
      app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt
  18. 528 0
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt
  19. 90 0
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt
  20. 1 2
      app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt
  21. 27 0
      app/src/main/java/io/legado/app/utils/StringUtils.kt

+ 15 - 7
.github/workflows/test.yml

@@ -273,19 +273,27 @@ jobs:
           # --no-merges: 排除合并提交
           # --no-merges: 排除合并提交
           COMMIT_LOG=$(git log "$LAST_TAG"..HEAD --pretty=format:"* %s" --no-merges)
           COMMIT_LOG=$(git log "$LAST_TAG"..HEAD --pretty=format:"* %s" --no-merges)
           
           
-          # 检查是否获取到内容
           if [ -z "$COMMIT_LOG" ]; then
           if [ -z "$COMMIT_LOG" ]; then
-            RELEASE_BODY="### 更新内容\n\n- 没有新的 Commit 记录."
+          RELEASE_BODY=$(cat <<EOF
+          ### 更新内容
+    
+          - 没有新的 Commit 记录.
+          EOF
+          )
           else
           else
-            # 格式化输出
-            RELEASE_BODY="### 更新内容\n\n$COMMIT_LOG"
+          RELEASE_BODY=$(cat <<EOF
+          ### 更新内容
+      
+          $COMMIT_LOG
+          EOF
+          )
           fi
           fi
-          
-          # 将多行字符串设置为 step output
+      
+            # 设置 step 输出
           echo "release_body<<EOF" >> $GITHUB_OUTPUT
           echo "release_body<<EOF" >> $GITHUB_OUTPUT
           echo "$RELEASE_BODY" >> $GITHUB_OUTPUT
           echo "$RELEASE_BODY" >> $GITHUB_OUTPUT
           echo "EOF" >> $GITHUB_OUTPUT
           echo "EOF" >> $GITHUB_OUTPUT
-          
+    
           echo "生成的 Release Body:"
           echo "生成的 Release Body:"
           echo "$RELEASE_BODY"
           echo "$RELEASE_BODY"
 
 

+ 1925 - 0
app/schemas/io.legado.app.data.AppDatabase/79.json

@@ -0,0 +1,1925 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 79,
+    "identityHash": "9f8bcb54f6faa9946037b60810c1d167",
+    "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, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` 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, 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": "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": "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"
+          }
+        ],
+        "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`)"
+          }
+        ]
+      },
+      {
+        "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, 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"
+          }
+        ],
+        "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, 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"
+          }
+        ],
+        "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, `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": "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, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `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": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "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, `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
+          },
+          {
+            "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, 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": "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"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "rssReadRecords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, 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
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "record"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecordDetail",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `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`, `date`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "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",
+            "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, `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": "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, 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"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "txtTocRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` 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": "rule",
+            "columnName": "rule",
+            "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, `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "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"
+          ]
+        }
+      },
+      {
+        "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, `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": "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"
+          ]
+        }
+      }
+    ],
+    "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, '9f8bcb54f6faa9946037b60810c1d167')"
+    ]
+  }
+}

+ 5 - 0
app/src/main/AndroidManifest.xml

@@ -332,6 +332,11 @@
             android:hardwareAccelerated="true"
             android:hardwareAccelerated="true"
             android:theme="@style/AppTheme.Transparent" />
             android:theme="@style/AppTheme.Transparent" />
         <!-- 阅读记录 -->
         <!-- 阅读记录 -->
+        <activity
+            android:name=".ui.book.readRecord.ReadRecordActivity"
+            android:enableOnBackInvokedCallback="true"
+            android:configChanges="orientation|screenSize"
+            android:hardwareAccelerated="true" />
         <activity
         <activity
             android:name=".ui.about.ReadRecordActivity"
             android:name=".ui.about.ReadRecordActivity"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"

+ 2 - 1
app/src/main/java/io/legado/app/App.kt

@@ -33,6 +33,7 @@ import io.legado.app.data.entities.rule.ContentRule
 import io.legado.app.data.entities.rule.ExploreRule
 import io.legado.app.data.entities.rule.ExploreRule
 import io.legado.app.data.entities.rule.SearchRule
 import io.legado.app.data.entities.rule.SearchRule
 import io.legado.app.di.appDatabaseModule
 import io.legado.app.di.appDatabaseModule
+import io.legado.app.di.readRecordModule
 import io.legado.app.help.AppFreezeMonitor
 import io.legado.app.help.AppFreezeMonitor
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.CrashHandler
 import io.legado.app.help.CrashHandler
@@ -75,7 +76,7 @@ class App : Application() {
 
 
     override fun onCreate() {
     override fun onCreate() {
         startKoin {
         startKoin {
-            modules(appDatabaseModule)
+            modules(appDatabaseModule, readRecordModule)
         }
         }
         if (getPrefString("app_theme", "0") == "12") {
         if (getPrefString("app_theme", "0") == "12") {
             if (AppConfig.customMode == "accent")
             if (AppConfig.customMode == "accent")

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

@@ -39,7 +39,7 @@ import io.legado.app.data.entities.Cookie
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.KeyboardAssist
 import io.legado.app.data.entities.KeyboardAssist
-import io.legado.app.data.entities.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.RssArticle
 import io.legado.app.data.entities.RssArticle
 import io.legado.app.data.entities.RssReadRecord
 import io.legado.app.data.entities.RssReadRecord
@@ -50,6 +50,8 @@ import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.data.entities.Server
 import io.legado.app.data.entities.Server
 import io.legado.app.data.entities.TxtTocRule
 import io.legado.app.data.entities.TxtTocRule
+import io.legado.app.data.entities.readRecord.ReadRecordDetail
+import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.help.DefaultData
 import io.legado.app.help.DefaultData
 import org.intellij.lang.annotations.Language
 import org.intellij.lang.annotations.Language
 import splitties.init.appCtx
 import splitties.init.appCtx
@@ -65,11 +67,12 @@ val appDb by lazy {
 }
 }
 
 
 @Database(
 @Database(
-    version = 78,
+    version = 79,
     exportSchema = true,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
-        RssSource::class, Bookmark::class, RssArticle::class, RssReadRecord::class,
+        RssSource::class, Bookmark::class, RssArticle::class,
+        RssReadRecord::class, ReadRecordDetail::class, ReadRecordSession::class,
         RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class,
         RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class,
         RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class],
         RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class],
     views = [BookSourcePart::class],
     views = [BookSourcePart::class],
@@ -108,7 +111,8 @@ val appDb by lazy {
         AutoMigration(from = 74, to = 75),
         AutoMigration(from = 74, to = 75),
         AutoMigration(from = 75, to = 76),
         AutoMigration(from = 75, to = 76),
         AutoMigration(from = 76, to = 77),
         AutoMigration(from = 76, to = 77),
-        AutoMigration(from = 77, to = 78)
+        AutoMigration(from = 77, to = 78),
+        AutoMigration(from = 78, to = 79)
     ]
     ]
 )
 )
 abstract class AppDatabase : RoomDatabase() {
 abstract class AppDatabase : RoomDatabase() {

+ 88 - 5
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -6,8 +6,10 @@ import androidx.room.Insert
 import androidx.room.OnConflictStrategy
 import androidx.room.OnConflictStrategy
 import androidx.room.Query
 import androidx.room.Query
 import androidx.room.Update
 import androidx.room.Update
-import io.legado.app.data.entities.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.ReadRecordShow
 import io.legado.app.data.entities.ReadRecordShow
+import io.legado.app.data.entities.readRecord.ReadRecordDetail
+import io.legado.app.data.entities.readRecord.ReadRecordSession
 
 
 @Dao
 @Dao
 interface ReadRecordDao {
 interface ReadRecordDao {
@@ -40,15 +42,17 @@ interface ReadRecordDao {
     @Query("select sum(readTime) from readRecord where bookName = :bookName")
     @Query("select sum(readTime) from readRecord where bookName = :bookName")
     fun getReadTime(bookName: String): Long?
     fun getReadTime(bookName: String): Long?
 
 
-    @Query("select readTime from readRecord where deviceId = :androidId and bookName = :bookName")
-    fun getReadTime(androidId: String, bookName: String): Long?
+    @Query("select readTime from readRecord where deviceId = :deviceId and bookName = :bookName")
+    fun getReadTime(deviceId: String, bookName: String): Long?
 
 
+    @Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName")
+    suspend fun getReadRecord(deviceId: String, bookName: String): ReadRecord?
 
 
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     @Insert(onConflict = OnConflictStrategy.REPLACE)
-    fun insert(vararg readRecord: ReadRecord)
+    suspend fun insert(vararg readRecord: ReadRecord)
 
 
     @Update
     @Update
-    fun update(vararg record: ReadRecord)
+    suspend fun update(vararg record: ReadRecord)
 
 
     @Delete
     @Delete
     fun delete(vararg record: ReadRecord)
     fun delete(vararg record: ReadRecord)
@@ -58,4 +62,83 @@ interface ReadRecordDao {
 
 
     @Query("delete from readRecord where bookName = :bookName")
     @Query("delete from readRecord where bookName = :bookName")
     fun deleteByName(bookName: String)
     fun deleteByName(bookName: String)
+
+    /**
+     * 插入或更新每日聚合统计记录。
+     */
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun insertDetail(detail: ReadRecordDetail)
+
+    /**
+     * 获取某一本书某一天的详细统计
+     * @param date 日期, 推荐格式: YYYY-MM-DD
+     */
+    @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND date = :date")
+    suspend fun getDetail(deviceId: String, bookName: String, date: String): ReadRecordDetail?
+
+    /**
+     * 查询所有发生过阅读的日期(用于日历标记)
+     */
+    @Query("SELECT DISTINCT date FROM readRecordDetail WHERE deviceId = :deviceId ORDER BY date DESC")
+    fun getAllReadDates(deviceId: String): List<String>
+
+    /**
+     * 获取某一天所有书籍的详细统计 (用于日历页面总览)
+     */
+    @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date")
+    suspend fun getDetailsByDate(deviceId: String, date: String): List<ReadRecordDetail>
+
+    // 清除每天的统计记录
+    @Query("DELETE FROM readRecordDetail WHERE bookName = :bookName")
+    fun deleteDetailByName(bookName: String)
+
+    /**
+     * 插入阅读会话记录。
+     */
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    fun insertSession(session: ReadRecordSession)
+
+    /** 获取所有 ReadRecord,按最后阅读时间倒序排列 */
+    @Query("SELECT * FROM readRecord ORDER BY lastRead DESC")
+    suspend fun getAllReadRecordsSortedByLastRead(): List<ReadRecord>
+
+    /** 搜索 ReadRecord,按最后阅读时间倒序排列 */
+    @Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC")
+    suspend fun searchReadRecordsByLastRead(query: String): List<ReadRecord>
+
+    /**
+     * 获取某一天某一本书的所有会话记录
+     */
+    @Query("""
+        SELECT * FROM readRecordSession 
+        WHERE deviceId = :deviceId 
+        AND bookName = :bookName 
+        AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch')) = :date 
+        ORDER BY startTime ASC
+    """)
+    suspend fun getSessionsByBookAndDate(deviceId: String, bookName: String, date: String): List<ReadRecordSession>
+
+    /**
+     * 获取某一天所有书籍的会话记录
+     */
+    @Query("""
+    SELECT * FROM readRecordSession 
+    WHERE deviceId = :deviceId 
+    AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date 
+    ORDER BY startTime DESC
+    """)
+    suspend fun getSessionsByDate(deviceId: String, date: String): List<ReadRecordSession>
+
+    // 清除会话记录
+    @Query("DELETE FROM readRecordSession WHERE bookName = :bookName")
+    fun deleteSessionByName(bookName: String)
+
+    @Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC")
+    suspend fun getAllDetails(): List<ReadRecordDetail>
+
+    @Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC")
+    suspend fun searchDetails(query: String): List<ReadRecordDetail>
+
+    @Delete
+    suspend fun deleteDetail(detail: ReadRecordDetail)
 }
 }

+ 1 - 1
app/src/main/java/io/legado/app/data/entities/ReadRecord.kt → app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt

@@ -1,4 +1,4 @@
-package io.legado.app.data.entities
+package io.legado.app.data.entities.readRecord
 
 
 import androidx.room.ColumnInfo
 import androidx.room.ColumnInfo
 import androidx.room.Entity
 import androidx.room.Entity

+ 28 - 0
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt

@@ -0,0 +1,28 @@
+package io.legado.app.data.entities.readRecord
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+
+@Entity(
+    tableName = "readRecordDetail",
+    primaryKeys = ["deviceId", "bookName", "date"]
+)
+data class ReadRecordDetail(
+    val deviceId: String = "",
+    val bookName: String = "",
+    val date: String = "",
+
+    // 当天阅读总时长
+    @ColumnInfo(defaultValue = "0")
+    var readTime: Long = 0L,
+
+    // 当天阅读总字数
+    @ColumnInfo(defaultValue = "0")
+    var readWords: Long = 0L,
+    // 当天第一次阅读时间
+    @ColumnInfo(defaultValue = "0")
+    var firstReadTime: Long = 0L,
+    // 当天最后一次阅读时间
+    @ColumnInfo(defaultValue = "0")
+    var lastReadTime: Long = 0L
+)

+ 20 - 0
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt

@@ -0,0 +1,20 @@
+package io.legado.app.data.entities.readRecord
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "readRecordSession")
+data class ReadRecordSession(
+    @PrimaryKey(autoGenerate = true)
+    val id: Long = 0,
+
+    val deviceId: String = "",
+    val bookName: String = "",
+
+    // 一次阅读的开始/结束
+    val startTime: Long = 0,
+    val endTime: Long = 0,
+
+    // 本次阅读的字数
+    val words: Long = 0
+)

+ 119 - 0
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -0,0 +1,119 @@
+package io.legado.app.data.repository
+
+import androidx.room.Transaction
+import cn.hutool.core.date.DatePattern
+import cn.hutool.core.date.DateUtil
+import io.legado.app.data.dao.ReadRecordDao
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordDetail
+import io.legado.app.data.entities.readRecord.ReadRecordSession
+import java.util.Date
+import kotlin.math.max
+import kotlin.math.min
+
+class ReadRecordRepository(
+    private val dao: ReadRecordDao
+) {
+
+    private fun getCurrentDeviceId(): String = ""
+
+    /**
+     * 保存一个完整的阅读会话,并同步更新 ReadRecordDetail 和 ReadRecord。
+     */
+    @Transaction
+    suspend fun saveReadSession(session: ReadRecordSession) {
+        dao.insertSession(session)
+
+        val sessionDuration = session.endTime - session.startTime
+        val dateString = DateUtil.format(Date(session.startTime), DatePattern.NORM_DATE_PATTERN)
+
+        updateReadRecordDetail(session, sessionDuration, dateString)
+        updateReadRecord(session, sessionDuration)
+    }
+
+    private suspend fun updateReadRecord(session: ReadRecordSession, sessionDuration: Long) {
+        val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
+
+        if (existingRecord != null) {
+            val updatedRecord = existingRecord.copy(
+                readTime = existingRecord.readTime + sessionDuration,
+                lastRead = session.endTime
+            )
+            dao.update(updatedRecord)
+        } else {
+            val newRecord = ReadRecord(
+                deviceId = session.deviceId,
+                bookName = session.bookName,
+                readTime = sessionDuration,
+                lastRead = session.endTime
+            )
+            dao.insert(newRecord)
+        }
+    }
+
+    private suspend fun updateReadRecordDetail(session: ReadRecordSession, sessionDuration: Long, dateString: String) {
+        val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
+
+        if (existingDetail != null) {
+            existingDetail.readTime += sessionDuration
+            existingDetail.readWords += session.words
+            existingDetail.firstReadTime = min(existingDetail.firstReadTime, session.startTime)
+            existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
+            dao.insertDetail(existingDetail)
+        } else {
+            val newDetail = ReadRecordDetail(
+                deviceId = session.deviceId,
+                bookName = session.bookName,
+                date = dateString,
+                readTime = sessionDuration,
+                readWords = session.words,
+                firstReadTime = session.startTime,
+                lastReadTime = session.endTime
+            )
+            dao.insertDetail(newDetail)
+        }
+    }
+
+    suspend fun getDailyDetails(deviceId: String, date: String): List<ReadRecordDetail> {
+        return dao.getDetailsByDate(deviceId, date)
+    }
+
+    suspend fun getDailySessions(deviceId: String, bookName: String, date: String): List<ReadRecordSession> {
+        return dao.getSessionsByBookAndDate(deviceId, bookName, date)
+    }
+
+    suspend fun getLatestReadRecords(query: String = ""): List<ReadRecord> {
+        return if (query.isBlank()) {
+            dao.getAllReadRecordsSortedByLastRead()
+        } else {
+            dao.searchReadRecordsByLastRead(query)
+        }
+    }
+
+    suspend fun getAllSessionsForDate(date: String): List<ReadRecordSession> {
+        val deviceId = getCurrentDeviceId()
+        return dao.getSessionsByDate(deviceId, date)
+    }
+
+    suspend fun getAllRecordDetails(query: String = ""): List<ReadRecordDetail> {
+        return if (query.isBlank()) {
+            dao.getAllDetails()
+        } else {
+            dao.searchDetails(query)
+        }
+    }
+
+    suspend fun deleteDetail(detail: ReadRecordDetail) {
+        dao.deleteDetail(detail)
+    }
+
+    suspend fun clearAll() {
+        dao.clear() // 清除总表
+        // dao.clearDetails()
+    }
+
+    // 暴露总时长
+    val allTime: Long
+        get() = dao.allTime
+
+}

+ 15 - 0
app/src/main/java/io/legado/app/di/readRecordModule.kt

@@ -0,0 +1,15 @@
+package io.legado.app.di
+
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.repository.ReadRecordRepository
+import io.legado.app.ui.book.readRecord.ReadRecordViewModel
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val readRecordModule = module {
+    single { get<AppDatabase>().readRecordDao }
+    single { get<AppDatabase>().bookDao }
+    single { ReadRecordRepository(get()) }
+
+    viewModel { ReadRecordViewModel(get(), get()) }
+}

+ 1 - 1
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -18,7 +18,7 @@ import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.KeyboardAssist
 import io.legado.app.data.entities.KeyboardAssist
-import io.legado.app.data.entities.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.RssSource
 import io.legado.app.data.entities.RssSource
 import io.legado.app.data.entities.RssStar
 import io.legado.app.data.entities.RssStar

+ 90 - 9
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -8,7 +8,9 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
-import io.legado.app.data.entities.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordSession
+import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.ContentProcessor
@@ -50,6 +52,8 @@ import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.Semaphore
 import kotlinx.coroutines.sync.Semaphore
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
 import splitties.init.appCtx
 import splitties.init.appCtx
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.ConcurrentHashMap
 import kotlin.coroutines.cancellation.CancellationException
 import kotlin.coroutines.cancellation.CancellationException
@@ -58,7 +62,7 @@ import kotlin.math.min
 
 
 
 
 @Suppress("MemberVisibilityCanBePrivate")
 @Suppress("MemberVisibilityCanBePrivate")
-object ReadBook : CoroutineScope by MainScope() {
+object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     var book: Book? = null
     var book: Book? = null
     var callBack: CallBack? = null
     var callBack: CallBack? = null
     var inBookshelf = false
     var inBookshelf = false
@@ -73,6 +77,8 @@ object ReadBook : CoroutineScope by MainScope() {
     var nextTextChapter: TextChapter? = null
     var nextTextChapter: TextChapter? = null
     var bookSource: BookSource? = null
     var bookSource: BookSource? = null
     var msg: String? = null
     var msg: String? = null
+    private val readRecordRepository: ReadRecordRepository by inject()
+    private var lastReadLength: Long = 0
     private val loadingChapters = arrayListOf<Int>()
     private val loadingChapters = arrayListOf<Int>()
     private val readRecord = ReadRecord()
     private val readRecord = ReadRecord()
     private val chapterLoadingJobs = ConcurrentHashMap<Int, Coroutine<*>>()
     private val chapterLoadingJobs = ConcurrentHashMap<Int, Coroutine<*>>()
@@ -280,16 +286,91 @@ object ReadBook : CoroutineScope by MainScope() {
         }
         }
     }
     }
 
 
+    // 阅读暂停的阈值
+    private const val PAUSE_THRESHOLD = 5 * 60 * 1000L
+    // 存储当前正在累积的阅读会话对象
+    private var currentActiveSession: ReadRecordSession? = null
+    //占位
+    private var currentReadLength: Long = 10L
+
     fun upReadTime() {
     fun upReadTime() {
-        executor.execute {
-            if (!AppConfig.enableReadRecord) {
-                return@execute
+        val currentLength = currentReadLength
+        if (!AppConfig.enableReadRecord) {
+            return
+        }
+        val currentBookName = book?.name ?: return
+        val endTime = System.currentTimeMillis()
+
+        //计算本次片段的时长和字数
+        val duration = endTime - readStartTime
+        var wordChange = currentLength - lastReadLength
+        if (wordChange < 0) wordChange = 0
+
+        //过滤无效记录
+        if (duration < 1000L && wordChange == 0L) {
+            readStartTime = endTime
+            lastReadLength = currentLength
+            return
+        }
+
+        //检查是否中断
+        if (currentActiveSession != null) {
+            // 计算从上一个片段结束到当前片段开始的时间间隔
+            val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime
+
+            if (timeSinceLastUpdate > PAUSE_THRESHOLD) {
+                // 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话
+                commitReadSession()
             }
             }
-            readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
-            readStartTime = System.currentTimeMillis()
-            readRecord.lastRead = System.currentTimeMillis()
-            appDb.readRecordDao.insert(readRecord)
         }
         }
+
+
+        //累加或创建新会话
+        if (currentActiveSession == null) {
+            // 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间
+            currentActiveSession = ReadRecordSession(
+                deviceId = "",
+                bookName = currentBookName,
+                startTime = readStartTime,
+                endTime = endTime,
+                words = 0
+            )
+        } else {
+            // 累加数据:只更新结束时间和总字数
+            currentActiveSession = currentActiveSession!!.copy(
+                endTime = endTime,
+                words = currentActiveSession!!.words + wordChange
+            )
+        }
+
+        readStartTime = endTime
+        lastReadLength = currentLength
+    }
+
+    /**
+     * 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。
+     * 此方法必须在后台线程上调用。
+     */
+    fun commitReadSession() {
+        // 检查是否有需要保存的数据
+        val sessionToSave = currentActiveSession ?: return
+
+        // 确保时长和字数有效
+        if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) {
+            currentActiveSession = null
+            return
+        }
+
+        //使用 runBlocking 确保数据库保存操作在当前线程上同步完成
+        try {
+            kotlinx.coroutines.runBlocking {
+                readRecordRepository.saveReadSession(sessionToSave)
+            }
+        } catch (e: Exception) {
+            AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e)
+        }
+
+        currentActiveSession = null
     }
     }
 
 
     fun upMsg(msg: String?) {
     fun upMsg(msg: String?) {

+ 92 - 9
app/src/main/java/io/legado/app/model/ReadManga.kt

@@ -6,7 +6,9 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
-import io.legado.app.data.entities.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordSession
+import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
@@ -39,10 +41,13 @@ import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.toList
 import kotlinx.coroutines.flow.toList
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Semaphore
 import kotlinx.coroutines.sync.Semaphore
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import kotlin.getValue
 import kotlin.math.min
 import kotlin.math.min
 
 
 @Suppress("MemberVisibilityCanBePrivate")
 @Suppress("MemberVisibilityCanBePrivate")
-object ReadManga : CoroutineScope by MainScope() {
+object ReadManga : CoroutineScope by MainScope() , KoinComponent{
     var inBookshelf = false
     var inBookshelf = false
     var book: Book? = null
     var book: Book? = null
     val executor = globalExecutor
     val executor = globalExecutor
@@ -68,6 +73,9 @@ object ReadManga : CoroutineScope by MainScope() {
     val mangaContents get() = buildMangaContent()
     val mangaContents get() = buildMangaContent()
     val hasNextChapter get() = durChapterIndex < simulatedChapterSize - 1
     val hasNextChapter get() = durChapterIndex < simulatedChapterSize - 1
 
 
+    private val readRecordRepository: ReadRecordRepository by inject()
+
+    private var lastReadLength: Long = 0
     fun resetData(book: Book) {
     fun resetData(book: Book) {
         ReadManga.book = book
         ReadManga.book = book
         readRecord.bookName = book.name
         readRecord.bookName = book.name
@@ -127,16 +135,91 @@ object ReadManga : CoroutineScope by MainScope() {
     }
     }
 
 
     //每次切换章节更新阅读记录
     //每次切换章节更新阅读记录
+    // 阅读暂停的阈值
+    private const val PAUSE_THRESHOLD = 5 * 60 * 1000L
+    // 存储当前正在累积的阅读会话对象
+    private var currentActiveSession: ReadRecordSession? = null
+    //占位
+    private var currentReadLength: Long = 10L
+
     fun upReadTime() {
     fun upReadTime() {
-        executor.execute {
-            if (!AppConfig.enableReadRecord) {
-                return@execute
+        val currentLength = currentReadLength
+        if (!AppConfig.enableReadRecord) {
+            return
+        }
+        val currentBookName = book?.name ?: return
+        val endTime = System.currentTimeMillis()
+
+        //计算本次片段的时长和字数
+        val duration = endTime - readStartTime
+        var wordChange = currentLength - lastReadLength
+        if (wordChange < 0) wordChange = 0
+
+        //过滤无效记录
+        if (duration < 1000L && wordChange == 0L) {
+            readStartTime = endTime
+            lastReadLength = currentLength
+            return
+        }
+
+        //检查是否中断
+        if (currentActiveSession != null) {
+            // 计算从上一个片段结束到当前片段开始的时间间隔
+            val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime
+
+            if (timeSinceLastUpdate > PAUSE_THRESHOLD) {
+                // 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话
+                commitReadSession()
+            }
+        }
+
+
+        //累加或创建新会话
+        if (currentActiveSession == null) {
+            // 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间
+            currentActiveSession = ReadRecordSession(
+                deviceId = "",
+                bookName = currentBookName,
+                startTime = readStartTime,
+                endTime = endTime,
+                words = 0
+            )
+        } else {
+            // 累加数据:只更新结束时间和总字数
+            currentActiveSession = currentActiveSession!!.copy(
+                endTime = endTime,
+                words = currentActiveSession!!.words + wordChange
+            )
+        }
+
+        readStartTime = endTime
+        lastReadLength = currentLength
+    }
+
+    /**
+     * 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。
+     * 此方法必须在后台线程上调用。
+     */
+    fun commitReadSession() {
+        // 检查是否有需要保存的数据
+        val sessionToSave = currentActiveSession ?: return
+
+        // 确保时长和字数有效
+        if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) {
+            currentActiveSession = null
+            return
+        }
+
+        //使用 runBlocking 确保数据库保存操作在当前线程上同步完成
+        try {
+            kotlinx.coroutines.runBlocking {
+                readRecordRepository.saveReadSession(sessionToSave)
             }
             }
-            readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
-            readStartTime = System.currentTimeMillis()
-            readRecord.lastRead = System.currentTimeMillis()
-            appDb.readRecordDao.insert(readRecord)
+        } catch (e: Exception) {
+            AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e)
         }
         }
+
+        currentActiveSession = null
     }
     }
 
 
     @Synchronized
     @Synchronized

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -13,7 +13,6 @@ import android.view.animation.LinearInterpolator
 import androidx.activity.addCallback
 import androidx.activity.addCallback
 import androidx.activity.viewModels
 import androidx.activity.viewModels
 import androidx.appcompat.app.AlertDialog
 import androidx.appcompat.app.AlertDialog
-import androidx.core.graphics.toColorInt
 import androidx.core.view.doOnLayout
 import androidx.core.view.doOnLayout
 import androidx.core.view.isGone
 import androidx.core.view.isGone
 import androidx.core.view.isVisible
 import androidx.core.view.isVisible
@@ -445,6 +444,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
                 Backup.autoBack(this)
                 Backup.autoBack(this)
             }
             }
         }
         }
+        ReadManga.commitReadSession()
         ReadManga.cancelPreDownloadTask()
         ReadManga.cancelPreDownloadTask()
         networkChangedListener.unRegister()
         networkChangedListener.unRegister()
         mScrollTimer.isEnabledPage = false
         mScrollTimer.isEnabledPage = false

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -337,8 +337,10 @@ class ReadBookActivity : BaseReadBookActivity(),
                 return@addCallback
                 return@addCallback
             }
             }
             if (savedInstanceState != null || !ReadBook.inBookshelf) {
             if (savedInstanceState != null || !ReadBook.inBookshelf) {
+                ReadBook.commitReadSession()
                 finish()
                 finish()
             } else {
             } else {
+                ReadBook.commitReadSession()
                 supportFinishAfterTransition()
                 supportFinishAfterTransition()
             }
             }
             //TODO: 有关测量相关问题
             //TODO: 有关测量相关问题
@@ -421,6 +423,7 @@ class ReadBookActivity : BaseReadBookActivity(),
         autoPageStop()
         autoPageStop()
         backupJob?.cancel()
         backupJob?.cancel()
         ReadBook.saveRead()
         ReadBook.saveRead()
+        ReadBook.commitReadSession()
         ReadBook.cancelPreDownloadTask()
         ReadBook.cancelPreDownloadTask()
         unregisterReceiver(timeBatteryReceiver)
         unregisterReceiver(timeBatteryReceiver)
         upSystemUiVisibility()
         upSystemUiVisibility()

+ 2 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt

@@ -41,7 +41,6 @@ import io.legado.app.model.ReadBook
 import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.widget.seekbar.SeekBarChangeListener
 import io.legado.app.ui.widget.seekbar.SeekBarChangeListener
 import io.legado.app.utils.ConstraintModify
 import io.legado.app.utils.ConstraintModify
-import io.legado.app.utils.VibrationUtils
 import io.legado.app.utils.activity
 import io.legado.app.utils.activity
 import io.legado.app.utils.applyNavigationBarPadding
 import io.legado.app.utils.applyNavigationBarPadding
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.dpToPx
@@ -451,14 +450,14 @@ class ReadMenu @JvmOverloads constructor(
             if (fromUser) {
             if (fromUser) {
                 if (AppConfig.progressBarBehavior == "page")
                 if (AppConfig.progressBarBehavior == "page")
                     ReadBook.skipToPage(value.toInt() - 1)
                     ReadBook.skipToPage(value.toInt() - 1)
-                VibrationUtils.vibrate(context, 12)
+                //VibrationUtils.vibrate(context, 12)
             }
             }
         }
         }
 
 
         seekReadPage.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
         seekReadPage.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
             override fun onStartTrackingTouch(slider: Slider) {
             override fun onStartTrackingTouch(slider: Slider) {
                 vwMenuBg.setOnClickListener(null)
                 vwMenuBg.setOnClickListener(null)
-                VibrationUtils.vibrate(context, 16)
+                //VibrationUtils.vibrate(context, 16)
             }
             }
 
 
             override fun onStopTrackingTouch(slider: Slider) {
             override fun onStopTrackingTouch(slider: Slider) {

+ 528 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt

@@ -0,0 +1,528 @@
+package io.legado.app.ui.book.readRecord
+
+import android.os.Bundle
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.lifecycleScope
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.readRecord.ReadRecordDetail
+import io.legado.app.utils.startActivityForBook
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.List
+import androidx.compose.material.icons.filled.*
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import cn.hutool.core.date.DateUtil
+import io.legado.app.base.BaseComposeActivity
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordSession
+import io.legado.app.utils.StringUtils.formatFriendlyDate
+import org.koin.androidx.compose.koinViewModel
+
+// 包含绘制时间线所需的上下文信息
+data class TimelineItem(
+    val session: ReadRecordSession,
+    // 是否显示封面和标题
+    val showHeader: Boolean
+)
+
+class ReadRecordActivity : BaseComposeActivity() {
+
+    @Composable
+    override fun Content() {
+        MaterialTheme {
+            val viewModel: ReadRecordViewModel = koinViewModel()
+            ReadRecordScreen(
+                viewModel = viewModel,
+                onBackClick = { finish() },
+                onBookClick = { bookName ->
+                    lifecycleScope.launch {
+                        val book = withContext(Dispatchers.IO) {
+                            appDb.bookDao.findByName(bookName).firstOrNull()
+                        }
+                        if (book != null) startActivityForBook(book)
+                    }
+                }
+            )
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+    }
+
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReadRecordScreen(
+    viewModel: ReadRecordViewModel,
+    onBackClick: () -> Unit,
+    onBookClick: (String) -> Unit
+) {
+    val state by viewModel.uiState.collectAsState()
+    val displayMode by viewModel.displayMode.collectAsState()
+    var showSearch by remember { mutableStateOf(false) }
+    var searchText by remember { mutableStateOf("") }
+
+    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+
+    LaunchedEffect(showSearch) {
+        if (!showSearch) {
+            viewModel.loadData("")
+        }
+    }
+
+    LaunchedEffect(searchText) {
+        if (showSearch) {
+            kotlinx.coroutines.delay(100L)
+            viewModel.loadData(searchText)
+        }
+    }
+
+    Scaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            Column {
+                LargeTopAppBar(
+                    title = { Text("阅读记录") },
+                    navigationIcon = {
+                        IconButton(onClick = onBackClick) {
+                            Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+                        }
+                    },
+                    actions = {
+                        IconButton(onClick = {
+                            val newMode = when (displayMode) {
+                                DisplayMode.AGGREGATE -> DisplayMode.TIMELINE
+                                DisplayMode.TIMELINE -> DisplayMode.LATEST
+                                DisplayMode.LATEST -> DisplayMode.AGGREGATE
+                            }
+                            viewModel.setDisplayMode(newMode)
+                        }) {
+                            val icon = when (displayMode) {
+                                DisplayMode.AGGREGATE -> Icons.Default.Timeline
+                                DisplayMode.TIMELINE -> Icons.Default.Schedule
+                                DisplayMode.LATEST -> Icons.AutoMirrored.Filled.List
+                            }
+                            val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate"
+                            Icon(icon, description)
+                        }
+                        IconButton(onClick = { showSearch = !showSearch }) {
+                            Icon(Icons.Default.Search, contentDescription = null)
+                        }
+                    },
+                    scrollBehavior = scrollBehavior
+                )
+
+                AnimatedVisibility(visible = showSearch) {
+                    SearchBarSection(
+                        query = searchText,
+                        onQueryChange = { searchText = it }
+                    )
+                }
+            }
+        }
+    ) { padding ->
+        Column(modifier = Modifier.padding(padding).fillMaxSize()) {
+
+            TotalTimeHeader(state.totalReadTime)
+
+                LazyColumn {
+                    when(displayMode){
+                        DisplayMode.AGGREGATE -> {
+                            state.groupedRecords.forEach { (date, details) ->
+
+                                val dailyTotalTime = details.sumOf { it.readTime }
+
+                                stickyHeader {
+                                    DateHeader(date, dailyTotalTime)
+                                }
+
+                                items(
+                                    items = details,
+                                    key = { it.bookName + it.readTime.toString() }
+                                ) { detail ->
+                                    ReadRecordItem(
+                                        detail = detail,
+                                        viewModel = viewModel,
+                                        onClick = { onBookClick(detail.bookName) },
+                                        onDelete = { viewModel.deleteDetail(detail) },
+                                        modifier = Modifier.animateItem()
+                                    )
+                                }
+                            }
+                        }
+                        DisplayMode.TIMELINE -> {
+                            state.timelineRecords.forEach { (date, sessions) ->
+                                val dailyTotalTime = sessions.sumOf { it.endTime - it.startTime }
+                                stickyHeader { DateHeader(date, dailyTotalTime) }
+
+                                val timelineItems = sessions.mapIndexed { index, session ->
+                                    val previousSession = sessions.getOrNull(index - 1)
+
+                                    val showHeader = index == 0 || session.bookName != previousSession?.bookName
+                                    TimelineItem(session, showHeader)
+                                }
+
+                                items(items = timelineItems, key = { it.session.id }) { item ->
+                                    TimelineSessionItem(
+                                        item = item,
+                                        onBookClick = onBookClick,
+                                        viewModel = viewModel
+                                    )
+                                }
+                            }
+                        }
+                        DisplayMode.LATEST -> {
+                            items(items = state.latestRecords, key = { it.bookName + it.deviceId }) { record ->
+                                LatestReadItem(
+                                    record = record,
+                                    viewModel = viewModel,
+                                    onClick = { onBookClick(record.bookName) },
+                                    modifier = Modifier.animateItem()
+                                )
+                            }
+                        }
+                    }
+
+                }
+        }
+    }
+}
+
+@Composable
+fun LatestReadItem(
+    record: ReadRecord,
+    viewModel: ReadRecordViewModel,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    var coverPath by remember { mutableStateOf<String?>(null) }
+
+    LaunchedEffect(record.bookName) {
+        coverPath = viewModel.getBookCover(record.bookName)
+    }
+
+    Row(
+        modifier = modifier
+            .fillMaxWidth()
+            .clickable(onClick = onClick)
+            .padding(16.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        BookCoverWithPlaceholder(coverPath)
+
+        Spacer(modifier = Modifier.width(16.dp))
+
+        Column(modifier = Modifier.weight(1f)) {
+            Text(
+                text = record.bookName,
+                style = MaterialTheme.typography.titleMedium,
+                maxLines = 2
+            )
+            Spacer(modifier = Modifier.height(4.dp))
+            Text(
+                text = "总时长: ${formatDuring(record.readTime)}",
+                style = MaterialTheme.typography.bodySmall,
+                color = Color.Gray
+            )
+            Text(
+                text = "最后阅读: ${DateUtil.format(java.util.Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
+                style = MaterialTheme.typography.bodySmall,
+                color = MaterialTheme.colorScheme.primary
+            )
+        }
+    }
+}
+
+@Composable
+fun TimelineSessionItem(
+    item: TimelineItem,
+    viewModel: ReadRecordViewModel,
+    onBookClick: (String) -> Unit
+) {
+    val session = item.session
+    var coverPath by remember { mutableStateOf<String?>(null) }
+
+    LaunchedEffect(session.bookName) {
+        coverPath = viewModel.getBookCover(session.bookName)
+    }
+
+    val startTimeText = DateUtil.format(java.util.Date(session.startTime), "HH:mm")
+    val endTimeText = DateUtil.format(java.util.Date(session.endTime), "HH:mm")
+    val duration = session.endTime - session.startTime
+
+    val nodeRadius = 4.dp
+    val lineWidth = 2.dp
+    val timelineX = 24.dp
+    val contentPaddingStart = 32.dp
+
+    val lineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+    val nodeColor = MaterialTheme.colorScheme.primary
+
+    Box(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 0.dp)
+            .clickable { onBookClick(session.bookName) }
+            .drawBehind {
+                val x = timelineX.toPx()
+                val h = size.height
+                val cy = h / 2f
+
+                drawLine(
+                    color = lineColor,
+                    start = Offset(x, 0f),
+                    end = Offset(x, h),
+                    strokeWidth = lineWidth.toPx()
+                )
+
+                drawCircle(
+                    color = nodeColor,
+                    radius = nodeRadius.toPx(),
+                    center = Offset(x, cy)
+                )
+
+            }
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(start = contentPaddingStart, end = 16.dp, top = 8.dp, bottom = 8.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Column(
+                modifier = Modifier.width(48.dp),
+                verticalArrangement = Arrangement.Center
+            ) {
+                Text(
+                    text = startTimeText,
+                    style = MaterialTheme.typography.bodySmall
+                )
+            }
+
+            Column(modifier = Modifier.weight(1f)) {
+                if (item.showHeader) {
+                    Row(verticalAlignment = Alignment.CenterVertically) {
+                        BookCoverWithPlaceholder(coverPath)
+                        Spacer(modifier = Modifier.width(8.dp))
+                        Text(
+                            text = session.bookName,
+                            style = MaterialTheme.typography.titleMedium,
+                            maxLines = 1
+                        )
+                    }
+                    Spacer(modifier = Modifier.height(6.dp))
+                }
+
+                Text(
+                    "时长: ${formatDuring(duration)}",
+                    style = MaterialTheme.typography.bodySmall
+                )
+               // Text(
+               //     "字数: ${session.words}",
+               //     style = MaterialTheme.typography.bodySmall,
+               //     color = MaterialTheme.colorScheme.onSurfaceVariant
+               // )
+            }
+        }
+    }
+}
+
+@Composable
+fun ReadRecordItem(
+    detail: ReadRecordDetail,
+    viewModel: ReadRecordViewModel,
+    onClick: () -> Unit,
+    onDelete: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    var coverPath by remember { mutableStateOf<String?>(null) }
+
+    LaunchedEffect(detail.bookName) {
+        coverPath = viewModel.getBookCover(detail.bookName)
+    }
+
+    Row(
+        modifier = modifier
+            .fillMaxWidth()
+            .clickable(onClick = onClick)
+            .padding(16.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        BookCoverWithPlaceholder(coverPath)
+
+        Spacer(modifier = Modifier.width(16.dp))
+
+        Column(modifier = Modifier.weight(1f)) {
+            Text(
+                text = detail.bookName,
+                style = MaterialTheme.typography.titleMedium,
+                maxLines = 2
+            )
+            Spacer(modifier = Modifier.height(4.dp))
+            Text(
+                text = "阅读时长: ${formatDuring(detail.readTime)}",
+                style = MaterialTheme.typography.bodySmall,
+                color = Color.Gray
+            )
+        }
+
+        IconButton(onClick = onDelete) {
+            Icon(Icons.Default.Close, "Delete", tint = Color.LightGray)
+        }
+    }
+}
+
+@Composable
+fun BookCoverWithPlaceholder(path: String?) {
+    Box(
+        modifier = Modifier
+            .width(48.dp)
+            .height(68.dp)
+            .clip(RoundedCornerShape(4.dp))
+            .background(Color(0xFFEEEEEE)), // 灰色背景
+        contentAlignment = Alignment.Center
+    ) {
+        if (path == null) {
+            Icon(Icons.Default.Book, null, tint = Color.Gray)
+        } else {
+            AsyncImage(
+                model = ImageRequest.Builder(LocalContext.current)
+                    .data(path)
+                    .crossfade(true)
+                    .build(),
+                contentDescription = null,
+                contentScale = ContentScale.Crop,
+                modifier = Modifier.fillMaxSize()
+            )
+        }
+    }
+}
+
+@Composable
+fun DateHeader(
+    date: String,
+    dailyTotalTime: Long
+) {
+    val dateText = formatFriendlyDate(date)
+    val totalTimeText = "阅读时长: ${formatDuring(dailyTotalTime)}"
+    Surface(
+        color = MaterialTheme.colorScheme.surface,
+        tonalElevation = 2.dp,
+        modifier = Modifier.fillMaxWidth()
+    ) {
+        Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+            Text(
+                text = dateText,
+                style = MaterialTheme.typography.titleMedium.copy(
+                    fontWeight = FontWeight.Bold
+                ),
+                color = MaterialTheme.colorScheme.secondary
+            )
+
+            Spacer(modifier = Modifier.height(4.dp))
+
+            Text(
+                text = totalTimeText,
+                style = MaterialTheme.typography.bodySmall,
+                color = MaterialTheme.colorScheme.onSurface
+            )
+        }
+    }
+}
+
+@Composable
+fun TotalTimeHeader(time: Long) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f))
+            .padding(16.dp),
+        horizontalAlignment = Alignment.CenterHorizontally
+    ) {
+        Text("总阅读时长", style = MaterialTheme.typography.labelMedium)
+        Text(
+            text = formatDuring(time),
+            style = MaterialTheme.typography.headlineSmall,
+            fontWeight = FontWeight.Bold
+        )
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchBarSection(
+    query: String,
+    onQueryChange: (String) -> Unit
+) {
+    Surface(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp, vertical = 8.dp),
+        shape = RoundedCornerShape(32.dp),
+        color = MaterialTheme.colorScheme.surfaceContainerLow
+    ) {
+
+        TextField(
+            value = query,
+            onValueChange = onQueryChange,
+            placeholder = { Text("搜索书名…") },
+            leadingIcon = { Icon(Icons.Default.Search, null) },
+            trailingIcon = {
+                Row(verticalAlignment = Alignment.CenterVertically) {
+                    if (query.isNotEmpty()) {
+                        IconButton(onClick = { onQueryChange("") }) {
+                            Icon(Icons.Default.Clear, contentDescription = null)
+                        }
+                    }
+                }
+            },
+            singleLine = true,
+            colors = TextFieldDefaults.colors(
+                focusedContainerColor = Color.Transparent,
+                unfocusedContainerColor = Color.Transparent,
+                disabledContainerColor = Color.Transparent,
+                focusedIndicatorColor = Color.Transparent,
+                unfocusedIndicatorColor = Color.Transparent,
+            )
+        )
+    }
+}
+
+fun formatDuring(mss: Long): String {
+    val days = mss / (1000 * 60 * 60 * 24)
+    val hours = mss % (1000 * 60 * 60 * 24) / (1000 * 60 * 60)
+    val minutes = mss % (1000 * 60 * 60) / (1000 * 60)
+    val seconds = mss % (1000 * 60) / 1000
+    val d = if (days > 0) "${days}天" else ""
+    val h = if (hours > 0) "${hours}小时" else ""
+    val m = if (minutes > 0) "${minutes}分钟" else ""
+    val s = if (seconds > 0) "${seconds}秒" else ""
+    return if ("$d$h$m$s".isBlank()) "0秒" else "$d$h$m$s"
+}

+ 90 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -0,0 +1,90 @@
+package io.legado.app.ui.book.readRecord
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.data.dao.BookDao
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordDetail
+import io.legado.app.data.entities.readRecord.ReadRecordSession
+import io.legado.app.data.repository.ReadRecordRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+data class ReadRecordUiState(
+    val isLoading: Boolean = true,
+    val totalReadTime: Long = 0,
+    //每日聚合明细
+    val groupedRecords: Map<String, List<ReadRecordDetail>> = emptyMap(),
+    //每日所有阅读会话
+    val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
+    //最后阅读列表
+    val latestRecords: List<ReadRecord> = emptyList()
+)
+
+enum class DisplayMode {
+    AGGREGATE,
+    TIMELINE,
+    LATEST
+}
+
+class ReadRecordViewModel(
+    private val repository: ReadRecordRepository,
+    private val bookDao: BookDao
+) : ViewModel() {
+    private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
+    val displayMode = _displayMode.asStateFlow()
+    private val _uiState = MutableStateFlow(ReadRecordUiState())
+    val uiState = _uiState.asStateFlow()
+
+    init {
+        loadData()
+    }
+
+    fun setDisplayMode(mode: DisplayMode) {
+        _displayMode.value = mode
+    }
+
+    fun loadData(query: String = "") {
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true) }
+
+
+            val details = repository.getAllRecordDetails(query)
+            val grouped = details.groupBy { it.date }
+            val uniqueDates = grouped.keys.toList()
+            val timelineMap = LinkedHashMap<String, List<ReadRecordSession>>()
+            for (date in uniqueDates) {
+                val sessions = repository.getAllSessionsForDate(date)
+                timelineMap[date] = sessions
+            }
+            val latest = repository.getLatestReadRecords(query)
+            val totalTime = withContext(Dispatchers.IO) { repository.allTime }
+            _uiState.update {
+                it.copy(
+                    isLoading = false,
+                    totalReadTime = totalTime,
+                    groupedRecords = grouped,
+                    timelineRecords = timelineMap,
+                    latestRecords = latest
+                )
+            }
+        }
+    }
+
+    fun deleteDetail(detail: ReadRecordDetail) {
+        viewModelScope.launch {
+            repository.deleteDetail(detail)
+            loadData()
+        }
+    }
+
+    suspend fun getBookCover(bookName: String): String? {
+        return withContext(Dispatchers.IO) {
+            bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
+        }
+    }
+}

+ 1 - 2
app/src/main/java/io/legado/app/ui/main/my/MyFragment.kt

@@ -15,11 +15,10 @@ import io.legado.app.databinding.FragmentMyConfigBinding
 import io.legado.app.lib.dialogs.selector
 import io.legado.app.lib.dialogs.selector
 import io.legado.app.lib.prefs.SwitchPreference
 import io.legado.app.lib.prefs.SwitchPreference
 import io.legado.app.lib.prefs.fragment.PreferenceFragment
 import io.legado.app.lib.prefs.fragment.PreferenceFragment
-//import io.legado.app.lib.theme.primaryColor
 import io.legado.app.service.WebService
 import io.legado.app.service.WebService
 import io.legado.app.ui.about.AboutActivity
 import io.legado.app.ui.about.AboutActivity
-import io.legado.app.ui.about.ReadRecordActivity
 import io.legado.app.ui.book.bookmark.AllBookmarkActivity
 import io.legado.app.ui.book.bookmark.AllBookmarkActivity
+import io.legado.app.ui.book.readRecord.ReadRecordActivity
 import io.legado.app.ui.book.source.manage.BookSourceActivity
 import io.legado.app.ui.book.source.manage.BookSourceActivity
 import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
 import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
 import io.legado.app.ui.config.ConfigActivity
 import io.legado.app.ui.config.ConfigActivity

+ 27 - 0
app/src/main/java/io/legado/app/utils/StringUtils.kt

@@ -8,6 +8,9 @@ import java.io.ByteArrayOutputStream
 import java.io.IOException
 import java.io.IOException
 import java.text.DecimalFormat
 import java.text.DecimalFormat
 import java.text.SimpleDateFormat
 import java.text.SimpleDateFormat
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.temporal.ChronoUnit
 import java.util.Calendar
 import java.util.Calendar
 import java.util.Locale
 import java.util.Locale
 import java.util.regex.Matcher
 import java.util.regex.Matcher
@@ -97,6 +100,30 @@ object StringUtils {
         return ""
         return ""
     }
     }
 
 
+    /**
+     * 将日期字符串转换为“今天”、“昨天”、“N天前”或标准日期格式
+     * @param dateString 待转换的日期字符串 yyyy-MM-dd
+     * @param pattern 用于解析 dateString 的格式,通常是 yyyy-MM-dd
+     */
+    fun formatFriendlyDate(dateString: String, pattern: String = "yyyy-MM-dd"): String {
+        return kotlin.runCatching {
+            val format = SimpleDateFormat(pattern, Locale.getDefault())
+            val date = format.parse(dateString) ?: return@runCatching dateString
+            val inputDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
+            val today = LocalDate.now(ZoneId.systemDefault())
+            val daysBetween = ChronoUnit.DAYS.between(inputDate, today)
+            when (daysBetween) {
+                0L -> "今天"
+                1L -> "昨天"
+                in 2L..5L -> "${daysBetween}天前"
+                else -> dateString
+            }
+        }.getOrElse {
+            it.printStackTrace()
+            dateString
+        }
+    }
+
     /**
     /**
      * 首字母大写
      * 首字母大写
      */
      */