Переглянути джерело

[新增] 合并了Sigma版本的一些更改(@Luoyacheng),具体合并了什么其实我也不知道,因为我头晕了

HapeLee 9 місяців тому
батько
коміт
cfa63797c9
49 змінених файлів з 4748 додано та 388 видалено
  1. 2008 0
      app/schemas/io.legado.app.data.AppDatabase/81.json
  2. 0 0
      app/src/debug/res/values-zh-rCN/strings.xml
  3. 1 0
      app/src/main/java/io/legado/app/constant/EventBus.kt
  4. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  5. 4 3
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  6. 11 4
      app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt
  7. 61 31
      app/src/main/java/io/legado/app/data/entities/BaseSource.kt
  8. 24 4
      app/src/main/java/io/legado/app/data/entities/RssArticle.kt
  9. 43 3
      app/src/main/java/io/legado/app/data/entities/RssReadRecord.kt
  10. 22 2
      app/src/main/java/io/legado/app/data/entities/RssStar.kt
  11. 2 0
      app/src/main/java/io/legado/app/data/entities/rule/FlexChildStyle.kt
  12. 26 4
      app/src/main/java/io/legado/app/data/entities/rule/RowUi.kt
  13. 2 0
      app/src/main/java/io/legado/app/di/appModule.kt
  14. 1 1
      app/src/main/java/io/legado/app/exception/ConcurrentException.kt
  15. 80 7
      app/src/main/java/io/legado/app/help/CacheManager.kt
  16. 79 74
      app/src/main/java/io/legado/app/help/ConcurrentRateLimiter.kt
  17. 215 26
      app/src/main/java/io/legado/app/help/JsExtensions.kt
  18. 2 0
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  19. 5 0
      app/src/main/java/io/legado/app/help/http/StrResponse.kt
  20. 21 15
      app/src/main/java/io/legado/app/help/source/SourceVerificationHelp.kt
  21. 384 0
      app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt
  22. 20 1
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt
  23. 139 19
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt
  24. 67 35
      app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt
  25. 46 9
      app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt
  26. 8 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt
  27. 85 54
      app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt
  28. 2 1
      app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
  29. 601 63
      app/src/main/java/io/legado/app/ui/login/SourceLoginDialog.kt
  30. 82 0
      app/src/main/java/io/legado/app/ui/login/SourceLoginJsExtensions.kt
  31. 41 6
      app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt
  32. 0 1
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt
  33. 0 1
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt
  34. 2 2
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt
  35. 18 1
      app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt
  36. 14 5
      app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt
  37. 1 1
      app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt
  38. 158 4
      app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt
  39. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt
  40. 358 0
      app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt
  41. 43 2
      app/src/main/java/io/legado/app/utils/StringExtensions.kt
  42. 7 0
      app/src/main/java/io/legado/app/utils/ViewExtensions.kt
  43. 16 0
      app/src/main/res/layout/dialog_web_view.xml
  44. 22 0
      app/src/main/res/layout/item_selector_single.xml
  45. 1 0
      app/src/main/res/values-zh-rCN/strings.xml
  46. 7 0
      app/src/main/res/values/array_values.xml
  47. 8 1
      app/src/main/res/values/arrays.xml
  48. 1 0
      app/src/main/res/values/strings.xml
  49. 7 4
      app/src/main/res/xml/pref_config_read.xml

+ 2008 - 0
app/schemas/io.legado.app.data.AppDatabase/81.json

@@ -0,0 +1,2008 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 81,
+    "identityHash": "521dbf23b10cd397e1f4612b1bbe6ae0",
+    "entities": [
+      {
+        "tableName": "books",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `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": "remark",
+            "columnName": "remark",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "charset",
+            "columnName": "charset",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "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, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link",
+            "sort"
+          ]
+        }
+      },
+      {
+        "tableName": "rssReadRecords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))",
+        "fields": [
+          {
+            "fieldPath": "record",
+            "columnName": "record",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "record"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssReadRecords_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ]
+      },
+      {
+        "tableName": "readRecordDetail",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `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, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "starTime",
+            "columnName": "starTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "txtTocRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `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, '521dbf23b10cd397e1f4612b1bbe6ae0')"
+    ]
+  }
+}

+ 0 - 0
app/src/debug/res/values-zh/strings.xml → app/src/debug/res/values-zh-rCN/strings.xml


+ 1 - 0
app/src/main/java/io/legado/app/constant/EventBus.kt

@@ -34,5 +34,6 @@ object EventBus {
     const val EXPORT_BOOK = "exportBook"
     const val UP_MANGA_CONFIG = "upMangaConfig"
     const val PLAY_MODE_CHANGED = "playModeChanged"
+    const val REFRESH_BOOK_INFO = "refreshBookInfo"
     const val UP_TOC = "upToc"
 }

+ 1 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -160,6 +160,7 @@ object PreferKey {
     const val showBookshelfFastScroller = "showBookshelfFastScroller"
     const val importKeepEnable = "importKeepEnable"
     const val previewImageByClick = "previewImageByClick"
+    const val clickImgWay = "clickImgWay"
     const val keyPageOnLongPress = "keyPageOnLongPress"
     const val volumeKeyPage = "volumeKeyPage"
     const val volumeKeyPageOnPlay = "volumeKeyPageOnPlay"

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

@@ -39,7 +39,6 @@ import io.legado.app.data.entities.Cookie
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.KeyboardAssist
-import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.RssArticle
 import io.legado.app.data.entities.RssReadRecord
@@ -50,6 +49,7 @@ import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.data.entities.Server
 import io.legado.app.data.entities.TxtTocRule
+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.help.DefaultData
@@ -67,7 +67,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 80,
+    version = 81,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -113,7 +113,8 @@ val appDb by lazy {
         AutoMigration(from = 76, to = 77),
         AutoMigration(from = 77, to = 78),
         AutoMigration(from = 78, to = 79),
-        AutoMigration(from = 79, to = 80)
+        AutoMigration(from = 79, to = 80),
+        AutoMigration(from = 80, to = 81)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {

+ 11 - 4
app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt

@@ -1,20 +1,27 @@
 package io.legado.app.data.dao
 
-import androidx.room.*
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
 import io.legado.app.data.entities.RssArticle
 import kotlinx.coroutines.flow.Flow
 
 @Dao
 interface RssArticleDao {
 
+    @Query("select * from rssArticles where origin = :origin and link = :link and sort = :sort")
+    fun get(origin: String, link: String, sort: String): RssArticle?
+
     @Query("select * from rssArticles where origin = :origin and link = :link")
-    fun get(origin: String, link: String): RssArticle?
+    fun getByLink(origin: String, link: String): RssArticle?
 
     @Query(
         """select t1.link, t1.sort, t1.origin, t1.`order`, t1.title, t1.content, 
-            t1.description, t1.image, t1.`group`, t1.pubDate, t1.variable, ifNull(t2.read, 0) as read
+            t1.description, t1.image, t1.`group`, t1.pubDate, t1.variable, t1.type, t1.durPos, ifNull(t2.read, 0) as read
         from rssArticles as t1 left join rssReadRecords as t2
-        on t1.link = t2.record  where origin = :origin and sort = :sort
+        on t1.link = t2.record  where t1.origin = :origin and t1.sort = :sort
         order by `order` desc"""
     )
     fun flowByOriginSort(origin: String, sort: String): Flow<List<RssArticle>>

+ 61 - 31
app/src/main/java/io/legado/app/data/entities/BaseSource.kt

@@ -1,5 +1,6 @@
 package io.legado.app.data.entities
 
+import android.webkit.JavascriptInterface
 import cn.hutool.crypto.symmetric.AES
 import com.script.ScriptBindings
 import com.script.buildScriptBindings
@@ -56,7 +57,7 @@ interface BaseSource : JsExtensions {
      */
     var jsLib: String?
 
-    fun getTag(): String
+    override fun getTag(): String
 
     fun getKey(): String
 
@@ -64,30 +65,6 @@ interface BaseSource : JsExtensions {
         return this
     }
 
-    fun loginUi(): List<RowUi>? {
-        val json = loginUi?.let {
-            val loginJS = getLoginJs() ?: ""
-            try {
-                when {
-                    it.startsWith("@js:") -> evalJS(loginJS + it.substring(4)){
-                        put("result", getLoginInfoMap())
-                    }.toString()
-                    it.startsWith("<js>") -> evalJS(loginJS + it.substring(4,it.lastIndexOf("<"))){
-                        put("result", getLoginInfoMap())
-                    }.toString()
-                    else -> it
-                }
-            } catch (e: Throwable) {
-                log(e)
-                e.printOnDebug()
-                null
-            }
-        }
-        return GSON.fromJsonArray<RowUi>(json).onFailure {
-            it.printOnDebug()
-        }.getOrNull()
-    }
-
     fun getLoginJs(): String? {
         val loginJs = loginUrl
         return when {
@@ -98,9 +75,7 @@ interface BaseSource : JsExtensions {
         }
     }
 
-    /**
-     * 调用login函数 实现登录请求
-     */
+    @JavascriptInterface
     fun login() {
         val loginJs = getLoginJs()
         if (!loginJs.isNullOrBlank()) {
@@ -116,6 +91,30 @@ interface BaseSource : JsExtensions {
         }
     }
 
+    fun loginUi(): List<RowUi>? {
+        val json = loginUi?.let {
+            val loginJS = getLoginJs() ?: ""
+            try {
+                when {
+                    it.startsWith("@js:") -> evalJS(loginJS + it.substring(4)){
+                        put("result", getLoginInfoMap())
+                    }.toString()
+                    it.startsWith("<js>") -> evalJS(loginJS + it.substring(4,it.lastIndexOf("<"))){
+                        put("result", getLoginInfoMap())
+                    }.toString()
+                    else -> it
+                }
+            } catch (e: Throwable) {
+                log(e)
+                e.printOnDebug()
+                null
+            }
+        }
+        return GSON.fromJsonArray<RowUi>(json).onFailure {
+            it.printOnDebug()
+        }.getOrNull()
+    }
+
     /**
      * 解析header规则
      */
@@ -194,9 +193,40 @@ interface BaseSource : JsExtensions {
         }
     }
 
-    fun getLoginInfoMap(): Map<String, String> {
-        val json = getLoginInfo() ?: return emptyMap()
-        return GSON.fromJsonObject<Map<String, String>>(json).getOrNull() ?: emptyMap()
+    private fun configureScriptBindings(): ScriptBindings.() -> Unit = {
+        put("result", mutableMapOf<String, String>())
+        put("book", null)
+        put("chapter", null)
+    }
+
+    fun getLoginInfoMap(): MutableMap<String, String> {
+        val json = getLoginInfo() ?: if (loginUi.isNullOrBlank()) {
+            return mutableMapOf()
+        } else {
+            val loginUiJson = loginUi?.let {
+                when {
+                    it.startsWith("@js:") -> evalJS(
+                        "${getLoginJs() ?: ""}\n${it.substring(4)}",
+                        configureScriptBindings()
+                    ).toString()
+
+                    it.startsWith("<js>") -> evalJS(
+                        "${getLoginJs() ?: ""}\n${it.substring(4, it.lastIndexOf("<"))}",
+                        configureScriptBindings()
+                    ).toString()
+
+                    else -> it
+                }
+            }
+            val longinInfo = GSON.fromJsonArray<RowUi>(loginUiJson).getOrNull()
+                ?.filter { it.type != "button" }
+                ?.associate { it.name to (it.default ?: "") }
+                ?.takeIf { it.isNotEmpty() }?.also {
+                    putLoginInfo(GSON.toJson(it))
+                }
+            return longinInfo?.toMutableMap() ?: mutableMapOf()
+        }
+        return GSON.fromJsonObject<MutableMap<String, String>>(json).getOrNull() ?: mutableMapOf()
     }
 
     /**

+ 24 - 4
app/src/main/java/io/legado/app/data/entities/RssArticle.kt

@@ -9,7 +9,7 @@ import kotlinx.parcelize.IgnoredOnParcel
 
 @Entity(
     tableName = "rssArticles",
-    primaryKeys = ["origin", "link"]
+    primaryKeys = ["origin", "link", "sort"]
 )
 data class RssArticle(
     override var origin: String = "",
@@ -24,14 +24,20 @@ data class RssArticle(
     @ColumnInfo(defaultValue = "默认分组")
     var group: String = "默认分组",
     var read: Boolean = false,
-    override var variable: String? = null
+    override var variable: String? = null,
+    /**类型 0网页,1图片,2视频**/
+    @ColumnInfo(defaultValue = "0")
+    var type: Int = 0,
+    /**阅读进度**/
+    @ColumnInfo(defaultValue = "0")
+    var durPos: Int = 0
 ) : BaseRssArticle {
 
     override fun hashCode() = link.hashCode()
 
     override fun equals(other: Any?): Boolean {
         other ?: return false
-        return if (other is RssArticle) origin == other.origin && link == other.link else false
+        return if (other is RssArticle) origin == other.origin && link == other.link && sort == other.sort else false
     }
 
     @delegate:Transient
@@ -52,6 +58,20 @@ data class RssArticle(
         content = content,
         image = image,
         group = group,
-        variable = variable
+        variable = variable,
+        type = type,
+        durPos = durPos
+    )
+
+    fun toRecord() = RssReadRecord(
+        origin = origin,
+        sort = sort,
+        title = title,
+        readTime = System.currentTimeMillis(),
+        record = link,
+        image = image,
+        type = type,
+        durPos = durPos,
+        pubDate = pubDate
     )
 }

+ 43 - 3
app/src/main/java/io/legado/app/data/entities/RssReadRecord.kt

@@ -1,13 +1,53 @@
 package io.legado.app.data.entities
 
+import androidx.room.ColumnInfo
 import androidx.room.Entity
+import androidx.room.Index
 import androidx.room.PrimaryKey
 
-@Entity(tableName = "rssReadRecords")
+@Entity(tableName = "rssReadRecords", indices = [Index(value = ["origin"], unique = false)])
 data class RssReadRecord(
     @PrimaryKey
     val record: String,
     val title: String? = null,
     val readTime: Long? = null,
-    val read: Boolean = true
-)
+    val read: Boolean = true,
+    @ColumnInfo(defaultValue = "")
+    val origin: String = "",
+    @ColumnInfo(defaultValue = "")
+    var sort: String = "",
+    var image: String? = null,
+    /**类型 0网页,1图片,2视频**/
+    @ColumnInfo(defaultValue = "0")
+    var type: Int = 0,
+    /**阅读进度**/
+    @ColumnInfo(defaultValue = "0")
+    var durPos: Int = 0,
+    var pubDate: String? = null
+) {
+    fun toRssArticle(): RssArticle {
+        return RssArticle(
+            title = title ?: "",
+            origin = origin,
+            link = record,
+            sort = sort,
+            image = image,
+            type = type,
+            durPos = durPos,
+            pubDate = pubDate
+        )
+    }
+
+    fun toStar(): RssStar {
+        return RssStar(
+            title = title ?: "",
+            origin = origin,
+            link = record,
+            sort = sort,
+            image = image,
+            type = type,
+            durPos = durPos,
+            pubDate = pubDate
+        )
+    }
+}

+ 22 - 2
app/src/main/java/io/legado/app/data/entities/RssStar.kt

@@ -24,7 +24,13 @@ data class RssStar(
     var image: String? = null,
     @ColumnInfo(defaultValue = "默认分组")
     var group: String = "默认分组",
-    override var variable: String? = null
+    override var variable: String? = null,
+    /**类型 0网页,1图片,2视频**/
+    @ColumnInfo(defaultValue = "0")
+    var type: Int = 0,
+    /**阅读进度**/
+    @ColumnInfo(defaultValue = "0")
+    var durPos: Int = 0
 ) : BaseRssArticle {
 
     @delegate:Transient
@@ -44,6 +50,20 @@ data class RssStar(
         content = content,
         image = image,
         group = group,
-        variable = variable
+        variable = variable,
+        type = type,
+        durPos = durPos
+    )
+
+    fun toRecord() = RssReadRecord(
+        origin = origin,
+        sort = sort,
+        title = title,
+        readTime = System.currentTimeMillis(),
+        record = link,
+        image = image,
+        type = type,
+        durPos = durPos,
+        pubDate = pubDate
     )
 }

+ 2 - 0
app/src/main/java/io/legado/app/data/entities/rule/FlexChildStyle.kt

@@ -9,6 +9,8 @@ data class FlexChildStyle(
     val layout_alignSelf: String = "auto",
     val layout_flexBasisPercent: Float = -1F,
     val layout_wrapBefore: Boolean = false,
+    /** 自定义的内部水平对齐属性 **/
+    val layout_justifySelf: String = "auto"
 ) {
 
     fun alignSelf(): Int {

+ 26 - 4
app/src/main/java/io/legado/app/data/entities/rule/RowUi.kt

@@ -1,10 +1,13 @@
 package io.legado.app.data.entities.rule
 
 data class RowUi(
-    var name: String = "",
-    var type: String = "text",
-    var action: String? = null,
-    var style: FlexChildStyle? = null
+    val name: String = "",
+    val type: String = "text",
+    val action: String? = null,
+    val chars: Array<String?>? = null,
+    val default: String? = null,
+    var viewName: String? = null,
+    val style: FlexChildStyle? = null
 ) {
 
     @Suppress("ConstPropertyName")
@@ -13,6 +16,8 @@ data class RowUi(
         const val text = "text"
         const val password = "password"
         const val button = "button"
+        const val toggle = "toggle"
+        const val select = "select"
 
     }
 
@@ -20,4 +25,21 @@ data class RowUi(
         return style ?: FlexChildStyle.defaultStyle
     }
 
+    override fun equals(other: Any?): Boolean {
+        if (other is RowUi) {
+            return other.name == name
+                    && other.type == type
+                    && other.action == action
+                    && other.default == default
+        }
+        return false
+    }
+
+    override fun hashCode(): Int {
+        var result = name.hashCode() + type.hashCode()
+        result = 31 * result + (action?.hashCode() ?: 0)
+        result = 31 * result + (default?.hashCode() ?: 0)
+        return result
+    }
+
 }

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

@@ -12,6 +12,7 @@ import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
 import io.legado.app.ui.book.explore.ExploreShowViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
+import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.main.my.MyViewModel
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleViewModel
@@ -43,6 +44,7 @@ val appModule = module {
         )
     }
     viewModel { ReplaceRuleViewModel(androidApplication()) }
+    viewModel { DictRuleViewModel(androidApplication()) }
     viewModel { ExploreShowViewModel(get()) }
     viewModel { SearchContentViewModel(get(), get()) }
     viewModel { MyViewModel(get()) }

+ 1 - 1
app/src/main/java/io/legado/app/exception/ConcurrentException.kt

@@ -5,4 +5,4 @@ package io.legado.app.exception
 /**
  * 并发限制
  */
-class ConcurrentException(msg: String, val waitTime: Int) : NoStackTraceException(msg)
+class ConcurrentException(msg: String, val waitTime: Long) : NoStackTraceException(msg)

+ 80 - 7
app/src/main/java/io/legado/app/help/CacheManager.kt

@@ -1,5 +1,6 @@
 package io.legado.app.help
 
+import android.webkit.JavascriptInterface
 import androidx.annotation.Keep
 import androidx.collection.LruCache
 import io.legado.app.data.appDb
@@ -60,8 +61,9 @@ object CacheManager {
         when (value) {
             is ByteArray -> ACache.get().put(key, value, saveTime)
             else -> {
-                val cache = Cache(key, value.toString(), deadline)
-                putMemory(key, value)
+                val valueStr = value.toString()
+                putMemory(key, valueStr)
+                val cache = Cache(key, valueStr, deadline)
                 appDb.cacheDao.insert(cache)
             }
         }
@@ -86,26 +88,50 @@ object CacheManager {
         }
         val cache = appDb.cacheDao.get(key)
         if (cache != null && (cache.deadline == 0L || cache.deadline > System.currentTimeMillis())) {
-            putMemory(key, cache.value ?: "")
+            return cache.value?.also {
+                putMemory(key, it)
+            }
+        }
+        return null
+    }
+
+    fun get(key: String, onlyDisk: Boolean): String? {
+        if (!onlyDisk) {
+            return get(key)
+        }
+        val cache = appDb.cacheDao.get(key)
+        if (cache != null && (cache.deadline == 0L || cache.deadline > System.currentTimeMillis())) {
             return cache.value
         }
         return null
     }
 
     fun getInt(key: String): Int? {
-        return get(key)?.toIntOrNull()
+        getFromMemory(key)?.let {
+            if (it is Int) return it
+        }
+        return get(key, true)?.toIntOrNull()
     }
 
     fun getLong(key: String): Long? {
-        return get(key)?.toLongOrNull()
+        getFromMemory(key)?.let {
+            if (it is Long) return it
+        }
+        return get(key, true)?.toLongOrNull()
     }
 
     fun getDouble(key: String): Double? {
-        return get(key)?.toDoubleOrNull()
+        getFromMemory(key)?.let {
+            if (it is Double) return it
+        }
+        return get(key, true)?.toDoubleOrNull()
     }
 
     fun getFloat(key: String): Float? {
-        return get(key)?.toFloatOrNull()
+        getFromMemory(key)?.let {
+            if (it is Float) return it
+        }
+        return get(key, true)?.toFloatOrNull()
     }
 
     fun getByteArray(key: String): ByteArray? {
@@ -125,4 +151,51 @@ object CacheManager {
         deleteMemory(key)
         ACache.get().remove(key)
     }
+}
+
+object WebCacheManager {
+    @JavascriptInterface
+    fun put(key: String, value: String, saveTime: Int = 0) {
+        CacheManager.put(key, value, saveTime)
+    }
+
+    @JavascriptInterface
+    fun putMemory(key: String, value: String) {
+        memoryLruCache.put(key, value)
+    }
+
+    @JavascriptInterface
+    fun getFromMemory(key: String): String? {
+        return memoryLruCache[key] as? String
+    }
+
+    @JavascriptInterface
+    fun deleteMemory(key: String) {
+        memoryLruCache.remove(key)
+    }
+
+    @JavascriptInterface
+    fun get(key: String): String? {
+        return CacheManager.get(key)
+    }
+
+    @JavascriptInterface
+    fun get(key: String, onlyDisk: Boolean): String? {
+        return CacheManager.get(key, onlyDisk)
+    }
+
+    @JavascriptInterface
+    fun putFile(key: String, value: String, saveTime: Int = 0) {
+        CacheManager.putFile(key, value, saveTime)
+    }
+
+    @JavascriptInterface
+    fun getFile(key: String): String? {
+        return CacheManager.getFile(key)
+    }
+
+    @JavascriptInterface
+    fun delete(key: String) {
+        CacheManager.delete(key)
+    }
 }

+ 79 - 74
app/src/main/java/io/legado/app/help/ConcurrentRateLimiter.kt

@@ -4,71 +4,95 @@ import io.legado.app.data.entities.BaseSource
 import io.legado.app.exception.ConcurrentException
 import io.legado.app.model.analyzeRule.AnalyzeUrl.ConcurrentRecord
 import kotlinx.coroutines.delay
+import java.util.concurrent.ConcurrentHashMap
 
-class ConcurrentRateLimiter(val source: BaseSource?) {
+class ConcurrentRateLimiter(source: BaseSource?) {
 
     companion object {
-        private val concurrentRecordMap = hashMapOf<String, ConcurrentRecord>()
+        val concurrentRecordMap = ConcurrentHashMap<String, ConcurrentRecord>()
+
+        /**
+         * 更新并发率
+         */
+        fun updateConcurrentRate(key: String, concurrentRate: String) {
+            concurrentRecordMap.compute(key) { _, record ->
+                try {
+                    val rateIndex = concurrentRate.indexOf("/")
+                    when {
+                        rateIndex > 0 -> {
+                            val accessLimit = concurrentRate.take(rateIndex).toInt()
+                            val interval = concurrentRate.substring(rateIndex + 1).toInt()
+                            if (accessLimit <= 0 || interval <= 0) throw NumberFormatException()
+                            ConcurrentRecord(
+                                record?.time ?: System.currentTimeMillis(),
+                                accessLimit,
+                                interval,
+                                record?.frequency ?: 0
+                            )
+                        }
+
+                        concurrentRate.toInt() > 0 -> {
+                            ConcurrentRecord(
+                                record?.time ?: System.currentTimeMillis(),
+                                1,
+                                concurrentRate.toInt(),
+                                record?.frequency ?: 0
+                            )
+                        }
+
+                        else -> record
+                    }
+                } catch (_: NumberFormatException) {
+                    record
+                }
+            }
+        }
     }
 
+    private val concurrentRate = source?.concurrentRate
+    private val key = source?.getKey()
     /**
      * 开始访问,并发判断
      */
     @Throws(ConcurrentException::class)
     private fun fetchStart(): ConcurrentRecord? {
-        source ?: return null
-        val concurrentRate = source.concurrentRate
         if (concurrentRate.isNullOrEmpty() || concurrentRate == "0") {
             return null
         }
-        val rateIndex = concurrentRate.indexOf("/")
-        var fetchRecord = concurrentRecordMap[source.getKey()]
-        if (fetchRecord == null) {
-            synchronized(concurrentRecordMap) {
-                fetchRecord = concurrentRecordMap[source.getKey()]
-                if (fetchRecord == null) {
-                    fetchRecord = ConcurrentRecord(rateIndex > 0, System.currentTimeMillis(), 1)
-                    concurrentRecordMap[source.getKey()] = fetchRecord
-                    return fetchRecord
-                }
+        val key = key ?: return null
+        var isNewRecord = false
+        val fetchRecord = concurrentRecordMap.computeIfAbsent(key) {
+            isNewRecord = true
+            val rateIndex = concurrentRate.indexOf("/")
+            if (rateIndex > 0) {
+                val accessLimit = concurrentRate.take(rateIndex).toIntOrNull() ?: 1
+                val interval = concurrentRate.substring(rateIndex + 1).toIntOrNull() ?: 0
+                ConcurrentRecord(System.currentTimeMillis(), accessLimit, interval, 1)
+            } else {
+                ConcurrentRecord(
+                    System.currentTimeMillis(),
+                    1,
+                    concurrentRate.toIntOrNull() ?: 0,
+                    1
+                )
             }
         }
-        val waitTime: Int = synchronized(fetchRecord!!) {
-            try {
-                if (!fetchRecord.isConcurrent) {
-                    //并发控制非 次数/毫秒
-                    if (fetchRecord.frequency > 0) {
-                        //已经有访问线程,直接等待
-                        return@synchronized concurrentRate.toInt()
-                    }
-                    //没有线程访问,判断还剩多少时间可以访问
-                    val nextTime = fetchRecord.time + concurrentRate.toInt()
-                    if (System.currentTimeMillis() >= nextTime) {
-                        fetchRecord.time = System.currentTimeMillis()
-                        fetchRecord.frequency = 1
-                        return@synchronized 0
-                    }
-                    return@synchronized (nextTime - System.currentTimeMillis()).toInt()
-                } else {
-                    //并发控制为 次数/毫秒
-                    val sj = concurrentRate.substring(rateIndex + 1)
-                    val nextTime = fetchRecord.time + sj.toInt()
-                    if (System.currentTimeMillis() >= nextTime) {
-                        //已经过了限制时间,重置开始时间
-                        fetchRecord.time = System.currentTimeMillis()
-                        fetchRecord.frequency = 1
-                        return@synchronized 0
-                    }
-                    val cs = concurrentRate.substring(0, rateIndex)
-                    if (fetchRecord.frequency > cs.toInt()) {
-                        return@synchronized (nextTime - System.currentTimeMillis()).toInt()
-                    } else {
-                        fetchRecord.frequency += 1
-                        return@synchronized 0
-                    }
-                }
-            } catch (_: Exception) {
+        if (isNewRecord) return fetchRecord
+        val waitTime: Long = synchronized(fetchRecord) {
+            //并发控制为 次数/毫秒 , 非并发实际为1/毫秒
+            val nextTime = fetchRecord.time + fetchRecord.interval.toLong()
+            val nowTime = System.currentTimeMillis()
+            if (nowTime >= nextTime) {
+                //已经过了限制时间,重置开始时间
+                fetchRecord.time = nowTime
+                fetchRecord.frequency = 1
+                return@synchronized 0
+            }
+            if (fetchRecord.frequency < fetchRecord.accessLimit) {
+                fetchRecord.frequency++
                 return@synchronized 0
+            } else {
+                return@synchronized nextTime - nowTime
             }
         }
         if (waitTime > 0) {
@@ -80,17 +104,6 @@ class ConcurrentRateLimiter(val source: BaseSource?) {
         return fetchRecord
     }
 
-    /**
-     * 访问结束
-     */
-    fun fetchEnd(concurrentRecord: ConcurrentRecord?) {
-        if (concurrentRecord != null && !concurrentRecord.isConcurrent) {
-            synchronized(concurrentRecord) {
-                concurrentRecord.frequency -= 1
-            }
-        }
-    }
-
     /**
      * 获取并发记录,若处于并发限制状态下则会等待
      */
@@ -99,7 +112,7 @@ class ConcurrentRateLimiter(val source: BaseSource?) {
             try {
                 return fetchStart()
             } catch (e: ConcurrentException) {
-                delay(e.waitTime.toLong())
+                delay(e.waitTime)
             }
         }
     }
@@ -109,27 +122,19 @@ class ConcurrentRateLimiter(val source: BaseSource?) {
             try {
                 return fetchStart()
             } catch (e: ConcurrentException) {
-                Thread.sleep(e.waitTime.toLong())
+                Thread.sleep(e.waitTime)
             }
         }
     }
 
     suspend inline fun <T> withLimit(block: () -> T): T {
-        val concurrentRecord = getConcurrentRecord()
-        try {
-            return block()
-        } finally {
-            fetchEnd(concurrentRecord)
-        }
+        getConcurrentRecord()
+        return block()
     }
 
     inline fun <T> withLimitBlocking(block: () -> T): T {
-        val concurrentRecord = getConcurrentRecordBlocking()
-        try {
-            return block()
-        } finally {
-            fetchEnd(concurrentRecord)
-        }
+        getConcurrentRecordBlocking()
+        return block()
     }
 
 }

+ 215 - 26
app/src/main/java/io/legado/app/help/JsExtensions.kt

@@ -1,7 +1,9 @@
 package io.legado.app.help
 
+import android.webkit.JavascriptInterface
 import android.webkit.WebSettings
 import androidx.annotation.Keep
+import androidx.core.net.toUri
 import cn.hutool.core.codec.Base64
 import cn.hutool.core.util.HexUtil
 import com.script.rhino.rhinoContext
@@ -13,6 +15,7 @@ import io.legado.app.constant.AppPattern
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.http.BackstageWebView
 import io.legado.app.help.http.CookieManager.cookieJarHeader
 import io.legado.app.help.http.CookieStore
@@ -23,6 +26,7 @@ import io.legado.app.help.source.getSourceType
 import io.legado.app.model.Debug
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.analyzeRule.QueryTTF
+import io.legado.app.ui.association.OnLineImportActivity
 import io.legado.app.ui.association.OpenUrlConfirmActivity
 import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.ChineseUtils
@@ -72,6 +76,7 @@ import java.util.zip.ZipInputStream
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
 
+
 /**
  * js扩展类, 在js中通过java变量调用
  * 添加方法,请更新文档/legado/app/src/main/assets/help/JsHelp.md
@@ -83,24 +88,34 @@ import kotlin.coroutines.EmptyCoroutineContext
 interface JsExtensions : JsEncodeUtils {
 
     fun getSource(): BaseSource?
+    fun getTag(): String?
 
     private val context: CoroutineContext
-        get() = rhinoContext.coroutineContext ?: EmptyCoroutineContext
+        get() = rhinoContextOrNull?.coroutineContext ?: EmptyCoroutineContext
 
     /**
      * 访问网络,返回String
      */
     fun ajax(url: Any): String? {
+        return ajax(url, null)
+    }
+
+    fun ajax(url: Any, callTimeout: Long?): String? {
         val urlStr = if (url is List<*>) {
             url.firstOrNull().toString()
         } else {
             url.toString()
         }
-        val analyzeUrl = AnalyzeUrl(urlStr, source = getSource(), coroutineContext = context)
+        val analyzeUrl = AnalyzeUrl(
+            urlStr,
+            source = getSource(),
+            callTimeout = callTimeout,
+            coroutineContext = context
+        )
         return kotlin.runCatching {
             analyzeUrl.getStrResponse().body
         }.onFailure {
-            rhinoContext.ensureActive()
+            rhinoContextOrNull?.ensureActive()
             AppLog.put("ajax(${urlStr}) error\n${it.localizedMessage}", it)
         }.getOrElse {
             it.stackTraceStr
@@ -111,6 +126,10 @@ interface JsExtensions : JsEncodeUtils {
      * 并发访问网络
      */
     fun ajaxAll(urlList: Array<String>): Array<StrResponse> {
+        return ajaxAll(urlList, false)
+    }
+
+    fun ajaxAll(urlList: Array<String>, skipRateLimit: Boolean): Array<StrResponse> {
         return runBlocking(context) {
             urlList.asFlow().mapAsync(AppConfig.threadCount) { url ->
                 val analyzeUrl = AnalyzeUrl(
@@ -118,11 +137,37 @@ interface JsExtensions : JsEncodeUtils {
                     source = getSource(),
                     coroutineContext = coroutineContext
                 )
-                analyzeUrl.getStrResponseAwait()
+                analyzeUrl.getStrResponseAwait(skipRateLimit = skipRateLimit)
+            }.flowOn(IO).toList().toTypedArray()
+        }
+    }
+
+    /**
+     * 并发测试网络
+     */
+    fun ajaxTestAll(urlList: Array<String>, timeout: Int): Array<StrResponse> {
+        return ajaxTestAll(urlList, timeout, false)
+    }
+
+    fun ajaxTestAll(
+        urlList: Array<String>,
+        timeout: Int,
+        skipRateLimit: Boolean
+    ): Array<StrResponse> {
+        return runBlocking(context) {
+            urlList.asFlow().mapAsync(AppConfig.threadCount) { url ->
+                val analyzeUrl = AnalyzeUrl(
+                    url,
+                    source = getSource(),
+                    coroutineContext = coroutineContext,
+                    callTimeout = timeout.toLong()
+                )
+                analyzeUrl.getStrResponseAwait(isTest = true, skipRateLimit = skipRateLimit)
             }.flowOn(IO).toList().toTypedArray()
         }
     }
 
+
     /**
      * 访问网络,返回Response<String>
      */
@@ -135,7 +180,7 @@ interface JsExtensions : JsEncodeUtils {
         return kotlin.runCatching {
             analyzeUrl.getStrResponse()
         }.onFailure {
-            rhinoContext.ensureActive()
+            rhinoContextOrNull?.ensureActive()
             AppLog.put("connect(${urlStr}) error\n${it.localizedMessage}", it)
         }.getOrElse {
             StrResponse(analyzeUrl.url, it.stackTraceStr)
@@ -143,31 +188,41 @@ interface JsExtensions : JsEncodeUtils {
     }
 
     fun connect(urlStr: String, header: String?): StrResponse {
+        return connect(urlStr, header, null)
+    }
+
+    fun connect(urlStr: String, header: String?, callTimeout: Long?): StrResponse {
         val headerMap = GSON.fromJsonObject<Map<String, String>>(header).getOrNull()
         val analyzeUrl = AnalyzeUrl(
             urlStr,
             headerMapF = headerMap,
             source = getSource(),
+            callTimeout = callTimeout,
             coroutineContext = context
         )
         return kotlin.runCatching {
             analyzeUrl.getStrResponse()
         }.onFailure {
-            rhinoContext.ensureActive()
-            AppLog.put("ajax($urlStr,$header) error\n${it.localizedMessage}", it)
+            rhinoContextOrNull?.ensureActive()
+            AppLog.put("connect($urlStr,$header) error\n${it.localizedMessage}", it)
         }.getOrElse {
             StrResponse(analyzeUrl.url, it.stackTraceStr)
         }
     }
 
+    fun webView(html: String?, url: String?, js: String?): String? {
+        return webView(html, url, js, false)
+    }
+
     /**
      * 使用webView访问网络
      * @param html 直接用webView载入的html, 如果html为空直接访问url
      * @param url html内如果有相对路径的资源不传入url访问不了
      * @param js 用来取返回值的js语句, 没有就返回整个源代码
+     * @param cacheFirst 优先使用缓存,为true能提高访问速度
      * @return 返回js获取的内容
      */
-    fun webView(html: String?, url: String?, js: String?): String? {
+    fun webView(html: String?, url: String?, js: String?, cacheFirst: Boolean): String? {
         if (isMainThread) {
             error("webView must be called on a background thread")
         }
@@ -182,10 +237,20 @@ interface JsExtensions : JsEncodeUtils {
         }
     }
 
+    fun webViewGetSource(html: String?, url: String?, js: String?, sourceRegex: String): String? {
+        return webViewGetSource(html, url, js, sourceRegex, false)
+    }
+
     /**
      * 使用webView获取资源url
      */
-    fun webViewGetSource(html: String?, url: String?, js: String?, sourceRegex: String): String? {
+    fun webViewGetSource(
+        html: String?,
+        url: String?,
+        js: String?,
+        sourceRegex: String,
+        cacheFirst: Boolean
+    ): String? {
         if (isMainThread) {
             error("webViewGetSource must be called on a background thread")
         }
@@ -201,6 +266,15 @@ interface JsExtensions : JsEncodeUtils {
         }
     }
 
+    fun webViewGetOverrideUrl(
+        html: String?,
+        url: String?,
+        js: String?,
+        overrideUrlRegex: String
+    ): String? {
+        return webViewGetOverrideUrl(html, url, js, overrideUrlRegex, false)
+    }
+
     /**
      * 使用webView获取跳转url
      */
@@ -208,7 +282,8 @@ interface JsExtensions : JsEncodeUtils {
         html: String?,
         url: String?,
         js: String?,
-        overrideUrlRegex: String
+        overrideUrlRegex: String,
+        cacheFirst: Boolean
     ): String? {
         if (isMainThread) {
             error("webViewGetOverrideUrl must be called on a background thread")
@@ -231,23 +306,37 @@ interface JsExtensions : JsEncodeUtils {
      * @param title 浏览器页面的标题
      */
     fun startBrowser(url: String, title: String) {
+        return startBrowser(url, title, null)
+    }
+
+    fun startBrowser(url: String, title: String, html: String?) {
         rhinoContext.ensureActive()
-        SourceVerificationHelp.startBrowser(getSource(), url, title)
+        SourceVerificationHelp.startBrowser(getSource(), url, title, html = html)
     }
 
     /**
      * 使用内置浏览器打开链接,并等待网页结果
      */
+    fun startBrowserAwait(url: String, title: String): StrResponse {
+        return startBrowserAwait(url, title, true, null)
+    }
+
     fun startBrowserAwait(url: String, title: String, refetchAfterSuccess: Boolean): StrResponse {
-        rhinoContext.ensureActive()
-        val body = SourceVerificationHelp.getVerificationResult(
-            getSource(), url, title, true, refetchAfterSuccess
-        )
-        return StrResponse(url, body)
+        return startBrowserAwait(url, title, refetchAfterSuccess, null)
     }
 
-    fun startBrowserAwait(url: String, title: String): StrResponse {
-        return startBrowserAwait(url, title, true)
+    fun startBrowserAwait(
+        url: String,
+        title: String,
+        refetchAfterSuccess: Boolean,
+        html: String?
+    ): StrResponse {
+        rhinoContext.ensureActive()
+        val pair = SourceVerificationHelp.getVerificationResult(
+            getSource(), url, title, true, refetchAfterSuccess, html
+        )
+        val (url2, body) = pair
+        return StrResponse(url2.ifEmpty { url }, body)
     }
 
     /**
@@ -255,12 +344,13 @@ interface JsExtensions : JsEncodeUtils {
      */
     fun getVerificationCode(imageUrl: String): String {
         rhinoContext.ensureActive()
-        return SourceVerificationHelp.getVerificationResult(getSource(), imageUrl, "", false)
+        return SourceVerificationHelp.getVerificationResult(getSource(), imageUrl, "", false).second
     }
 
     /**
      * 可从网络,本地文件(阅读私有数据目录相对路径)导入JavaScript脚本
      */
+    @JavascriptInterface
     fun importScript(path: String): String {
         val result = when {
             path.startsWith("http") -> cacheFile(path)
@@ -275,6 +365,7 @@ interface JsExtensions : JsEncodeUtils {
      * @param urlStr 网络文件的链接
      * @return 返回缓存后的文件内容
      */
+    @JavascriptInterface
     fun cacheFile(urlStr: String): String {
         return cacheFile(urlStr, 0)
     }
@@ -283,6 +374,7 @@ interface JsExtensions : JsEncodeUtils {
      * 缓存以文本方式保存的文件 如.js .txt等
      * @param saveTime 缓存时间,单位:秒
      */
+    @JavascriptInterface
     fun cacheFile(urlStr: String, saveTime: Int): String {
         val key = md5Encode16(urlStr)
         val cachePath = CacheManager.get(key)
@@ -302,10 +394,12 @@ interface JsExtensions : JsEncodeUtils {
     /**
      *js实现读取cookie
      */
+    @JavascriptInterface
     fun getCookie(tag: String): String {
         return getCookie(tag, null)
     }
 
+    @JavascriptInterface
     fun getCookie(tag: String, key: String?): String {
         return if (key != null) {
             CookieStore.getKey(tag, key)
@@ -319,8 +413,9 @@ interface JsExtensions : JsEncodeUtils {
      * @param url 下载地址:可带参数type
      * @return 下载的文件相对路径
      */
+    @JavascriptInterface
     fun downloadFile(url: String): String {
-        rhinoContext.ensureActive()
+        rhinoContextOrNull?.ensureActive()
         val analyzeUrl = AnalyzeUrl(url, source = getSource(), coroutineContext = context)
         val type = analyzeUrl.type ?: UrlUtil.getSuffix(url)
         val path = FileUtils.getPath(
@@ -354,8 +449,9 @@ interface JsExtensions : JsEncodeUtils {
         "Deprecated",
         ReplaceWith("downloadFile(url)")
     )
+    @JavascriptInterface
     fun downloadFile(content: String, url: String): String {
-        rhinoContext.ensureActive()
+        rhinoContextOrNull?.ensureActive()
         val type = AnalyzeUrl(url, source = getSource(), coroutineContext = context).type
             ?: return ""
         val path = FileUtils.getPath(
@@ -376,14 +472,19 @@ interface JsExtensions : JsEncodeUtils {
      * js实现重定向拦截,网络访问get
      */
     fun get(urlStr: String, headers: Map<String, String>): Connection.Response {
+        return get(urlStr, headers, null)
+    }
+
+    fun get(urlStr: String, headers: Map<String, String>, timeout: Int?): Connection.Response {
         val requestHeaders = if (getSource()?.enabledCookieJar == true) {
             headers.toMutableMap().apply { put(cookieJarHeader, "1") }
         } else headers
         val rateLimiter = ConcurrentRateLimiter(getSource())
         val response = rateLimiter.withLimitBlocking {
-            rhinoContext.ensureActive()
+            rhinoContextOrNull?.ensureActive()
             Jsoup.connect(urlStr)
                 .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
+                .timeout(timeout ?: 30000)
                 .ignoreContentType(true)
                 .followRedirects(false)
                 .headers(requestHeaders)
@@ -397,14 +498,19 @@ interface JsExtensions : JsEncodeUtils {
      * js实现重定向拦截,网络访问head,不返回Response Body更省流量
      */
     fun head(urlStr: String, headers: Map<String, String>): Connection.Response {
+        return head(urlStr, headers, null)
+    }
+
+    fun head(urlStr: String, headers: Map<String, String>, timeout: Int?): Connection.Response {
         val requestHeaders = if (getSource()?.enabledCookieJar == true) {
             headers.toMutableMap().apply { put(cookieJarHeader, "1") }
         } else headers
         val rateLimiter = ConcurrentRateLimiter(getSource())
         val response = rateLimiter.withLimitBlocking {
-            rhinoContext.ensureActive()
+            rhinoContextOrNull?.ensureActive()
             Jsoup.connect(urlStr)
                 .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
+                .timeout(timeout ?: 30000)
                 .ignoreContentType(true)
                 .followRedirects(false)
                 .headers(requestHeaders)
@@ -418,14 +524,24 @@ interface JsExtensions : JsEncodeUtils {
      * 网络访问post
      */
     fun post(urlStr: String, body: String, headers: Map<String, String>): Connection.Response {
+        return post(urlStr, body, headers, null)
+    }
+
+    fun post(
+        urlStr: String,
+        body: String,
+        headers: Map<String, String>,
+        timeout: Int?
+    ): Connection.Response {
         val requestHeaders = if (getSource()?.enabledCookieJar == true) {
             headers.toMutableMap().apply { put(cookieJarHeader, "1") }
         } else headers
         val rateLimiter = ConcurrentRateLimiter(getSource())
         val response = rateLimiter.withLimitBlocking {
-            rhinoContext.ensureActive()
+            rhinoContextOrNull?.ensureActive()
             Jsoup.connect(urlStr)
                 .sslSocketFactory(SSLHelper.unsafeSSLSocketFactory)
+                .timeout(timeout ?: 30000)
                 .ignoreContentType(true)
                 .followRedirects(false)
                 .requestBody(body)
@@ -457,14 +573,17 @@ interface JsExtensions : JsEncodeUtils {
     /**
      * js实现base64解码,不能删
      */
+    @JavascriptInterface
     fun base64Decode(str: String?): String {
         return Base64.decodeStr(str)
     }
 
+    @JavascriptInterface
     fun base64Decode(str: String?, charset: String): String {
         return Base64.decodeStr(str, charset(charset))
     }
 
+    @JavascriptInterface
     fun base64Decode(str: String, flags: Int): String {
         return EncoderUtils.base64Decode(str, flags)
     }
@@ -483,10 +602,12 @@ interface JsExtensions : JsEncodeUtils {
         return EncoderUtils.base64DecodeToByteArray(str, flags)
     }
 
+    @JavascriptInterface
     fun base64Encode(str: String): String? {
         return EncoderUtils.base64Encode(str, 2)
     }
 
+    @JavascriptInterface
     fun base64Encode(str: String, flags: Int): String? {
         return EncoderUtils.base64Encode(str, flags)
     }
@@ -497,11 +618,13 @@ interface JsExtensions : JsEncodeUtils {
     }
 
     /* hexString 解码为utf8String*/
+    @JavascriptInterface
     fun hexDecodeToString(hex: String): String? {
         return HexUtil.decodeHexStr(hex)
     }
 
     /* utf8 编码为hexString */
+    @JavascriptInterface
     fun hexEncodeToString(utf8: String): String? {
         return HexUtil.encodeHexStr(utf8)
     }
@@ -509,6 +632,7 @@ interface JsExtensions : JsEncodeUtils {
     /**
      * 格式化时间
      */
+    @JavascriptInterface
     fun timeFormatUTC(time: Long, format: String, sh: Int): String? {
         val utc = SimpleTimeZone(sh, "UTC")
         return SimpleDateFormat(format, Locale.getDefault()).run {
@@ -520,10 +644,12 @@ interface JsExtensions : JsEncodeUtils {
     /**
      * 时间格式化
      */
+    @JavascriptInterface
     fun timeFormat(time: Long): String {
         return dateFormat.format(Date(time))
     }
 
+    @JavascriptInterface
     fun encodeURI(str: String): String {
         return try {
             URLEncoder.encode(str, "UTF-8")
@@ -532,6 +658,7 @@ interface JsExtensions : JsEncodeUtils {
         }
     }
 
+    @JavascriptInterface
     fun encodeURI(str: String, enc: String): String {
         return try {
             URLEncoder.encode(str, enc)
@@ -540,18 +667,22 @@ interface JsExtensions : JsEncodeUtils {
         }
     }
 
+    @JavascriptInterface
     fun htmlFormat(str: String): String {
         return HtmlFormatter.formatKeepImg(str)
     }
 
+    @JavascriptInterface
     fun t2s(text: String): String {
         return ChineseUtils.t2s(text)
     }
 
+    @JavascriptInterface
     fun s2t(text: String): String {
         return ChineseUtils.s2t(text)
     }
 
+    @JavascriptInterface
     fun getWebViewUA(): String {
         return WebSettings.getDefaultUserAgent(appCtx)
     }
@@ -586,6 +717,7 @@ interface JsExtensions : JsEncodeUtils {
         return null
     }
 
+    @JavascriptInterface
     fun readTxtFile(path: String): String {
         val file = getFile(path)
         if (file.exists()) {
@@ -595,6 +727,7 @@ interface JsExtensions : JsEncodeUtils {
         return ""
     }
 
+    @JavascriptInterface
     fun readTxtFile(path: String, charsetName: String): String {
         val file = getFile(path)
         if (file.exists()) {
@@ -606,6 +739,7 @@ interface JsExtensions : JsEncodeUtils {
     /**
      * 删除本地文件
      */
+    @JavascriptInterface
     fun deleteFile(path: String): Boolean {
         val file = getFile(path)
         return FileUtils.delete(file, true)
@@ -616,6 +750,7 @@ interface JsExtensions : JsEncodeUtils {
      * @param zipPath 相对路径
      * @return 相对路径
      */
+    @JavascriptInterface
     fun unzipFile(zipPath: String): String {
         return unArchiveFile(zipPath)
     }
@@ -625,6 +760,7 @@ interface JsExtensions : JsEncodeUtils {
      * @param zipPath 相对路径
      * @return 相对路径
      */
+    @JavascriptInterface
     fun un7zFile(zipPath: String): String {
         return unArchiveFile(zipPath)
     }
@@ -634,6 +770,7 @@ interface JsExtensions : JsEncodeUtils {
      * @param zipPath 相对路径
      * @return 相对路径
      */
+    @JavascriptInterface
     fun unrarFile(zipPath: String): String {
         return unArchiveFile(zipPath)
     }
@@ -643,6 +780,7 @@ interface JsExtensions : JsEncodeUtils {
      * @param zipPath 相对路径
      * @return 相对路径
      */
+    @JavascriptInterface
     fun unArchiveFile(zipPath: String): String {
         if (zipPath.isEmpty()) return ""
         val zipFile = getFile(zipPath)
@@ -656,6 +794,7 @@ interface JsExtensions : JsEncodeUtils {
      * @param path 文件夹相对路径
      * @return 所有文件字符串换行连接
      */
+    @JavascriptInterface
     fun getTxtInFolder(path: String): String {
         if (path.isEmpty()) return ""
         val folder = getFile(path)
@@ -680,12 +819,14 @@ interface JsExtensions : JsEncodeUtils {
      * @param path 所需获取文件在zip内的路径
      * @return zip指定文件的数据
      */
+    @JavascriptInterface
     fun getZipStringContent(url: String, path: String): String {
         val byteArray = getZipByteArrayContent(url, path) ?: return ""
         val charsetName = EncodingDetect.getEncode(byteArray)
         return String(byteArray, Charset.forName(charsetName))
     }
 
+    @JavascriptInterface
     fun getZipStringContent(url: String, path: String, charsetName: String): String {
         val byteArray = getZipByteArrayContent(url, path) ?: return ""
         return String(byteArray, Charset.forName(charsetName))
@@ -697,12 +838,14 @@ interface JsExtensions : JsEncodeUtils {
      * @param path 所需获取文件在zip内的路径
      * @return zip指定文件的数据
      */
+    @JavascriptInterface
     fun getRarStringContent(url: String, path: String): String {
         val byteArray = getRarByteArrayContent(url, path) ?: return ""
         val charsetName = EncodingDetect.getEncode(byteArray)
         return String(byteArray, Charset.forName(charsetName))
     }
 
+    @JavascriptInterface
     fun getRarStringContent(url: String, path: String, charsetName: String): String {
         val byteArray = getRarByteArrayContent(url, path) ?: return ""
         return String(byteArray, Charset.forName(charsetName))
@@ -714,12 +857,14 @@ interface JsExtensions : JsEncodeUtils {
      * @param path 所需获取文件在7zip内的路径
      * @return zip指定文件的数据
      */
+    @JavascriptInterface
     fun get7zStringContent(url: String, path: String): String {
         val byteArray = get7zByteArrayContent(url, path) ?: return ""
         val charsetName = EncodingDetect.getEncode(byteArray)
         return String(byteArray, Charset.forName(charsetName))
     }
 
+    @JavascriptInterface
     fun get7zStringContent(url: String, path: String, charsetName: String): String {
         val byteArray = get7zByteArrayContent(url, path) ?: return ""
         return String(byteArray, Charset.forName(charsetName))
@@ -913,6 +1058,7 @@ interface JsExtensions : JsEncodeUtils {
     /**
      * 章节数转数字
      */
+    @JavascriptInterface
     fun toNumChapter(s: String?): String? {
         s ?: return null
         val matcher = AppPattern.titleNumPattern.matcher(s)
@@ -956,7 +1102,7 @@ interface JsExtensions : JsEncodeUtils {
         getSource()?.let {
             Debug.log(it.getKey(), msg.toString())
         } ?: Debug.log(msg.toString())
-        AppLog.putDebug("${getSource()?.getTag() ?: "源"}调试输出: $msg")
+        AppLog.putDebug("${getTag() ?: "源"}调试输出: $msg")
         return msg
     }
 
@@ -974,22 +1120,32 @@ interface JsExtensions : JsEncodeUtils {
     /**
      * 生成UUID
      */
+    @JavascriptInterface
     fun randomUUID(): String {
         return UUID.randomUUID().toString()
     }
 
+    @JavascriptInterface
     fun androidId(): String {
         return AppConst.androidId
     }
 
+    @JavascriptInterface
     fun openUrl(url: String) {
         openUrl(url, null)
     }
 
     // 新增 mimeType 参数,默认为 null(保持兼容性)
+    @JavascriptInterface
     fun openUrl(url: String, mimeType: String? = null) {
         require(url.length < 64 * 1024) { "openUrl parameter url too long" }
-        rhinoContext.ensureActive()
+        rhinoContextOrNull?.ensureActive()
+        if (url.startsWith("legado://") || url.startsWith("yuedu://")) {
+            appCtx.startActivity<OnLineImportActivity> {
+                data = url.toUri()
+            }
+            return
+        }
         val source = getSource() ?: throw NoStackTraceException("openUrl source cannot be null")
         appCtx.startActivity<OpenUrlConfirmActivity> {
             putExtra("uri", url)
@@ -1000,4 +1156,37 @@ interface JsExtensions : JsEncodeUtils {
         }
     }
 
+    /**
+     * 获取阅读配置
+     */
+    @JavascriptInterface
+    fun getReadBookConfig(): String {
+        return GSON.toJson(ReadBookConfig.durConfig)
+    }
+
+    /* fun getReadBookConfigMap(): Map<String, Any> {
+        return ReadBookConfig.durConfig.toMap()
+    } */
+
+    /**
+     * 获取主题模式
+     */
+    @JavascriptInterface
+    fun getThemeMode(): String {
+        return AppConfig.themeMode ?: "0"
+    }
+
+    /**
+     * 获取主题配置
+     */
+    //@JavascriptInterface
+    /*fun getThemeConfig(): String {
+        val themeConfig = ThemeConfig.getDurConfig(appCtx)
+        return GSON.toJson(themeConfig)
+    }*/
+
+    /* fun getThemeConfigMap(): Map<String, Any?> {
+        return ThemeConfig.getDurConfig(appCtx).toMap()
+    } */
+
 }

+ 2 - 0
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -566,6 +566,8 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             appCtx.putPrefBoolean(PreferKey.previewImageByClick, value)
         }
 
+    val clickImgWay: String?
+        get() = appCtx.getPrefString(PreferKey.clickImgWay)
     var preDownloadNum
         get() = appCtx.getPrefInt(PreferKey.preDownloadNum, 10)
         set(value) {

+ 5 - 0
app/src/main/java/io/legado/app/help/http/StrResponse.kt

@@ -20,6 +20,7 @@ class StrResponse {
         private set
     var errorBody: ResponseBody? = null
         private set
+    var callTime = 0
 
     constructor(rawResponse: Response, body: String?) {
         this.raw = rawResponse
@@ -46,7 +47,11 @@ class StrResponse {
         this.errorBody = errorBody
     }
 
+    fun putCallTime(callTime: Int) {
+        this.callTime = callTime
+    }
     fun raw() = raw
+    fun callTime() = callTime
 
     fun url(): String {
         raw.networkResponse?.let {

+ 21 - 15
app/src/main/java/io/legado/app/help/source/SourceVerificationHelp.kt

@@ -35,8 +35,9 @@ object SourceVerificationHelp {
         url: String,
         title: String,
         useBrowser: Boolean,
-        refetchAfterSuccess: Boolean = true
-    ): String {
+        refetchAfterSuccess: Boolean = true,
+        html: String? = null
+    ): Pair<String, String> {
         source
             ?: throw NoStackTraceException("getVerificationResult parameter source cannot be null")
         require(url.length < 64 * 1024) { "getVerificationResult parameter url too long" }
@@ -53,23 +54,21 @@ object SourceVerificationHelp {
                 IntentData.put(getVerificationResultKey(source), Thread.currentThread())
             }
         } else {
-            startBrowser(source, url, title, true, refetchAfterSuccess)
+            startBrowser(source, url, title, true, refetchAfterSuccess, html)
         }
 
         var waitUserInput = false
         while (getResult(source.getKey()) == null) {
-            if (!waitUserInput) {
+            if (!waitUserInput && html == null) {
                 AppLog.putDebug("等待返回验证结果...")
                 waitUserInput = true
             }
             LockSupport.parkNanos(this, waitTime)
         }
-
-        return getResult(source.getKey())!!.let {
-            it.ifBlank {
-                throw NoStackTraceException("验证结果为空")
-            }
-        }
+        val result = getResult(source.getKey()) ?: throw NoStackTraceException("验证结果为空")
+        clearResult(source.getKey())
+        if (result.second.isEmpty()) throw NoStackTraceException("验证结果为空")
+        return result
     }
 
     /**
@@ -81,7 +80,8 @@ object SourceVerificationHelp {
         url: String,
         title: String,
         saveResult: Boolean? = false,
-        refetchAfterSuccess: Boolean? = true
+        refetchAfterSuccess: Boolean? = true,
+        html: String? = null
     ) {
         source ?: throw NoStackTraceException("startBrowser parameter source cannot be null")
         require(url.length < 64 * 1024) { "startBrowser parameter url too long" }
@@ -93,6 +93,7 @@ object SourceVerificationHelp {
             putExtra("sourceType", source.getSourceType())
             putExtra("sourceVerificationEnable", saveResult)
             putExtra("refetchAfterSuccess", refetchAfterSuccess)
+            putExtra("html", html)
             IntentData.put(getVerificationResultKey(source), Thread.currentThread())
         }
     }
@@ -104,12 +105,17 @@ object SourceVerificationHelp {
         LockSupport.unpark(thread)
     }
 
-    fun setResult(sourceKey: String, result: String?) {
-        CacheManager.putMemory(getVerificationResultKey(sourceKey), result ?: "")
+    fun setResult(sourceKey: String, result: String, url: String = "") {
+        CacheManager.putMemory(getVerificationResultKey(sourceKey), (url to result))
     }
 
-    fun getResult(sourceKey: String): String? {
-        return CacheManager.get(getVerificationResultKey(sourceKey))
+    fun getResult(sourceKey: String): Pair<String, String>? {
+        val pair = CacheManager.getFromMemory(getVerificationResultKey(sourceKey)) as? Pair<*, *>
+            ?: return null
+        if (pair.first is String && pair.second is String) {
+            return pair.first as String to pair.second as String
+        }
+        return null
     }
 
     fun clearResult(sourceKey: String) {

+ 384 - 0
app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt

@@ -0,0 +1,384 @@
+package io.legado.app.help.webView
+
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import io.legado.app.constant.BookType
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.BaseSource
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.model.AudioPlay
+import io.legado.app.model.ReadBook
+import io.legado.app.model.analyzeRule.AnalyzeRule
+import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
+import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
+import io.legado.app.ui.rss.read.RssJsExtensions
+import io.legado.app.utils.GSON
+import io.legado.app.utils.escapeForJs
+import io.legado.app.utils.fromJsonObject
+import java.util.UUID
+
+@Suppress("unused")
+class WebJsExtensions(
+    source: BaseSource,
+    activity: AppCompatActivity,
+    private val webView: WebView,
+    private val bookType: Int = 0
+) : RssJsExtensions(activity, source) {
+    private val bookAndChapter by lazy {
+        var book: Book? = null
+        var chapter: BookChapter? = null
+        when (bookType) {
+            BookType.text -> {
+                book = ReadBook.book?.also {
+                    chapter = appDb.bookChapterDao.getChapter(
+                        it.bookUrl,
+                        ReadBook.durChapterIndex
+                    )
+                }
+            }
+
+            BookType.audio -> {
+                book = AudioPlay.book
+                chapter = AudioPlay.durChapter
+            }
+        }
+        Pair(book, chapter)
+    }
+    private val book: Book? get() = bookAndChapter.first
+    private val chapter: BookChapter? get() = bookAndChapter.second
+
+    /**
+     * 由软件主动注入的js函数调用
+     */
+    @JavascriptInterface
+    fun request(funName: String, jsParam: Array<String>, id: String) {
+        val activity = activityRef.get() ?: return
+        Coroutine.async(activity.lifecycleScope) {
+            when (funName) {
+                "run" -> AnalyzeRule(book, getSource()).run {
+                    setCoroutineContext(coroutineContext)
+                    setChapter(chapter)
+                    evalJS(jsParam[0]).toString()
+                }
+
+                "ajaxAwait" -> {
+                    ajax(jsParam[0], jsParam[1].toIntOrNull()).toString()
+                }
+
+                "connectAwait" -> {
+                    connect(jsParam[0], jsParam[1], jsParam[2].toIntOrNull())
+                }
+
+                "getAwait" -> {
+                    get(jsParam[0], jsParam[1], jsParam[2].toIntOrNull())
+                }
+
+                "headAwait" -> {
+                    head(jsParam[0], jsParam[1], jsParam[2].toIntOrNull())
+                }
+
+                "postAwait" -> {
+                    post(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toIntOrNull())
+                }
+
+                "webViewAwait" -> {
+                    webView(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toBoolean()).toString()
+                }
+
+                "decryptStrAwait" -> {
+                    createSymmetricCrypto(jsParam[0], jsParam[1], jsParam[2]).decryptStr(jsParam[3])
+                }
+
+                "encryptBase64Await" -> {
+                    createSymmetricCrypto(
+                        jsParam[0],
+                        jsParam[1],
+                        jsParam[2]
+                    ).encryptBase64(jsParam[3])
+                }
+
+                "encryptHexAwait" -> {
+                    createSymmetricCrypto(jsParam[0], jsParam[1], jsParam[2]).encryptHex(jsParam[3])
+                }
+
+                "createSignHexAwait" -> {
+                    createSign(jsParam[0]).setPublicKey(jsParam[1]).setPrivateKey(jsParam[2])
+                        .signHex(jsParam[3])
+                }
+
+                "downloadFileAwait" -> {
+                    downloadFile(jsParam[0])
+                }
+
+                "readTxtFileAwait" -> {
+                    readTxtFile(jsParam[0])
+                }
+
+                "importScriptAwait" -> {
+                    importScript(jsParam[0])
+                }
+
+                "getStringAwait" -> AnalyzeRule(book, getSource()).run {
+                    setCoroutineContext(coroutineContext)
+                    setChapter(chapter)
+                    getString(jsParam[0], jsParam[1])
+                }
+
+                else -> "error funName"
+            }
+        }.onSuccess { data ->
+            webView.evaluateJavascript(
+                "window.$JSBridgeResult('$id', '${data.escapeForJs()}', null);",
+                null
+            )
+        }.onError {
+            webView.evaluateJavascript(
+                "window.$JSBridgeResult('$id', null, '${it.localizedMessage?.escapeForJs()}');",
+                null
+            )
+        }
+    }
+
+    @JavascriptInterface
+    fun toast(msg: String?) {
+        super.toast(msg)
+    }
+
+    @JavascriptInterface
+    fun longToast(msg: String?) {
+        super.longToast(msg)
+    }
+
+    @JavascriptInterface
+    fun log(msg: String?): String {
+        return super.log(msg).toString()
+    }
+
+    @JavascriptInterface
+    fun ajax(url: String): String? {
+        return super.ajax(url, 9000)
+    }
+
+    @JavascriptInterface
+    fun ajax(url: String, callTimeout: Int?): String? {
+        return super.ajax(url, callTimeout?.toLong())
+    }
+
+    @JavascriptInterface
+    fun connect(urlStr: String?): String {
+        if (urlStr.isNullOrEmpty()) return "error empty url"
+        return super.connect(urlStr, null, 9000).toString()
+    }
+
+    @JavascriptInterface
+    fun connect(urlStr: String, header: String): String {
+        return super.connect(urlStr, header, 9000).toString()
+    }
+
+    @JavascriptInterface
+    fun connect(urlStr: String, header: String, callTimeout: Int?): String {
+        return super.connect(urlStr, header, callTimeout?.toLong()).toString()
+    }
+
+    @JavascriptInterface
+    fun get(urlStr: String, headers: String): String {
+        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
+        return super.get(urlStr, headerMap, 9000).body()
+    }
+
+    @JavascriptInterface
+    fun get(urlStr: String, headers: String, timeout: Int?): String {
+        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
+        return super.get(urlStr, headerMap, timeout).body()
+    }
+
+    @JavascriptInterface
+    fun post(urlStr: String, body: String, headers: String): String {
+        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
+        return super.post(urlStr, body, headerMap, 9000).body()
+    }
+
+    @JavascriptInterface
+    fun post(urlStr: String, body: String, headers: String, timeout: Int?): String {
+        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
+        return super.post(urlStr, body, headerMap, timeout).body()
+    }
+
+    @JavascriptInterface
+    fun head(urlStr: String, headers: String): String {
+        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
+        return GSON.toJson(super.head(urlStr, headerMap, 9000).headers())
+    }
+
+    @JavascriptInterface
+    fun head(urlStr: String, headers: String, timeout: Int?): String {
+        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
+        return GSON.toJson(super.head(urlStr, headerMap, timeout).headers())
+    }
+
+    companion object {
+        private fun getRandomLetter(): Char {
+            val letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_"
+            return letters.random()
+        }
+
+        val uuid by lazy { UUID.randomUUID().toString().split("-") }
+        val uuid2 by lazy { UUID.randomUUID().toString().split("-") }
+        val nameJava by lazy { getRandomLetter() + uuid[0] + uuid[1] }
+        val nameCache by lazy { getRandomLetter() + uuid[2] + uuid[3] }
+        val nameSource by lazy { getRandomLetter() + uuid[4] }
+        val nameBasic by lazy { getRandomLetter() + uuid2[1] + uuid2[2] }
+        val JSBridgeResult by lazy { getRandomLetter() + uuid2[3] + uuid2[4] }
+        val JS_INJECTION by lazy {
+            """
+            const requestId = n => 'req_' + n + '_' + Date.now() + '_' + Math.random().toString(36).slice(-3);
+            const JSBridgeCallbacks = {};
+            const java = window.$nameJava;
+            const source = window.$nameSource;
+            const cache = window.$nameCache;
+            function run(jsCode) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("run");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("run", [String(jsCode)], id);
+                });
+            };
+            function ajaxAwait(url, callTimeout) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("ajaxAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("ajaxAwait", [String(url), String(callTimeout)], id);
+                });
+            };
+            function connectAwait(url, header, callTimeout) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("connectAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("connectAwait", [String(url), String(header), String(callTimeout)], id);
+                });
+            };
+            function getAwait(url, header, callTimeout) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("getAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("getAwait", [String(url), String(header), String(callTimeout)], id);
+                });
+            };
+            function headAwait(url, header, callTimeout) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("headAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("headAwait", [String(url), String(header), String(callTimeout)], id);
+                });
+            };
+            function postAwait(url, body, header, callTimeout) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("postAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("postAwait", [String(url), String(body), String(header), String(callTimeout)], id);
+                });
+            };
+            function webViewAwait(html, url, js, cacheFirst) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("webViewAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("webViewAwait", [String(html), String(url), String(js), String(cacheFirst)], id);
+                });
+            };
+            function decryptStrAwait(transformation, key, iv, data) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("decryptStrAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("decryptStrAwait", [String(transformation), String(key), String(iv), String(data)], id);
+                });
+            };
+            function encryptBase64Await(transformation, key, iv, data) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("encryptBase64Await");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("encryptBase64Await", [String(transformation), String(key), String(iv), String(data)], id);
+                });
+            };
+            function encryptHexAwait(transformation, key, iv, data) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("encryptHexAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("encryptHexAwait", [String(transformation), String(key), String(iv), String(data)], id);
+                });
+            };
+            function createSignHexAwait(algorithm, publicKey, privateKey, data) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("createSignHexAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("createSignHexAwait", [String(algorithm), String(publicKey), String(privateKey), String(data)], id);
+                });
+            };
+            function downloadFileAwait(url) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("downloadFileAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("downloadFileAwait", [String(url)], id);
+                });
+            };
+            function readTxtFileAwait(path) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("readTxtFileAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("readTxtFileAwait", [String(path)], id);
+                });
+            };
+            function importScriptAwait(url) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("importScriptAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("importScriptAwait", [String(url)], id);
+                });
+            };
+            function getStringAwait(ruleStr, mContent) {
+                return new Promise((resolve, reject) => {
+                    const id = requestId("getStringAwait");
+                    JSBridgeCallbacks[id] = { resolve, reject };
+                    window.$nameJava?.request("getStringAwait", [String(ruleStr), String(mContent)], id);
+                });
+            };
+            window.$JSBridgeResult = function(requestId, result, error) {
+                if (JSBridgeCallbacks[requestId]) {
+                    if (error) {
+                        JSBridgeCallbacks[requestId].reject(error);
+                    } else {
+                        JSBridgeCallbacks[requestId].resolve(result);
+                    }
+                    delete JSBridgeCallbacks[requestId];
+                }
+            };"""
+        }
+
+        val basicJs by lazy {
+            """
+            (function() {
+            if (screen.orientation && !screen.orientation.__patched) {
+                screen.orientation.lock = function(orientation) {
+                    return new Promise((resolve, reject) => {
+                        window.$nameBasic?.lockOrientation(orientation) 
+                        resolve()
+                    });
+                };
+                screen.orientation.unlock = function() {
+                    return new Promise((resolve, reject) => {
+                        window.$nameBasic?.lockOrientation('unlock') 
+                        resolve()
+                    });
+                };
+                screen.orientation.__patched = true;
+            };
+            window.close = function() {
+                window.$nameBasic?.onCloseRequested();
+            };
+            })();"""
+        }
+    }
+}

+ 20 - 1
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt

@@ -52,7 +52,8 @@ import kotlin.coroutines.EmptyCoroutineContext
 class AnalyzeRule(
     private var ruleData: RuleDataInterface? = null,
     private val source: BaseSource? = null,
-    private val preUpdateJs: Boolean = false
+    private val preUpdateJs: Boolean = false,
+    private var isFromBookInfo: Boolean = false
 ) : JsExtensions {
 
     private val book get() = ruleData as? BaseBook
@@ -79,6 +80,12 @@ class AnalyzeRule(
     private var coroutineContext: CoroutineContext = EmptyCoroutineContext
 
     private var loggedNonStandardJSON = false
+    private var ruleName: String? = null
+    fun setRuleName(name: String) {
+        if (name.isNotBlank()) {
+            ruleName = name
+        }
+    }
 
     @JvmOverloads
     fun setContent(content: Any?, baseUrl: String? = null): AnalyzeRule {
@@ -782,6 +789,7 @@ class AnalyzeRule(
             bindings["src"] = content
             bindings["nextChapterUrl"] = nextChapterUrl
             bindings["rssArticle"] = rssArticle
+            bindings["fromBookInfo"] = isFromBookInfo
         }
         val topScope = source?.getShareScope(coroutineContext) ?: topScopeRef?.get()
         val scope = if (topScope == null) {
@@ -810,6 +818,10 @@ class AnalyzeRule(
         return source
     }
 
+    override fun getTag(): String? {
+        return source?.getTag() ?: ruleName
+    }
+
     /**
      * js实现跨域访问,不能删
      */
@@ -841,6 +853,9 @@ class AnalyzeRule(
      */
     fun reGetBook() {
         if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用")
+        if (isFromBookInfo) {
+            log("重新获取book")
+        }
         val bookSource = source as? BookSource
         val book = book as? Book
         if (bookSource == null || book == null) return
@@ -863,6 +878,10 @@ class AnalyzeRule(
      */
     fun refreshTocUrl() {
         if (!preUpdateJs) throw NoStackTraceException("只能在 preUpdateJs 中调用")
+        if (isFromBookInfo) {
+            log("已跳过重复加载详情页,请优化代码")
+            return
+        }
         val bookSource = source as? BookSource
         val book = book as? Book
         if (bookSource == null || book == null) return

+ 139 - 19
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt

@@ -13,8 +13,6 @@ import com.script.rhino.RhinoScriptEngine
 import com.script.rhino.runScriptWithContext
 import io.legado.app.constant.AppConst.UA_NAME
 import io.legado.app.constant.AppPattern
-import io.legado.app.constant.AppPattern.JS_PATTERN
-import io.legado.app.constant.AppPattern.dataUriRegex
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
@@ -50,15 +48,21 @@ import io.legado.app.utils.isJson
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.isXml
+import io.legado.app.utils.parseIpsFromString
+import io.legado.app.utils.stackTraceStr
 import kotlinx.coroutines.runBlocking
+import okhttp3.Dns
 import okhttp3.MediaType.Companion.toMediaType
 import okhttp3.OkHttpClient
+import okhttp3.Request
 import okhttp3.RequestBody.Companion.toRequestBody
 import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
 import java.io.ByteArrayInputStream
 import java.io.InputStream
 import java.net.URLEncoder
 import java.nio.charset.Charset
+import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.TimeUnit
 import java.util.regex.Pattern
 import kotlin.coroutines.ContinuationInterceptor
@@ -87,8 +91,10 @@ class AnalyzeUrl(
     private val callTimeout: Long? = null,
     private var coroutineContext: CoroutineContext = EmptyCoroutineContext,
     headerMapF: Map<String, String>? = null,
-    hasLoginHeader: Boolean = true
+    hasLoginHeader: Boolean = true,
+    private val infoMap: MutableMap<String, String>? = null
 ) : JsExtensions {
+    constructor(mUrl: String) : this(mUrl, null)
 
     var ruleUrl = ""
         private set
@@ -98,7 +104,8 @@ class AnalyzeUrl(
         private set
     val headerMap = LinkedHashMap<String, String>()
     private var body: String? = null
-    private var urlNoQuery: String = ""
+    var urlNoQuery: String = ""
+        private set
     private var encodedForm: String? = null
     private var encodedQuery: String? = null
     private var charset: String? = null
@@ -107,6 +114,8 @@ class AnalyzeUrl(
     private var retry: Int = 0
     private var useWebView: Boolean = false
     private var webJs: String? = null
+    private var bodyJs: String? = null
+    private var dnsIp: String? = null
     private val enabledCookieJar = source?.enabledCookieJar == true
     private val domain: String
     private var webViewDelayTime: Long = 0
@@ -151,7 +160,7 @@ class AnalyzeUrl(
      */
     private fun analyzeJs() {
         var start = 0
-        val jsMatcher = JS_PATTERN.matcher(ruleUrl)
+        val jsMatcher = AppPattern.JS_PATTERN.matcher(ruleUrl)
         var result = ruleUrl
         while (jsMatcher.find()) {
             if (jsMatcher.start() > start) {
@@ -184,9 +193,9 @@ class AnalyzeUrl(
             //替换所有内嵌{{js}}
             val url = analyze.innerRule("{{", "}}") {
                 val jsEval = evalJS(it) ?: ""
-                when {
-                    jsEval is String -> jsEval
-                    jsEval is Double && jsEval % 1.0 == 0.0 -> String.format("%.0f", jsEval)
+                when (jsEval) {
+                    is String -> jsEval
+                    is Double if jsEval % 1.0 == 0.0 -> String.format("%.0f", jsEval)
                     else -> jsEval.toString()
                 }
             }
@@ -242,13 +251,15 @@ class AnalyzeUrl(
                 retry = option.getRetry()
                 useWebView = option.useWebView()
                 webJs = option.getWebJs()
+                bodyJs = option.getBodyJs()
+                dnsIp = option.getDnsIp()
                 option.getJs()?.let { jsStr ->
                     evalJS(jsStr, url)?.toString()?.let {
                         url = it
                     }
-                    serverID = option.getServerID()
-                    webViewDelayTime = max(0, option.getWebViewDelayTime() ?: 0)
                 }
+                serverID = option.getServerID()
+                webViewDelayTime = max(0, option.getWebViewDelayTime() ?: 0)
             }
         }
         urlNoQuery = url
@@ -341,6 +352,7 @@ class AnalyzeUrl(
         }
     }
 
+
     /**
      * 执行JS
      */
@@ -357,6 +369,7 @@ class AnalyzeUrl(
             bindings["book"] = ruleData as? Book
             bindings["source"] = source
             bindings["result"] = result
+            bindings["infoMap"] = infoMap
         }
         val sharedScope = source?.getShareScope(coroutineContext)
         val scope = if (sharedScope == null) {
@@ -397,13 +410,30 @@ class AnalyzeUrl(
         jsStr: String? = null,
         sourceRegex: String? = null,
         useWebView: Boolean = true,
+        isTest: Boolean = false,
+        skipRateLimit: Boolean = false
     ): StrResponse {
         if (type != null) {
             return StrResponse(url, HexUtil.encodeHexStr(getByteArrayAwait()))
         }
+        if (skipRateLimit) {
+            return executeStrRequest(jsStr, sourceRegex, useWebView, isTest)
+        }
         concurrentRateLimiter.withLimit {
-            setCookie()
-            val strResponse: StrResponse
+            return executeStrRequest(jsStr, sourceRegex, useWebView, isTest)
+        }
+    }
+
+    private suspend fun executeStrRequest(
+        jsStr: String? = null,
+        sourceRegex: String? = null,
+        useWebView: Boolean = true,
+        isTest: Boolean = false
+    ): StrResponse {
+        setCookie()
+        val startTime = System.currentTimeMillis()
+        val strResponse: StrResponse
+        try {
             if (this.useWebView && useWebView) {
                 strResponse = when (method) {
                     RequestMethod.POST -> {
@@ -461,10 +491,36 @@ class AnalyzeUrl(
                         ?.matches(AppPattern.xmlContentTypeRegex) == true
                     if (isXml && it.body?.trim()?.startsWith("<?xml", true) == false) {
                         StrResponse(it.raw, "<?xml version=\"1.0\"?>" + it.body)
+                    } else if (bodyJs != null) {
+                        val body = evalJS(bodyJs!!, it.body).toString()
+                        StrResponse(it.raw, body)
                     } else it
                 }
             }
+            val connectionTime = System.currentTimeMillis() - startTime
+            strResponse.putCallTime(connectionTime.toInt())
             return strResponse
+        } catch (e: Exception) {
+            if (!isTest) {
+                throw e
+            }
+            val errorCode = when (e) {
+                is java.net.SocketTimeoutException -> -2  // 超时错误
+                is java.net.UnknownHostException -> -3   // 未找到域名
+                is java.net.ConnectException -> -4       // 连接被拒绝
+                is java.net.SocketException -> -5        // Socket错误(包括连接重置)
+                is javax.net.ssl.SSLException -> -6      // SSL证书或握手错误
+                is java.io.InterruptedIOException -> {
+                    if (e.message?.contains("timeout") == true) {
+                        -1  // 超过设定时间
+                    } else -7
+                }
+
+                else -> -7  // 其它错误
+            }
+            return StrResponse(url, e.message).apply {
+                putCallTime(errorCode)
+            }
         }
     }
 
@@ -509,11 +565,31 @@ class AnalyzeUrl(
         }
     }
 
+    /**
+     * 返回一个errResponse
+     */
+    fun getErrResponse(e: Throwable): Response = Response.Builder()
+        .request(Request.Builder().url(url).build())
+        .protocol(okhttp3.Protocol.HTTP_1_1)
+        .code(500)
+        .message(e.message ?: "Error Response")
+        .body(e.stackTraceStr.toResponseBody(null))
+        .build()
+
+    /**
+     * 返回一个errStrResponse
+     */
+    fun getErrStrResponse(e: Throwable): StrResponse =
+        StrResponse(getErrResponse(e), e.stackTraceStr)
+
     private fun getClient(): OkHttpClient {
         val client = getProxyClient(proxy)
-        if (readTimeout == null && callTimeout == null) {
+        if (readTimeout == null && callTimeout == null && dnsIp == null) {
             return client
         }
+        if (AppConfig.isCronet && dnsIp != null) {
+            customIp[urlNoQuery] = dnsIp!!
+        }
         return client.newBuilder().run {
             if (readTimeout != null) {
                 readTimeout(readTimeout, TimeUnit.MILLISECONDS)
@@ -522,10 +598,21 @@ class AnalyzeUrl(
             if (callTimeout != null) {
                 callTimeout(callTimeout, TimeUnit.MILLISECONDS)
             }
+            if (dnsIp != null) {
+                val inetAddress = dnsIp!!.parseIpsFromString()
+                dns { hostname ->
+                    inetAddress ?: Dns.SYSTEM.lookup(hostname)
+                }
+            }
             build()
         }
     }
 
+    private fun extractHostFromUrl(url: String): String? {
+        return AppPattern.domainRegex.find(url)?.groupValues?.getOrNull(1)
+    }
+
+
     fun getResponse(): Response {
         return runBlocking(coroutineContext) {
             getResponseAwait()
@@ -536,7 +623,7 @@ class AnalyzeUrl(
         if (!urlNoQuery.startsWith("data:")) {
             return null
         }
-        val dataUriFindResult = dataUriRegex.find(urlNoQuery)
+        val dataUriFindResult = AppPattern.dataUriRegex.find(urlNoQuery)
         if (dataUriFindResult != null) {
             val dataUriBase64 = dataUriFindResult.groupValues[1]
             val byteArray = Base64.decode(dataUriBase64, Base64.DEFAULT)
@@ -666,12 +753,16 @@ class AnalyzeUrl(
         return source
     }
 
+    override fun getTag(): String? {
+        return source?.getTag()
+    }
+
     companion object {
         val paramPattern: Pattern = Pattern.compile("\\s*,\\s*(?=\\{)")
         private val pagePattern = Pattern.compile("<(.*?)>")
         private val queryEncoder =
             RFC3986.UNRESERVED.orNew(PercentCodec.of("!$%&()*+,/:;=?@[\\]^`{|}"))
-
+        val customIp by lazy { ConcurrentHashMap<String, String>() }
         fun AnalyzeUrl.getMediaItem(): MediaItem {
             setCookie()
             return ExoPlayerHelper.createMediaItem(url, headerMap)
@@ -705,11 +796,20 @@ class AnalyzeUrl(
          * webView中执行的js
          **/
         private var webJs: String? = null,
+        /**
+         * 自定义的域名ip
+         **/
+        private var dnsIp: String? = null,
         /**
          * 解析完url参数时执行的js
          * 执行结果会赋值给url
          */
         private var js: String? = null,
+        /**
+         * 得到访问结果后执行的js,对结果进行二次处理
+         * 执行结果返回为body
+         */
+        private var bodyJs: String? = null,
         /**
          * 服务器id
          */
@@ -808,6 +908,13 @@ class AnalyzeUrl(
         fun getWebJs(): String? {
             return webJs
         }
+        fun setDnsIp(value: String?) {
+            dnsIp = if (value.isNullOrBlank()) null else value
+        }
+
+        fun getDnsIp(): String? {
+            return dnsIp
+        }
 
         fun setJs(value: String?) {
             js = if (value.isNullOrBlank()) null else value
@@ -817,6 +924,14 @@ class AnalyzeUrl(
             return js
         }
 
+        fun setBodyJs(value: String?) {
+            bodyJs = if (value.isNullOrBlank()) null else value
+        }
+
+        fun getBodyJs(): String? {
+            return bodyJs
+        }
+
         fun setServerID(value: String?) {
             serverID = if (value.isNullOrBlank()) null else value.toLong()
         }
@@ -835,14 +950,18 @@ class AnalyzeUrl(
     }
 
     data class ConcurrentRecord(
-        /**
-         * 是否按频率
-         */
-        val isConcurrent: Boolean,
         /**
          * 开始访问时间
          */
         var time: Long,
+        /**
+         * 限制次数
+         */
+        var accessLimit: Int,
+        /**
+         * 间隔时间
+         */
+        var interval: Int,
         /**
          * 正在访问的个数
          */
@@ -850,3 +969,4 @@ class AnalyzeUrl(
     )
 
 }
+

+ 67 - 35
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
 import com.google.android.material.transition.platform.MaterialContainerTransform
 import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
 import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
+import com.script.rhino.runScriptWithContext
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.AppLog
@@ -63,6 +64,7 @@ import io.legado.app.model.ReadBook
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
+import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
 import io.legado.app.model.localBook.EpubFile
 import io.legado.app.model.localBook.MobiFile
 import io.legado.app.receiver.NetworkChangedListener
@@ -102,6 +104,7 @@ import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog
 import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.dict.DictDialog
 import io.legado.app.ui.login.SourceLoginActivity
+import io.legado.app.ui.login.SourceLoginJsExtensions
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleActivity
 import io.legado.app.ui.widget.PopupAction
@@ -121,7 +124,6 @@ import io.legado.app.utils.hexString
 import io.legado.app.utils.iconItemOnLongClick
 import io.legado.app.utils.invisible
 import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.isTrue
 import io.legado.app.utils.navigationBarGravity
 import io.legado.app.utils.observeEvent
@@ -1521,45 +1523,75 @@ class ReadBookActivity : BaseReadBookActivity(),
         showHelp("readMenuHelp")
     }
 
-    override fun clickImg(clickjs: String) {
-        val braceIndex = clickjs.indexOf(",{")
-        val braceIndex2 = if (braceIndex == -1) clickjs.indexOf(", {") else -1
-        if (braceIndex != -1 || braceIndex2 != -1) {
-            val book = ReadBook.book ?: return
-            val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
-            if (chapter == null) {
-                toastOnUi("章节不存在")
-                return
-            }
-            val (result, urlOptionStr) = when {
-                braceIndex != -1 -> {
-                    clickjs.take(braceIndex) to clickjs.substring(braceIndex + 1)
-                }
-                else -> {
-                    clickjs.take(braceIndex2) to clickjs.substring(braceIndex2 + 2)
-                }
-            }
-            if (urlOptionStr.isJsonObject()) {
-                val urlOptionMap = GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrThrow()
-                val jsStr = urlOptionMap["js"]
-                jsStr?.let {
-                    Coroutine.async(lifecycleScope) {
-                        val source = ReadBook.bookSource ?: throw Exception("书源不存在")
-                        AnalyzeRule(book, source).apply {
-                            setCoroutineContext(coroutineContext)
-                            setBaseUrl(chapter.url)
-                            setChapter(chapter)
-                            evalJS(jsStr, result).toString()
+    /**
+     * 点击图片
+     */
+    override fun oldClickImg(src: String): Boolean {
+        val urlMatcher = paramPattern.matcher(src)
+        if (urlMatcher.find()) {
+            val urlOptionStr = src.substring(urlMatcher.end())
+            val urlOptionMap = GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()
+            val click = urlOptionMap?.get("click")
+            if (click != null) {
+                Coroutine.async(lifecycleScope, IO) {
+                    val source = ReadBook.bookSource ?: return@async
+                    val java = SourceLoginJsExtensions(this@ReadBookActivity, source, BookType.text)
+                    val book = ReadBook.book ?: return@async
+                    val chapter =
+                        appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
+                            ?: throw Exception("no find chapter")
+                    runScriptWithContext {
+                        source.evalJS(click) {
+                            put("java", java)
+                            put("book", book)
+                            put("chapter", chapter)
+                            put("result", src)
                         }
-                    }.onError {
-                        AppLog.put("图片点击执行出错\n${it.localizedMessage}", it, true)
                     }
+                }.onError {
+                    AppLog.put("执行图片链接click键值出错\n${it.localizedMessage}", it, true)
                 }
+                return true
             }
-            else {
-                toastOnUi("链接格式错误")
-                return
+            val jsStr = urlOptionMap?.get("js") ?: return false
+            Coroutine.async(lifecycleScope, IO) {
+                val source = ReadBook.bookSource ?: return@async
+                val book = ReadBook.book ?: return@async
+                val chapter =
+                    appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
+                        ?: throw Exception("no find chapter")
+                val urlNoOption = src.take(urlMatcher.start())
+                AnalyzeRule(book, source).apply {
+                    setCoroutineContext(coroutineContext)
+                    setBaseUrl(chapter.url)
+                    setChapter(chapter)
+                    evalJS(jsStr, urlNoOption)
+                }
+            }.onError {
+                AppLog.put("执行图片链接js键值出错\n${it.localizedMessage}", it, true)
+            }
+            return true
+        }
+        return false
+    }
+
+    override fun clickImg(click: String, src: String) {
+        Coroutine.async(lifecycleScope, IO) {
+            val source = ReadBook.bookSource ?: return@async
+            val java = SourceLoginJsExtensions(this@ReadBookActivity, source, BookType.text)
+            val book = ReadBook.book ?: return@async
+            val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
+                ?: throw Exception("no find chapter")
+            runScriptWithContext {
+                source.evalJS(click) {
+                    put("java", java)
+                    put("book", book)
+                    put("chapter", chapter)
+                    put("result", src)
+                }
             }
+        }.onError {
+            AppLog.put("执行图片链接click键值出错\n${it.localizedMessage}", it, true)
         }
     }
 

+ 46 - 9
app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt

@@ -11,6 +11,7 @@ import io.legado.app.data.entities.Bookmark
 import io.legado.app.help.book.isOnLineTxt
 import io.legado.app.help.config.AppConfig
 import io.legado.app.model.ReadBook
+import io.legado.app.ui.association.OpenUrlConfirmActivity
 import io.legado.app.ui.book.read.page.delegate.PageDelegate
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
@@ -20,6 +21,7 @@ import io.legado.app.ui.book.read.page.entities.column.ButtonColumn
 import io.legado.app.ui.book.read.page.entities.column.ImageColumn
 import io.legado.app.ui.book.read.page.entities.column.ReviewColumn
 import io.legado.app.ui.book.read.page.entities.column.TextColumn
+import io.legado.app.ui.book.read.page.entities.column.TextHtmlColumn
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.widget.dialog.PhotoDialog
@@ -27,6 +29,7 @@ import io.legado.app.utils.activity
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.getCompatColor
 import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.startActivity
 import io.legado.app.utils.toastOnUi
 import java.util.concurrent.Executors
 import kotlin.math.max
@@ -246,18 +249,51 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
                     handled = true
                 }
 
-                is ImageColumn -> if (AppConfig.previewImageByClick) {
-                    activity?.showDialogFragment(PhotoDialog(column.src))
-                    handled = true
-                } else {
-                    if (ReadBook.book?.isOnLineTxt == true) {
-                        val src = column.src
-                        if (src.contains("\"js\"") || src.contains("'js'")) {
-                            callBack.clickImg(src)
+                is ImageColumn -> when (AppConfig.clickImgWay) {
+                    "1" -> { //预览图片
+                        activity?.showDialogFragment(PhotoDialog(column.src))
+                        handled = true
+                    }
+
+                    "2" -> { //兼容处理
+                        if (ReadBook.book?.isOnLineTxt == true) {
+                            val click = column.click
+                            val src = column.src
+                            if (!click.isNullOrBlank()) {
+                                callBack.clickImg(click, src)
+                                handled = true
+                            } else {
+                                handled = callBack.oldClickImg(src)
+                            }
+                        }
+                    }
+
+                    "3" -> { //关闭
+                        handled = false
+                    }
+
+                    else -> { //默认点击
+                        val click = column.click
+                        if (!click.isNullOrBlank()) {
+                            callBack.clickImg(click, column.src)
                             handled = true
                         }
                     }
                 }
+
+                is TextHtmlColumn -> {
+                    column.linkUrl?.let {
+                        activity?.startActivity<OpenUrlConfirmActivity> {
+                            putExtra("uri", it)
+//                            putExtra("mimeType", mimeType)
+//                            putExtra("sourceOrigin", source.getKey())
+//                            putExtra("sourceName", source.getTag())
+//                            putExtra("sourceType", source.getSourceType())
+                        }
+                        handled = true
+                    }
+
+                }
             }
         }
         return handled
@@ -723,6 +759,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
         fun onImageLongPress(x: Float, y: Float, src: String)
         fun onCancelSelect()
         fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean
-        fun clickImg(clickjs: String)
+        fun oldClickImg(src: String): Boolean
+        fun clickImg(click: String, src: String)
     }
 }

+ 8 - 2
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt

@@ -8,6 +8,7 @@ import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
+import io.legado.app.utils.dpToPx
 import io.legado.app.utils.toastOnUi
 import splitties.init.appCtx
 
@@ -18,7 +19,8 @@ import splitties.init.appCtx
 data class ImageColumn(
     override var start: Float,
     override var end: Float,
-    var src: String
+    var src: String,
+    var click: String? = null
 ) : BaseColumn {
 
     override var textLine: TextLine = emptyTextLine
@@ -49,4 +51,8 @@ data class ImageColumn(
         }
     }
 
-}
+    override fun isTouch(x: Float): Boolean {
+        return x > start && x < end + 20.dpToPx()
+    }
+
+}

+ 85 - 54
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt

@@ -27,6 +27,7 @@ import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.ImageProvider
 import io.legado.app.model.ReadBook
+import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
@@ -37,9 +38,11 @@ import io.legado.app.ui.book.read.page.provider.ChapterProvider.reviewChar
 import io.legado.app.ui.book.read.page.provider.ChapterProvider.srcReplaceChar
 import io.legado.app.ui.book.read.page.provider.ChapterProvider.srcReplaceCharC
 import io.legado.app.ui.book.read.page.provider.ChapterProvider.srcReplaceCharD
+import io.legado.app.utils.GSON
 import io.legado.app.utils.StringUtils
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.fastSum
+import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.getTextWidthsCompat
 import io.legado.app.utils.splitNotBlank
 import kotlinx.coroutines.CancellationException
@@ -340,6 +343,7 @@ class TextChapterLayout(
                 }
                 var start = 0
                 val srcList = LinkedList<String>()
+                val clickList = LinkedList<String?>()
                 sb.setLength(0)
                 var isFirstLine = true
                 if (content.contains("<img")) {
@@ -347,51 +351,58 @@ class TextChapterLayout(
                     while (matcher.find()) {
                         currentCoroutineContext().ensureActive()
                         val imgSrc = matcher.group(1)!!
-                        var iStyle = imageStyle
-                        var isSmallImage = true
-                        val matchResult = AppPattern.imgStyRegex.find(imgSrc)
-                        var imgSize: Size? = null
-                        if (matchResult != null) {
-                            val styleValue = matchResult.groupValues[1].trim()
-                            if (styleValue.equals("text", true)) { //忽略大小写
-                                iStyle = styleValue
-                            } else {
-                                imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
-                                iStyle = styleValue
-                                isSmallImage = false
-                            }
-                        } else {
-                            imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
-                            if (imgSize.width < 80 && imgSize.height < 80) {
-                                iStyle = "text"
-                            } else {
-                                isSmallImage = false
+                        var iStyle: String? = null
+                        var click: String? = null
+                        var imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
+                        val urlMatcher = paramPattern.matcher(imgSrc)
+                        if (urlMatcher.find()) {
+                            var width: String? = null
+                            val urlOptionStr = imgSrc.substring(urlMatcher.end())
+                            GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()
+                                ?.let { map ->
+                                    map.forEach { (key, value) ->
+                                        when (key) {
+                                            "style" -> iStyle = value
+                                            "width" -> width = value
+                                            "click" -> click = value
+                                        }
+                                    }
+                                }
+                            width?.let {
+                                if (it.endsWith("%")) {
+                                    it.dropLast(1).toIntOrNull()?.let { percentage ->
+                                        val imgWidth = visibleWidth * percentage / 100
+                                        val newHeight = imgSize.height * imgWidth / imgSize.width
+                                        imgSize = Size(imgWidth, newHeight)
+                                    }
+                                } else {
+                                    it.toIntOrNull()?.let { w ->
+                                        val newHeight = imgSize.height * w / imgSize.width
+                                        imgSize = Size(w, newHeight)
+                                    }
+                                }
                             }
                         }
+                        if (iStyle == null) {
+                            iStyle =
+                                if (imgSize.width < 80 && imgSize.height < 80) "text" else imageStyle
+                        }
+
                         if (start < matcher.start()) {
                             sb.append(text.substring(start, matcher.start()))
                         }
-                        if (isSmallImage) {
-                            sb.append(
-                                if (iStyle == "TEXT")
-                                    reviewChar
-                                else
-                                    srcReplaceChar
-                            )
+                        if (iStyle == "text" || iStyle == "TEXT") {
+                            sb.append(if (iStyle == "TEXT") reviewChar else srcReplaceChar)
                             srcList.add(imgSrc)
+                            clickList.add(click)
                         } else {
                             val textBefore = sb.toString()
                             if (textBefore.isNotBlank()) {
                                 wordCount += textBefore.replace(noWordCountRegex,"").length
                                 setTypeText(
-                                    book,
-                                    sb.toString(),
-                                    contentPaint,
-                                    contentPaintTextHeight,
-                                    contentPaintFontMetrics,
-                                    "TEXT",
-                                    isFirstLine = isFirstLine,
-                                    srcList = srcList
+                                    book, sb.toString(), contentPaint, contentPaintTextHeight,
+                                    contentPaintFontMetrics, "TEXT", isFirstLine = isFirstLine,
+                                    srcList = srcList, clickList = clickList
                                 )
                                 sb.setLength(0)
                                 isFirstLine = false
@@ -401,8 +412,9 @@ class TextChapterLayout(
                                 imgSrc,
                                 contentPaintTextHeight,
                                 iStyle,
-                                imgSize!!
-                            )
+                                imgSize,
+                                click
+                            ) // 传递点击信息
                             isSetTypedImage = true
                         }
                         start = matcher.end()
@@ -427,7 +439,8 @@ class TextChapterLayout(
                         contentPaintFontMetrics,
                         "TEXT",
                         isFirstLine = isFirstLine,
-                        srcList = srcList.ifEmpty { null }
+                        srcList = srcList.ifEmpty { null },
+                        clickList = clickList.ifEmpty { null }
                     )
                 }
             }
@@ -459,7 +472,8 @@ class TextChapterLayout(
         src: String,
         textHeight: Float,
         imageStyle: String?,
-        size: Size
+        size: Size,
+        click: String? = null
     ) {
         if (size.width > 0 && size.height > 0) {
             prepareNextPageIfNeed(durY)
@@ -776,7 +790,8 @@ class TextChapterLayout(
         isFirstLine: Boolean = true,
         emptyContent: Boolean = false,
         isVolumeTitle: Boolean = false,
-        srcList: LinkedList<String>? = null
+        srcList: LinkedList<String>? = null,
+        clickList: LinkedList<String?>? = null
     ) {
         val widthsArray = allocateFloatArray(text.length)
         textPaint.getTextWidthsCompat(text, widthsArray)
@@ -836,7 +851,7 @@ class TextChapterLayout(
                     //多行的第一行 非标题
                     addCharsToLineFirst(
                         book, absStartX, textLine, words, textPaint,
-                        desiredWidth, widths, srcList
+                        desiredWidth, widths, srcList, clickList
                     )
                 }
 
@@ -854,7 +869,7 @@ class TextChapterLayout(
                     }
                     addCharsToLineNatural(
                         book, absStartX, textLine, words,
-                        startX, !isTitle && lineIndex == 0, widths, srcList
+                        startX, !isTitle && lineIndex == 0, widths, srcList, clickList
                     )
                 }
                 else -> {
@@ -867,13 +882,13 @@ class TextChapterLayout(
                         val startX = (visibleWidth - desiredWidth) / 2
                         addCharsToLineNatural(
                             book, absStartX, textLine, words,
-                            startX, false, widths, srcList
+                            startX, false, widths, srcList, clickList
                         )
                     } else {
                         //中间行
                         addCharsToLineMiddle(
                             book, absStartX, textLine, words, textPaint,
-                            desiredWidth, 0f, widths, srcList
+                            desiredWidth, 0f, widths, srcList, clickList
                         )
                     }
                 }
@@ -926,13 +941,14 @@ class TextChapterLayout(
         /**自然排版长度**/
         desiredWidth: Float,
         textWidths: List<Float>,
-        srcList: LinkedList<String>?
+        srcList: LinkedList<String>?,
+        clickList: LinkedList<String?>?
     ) {
         var x = 0f
         if (!textFullJustify) {
             addCharsToLineNatural(
                 book, absStartX, textLine, words,
-                x, true, textWidths, srcList
+                x, true, textWidths, srcList, clickList
             )
             return
         }
@@ -955,7 +971,7 @@ class TextChapterLayout(
             val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size)
             addCharsToLineMiddle(
                 book, absStartX, textLine, text1, textPaint,
-                desiredWidth, x, textWidths1, srcList
+                desiredWidth, x, textWidths1, srcList, clickList
             )
         }
     }
@@ -974,12 +990,13 @@ class TextChapterLayout(
         /**起始x坐标**/
         startX: Float,
         textWidths: List<Float>,
-        srcList: LinkedList<String>?
+        srcList: LinkedList<String>?,
+        clickList: LinkedList<String?>?
     ) {
         if (!textFullJustify) {
             addCharsToLineNatural(
                 book, absStartX, textLine, words,
-                startX, false, textWidths, srcList
+                startX, false, textWidths, srcList, clickList
             )
             return
         }
@@ -1000,7 +1017,7 @@ class TextChapterLayout(
                 }
                 addCharToLine(
                     book, absStartX, textLine, char,
-                    x, x1, index + 1 == words.size, srcList
+                    x, x1, index + 1 == words.size, srcList, clickList
                 )
                 x = x1
             }
@@ -1016,7 +1033,7 @@ class TextChapterLayout(
                 val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw)
                 addCharToLine(
                     book, absStartX, textLine, char,
-                    x, x1, index + 1 == words.size, srcList
+                    x, x1, index + 1 == words.size, srcList, clickList
                 )
                 x = x1
             }
@@ -1035,7 +1052,8 @@ class TextChapterLayout(
         startX: Float,
         hasIndent: Boolean,
         textWidths: List<Float>,
-        srcList: LinkedList<String>?
+        srcList: LinkedList<String>?,
+        clickList: LinkedList<String?>?
     ) {
         val indentLength = paragraphIndent.length
         var x = startX
@@ -1044,7 +1062,17 @@ class TextChapterLayout(
             val char = words[index]
             val cw = textWidths[index]
             val x1 = x + cw
-            addCharToLine(book, absStartX, textLine, char, x, x1, index + 1 == words.size, srcList)
+            addCharToLine(
+                book,
+                absStartX,
+                textLine,
+                char,
+                x,
+                x1,
+                index + 1 == words.size,
+                srcList,
+                clickList
+            )
             x = x1
             if (hasIndent && index == indentLength - 1) {
                 textLine.indentWidth = x
@@ -1064,16 +1092,19 @@ class TextChapterLayout(
         xStart: Float,
         xEnd: Float,
         isLineEnd: Boolean,
-        srcList: LinkedList<String>?
+        srcList: LinkedList<String>?,
+        clickList: LinkedList<String?>?
     ) {
         val column = when {
             !srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> {
                 val src = srcList.removeFirst()
+                val click = clickList?.removeFirst()
                 ImageProvider.cacheImage(book, src, ReadBook.bookSource)
                 ImageColumn(
                     start = absStartX + xStart,
                     end = absStartX + xEnd,
-                    src = src
+                    src = src,
+                    click = click
                 )
             }
 //            isLineEnd && char == ChapterProvider.reviewChar -> {

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt

@@ -663,9 +663,10 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
 
     companion object {
 
-        fun start(context: Context, key: String?) {
+        fun start(context: Context, key: String?, searchScope: String? = null) {
             context.startActivity<SearchActivity> {
                 putExtra("key", key)
+                putExtra("searchScope", searchScope)
             }
         }
 

+ 601 - 63
app/src/main/java/io/legado/app/ui/login/SourceLoginDialog.kt

@@ -1,10 +1,20 @@
 package io.legado.app.ui.login
 
 //import io.legado.app.lib.theme.primaryColor
+import android.annotation.SuppressLint
 import android.content.DialogInterface
+import android.graphics.Rect
 import android.os.Bundle
 import android.text.InputType
+import android.view.Gravity
 import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.AppCompatSpinner
 import androidx.core.view.setPadding
 import androidx.fragment.app.activityViewModels
 import androidx.lifecycle.lifecycleScope
@@ -14,18 +24,25 @@ import io.legado.app.base.BaseBottomSheetDialogFragment
 import io.legado.app.constant.AppLog
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.rule.RowUi
+import io.legado.app.data.entities.rule.RowUi.Type
 import io.legado.app.databinding.DialogLoginBinding
 import io.legado.app.databinding.ItemFilletTextBinding
+import io.legado.app.databinding.ItemSelectorSingleBinding
 import io.legado.app.databinding.ItemSourceEditBinding
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.ui.about.AppLogDialog
+import io.legado.app.ui.widget.text.TextInputLayout
 import io.legado.app.utils.GSON
 import io.legado.app.utils.applyTint
 import io.legado.app.utils.dpToPx
+import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.isAbsUrl
+import io.legado.app.utils.isTrue
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.sendToClip
+import io.legado.app.utils.setLayout
+import io.legado.app.utils.setSelectionSafely
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -35,72 +52,543 @@ import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
-import splitties.views.onClick
+import kotlin.math.abs
 
 
 class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
 
     private val binding by viewBinding(DialogLoginBinding::bind)
     private val viewModel by activityViewModels<SourceLoginViewModel>()
+    private var lastClickTime: Long = 0
+    private var oKToClose = false
+    private var rowUis: List<RowUi>? = null
+    private var rowUiName = arrayListOf<String>()
+    private var hasChange = false
+    private val sourceLoginJsExtensions by lazy {
+        SourceLoginJsExtensions(
+            activity as AppCompatActivity,
+            viewModel.source,
+            viewModel.bookType,
+            callback = object : SourceLoginJsExtensions.Callback {
+                override fun upUiData(data: Map<String, String?>?) {
+                    activity?.runOnUiThread { // 在主线程中更新 UI
+                        handleUpUiData(data)
+                    }
+                }
 
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
+                override fun reUiView() {
+                    activity?.runOnUiThread {
+                        handleReUiView()
+                    }
+                }
+            }
+        )
+    }
+
+    private fun handleReUiView() {
         val source = viewModel.source ?: return
-        //binding.toolBar.setBackgroundColor(primaryColor)
-        binding.toolBar.title = getString(R.string.login_source, source.getTag())
-        val loginInfo = source.getLoginInfoMap()
-        val loginUi = source.loginUi()
+        val loginUiStr = source.loginUi ?: return
+        val codeStr = loginUiStr.let {
+            when {
+                it.startsWith("@js:") -> it.substring(4)
+                it.startsWith("<js>") -> it.substring(4, it.lastIndexOf("<"))
+                else -> null
+            }
+        }
+        if (codeStr != null) {
+            hasChange = true
+            lifecycleScope.launch(Main) {
+                val loginUiJson = evalUiJs(codeStr)
+                rowUis = loginUi(loginUiJson)
+                binding.flexbox.removeAllViews()
+                rowUiBuilder(source, rowUis)
+            }
+        } else {
+            rowUis = loginUi(loginUiStr)
+            binding.flexbox.removeAllViews()
+            rowUiBuilder(source, rowUis)
+        }
+    }
+
+    @SuppressLint("SetTextI18n")
+    private fun handleUpUiData(data: Map<String, String?>?) {
+        hasChange = true
+        if (data == null) {
+            val newLoginInfo: MutableMap<String, String> = mutableMapOf()
+            rowUis?.forEachIndexed { index, rowUi ->
+                val default = rowUi.default
+                when (val rowView = binding.root.findViewById<View>(index + 1000)) {
+                    is TextInputLayout -> {
+                        newLoginInfo[rowUi.name] = default ?: ""
+                        rowView.editText?.setText(default ?: "")
+                    }
+
+                    is TextView -> {
+                        when (rowUi.type) {
+                            Type.button -> {
+                                rowView.text = rowUi.viewName ?: rowUi.name
+                            }
+
+                            Type.toggle -> {
+                                val char = default ?: run {
+                                    val chars =
+                                        rowUi.chars?.filterNotNull() ?: listOf("chars is null")
+                                    chars.getOrNull(0) ?: ""
+                                }
+                                newLoginInfo[rowUi.name] = char
+                                val name = rowUi.viewName ?: rowUi.name
+                                val left = rowUi.style?.layout_justifySelf != "right"
+                                rowView.text = if (left) char + name else name + char
+                            }
+                        }
+                    }
+
+                    is LinearLayout -> {
+                        val chars = rowUi.chars?.filterNotNull() ?: listOf("chars", "is null")
+                        val index = chars.indexOf(default)
+                        newLoginInfo[rowUi.name] = default ?: run {
+                            chars.getOrNull(0) ?: ""
+                        }
+                        rowView.findViewById<AppCompatSpinner>(R.id.sp_type)
+                            ?.setSelectionSafely(index)
+                    }
+                }
+            }
+            viewModel.loginInfo = newLoginInfo
+            return
+        }
+        val loginInfo = viewModel.loginInfo
+        data.forEach { (key, value) ->
+            val index = rowUiName.indexOf(key)
+            if (index != -1) {
+                when (val rowView = binding.root.findViewById<View>(index + 1000)) {
+                    is TextInputLayout -> {
+                        val value = value ?: run {
+                            val rowUi = rowUis?.getOrNull(index) ?: return@forEach
+                            rowUi.default ?: ""
+                        }
+                        rowView.editText?.setText(value)
+                    }
+
+                    is TextView -> {
+                        val rowUi = rowUis?.getOrNull(index) ?: return@forEach
+                        when (rowUi.type) {
+                            Type.button -> {
+                                rowView.text = value ?: key
+                            }
+
+                            Type.toggle -> {
+                                val char = value ?: run {
+                                    val chars =
+                                        rowUi.chars?.filterNotNull() ?: listOf("chars is null")
+                                    chars.getOrNull(0) ?: ""
+                                }
+                                loginInfo[rowUi.name] = char
+                                val name = rowUi.viewName ?: rowUi.name
+                                val left = rowUi.style?.layout_justifySelf != "right"
+                                rowView.text = if (left) char + name else name + char
+                            }
+                        }
+                    }
+
+                    is LinearLayout -> {
+                        val rowUi = rowUis?.getOrNull(index) ?: return@forEach
+                        val items = rowUi.chars?.filterNotNull() ?: listOf("chars", "is null")
+                        val index = items.indexOf(value)
+                        loginInfo[rowUi.name] = value ?: run {
+                            items.getOrNull(0) ?: ""
+                        }
+                        rowView.findViewById<AppCompatSpinner>(R.id.sp_type)
+                            ?.setSelectionSafely(index)
+                    }
+                }
+            } else {
+                loginInfo[key] = value ?: ""
+            }
+        }
+    }
+
+    override fun onStart() {
+        super.onStart()
+        setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
+    }
+
+    suspend fun evalUiJs(jsStr: String): String? = withContext(IO) {
+        val source = viewModel.source ?: return@withContext null
+        val loginJS = source.getLoginJs() ?: ""
+        val result = rowUis?.let {
+            getLoginData(it)
+        } ?: viewModel.loginInfo.toMutableMap()
         try {
-            loginUi?.forEachIndexed { index, rowUi ->
-                when (rowUi.type) {
-                    RowUi.Type.text -> ItemSourceEditBinding.inflate(
-                        layoutInflater,
-                        binding.root,
-                        false
-                    ).let {
-                        binding.flexbox.addView(it.root)
-                        it.root.id = index + 1000
-                        it.textInputLayout.hint = rowUi.name
-                        it.editText.setText(loginInfo?.get(rowUi.name))
-                    }
-
-                    RowUi.Type.password -> ItemSourceEditBinding.inflate(
-                        layoutInflater,
-                        binding.root,
-                        false
-                    ).let {
-                        binding.flexbox.addView(it.root)
-                        it.root.id = index + 1000
-                        it.textInputLayout.hint = rowUi.name
-                        it.editText.inputType =
-                            InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
-                        it.editText.setText(loginInfo?.get(rowUi.name))
-                    }
-
-                    RowUi.Type.button -> ItemFilletTextBinding.inflate(
-                        layoutInflater,
-                        binding.root,
-                        false
-                    ).let {
-                        binding.flexbox.addView(it.root)
-                        rowUi.style().apply(it.root)
-                        it.root.id = index + 1000
-                        it.textView.text = rowUi.name
-                        it.textView.setPadding(16.dpToPx())
-                        it.root.onClick {
-                            handleButtonClick(source, rowUi, loginUi)
+            runScriptWithContext {
+                source.evalJS("$loginJS\n$jsStr") {
+                    put("result", result)
+                    put("book", viewModel.book)
+                    put("chapter", viewModel.chapter)
+                }.toString()
+            }
+        } catch (e: Exception) {
+            AppLog.put(source.getTag() + " loginUi err:" + (e.localizedMessage ?: e.toString()), e)
+            null
+        }
+    }
+
+    fun loginUi(json: String?): List<RowUi>? {
+        return GSON.fromJsonArray<RowUi>(json).onFailure {
+            it.printOnDebug()
+        }.getOrNull()
+    }
+
+    @SuppressLint("SetTextI18n", "ClickableViewAccessibility")
+    private fun rowUiBuilder(source: BaseSource, rowUis: List<RowUi>?) {
+        val loginInfo = viewModel.loginInfo
+        rowUiName.clear()
+        rowUis?.forEachIndexed { index, rowUi ->
+            val type = rowUi.type
+            val name = rowUi.name
+            val viewName = rowUi.viewName
+            val action = rowUi.action
+            rowUiName.add(name)
+            when (type) {
+                Type.text -> ItemSourceEditBinding.inflate(
+                    layoutInflater,
+                    binding.root,
+                    false
+                ).let {
+                    val editText = it.editText
+                    binding.flexbox.addView(it.root)
+                    rowUi.style().apply {
+                        when (this.layout_justifySelf) {
+                            "center" -> editText.gravity = Gravity.CENTER
+                            "flex_end" -> editText.gravity = Gravity.END
+                        }
+                        apply(it.root)
+                    }
+                    it.root.id = index + 1000
+                    if (viewName == null) {
+                        it.textInputLayout.hint = name
+                    } else if (viewName.length in 3..19 && viewName.first() == '\'' && viewName.last() == '\'') {
+                        it.textInputLayout.hint = viewName.substring(1, viewName.length - 1)
+                    } else {
+                        it.textInputLayout.hint = name
+                        execute {
+                            evalUiJs(viewName)
+                        }.onSuccess { n ->
+                            if (n.isNullOrEmpty()) {
+                                it.textInputLayout.hint = "null"
+                            } else {
+                                it.textInputLayout.hint = n
+                            }
+                        }.onError { _ ->
+                            it.textInputLayout.hint = "err"
+                        }
+                    }
+                    editText.setText(loginInfo[name])
+                    action?.let { jsStr ->
+                        var content: String? = null
+                        editText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
+                            if (hasFocus) {
+                                content = editText.text.toString()
+                            } else {
+                                val reContent = editText.text.toString()
+                                if (content != reContent) {
+                                    execute {
+                                        evalUiJs(jsStr)
+                                    }.onSuccess { result ->
+                                        if (result.isTrue()) {
+                                            loginInfo[name] = reContent
+                                            hasChange = true
+                                        }
+                                    }.onError { e ->
+                                        AppLog.put("LoginUI Text $name JavaScript error", e)
+                                    }
+                                }
+                            }
+                        }
+                        editText.viewTreeObserver.addOnGlobalLayoutListener {
+                            if (!editText.hasFocus()) {
+                                return@addOnGlobalLayoutListener
+                            }
+                            val rect = Rect()
+                            binding.root.getWindowVisibleDisplayFrame(rect)
+                            val screenHeight = binding.root.height
+                            val keypadHeight = screenHeight - rect.bottom
+                            if (abs(keypadHeight) < screenHeight / 5) {
+                                editText.clearFocus()
+                            }
+                        }
+                    }
+                }
+
+                Type.password -> ItemSourceEditBinding.inflate(
+                    layoutInflater,
+                    binding.root,
+                    false
+                ).let {
+                    val editText = it.editText
+                    binding.flexbox.addView(it.root)
+                    rowUi.style().apply {
+                        when (this.layout_justifySelf) {
+                            "center" -> editText.gravity = Gravity.CENTER
+                            "flex_end" -> editText.gravity = Gravity.END
+                        }
+                        apply(it.root)
+                    }
+                    it.root.id = index + 1000
+                    if (viewName == null) {
+                        it.textInputLayout.hint = name
+                    } else if (viewName.length in 3..19 && viewName.first() == '\'' && viewName.last() == '\'') {
+                        it.textInputLayout.hint = viewName.substring(1, viewName.length - 1)
+                    } else {
+                        it.textInputLayout.hint = name
+                        execute {
+                            evalUiJs(viewName)
+                        }.onSuccess { n ->
+                            if (n.isNullOrEmpty()) {
+                                it.textInputLayout.hint = "null"
+                            } else {
+                                it.textInputLayout.hint = n
+                            }
+                        }.onError { _ ->
+                            it.textInputLayout.hint = "err"
                         }
                     }
+                    editText.inputType =
+                        InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
+                    editText.setText(loginInfo[name])
+                    action?.let { jsStr ->
+                        var content: String? = null
+                        editText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
+                            if (hasFocus) {
+                                content = editText.text.toString()
+                            } else {
+                                val reContent = editText.text.toString()
+                                if (content != reContent) {
+                                    execute {
+                                        evalUiJs(jsStr)
+                                    }.onSuccess { result ->
+                                        if (result.isTrue()) {
+                                            loginInfo[name] = reContent
+                                            hasChange = true
+                                        }
+                                    }.onError { e ->
+                                        AppLog.put("LoginUI Text $name JavaScript error", e)
+                                    }
+                                }
+                            }
+                        }
+                        editText.viewTreeObserver.addOnGlobalLayoutListener {
+                            if (!editText.hasFocus()) {
+                                return@addOnGlobalLayoutListener
+                            }
+                            val rect = Rect()
+                            binding.root.getWindowVisibleDisplayFrame(rect)
+                            val screenHeight = binding.root.height
+                            val keypadHeight = screenHeight - rect.bottom
+                            if (abs(keypadHeight) < screenHeight / 5) {
+                                editText.clearFocus()
+                            }
+                        }
+                    }
+                }
+
+                Type.select -> ItemSelectorSingleBinding.inflate(
+                    layoutInflater,
+                    binding.root,
+                    false
+                ).let {
+                    if (viewName == null) {
+                        it.spName.text = name
+                    } else if (viewName.length in 3..19 && viewName.first() == '\'' && viewName.last() == '\'') {
+                        it.spName.text = viewName.substring(1, viewName.length - 1)
+                    } else {
+                        it.spName.text = name
+                        execute {
+                            evalUiJs(viewName)
+                        }.onSuccess { n ->
+                            if (n.isNullOrEmpty()) {
+                                it.spName.text = "null"
+                            } else {
+                                it.spName.text = n
+                            }
+                        }.onError { _ ->
+                            it.spName.text = "err"
+                        }
+                    }
+                    val chars = rowUi.chars?.filterNotNull() ?: listOf("chars", "is null")
+                    val adapter = ArrayAdapter(
+                        requireContext(),
+                        android.R.layout.simple_list_item_1,
+                        chars
+                    )
+                    val selector = it.spType
+                    selector.adapter = adapter
+                    val infoV = loginInfo[name]
+                    val char = if (infoV.isNullOrEmpty()) {
+                        hasChange = true
+                        rowUi.default ?: chars[0]
+                    } else {
+                        infoV
+                    }
+                    loginInfo[name] = char
+                    val i = chars.indexOf(char)
+                    selector.setSelectionSafely(i)
+                    selector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+                        var isInitializing = true
+                        override fun onItemSelected(
+                            parent: AdapterView<*>?,
+                            view: View?,
+                            position: Int,
+                            id: Long
+                        ) {
+                            if (isInitializing) { //忽略初始化选择
+                                isInitializing = false
+                                return
+                            }
+                            hasChange = true
+                            loginInfo[name] = chars[position]
+                            if (action != null) {
+                                execute {
+                                    handleButtonClick(source, action, name, rowUis, false)
+                                }.onError { e ->
+                                    AppLog.put("LoginUI Select $name JavaScript error", e)
+                                }
+                            }
+                        }
+
+                        override fun onNothingSelected(parent: AdapterView<*>?) {
+                        }
+                    }
+                    binding.flexbox.addView(it.root)
+                    rowUi.style().apply {
+                        when (this.layout_justifySelf) {
+                            "flex_start" -> selector.gravity = Gravity.START
+                            "flex_end" -> selector.gravity = Gravity.END
+                        }
+                        apply(it.root)
+                    }
+                    it.root.id = index + 1000
+                }
+
+                Type.button -> ItemFilletTextBinding.inflate(
+                    layoutInflater,
+                    binding.root,
+                    false
+                ).let {
+                    binding.flexbox.addView(it.root)
+                    rowUi.style().apply {
+                        when (this.layout_justifySelf) {
+                            "flex_start" -> it.textView.gravity = Gravity.START
+                            "flex_end" -> it.textView.gravity = Gravity.END
+                        }
+                        apply(it.root)
+                    }
+                    it.root.id = index + 1000
+                    it.textView.text = viewName ?: name
+                    it.textView.setPadding(16.dpToPx())
+
+                    it.root.setOnClickListener {
+                        val upTime = System.currentTimeMillis()
+                        if (upTime - lastClickTime < 200) return@setOnClickListener
+                        lastClickTime = upTime
+                        handleButtonClick(source, action, name, rowUis, false)
+                    }
+
+                    it.root.setOnLongClickListener {
+                        handleButtonClick(source, action, name, rowUis, true)
+                        true
+                    }
+                }
+
+                Type.toggle -> ItemFilletTextBinding.inflate(
+                    layoutInflater,
+                    binding.root,
+                    false
+                ).let { itBinding ->
+                    var newName = name
+                    var left = true
+                    binding.flexbox.addView(itBinding.root)
+
+                    rowUi.style().apply {
+                        when (this.layout_justifySelf) {
+                            "flex_start" -> itBinding.textView.gravity = Gravity.START
+                            "flex_end" -> itBinding.textView.gravity = Gravity.END
+                            "right" -> left = false
+                        }
+                        apply(itBinding.root)
+                    }
+
+                    itBinding.root.id = index + 1000
+                    val chars = rowUi.chars?.filterNotNull() ?: listOf("chars is null")
+                    val infoV = loginInfo[name]
+                    var char = if (infoV.isNullOrEmpty()) {
+                        hasChange = true
+                        rowUi.default ?: chars[0]
+                    } else {
+                        infoV
+                    }
+                    loginInfo[name] = char
+
+                    val updateText = { currentChar: String, displayName: String ->
+                        itBinding.textView.text =
+                            if (left) currentChar + displayName else displayName + currentChar
+                    }
+
+                    if (viewName == null) {
+                        updateText(char, name)
+                    } else if (viewName.length in 3..19 && viewName.first() == '\'' && viewName.last() == '\'') {
+                        val n = viewName.substring(1, viewName.length - 1)
+                        rowUi.viewName = n
+                        newName = n
+                        updateText(char, n)
+                    } else {
+                        updateText(char, name)
+                        execute {
+                            evalUiJs(viewName)
+                        }.onSuccess { n ->
+                            if (!n.isNullOrEmpty()) {
+                                rowUi.viewName = n
+                                newName = n
+                                updateText(char, n)
+                            }
+                        }
+                    }
+
+                    itBinding.textView.setPadding(16.dpToPx())
+
+                    val toggleAction = { isLong: Boolean ->
+                        val currentIndex = chars.indexOf(char)
+                        val nextIndex = (currentIndex + 1) % chars.size
+                        char = chars.getOrNull(nextIndex) ?: ""
+                        hasChange = true
+                        loginInfo[name] = char
+                        updateText(char, newName)
+                        handleButtonClick(source, action, name, rowUis, isLong)
+                    }
+
+                    itBinding.root.setOnClickListener {
+                        val upTime = System.currentTimeMillis()
+                        if (upTime - lastClickTime < 200) return@setOnClickListener
+                        lastClickTime = upTime
+                        toggleAction(false)
+                    }
+
+                    itBinding.root.setOnLongClickListener {
+                        toggleAction(true)
+                        true
+                    }
                 }
             }
-        } catch (e: NullPointerException) {
-            AppLog.put("登录UI JSON 数据错误", e, true)
         }
-        binding.toolBar.inflateMenu(R.menu.source_login)
-        binding.toolBar.menu.applyTint(requireContext())
+    }
+
+    private fun buttonUi(source: BaseSource, rowUis: List<RowUi>?) {
+        rowUiBuilder(source, rowUis)
         binding.toolBar.setOnMenuItemClickListener { item ->
             when (item.itemId) {
                 R.id.menu_ok -> {
-                    val loginData = getLoginData(loginUi)
+                    oKToClose = true
+                    val loginData = getLoginData(rowUis, true)
                     login(source, loginData)
                 }
 
@@ -121,44 +609,86 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
         }
     }
 
-    private fun handleButtonClick(source: BaseSource, rowUi: RowUi, loginUi: List<RowUi>) {
+    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
+        val source = viewModel.source ?: return
+        val loginUiStr = source.loginUi ?: return
+        val codeStr = loginUiStr.let {
+            when {
+                it.startsWith("@js:") -> it.substring(4)
+                it.startsWith("<js>") -> it.substring(4, it.lastIndexOf("<"))
+                else -> null
+            }
+        }
+        if (codeStr != null) {
+            lifecycleScope.launch(Main) {
+                val loginUiJson = evalUiJs(codeStr)
+                rowUis = loginUi(loginUiJson)
+                buttonUi(source, rowUis)
+            }
+        } else {
+            rowUis = loginUi(loginUiStr)
+            buttonUi(source, rowUis)
+        }
+        binding.toolBar.title = getString(R.string.login_source, source.getTag())
+        binding.toolBar.inflateMenu(R.menu.source_login)
+        binding.toolBar.menu.applyTint(requireContext())
+    }
+
+    private fun handleButtonClick(
+        source: BaseSource,
+        action: String?,
+        name: String,
+        rowUis: List<RowUi>,
+        isLongClick: Boolean
+    ) {
         lifecycleScope.launch(IO) {
-            if (rowUi.action.isAbsUrl()) {
-                context?.openUrl(rowUi.action!!)
-            } else if (rowUi.action != null) {
+            if (action.isAbsUrl()) {
+                context?.openUrl(action!!)
+            } else if (action != null) {
                 // JavaScript
-                val buttonFunctionJS = rowUi.action!!
+                val buttonFunctionJS = action
                 val loginJS = source.getLoginJs() ?: return@launch
                 kotlin.runCatching {
                     runScriptWithContext {
                         source.evalJS("$loginJS\n$buttonFunctionJS") {
-                            put("result", getLoginData(loginUi))
+                            put("java", sourceLoginJsExtensions)
+                            put("result", getLoginData(rowUis))
+                            put("book", viewModel.book)
+                            put("chapter", viewModel.chapter)
+                            put("isLongClick", isLongClick)
                         }
                     }
                 }.onFailure { e ->
                     ensureActive()
-                    AppLog.put("LoginUI Button ${rowUi.name} JavaScript error", e)
+                    AppLog.put("LoginUI Button $name JavaScript error", e)
                 }
             }
         }
     }
 
-    private fun getLoginData(loginUi: List<RowUi>?): HashMap<String, String> {
+    private fun getLoginData(
+        rowUis: List<RowUi>?,
+        save: Boolean = false
+    ): MutableMap<String, String> {
         val loginData = hashMapOf<String, String>()
-        loginUi?.forEachIndexed { index, rowUi ->
+        rowUis?.forEachIndexed { index, rowUi ->
             when (rowUi.type) {
-                "text", "password" -> {
+                Type.text, Type.password -> {
                     val rowView = binding.root.findViewById<View>(index + 1000)
-                    ItemSourceEditBinding.bind(rowView).editText.text?.let {
-                        loginData[rowUi.name] = it.toString()
+                    ItemSourceEditBinding.bind(rowView).editText.text.let {
+                        loginData[rowUi.name] =
+                            it?.toString() ?: rowUi.default ?: "" //没文本的时候存空字符串,而不是删除loginInfo
                     }
                 }
             }
         }
-        return loginData
+        if (save) {
+            return viewModel.loginInfo.apply { putAll(loginData) }
+        }
+        return viewModel.loginInfo.toMutableMap().apply { putAll(loginData) }
     }
 
-    private fun login(source: BaseSource, loginData: HashMap<String, String>) {
+    private fun login(source: BaseSource, loginData: MutableMap<String, String>) {
         lifecycleScope.launch(IO) {
             if (loginData.isEmpty()) {
                 source.removeLoginInfo()
@@ -184,6 +714,14 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
     }
 
     override fun onDismiss(dialog: DialogInterface) {
+        if (!oKToClose && hasChange) {
+            val loginInfo = viewModel.loginInfo
+            if (loginInfo.isEmpty()) {
+                viewModel.source?.removeLoginInfo()
+            } else {
+                viewModel.source?.putLoginInfo(GSON.toJson(loginInfo))
+            }
+        }
         super.onDismiss(dialog)
         activity?.finish()
     }

+ 82 - 0
app/src/main/java/io/legado/app/ui/login/SourceLoginJsExtensions.kt

@@ -0,0 +1,82 @@
+package io.legado.app.ui.login
+
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import io.legado.app.R
+import io.legado.app.constant.EventBus
+import io.legado.app.data.entities.BaseSource
+import io.legado.app.data.entities.HttpTTS
+import io.legado.app.model.ReadAloud
+import io.legado.app.ui.rss.read.RssJsExtensions
+import io.legado.app.ui.widget.dialog.BottomWebViewDialog
+import io.legado.app.utils.FileUtils
+import io.legado.app.utils.postEvent
+import io.legado.app.utils.sendToClip
+import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.launch
+import java.io.File
+
+@Suppress("unused")
+class SourceLoginJsExtensions(
+    activity: AppCompatActivity?, source: BaseSource?,
+    private val bookType: Int = 0,
+    private val callback: Callback? = null
+) : RssJsExtensions(activity, source) {
+
+    interface Callback {
+        fun upUiData(data: Map<String, String?>?)
+        fun reUiView()
+    }
+
+    fun upLoginData(data: Map<String, String?>?) {
+        callback?.upUiData(data)
+    }
+
+    fun reLoginView() {
+        callback?.reUiView()
+    }
+
+    fun refreshExplore() {
+        callback?.reUiView()
+    }
+
+    fun refreshBookInfo() {
+        postEvent(EventBus.REFRESH_BOOK_INFO, true)
+    }
+
+    fun copyText(text: String) {
+        activityRef.get()?.sendToClip(text)
+    }
+
+    fun clearTtsCache() {
+        if (getSource() !is HttpTTS) return
+        val activity = activityRef.get() ?: return
+        activity.lifecycleScope.launch(IO) {
+            ReadAloud.upReadAloudClass()
+            val ttsFolderPath =
+                "${activity.cacheDir.absolutePath}${File.separator}httpTTS${File.separator}"
+            FileUtils.listDirsAndFiles(ttsFolderPath)?.forEach {
+                FileUtils.delete(it.absolutePath)
+            }
+            activity.toastOnUi(R.string.clear_cache_success)
+        }
+    }
+
+    @JvmOverloads
+    fun showBrowser(url: String, html: String, preloadJs: String? = null) {
+        val activity = activityRef.get() ?: return
+        val source = getSource() ?: return
+        activity.showDialogFragment(
+            BottomWebViewDialog(
+                source.getKey(),
+                bookType,
+                url,
+                html,
+                preloadJs
+            )
+        )
+    }
+
+}

+ 41 - 6
app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt

@@ -5,28 +5,63 @@ import android.content.Intent
 import com.script.rhino.runScriptWithContext
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
+import io.legado.app.constant.BookType
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BaseSource
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
 import io.legado.app.exception.NoStackTraceException
+import io.legado.app.model.AudioPlay
+import io.legado.app.model.ReadBook
 import io.legado.app.utils.toastOnUi
 
 class SourceLoginViewModel(application: Application) : BaseViewModel(application) {
 
     var source: BaseSource? = null
     var headerMap: Map<String, String> = emptyMap()
+    var book: Book? = null
+    var bookType: Int = 0
+    var chapter: BookChapter? = null
+    var loginInfo: MutableMap<String, String> = mutableMapOf()
 
     fun initData(intent: Intent, success: (bookSource: BaseSource) -> Unit, error: () -> Unit) {
         execute {
-            val sourceKey = intent.getStringExtra("key")
-                ?: throw NoStackTraceException("没有参数")
-            when (intent.getStringExtra("type")) {
-                "bookSource" -> source = appDb.bookSourceDao.getBookSource(sourceKey)
-                "rssSource" -> source = appDb.rssSourceDao.getByKey(sourceKey)
-                "httpTts" -> source = appDb.httpTTSDao.get(sourceKey.toLong())
+            bookType = intent.getIntExtra("bookType", 0)
+            when (bookType) {
+                BookType.text -> {
+                    source = ReadBook.bookSource
+                    book = ReadBook.book?.also {
+                        chapter =
+                            appDb.bookChapterDao.getChapter(it.bookUrl, ReadBook.durChapterIndex)
+                    }
+                }
+
+                BookType.audio -> {
+                    source = AudioPlay.bookSource
+                    book = AudioPlay.book
+                    chapter = AudioPlay.durChapter
+                }
+
+                else -> {
+                    val sourceKey = intent.getStringExtra("key")
+                        ?: throw NoStackTraceException("没有参数")
+                    val type = intent.getStringExtra("type")
+                    source = when (type) {
+                        "bookSource" -> appDb.bookSourceDao.getBookSource(sourceKey)
+                        "rssSource" -> appDb.rssSourceDao.getByKey(sourceKey)
+                        "httpTts" -> appDb.httpTTSDao.get(sourceKey.toLong())
+                        else -> null
+                    }
+                    val bookUrl = intent.getStringExtra("bookUrl")
+                    book = bookUrl?.let {
+                        appDb.bookDao.getBook(it) ?: appDb.searchBookDao.getSearchBook(it)?.toBook()
+                    }
+                }
             }
             headerMap = runScriptWithContext {
                 source?.getHeaderMap(true) ?: emptyMap()
             }
+            source?.let { loginInfo = it.getLoginInfoMap() }
             source
         }.onSuccess {
             if (it != null) {

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

@@ -121,7 +121,6 @@ fun ReplaceRuleScreen(
 
     val uploadRepository: UploadRepository = koinInject()
 
-    //TODO: 期望换为Navigation
     val context = LocalContext.current
     val scope = rememberCoroutineScope()
     val listState = rememberLazyListState()

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

@@ -12,7 +12,6 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.repository.ReplaceRuleRepository
-import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.ReplaceAnalyzer
 import io.legado.app.help.http.decompressed
 import io.legado.app.help.http.newCallResponseBody

+ 2 - 2
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt

@@ -80,9 +80,9 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
             return
         }
         val firstArticle = articles.first()
-        val dbFirstArticle = appDb.rssArticleDao.get(firstArticle.origin, firstArticle.link)
+        val dbFirstArticle = appDb.rssArticleDao.getByLink(firstArticle.origin, firstArticle.link)
         val lastArticle = articles.last()
-        val dbLastArticle = appDb.rssArticleDao.get(lastArticle.origin, lastArticle.link)
+        val dbLastArticle = appDb.rssArticleDao.getByLink(lastArticle.origin, lastArticle.link)
         if (dbFirstArticle != null && dbLastArticle != null) {
             loadFinallyLiveData.postValue(false)
         } else {

+ 18 - 1
app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt

@@ -2,6 +2,7 @@
 
 package io.legado.app.ui.rss.article
 
+import android.content.Context
 import android.graphics.Color
 import android.graphics.drawable.GradientDrawable
 import android.os.Bundle
@@ -23,8 +24,14 @@ import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.rss.read.ReadRssActivity.RedirectPolicy
 import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
 import io.legado.app.ui.widget.dialog.VariableDialog
-import io.legado.app.utils.*
+import io.legado.app.utils.StartActivityContract
+import io.legado.app.utils.gone
+import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.startActivity
+import io.legado.app.utils.themeColor
+import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.viewbindingdelegate.viewBinding
+import io.legado.app.utils.visible
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -245,4 +252,14 @@ class RssSortActivity : VMBaseActivity<ActivityRssArtivlesBinding, RssSortViewMo
             return RssArticlesFragment(sort.first, sort.second)
         }
     }
+
+    companion object {
+        fun start(context: Context, sortUrl: String?, sourceUrl: String, key: String? = null) {
+            context.startActivity<RssSortActivity> {
+                putExtra("sortUrl", sortUrl)
+                putExtra("sourceUrl", sourceUrl)
+                putExtra("key", key)
+            }
+        }
+    }
 }

+ 14 - 5
app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.rss.read
 
 import android.annotation.SuppressLint
+import android.content.Context
 import android.content.pm.ActivityInfo
 import android.content.res.Configuration
 import android.net.Uri
@@ -17,7 +18,6 @@ import android.webkit.SslErrorHandler
 import android.webkit.URLUtil
 import android.webkit.WebChromeClient
 import android.webkit.WebResourceRequest
-import android.webkit.WebResourceResponse
 import android.webkit.WebSettings
 import android.webkit.WebView
 import android.webkit.WebViewClient
@@ -55,7 +55,6 @@ import io.legado.app.utils.setDarkeningAllowed
 import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
 import io.legado.app.utils.share
 import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.startActivity
 import io.legado.app.utils.textArray
 import io.legado.app.utils.toastOnUi
@@ -66,9 +65,7 @@ import kotlinx.coroutines.launch
 import org.apache.commons.text.StringEscapeUtils
 import org.jsoup.Jsoup
 import splitties.views.bottomPadding
-import java.io.ByteArrayInputStream
 import java.net.URLDecoder
-import java.util.regex.PatternSyntaxException
 
 /**
  * rss阅读界面
@@ -102,7 +99,7 @@ class ReadRssActivity : VMBaseActivity<ActivityRssReadBinding, ReadRssViewModel>
     private var redirectPolicyMenu: MenuItem? = null
     private var isFullScreen = false
     private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null
-    private val rssJsExtensions by lazy { RssJsExtensions(this) }
+    private val rssJsExtensions by lazy { RssJsExtensions(this, viewModel.rssSource) }
 
     fun getSource(): RssSource? {
         return viewModel.rssSource
@@ -615,4 +612,16 @@ class ReadRssActivity : VMBaseActivity<ActivityRssReadBinding, ReadRssViewModel>
         }
     }
 
+    companion object {
+        fun start(context: Context, title: String?, url: String, origin: String) {
+            context.startActivity<ReadRssActivity> {
+                putExtra("title", title ?: "")
+                putExtra("origin", origin)
+                putExtra("openUrl", url)
+            }
+        }
+
+        private val webCookieManager by lazy { android.webkit.CookieManager.getInstance() }
+    }
+
 }

+ 1 - 1
app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt

@@ -46,7 +46,7 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
             }
             if (link != null) {
                 rssStar = appDb.rssStarDao.get(origin, link)
-                rssArticle = rssStar?.toRssArticle() ?: appDb.rssArticleDao.get(origin, link)
+                rssArticle = rssStar?.toRssArticle() ?: appDb.rssArticleDao.getByLink(origin, link)
                 val rssArticle = rssArticle ?: return@execute
                 if (!rssArticle.description.isNullOrBlank()) {
                     contentLiveData.postValue(rssArticle.description!!)

+ 158 - 4
app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt

@@ -1,24 +1,178 @@
 package io.legado.app.ui.rss.read
 
+import android.webkit.JavascriptInterface
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import io.legado.app.data.appDb
 import io.legado.app.data.entities.BaseSource
+import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.RssReadRecord
+import io.legado.app.data.entities.RssSource
 import io.legado.app.help.JsExtensions
 import io.legado.app.ui.association.AddToBookshelfDialog
+import io.legado.app.ui.book.explore.ExploreShowActivity
 import io.legado.app.ui.book.search.SearchActivity
+import io.legado.app.ui.login.SourceLoginActivity
+import io.legado.app.ui.rss.article.RssSortActivity
+import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.lang.ref.WeakReference
 
 @Suppress("unused")
-class RssJsExtensions(private val activity: ReadRssActivity) : JsExtensions {
+open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) : JsExtensions {
+
+    val activityRef: WeakReference<AppCompatActivity> = WeakReference(activity)
+    val sourceRef: WeakReference<BaseSource?> = WeakReference(source)
 
     override fun getSource(): BaseSource? {
-        return activity.getSource()
+        return sourceRef.get()
+    }
+
+    override fun getTag(): String? {
+        return sourceRef.get()?.getTag()
+    }
+
+    @JavascriptInterface
+    fun put(key: String, value: String): String {
+        getSource()?.put(key, value)
+        return value
+    }
+
+    @JavascriptInterface
+    fun get(key: String): String {
+        return getSource()?.get(key) ?: ""
     }
 
+    @JavascriptInterface
     fun searchBook(key: String) {
-        SearchActivity.start(activity, key)
+        searchBook(key, null)
+    }
+
+    @JavascriptInterface
+    fun searchBook(key: String, searchScope: String?) {
+        activityRef.get()?.let {
+            SearchActivity.start(it, key, searchScope)
+        }
     }
 
+    @JavascriptInterface
     fun addBook(bookUrl: String) {
-        activity.showDialogFragment(AddToBookshelfDialog(bookUrl))
+        activityRef.get()?.showDialogFragment(AddToBookshelfDialog(bookUrl))
+    }
+
+    @JavascriptInterface
+    @JvmOverloads
+    fun open(name: String, url: String? = null, title: String? = null, origin: String? = null) {
+        val activity = activityRef.get() ?: return
+        activity.lifecycleScope.launch(IO) {
+            val source = getSource() ?: return@launch
+            when (name) {
+                "login" -> {
+                    if (activity is SourceLoginActivity) {
+                        activity.toastOnUi("已在登录界面")
+                        return@launch
+                    }
+                    val toSource = origin?.let { o ->
+                        appDb.bookSourceDao.getBookSource(o)
+                    } ?: source
+                    if (toSource.loginUrl.isNullOrBlank()) {
+                        activity.toastOnUi("源未配置登录")
+                        return@launch
+                    }
+                    when (toSource) {
+                        is BookSource -> {
+                            withContext(Main) {
+                                activity.startActivity<SourceLoginActivity> {
+                                    putExtra("type", "bookSource")
+                                    putExtra("key", toSource.bookSourceUrl)
+                                }
+                            }
+                        }
+
+                        is RssSource -> {
+                            withContext(Main) {
+                                activity.startActivity<SourceLoginActivity> {
+                                    putExtra("type", "rssSource")
+                                    putExtra("key", toSource.sourceUrl)
+                                }
+                            }
+                        }
+                    }
+                }
+
+                "sort" -> {
+                    val toSource = origin?.let { o ->
+                        appDb.rssSourceDao.getByKey(o)
+                    } ?: (source as? RssSource) ?: return@launch
+                    val sortUrl = if (url.isJsonObject()) {
+                        url
+                    } else {
+                        title?.let {
+                            JSONObject().put(title, url).toString()
+                        } ?: url
+                    }
+                    val sourceUrl = toSource.sourceUrl
+                    withContext(Main) {
+                        RssSortActivity.start(activity, sortUrl, sourceUrl)
+                    }
+                }
+
+                "rss" -> {
+                    val toSource = origin?.let { o ->
+                        appDb.rssSourceDao.getByKey(o)
+                    } ?: (source as? RssSource) ?: return@launch
+                    val title = title ?: toSource.sourceName
+                    val sourceUrl = toSource.sourceUrl
+                    val link = url ?: return@launch
+                    val rss = appDb.rssStarDao.get(sourceUrl, link)?.toRecord()
+                        ?: appDb.rssArticleDao.getByLink(sourceUrl, link)?.toRecord()
+                    val rssReadRecord = rss ?: RssReadRecord(
+                        record = link,
+                        title = title,
+                        origin = sourceUrl,
+                        readTime = System.currentTimeMillis()
+                    )
+                    appDb.rssReadRecordDao.insertRecord(rssReadRecord) //留下历史记录
+                    withContext(Main) {
+                        ReadRssActivity.start(activity, title, url, sourceUrl)
+                    }
+                }
+
+                "search" -> {
+                    title?.let {
+                        val searchScope = origin?.let { o ->
+                            appDb.bookSourceDao.getBookSource(o)?.let { s ->
+                                "${s.bookSourceName.replace(":", "")}::${o}"
+                            }
+                        }
+                        withContext(Main) {
+                            searchBook(it, searchScope)
+                        }
+                    }
+                }
+
+                "explore" -> {
+                    val toSource = origin?.let { o ->
+                        appDb.bookSourceDao.getBookSource(o)
+                    } ?: (source as? BookSource) ?: return@launch
+                    val sourceUrl = toSource.bookSourceUrl
+                    withContext(Main) {
+                        activity.startActivity<ExploreShowActivity> {
+                            putExtra("exploreName", title)
+                            putExtra("sourceUrl", sourceUrl)
+                            putExtra("exploreUrl", url)
+                        }
+                    }
+                }
+            }
+        }
     }
 
 }

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt

@@ -28,6 +28,7 @@ import androidx.compose.material3.AssistChip
 import androidx.compose.material3.Button
 import androidx.compose.material3.Card
 import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CenterAlignedTopAppBar
 import androidx.compose.material3.Checkbox
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -44,7 +45,6 @@ import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.material3.rememberModalBottomSheetState
 import androidx.compose.runtime.Composable
@@ -148,7 +148,7 @@ fun <T> BatchImportDialog(
                 .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f),
             containerColor = Color.Transparent,
             topBar = {
-                TopAppBar(
+                CenterAlignedTopAppBar(
                     title = {
                         AnimatedText(
                             if (selectedCount > 0)

+ 358 - 0
app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt

@@ -0,0 +1,358 @@
+package io.legado.app.ui.widget.dialog
+
+import android.annotation.SuppressLint
+import android.content.pm.ActivityInfo
+import android.graphics.Bitmap
+import android.net.Uri
+import android.net.http.SslError
+import android.os.Bundle
+import android.view.KeyEvent
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.JavascriptInterface
+import android.webkit.SslErrorHandler
+import android.webkit.WebChromeClient
+import android.webkit.WebChromeClient.CustomViewCallback
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.net.toUri
+import androidx.core.view.isNotEmpty
+import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import io.legado.app.R
+import io.legado.app.base.BaseBottomSheetDialogFragment
+import io.legado.app.constant.AppConst
+import io.legado.app.constant.AppLog
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.BaseSource
+import io.legado.app.databinding.DialogWebViewBinding
+import io.legado.app.help.WebCacheManager
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.webView.WebJsExtensions
+import io.legado.app.help.webView.WebJsExtensions.Companion.JS_INJECTION
+import io.legado.app.help.webView.WebJsExtensions.Companion.basicJs
+import io.legado.app.help.webView.WebJsExtensions.Companion.nameBasic
+import io.legado.app.help.webView.WebJsExtensions.Companion.nameCache
+import io.legado.app.help.webView.WebJsExtensions.Companion.nameJava
+import io.legado.app.help.webView.WebJsExtensions.Companion.nameSource
+import io.legado.app.lib.theme.ThemeStore
+import io.legado.app.model.analyzeRule.AnalyzeUrl
+import io.legado.app.ui.association.OnLineImportActivity
+import io.legado.app.utils.get
+import io.legado.app.utils.invisible
+import io.legado.app.utils.longSnackbar
+import io.legado.app.utils.openUrl
+import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
+import io.legado.app.utils.viewbindingdelegate.viewBinding
+import io.legado.app.utils.visible
+import kotlinx.coroutines.launch
+import java.lang.ref.WeakReference
+
+class BottomWebViewDialog() :
+    BaseBottomSheetDialogFragment(R.layout.dialog_web_view) {
+
+    constructor(
+        sourceKey: String,
+        bookType: Int,
+        url: String,
+        html: String,
+        preloadJs: String? = null
+    ) : this() {
+        arguments = Bundle().apply {
+            putString("sourceKey", sourceKey)
+            putInt("bookType", bookType)
+            putString("url", url)
+            putString("html", html)
+            putString("preloadJs", preloadJs)
+        }
+    }
+
+    private val binding by viewBinding(DialogWebViewBinding::bind)
+
+    private val bottomSheet by lazy {
+        dialog?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
+    }
+
+    private val behavior by lazy {
+        bottomSheet?.let { BottomSheetBehavior.from(it) }
+    }
+
+    private lateinit var currentWebView: WebView
+
+    private var source: BaseSource? = null
+    private var isFullScreen = false
+    private var customWebViewCallback: CustomViewCallback? = null
+    private var originOrientation: Int? = null
+    private var needClearHistory = true
+
+    override fun show(manager: FragmentManager, tag: String?) {
+        runCatching {
+            manager.beginTransaction().remove(this).commit()
+            super.show(manager, tag)
+        }.onFailure {
+            AppLog.put("显示对话框失败 tag:$tag", it)
+        }
+    }
+
+    override fun onFragmentCreated(
+        view: View,
+        savedInstanceState: Bundle?
+    ) {
+        if (!AppConfig.isEInkMode) {
+            view.setBackgroundColor(ThemeStore.backgroundColor())
+        }
+
+        currentWebView = createWebView()
+        binding.webViewContainer.addView(currentWebView)
+
+        lifecycleScope.launch {
+            val args = arguments ?: run {
+                dismiss(); return@launch
+            }
+
+            val sourceKey = args.getString("sourceKey") ?: return@launch
+            val url = args.getString("url") ?: return@launch
+            var html = args.getString("html") ?: return@launch
+
+            args.getString("preloadJs")?.let { preloadJs ->
+                html = if (html.contains("<head>")) {
+                    html.replaceFirst(
+                        "<head>",
+                        "<head><script>(() => {$JS_INJECTION\n$preloadJs\n})();</script>"
+                    )
+                } else {
+                    "<head><script>(() => {$JS_INJECTION\n$preloadJs\n})();</script></head>$html"
+                }
+            }
+
+            source = appDb.bookSourceDao.getBookSource(sourceKey) ?: run {
+                activity?.toastOnUi("no find bookSource")
+                dismiss()
+                return@launch
+            }
+
+            val bookType = args.getInt("bookType", 0)
+            val analyzeUrl = AnalyzeUrl(url, source = source, coroutineContext = coroutineContext)
+
+            currentWebView.setOnScrollChangeListener { _, _, scrollY, _, _ ->
+                behavior?.isDraggable = scrollY == 0
+            }
+
+            currentWebView.post {
+                initWebView(
+                    analyzeUrl.url,
+                    html,
+                    analyzeUrl.headerMap,
+                    bookType
+                )
+                currentWebView.clearHistory()
+            }
+        }
+
+        dialog?.setOnKeyListener { _, keyCode, event ->
+            if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
+                when {
+                    binding.customWebView.isNotEmpty() -> {
+                        customWebViewCallback?.onCustomViewHidden()
+                        true
+                    }
+
+                    currentWebView.canGoBack() -> {
+                        currentWebView.goBack()
+                        true
+                    }
+
+                    else -> {
+                        dismiss()
+                        true
+                    }
+                }
+            } else false
+        }
+    }
+
+    private fun createWebView(): WebView =
+        WebView(requireContext()).apply {
+            layoutParams = ViewGroup.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                ViewGroup.LayoutParams.MATCH_PARENT
+            )
+        }
+
+    private fun initWebView(
+        url: String,
+        html: String,
+        headerMap: HashMap<String, String>,
+        bookType: Int
+    ) {
+        currentWebView.apply {
+            webChromeClient = CustomWebChromeClient()
+            webViewClient = CustomWebViewClient()
+
+            settings.userAgentString =
+                headerMap.get(AppConst.UA_NAME, true)
+
+            addJavascriptInterface(JSInterface(this@BottomWebViewDialog), nameBasic)
+
+            source?.let { source ->
+                (activity as? AppCompatActivity)?.let { act ->
+                    addJavascriptInterface(
+                        WebJsExtensions(source, act, this, bookType),
+                        nameJava
+                    )
+                }
+                addJavascriptInterface(source, nameSource)
+                addJavascriptInterface(WebCacheManager, nameCache)
+            }
+
+            loadDataWithBaseURL(url, html, "text/html", "utf-8", url)
+        }
+    }
+
+    override fun onDestroyView() {
+        customWebViewCallback?.onCustomViewHidden()
+
+        currentWebView.apply {
+            stopLoading()
+            loadUrl("about:blank")
+            clearHistory()
+            removeAllViews()
+
+            webChromeClient = WebChromeClient()
+            webViewClient = WebViewClient()
+
+            destroy()
+        }
+
+
+        originOrientation?.let {
+            activity?.requestedOrientation = it
+        }
+
+        super.onDestroyView()
+    }
+
+    private class JSInterface(dialog: BottomWebViewDialog) {
+        private val ref = WeakReference(dialog)
+
+        @JavascriptInterface
+        fun lockOrientation(orientation: String) {
+            val fra = ref.get() ?: return
+            val act = fra.activity ?: return
+
+            if (fra.isFullScreen && fra.dialog?.isShowing == true) {
+                act.runOnUiThread {
+                    act.requestedOrientation = when (orientation) {
+                        "portrait", "portrait-primary" ->
+                            ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+
+                        "portrait-secondary" ->
+                            ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
+
+                        "landscape", "landscape-primary" ->
+                            ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+
+                        "landscape-secondary" ->
+                            ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
+
+                        "any", "unspecified" ->
+                            ActivityInfo.SCREEN_ORIENTATION_SENSOR
+
+                        else ->
+                            ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+                    }
+                }
+            }
+        }
+
+        @JavascriptInterface
+        fun onCloseRequested() {
+            ref.get()?.activity?.runOnUiThread {
+                ref.get()?.dismiss()
+            }
+        }
+    }
+
+    inner class CustomWebChromeClient : WebChromeClient() {
+
+        override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
+            isFullScreen = true
+            binding.webViewContainer.invisible()
+            binding.customWebView.addView(view)
+            customWebViewCallback = callback
+            behavior?.state = BottomSheetBehavior.STATE_EXPANDED
+            originOrientation = activity?.requestedOrientation
+        }
+
+        override fun onHideCustomView() {
+            isFullScreen = false
+            binding.webViewContainer.visible()
+            binding.customWebView.removeAllViews()
+            customWebViewCallback = null
+            originOrientation?.let {
+                activity?.requestedOrientation = it
+            }
+        }
+
+        override fun onCloseWindow(window: WebView?) {
+            dismiss()
+        }
+    }
+
+    inner class CustomWebViewClient : WebViewClient() {
+
+        override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+            if (needClearHistory) {
+                needClearHistory = false
+                currentWebView.clearHistory()
+            }
+            super.onPageStarted(view, url, favicon)
+            currentWebView.evaluateJavascript(basicJs, null)
+        }
+
+        override fun shouldOverrideUrlLoading(
+            view: WebView?,
+            request: WebResourceRequest?
+        ): Boolean {
+            return request?.url?.let(::handleUri) ?: true
+        }
+
+        @Deprecated("Deprecated in Java")
+        @Suppress("DEPRECATION")
+        override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
+            return url?.toUri()?.let(::handleUri) ?: true
+        }
+
+        private fun handleUri(uri: Uri): Boolean =
+            when (uri.scheme) {
+                "http", "https" -> false
+                "legado", "yuedu" -> {
+                    startActivity<OnLineImportActivity> { data = uri }
+                    true
+                }
+
+                else -> {
+                    binding.root.longSnackbar(
+                        R.string.jump_to_another_app,
+                        R.string.confirm
+                    ) {
+                        activity?.openUrl(uri)
+                    }
+                    true
+                }
+            }
+
+        @SuppressLint("WebViewClientOnReceivedSslError")
+        override fun onReceivedSslError(
+            view: WebView?,
+            handler: SslErrorHandler?,
+            error: SslError?
+        ) {
+            handler?.proceed()
+        }
+    }
+}

+ 43 - 2
app/src/main/java/io/legado/app/utils/StringExtensions.kt

@@ -7,15 +7,18 @@ import android.icu.text.Collator
 import android.icu.util.ULocale
 import android.net.Uri
 import android.text.Editable
+import androidx.core.net.toUri
 import cn.hutool.core.net.URLEncodeUtil
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.AppPattern.dataUriRegex
 import java.io.File
 import java.lang.Character.codePointCount
 import java.lang.Character.offsetByCodePoints
+import java.net.InetAddress
 import java.util.Locale
 import java.util.regex.Pattern
 
+
 fun String?.safeTrim() = if (this.isNullOrBlank()) null else this.trim()
 
 fun String?.isContentScheme(): Boolean = this?.startsWith("content://") == true
@@ -23,7 +26,7 @@ fun String?.isContentScheme(): Boolean = this?.startsWith("content://") == true
 fun String.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this)
 
 fun String.parseToUri(): Uri {
-    return if (isUri()) Uri.parse(this) else {
+    return if (isUri()) this.toUri() else {
         Uri.fromFile(File(this))
     }
 }
@@ -75,7 +78,7 @@ fun String?.isTrue(nullIsTrue: Boolean = false): Boolean {
     if (this.isNullOrBlank() || this == "null") {
         return nullIsTrue
     }
-    return !this.trim().matches("(?i)^(false|no|not|0)$".toRegex())
+    return !this.trim().matches("(?i)^(?:false|no|not|0|0.0)$".toRegex())
 }
 
 fun String.isHex(): Boolean {
@@ -144,3 +147,41 @@ fun String.encodeURI(): String = URLEncodeUtil.encodeQuery(this)
 fun String.normalizeFileName(): String {
     return replace(AppPattern.fileNameRegex2, "_")
 }
+
+/**
+ * 将字符串加上转义,方便传递字符串到浏览器
+ */
+fun String.escapeForJs(): String {
+    return this.replace("\\", "\\\\")
+        .replace("\"", "\\\"").replace("'", "\\'")
+        .replace("\n", "\\n").replace("\r", "\\r")
+        .replace("\t", "\\t")
+        .replace("\u2028", "\\u2028")
+        .replace("\u2029", "\\u2029")
+}
+
+/**
+ * 将ip字符串转为InetAddress
+ */
+fun String.parseIpsFromString(): List<InetAddress>? =
+    split(",")
+        .map { it.trim() }
+        .filter { it.isNotEmpty() }
+        .mapNotNull { it.runCatching { InetAddress.getByName(this) }.getOrNull() }
+        .takeIf { it.isNotEmpty() }
+
+
+fun String.quoteReplacementJs(): String {
+    if (!this.contains('\\')) {
+        return this
+    }
+    val sb = StringBuilder()
+    for (c in this) {
+        if (c == '\\') {
+            sb.append("\\\\")
+        } else {
+            sb.append(c)
+        }
+    }
+    return sb.toString()
+}

+ 7 - 0
app/src/main/java/io/legado/app/utils/ViewExtensions.kt

@@ -22,6 +22,7 @@ import android.widget.EdgeEffect
 import android.widget.EditText
 import android.widget.RadioGroup
 import android.widget.SeekBar
+import android.widget.Spinner
 import android.widget.TextView
 import androidx.annotation.ColorInt
 import androidx.annotation.DrawableRes
@@ -330,3 +331,9 @@ fun View.setOnApplyWindowInsetsListenerCompat(listener: (View, WindowInsetsCompa
     }
 }
 
+fun Spinner.setSelectionSafely(position: Int) {
+    val count = adapter?.count ?: 0
+    if (count > 0) {
+        setSelection(position.coerceIn(0, count - 1))
+    }
+}

+ 16 - 0
app/src/main/res/layout/dialog_web_view.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <FrameLayout
+        android:id="@+id/web_view_container"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
+    <FrameLayout
+        android:id="@+id/custom_web_view"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
+</FrameLayout>

+ 22 - 0
app/src/main/res/layout/item_selector_single.xml

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:orientation="horizontal"
+    android:padding="4dp">
+
+    <TextView
+        android:id="@+id/sp_name"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="3dp"
+        android:text="@string/book_type" />
+
+    <androidx.appcompat.widget.AppCompatSpinner
+        android:id="@+id/sp_type"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:gravity="center" />
+
+</LinearLayout>

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

@@ -1382,4 +1382,5 @@
     <string name="audio_cache_clean_time">音频缓存保留时间(分钟)</string>
     <string name="audio_cache_clean_time_summary">当前%d, 输入 0 代表退出即刻清理。</string>
     <string name="show_swipe_animation">启用滑动时动画</string>
+    <string name="click_image_way">点击图片行为</string>
 </resources>

+ 7 - 0
app/src/main/res/values/array_values.xml

@@ -52,6 +52,13 @@
         <item>4</item>
     </string-array>
 
+    <string-array name="click_image_way_value">
+        <item>0</item>
+        <item>1</item>
+        <item>2</item>
+        <item>3</item>
+    </string-array>
+
     <string-array name="double_page_value">
         <item>0</item>
         <item>1</item>

+ 8 - 1
app/src/main/res/values/arrays.xml

@@ -116,7 +116,14 @@
 		<item>@string/screen_portrait_reversed</item>
 	</string-array>
 
-	<string-array name="double_page_title">
+    <string-array name="click_image_way_title">
+        <item>新包名阅读行为</item>
+        <item>预览图片</item>
+        <item>经典阅读行为</item>
+        <item>不响应</item>
+    </string-array>
+
+    <string-array name="double_page_title">
 		<item>Single page</item>
 		<item>Double page</item>
 		<item>Double page in landscape</item>

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

@@ -1386,4 +1386,5 @@
     <string name="audio_cache_clean_time">音频缓存保留时间(分钟)</string>
     <string name="audio_cache_clean_time_summary">当前%d, 输入 0 代表退出即刻清理。</string>
     <string name="show_swipe_animation">启用滑动时动画</string>
+    <string name="click_image_way">点击图片行为</string>
 </resources>

+ 7 - 4
app/src/main/res/xml/pref_config_read.xml

@@ -196,10 +196,13 @@
             android:title="@string/no_anim_scroll_page"
             app:isBottomBackground="true" />
 
-        <io.legado.app.lib.prefs.SwitchPreference
-            android:defaultValue="false"
-            android:key="previewImageByClick"
-            android:title="@string/preview_image_by_click"
+        <io.legado.app.lib.prefs.NameListPreference
+            android:defaultValue="2"
+            android:entries="@array/click_image_way_title"
+            android:entryValues="@array/click_image_way_value"
+            android:key="clickImgWay"
+            android:title="@string/click_image_way"
+            app:iconSpaceReserved="false"
             app:isBottomBackground="true" />
 
         <io.legado.app.lib.prefs.SwitchPreference