diff --git a/app/schemas/io.legado.app.data.AppDatabase/81.json b/app/schemas/io.legado.app.data.AppDatabase/81.json new file mode 100644 index 000000000..1267e3bb8 --- /dev/null +++ b/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')" + ] + } +} \ No newline at end of file diff --git a/app/src/debug/res/values-zh/strings.xml b/app/src/debug/res/values-zh-rCN/strings.xml similarity index 100% rename from app/src/debug/res/values-zh/strings.xml rename to app/src/debug/res/values-zh-rCN/strings.xml diff --git a/app/src/main/java/io/legado/app/constant/EventBus.kt b/app/src/main/java/io/legado/app/constant/EventBus.kt index 9b984e6cf..1f7dc8e33 100644 --- a/app/src/main/java/io/legado/app/constant/EventBus.kt +++ b/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" } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index 506620186..7ccb49ee4 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/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" diff --git a/app/src/main/java/io/legado/app/data/AppDatabase.kt b/app/src/main/java/io/legado/app/data/AppDatabase.kt index ee6a77c0d..960cad078 100644 --- a/app/src/main/java/io/legado/app/data/AppDatabase.kt +++ b/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() { diff --git a/app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt b/app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt index c8742f8fc..d87c16c97 100644 --- a/app/src/main/java/io/legado/app/data/dao/RssArticleDao.kt +++ b/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> diff --git a/app/src/main/java/io/legado/app/data/entities/BaseSource.kt b/app/src/main/java/io/legado/app/data/entities/BaseSource.kt index b56e843a6..ca0718f3d 100644 --- a/app/src/main/java/io/legado/app/data/entities/BaseSource.kt +++ b/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,6 +65,32 @@ interface BaseSource : JsExtensions { return this } + fun getLoginJs(): String? { + val loginJs = loginUrl + return when { + loginJs == null -> null + loginJs.startsWith("@js:") -> loginJs.substring(4) + loginJs.startsWith("") -> loginJs.substring(4, loginJs.lastIndexOf("<")) + else -> loginJs + } + } + + @JavascriptInterface + fun login() { + val loginJs = getLoginJs() + if (!loginJs.isNullOrBlank()) { + @Language("js") + val js = """$loginJs + if(typeof login=='function'){ + login.apply(this); + } else { + throw('Function login not implements!!!') + } + """.trimIndent() + evalJS(js) + } + } + fun loginUi(): List? { val json = loginUi?.let { val loginJS = getLoginJs() ?: "" @@ -88,34 +115,6 @@ interface BaseSource : JsExtensions { }.getOrNull() } - fun getLoginJs(): String? { - val loginJs = loginUrl - return when { - loginJs == null -> null - loginJs.startsWith("@js:") -> loginJs.substring(4) - loginJs.startsWith("") -> loginJs.substring(4, loginJs.lastIndexOf("<")) - else -> loginJs - } - } - - /** - * 调用login函数 实现登录请求 - */ - fun login() { - val loginJs = getLoginJs() - if (!loginJs.isNullOrBlank()) { - @Language("js") - val js = """$loginJs - if(typeof login=='function'){ - login.apply(this); - } else { - throw('Function login not implements!!!') - } - """.trimIndent() - evalJS(js) - } - } - /** * 解析header规则 */ @@ -194,9 +193,40 @@ interface BaseSource : JsExtensions { } } - fun getLoginInfoMap(): Map { - val json = getLoginInfo() ?: return emptyMap() - return GSON.fromJsonObject>(json).getOrNull() ?: emptyMap() + private fun configureScriptBindings(): ScriptBindings.() -> Unit = { + put("result", mutableMapOf()) + put("book", null) + put("chapter", null) + } + + fun getLoginInfoMap(): MutableMap { + 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("") -> evalJS( + "${getLoginJs() ?: ""}\n${it.substring(4, it.lastIndexOf("<"))}", + configureScriptBindings() + ).toString() + + else -> it + } + } + val longinInfo = GSON.fromJsonArray(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>(json).getOrNull() ?: mutableMapOf() } /** diff --git a/app/src/main/java/io/legado/app/data/entities/RssArticle.kt b/app/src/main/java/io/legado/app/data/entities/RssArticle.kt index a6aa1e639..8871004b2 100644 --- a/app/src/main/java/io/legado/app/data/entities/RssArticle.kt +++ b/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 ) } diff --git a/app/src/main/java/io/legado/app/data/entities/RssReadRecord.kt b/app/src/main/java/io/legado/app/data/entities/RssReadRecord.kt index 4fffca3e5..5f2fd07dd 100644 --- a/app/src/main/java/io/legado/app/data/entities/RssReadRecord.kt +++ b/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 -) \ No newline at end of file + 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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/entities/RssStar.kt b/app/src/main/java/io/legado/app/data/entities/RssStar.kt index f9a74109d..ac6a088ca 100644 --- a/app/src/main/java/io/legado/app/data/entities/RssStar.kt +++ b/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 ) } diff --git a/app/src/main/java/io/legado/app/data/entities/rule/FlexChildStyle.kt b/app/src/main/java/io/legado/app/data/entities/rule/FlexChildStyle.kt index 7efa75055..0416e148f 100644 --- a/app/src/main/java/io/legado/app/data/entities/rule/FlexChildStyle.kt +++ b/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 { diff --git a/app/src/main/java/io/legado/app/data/entities/rule/RowUi.kt b/app/src/main/java/io/legado/app/data/entities/rule/RowUi.kt index 8946707eb..c7cd32f91 100644 --- a/app/src/main/java/io/legado/app/data/entities/rule/RowUi.kt +++ b/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? = 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 + } + } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 5d65bd316..2c9a52a2c 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/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()) } diff --git a/app/src/main/java/io/legado/app/exception/ConcurrentException.kt b/app/src/main/java/io/legado/app/exception/ConcurrentException.kt index 0ec3d6cca..7f4bcf7f3 100644 --- a/app/src/main/java/io/legado/app/exception/ConcurrentException.kt +++ b/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) \ No newline at end of file +class ConcurrentException(msg: String, val waitTime: Long) : NoStackTraceException(msg) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/CacheManager.kt b/app/src/main/java/io/legado/app/help/CacheManager.kt index a24f2bdd3..7451270d6 100644 --- a/app/src/main/java/io/legado/app/help/CacheManager.kt +++ b/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) + } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/ConcurrentRateLimiter.kt b/app/src/main/java/io/legado/app/help/ConcurrentRateLimiter.kt index d36144e02..6c3dc4ba6 100644 --- a/app/src/main/java/io/legado/app/help/ConcurrentRateLimiter.kt +++ b/app/src/main/java/io/legado/app/help/ConcurrentRateLimiter.kt @@ -4,72 +4,96 @@ 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() + val concurrentRecordMap = ConcurrentHashMap() + + /** + * 更新并发率 + */ + 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) { throw ConcurrentException( @@ -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 withLimit(block: () -> T): T { - val concurrentRecord = getConcurrentRecord() - try { - return block() - } finally { - fetchEnd(concurrentRecord) - } + getConcurrentRecord() + return block() } inline fun withLimitBlocking(block: () -> T): T { - val concurrentRecord = getConcurrentRecordBlocking() - try { - return block() - } finally { - fetchEnd(concurrentRecord) - } + getConcurrentRecordBlocking() + return block() } } diff --git a/app/src/main/java/io/legado/app/help/JsExtensions.kt b/app/src/main/java/io/legado/app/help/JsExtensions.kt index 63a53414f..66ffe8ffa 100644 --- a/app/src/main/java/io/legado/app/help/JsExtensions.kt +++ b/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): Array { + return ajaxAll(urlList, false) + } + + fun ajaxAll(urlList: Array, skipRateLimit: Boolean): Array { 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, timeout: Int): Array { + return ajaxTestAll(urlList, timeout, false) + } + + fun ajaxTestAll( + urlList: Array, + timeout: Int, + skipRateLimit: Boolean + ): Array { + 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 */ @@ -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>(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, refetchAfterSuccess: Boolean): StrResponse { - rhinoContext.ensureActive() - val body = SourceVerificationHelp.getVerificationResult( - getSource(), url, title, true, refetchAfterSuccess - ) - return StrResponse(url, body) + fun startBrowserAwait(url: String, title: String): StrResponse { + return startBrowserAwait(url, title, true, null) } - fun startBrowserAwait(url: String, title: String): StrResponse { - return startBrowserAwait(url, title, true) + fun startBrowserAwait(url: String, title: String, refetchAfterSuccess: Boolean): StrResponse { + return startBrowserAwait(url, title, refetchAfterSuccess, null) + } + + 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): Connection.Response { + return get(urlStr, headers, null) + } + + fun get(urlStr: String, headers: Map, 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): Connection.Response { + return head(urlStr, headers, null) + } + + fun head(urlStr: String, headers: Map, 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): Connection.Response { + return post(urlStr, body, headers, null) + } + + fun post( + urlStr: String, + body: String, + headers: Map, + 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 { + data = url.toUri() + } + return + } val source = getSource() ?: throw NoStackTraceException("openUrl source cannot be null") appCtx.startActivity { putExtra("uri", url) @@ -1000,4 +1156,37 @@ interface JsExtensions : JsEncodeUtils { } } + /** + * 获取阅读配置 + */ + @JavascriptInterface + fun getReadBookConfig(): String { + return GSON.toJson(ReadBookConfig.durConfig) + } + + /* fun getReadBookConfigMap(): Map { + 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 { + return ThemeConfig.getDurConfig(appCtx).toMap() + } */ + } diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt index 82bd9280d..26b0c8160 100644 --- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt +++ b/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) { diff --git a/app/src/main/java/io/legado/app/help/http/StrResponse.kt b/app/src/main/java/io/legado/app/help/http/StrResponse.kt index 46fed6291..ee9d52e34 100644 --- a/app/src/main/java/io/legado/app/help/http/StrResponse.kt +++ b/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 { diff --git a/app/src/main/java/io/legado/app/help/source/SourceVerificationHelp.kt b/app/src/main/java/io/legado/app/help/source/SourceVerificationHelp.kt index 80b514365..0d0bf4e86 100644 --- a/app/src/main/java/io/legado/app/help/source/SourceVerificationHelp.kt +++ b/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 { 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? { + 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) { diff --git a/app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt b/app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt new file mode 100644 index 000000000..57f35ba49 --- /dev/null +++ b/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, 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>(headers).getOrNull() ?: emptyMap() + return super.get(urlStr, headerMap, 9000).body() + } + + @JavascriptInterface + fun get(urlStr: String, headers: String, timeout: Int?): String { + val headerMap = GSON.fromJsonObject>(headers).getOrNull() ?: emptyMap() + return super.get(urlStr, headerMap, timeout).body() + } + + @JavascriptInterface + fun post(urlStr: String, body: String, headers: String): String { + val headerMap = GSON.fromJsonObject>(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>(headers).getOrNull() ?: emptyMap() + return super.post(urlStr, body, headerMap, timeout).body() + } + + @JavascriptInterface + fun head(urlStr: String, headers: String): String { + val headerMap = GSON.fromJsonObject>(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>(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(); + }; + })();""" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt index 43de180be..93b9a90c9 100644 --- a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt b/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt index cdc512371..6b9b5fd5d 100644 --- a/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt +++ b/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? = null, - hasLoginHeader: Boolean = true + hasLoginHeader: Boolean = true, + private val infoMap: MutableMap? = null ) : JsExtensions { + constructor(mUrl: String) : this(mUrl, null) var ruleUrl = "" private set @@ -98,7 +104,8 @@ class AnalyzeUrl( private set val headerMap = LinkedHashMap() 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("" + 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() } 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( ) } + diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt index ee2876072..8cdd5ca55 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt +++ b/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>(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>(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 + } + 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) } } - else { - toastOnUi("链接格式错误") - return - } + }.onError { + AppLog.put("执行图片链接click键值出错\n${it.localizedMessage}", it, true) } } diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt b/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt index f8ec4d0bc..d39ae4187 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt +++ b/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 { + 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) } } diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt index 084d8d320..033df9675 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/ImageColumn.kt +++ b/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() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt index cd52c8d76..5feae792a 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt +++ b/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() + val clickList = LinkedList() sb.setLength(0) var isFirstLine = true if (content.contains(">(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? = null + srcList: LinkedList? = null, + clickList: LinkedList? = 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, - srcList: LinkedList? + srcList: LinkedList?, + clickList: LinkedList? ) { 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, - srcList: LinkedList? + srcList: LinkedList?, + clickList: LinkedList? ) { 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, - srcList: LinkedList? + srcList: LinkedList?, + clickList: LinkedList? ) { 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? + srcList: LinkedList?, + clickList: LinkedList? ) { 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 -> { diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt index 28773538a..4eafc5949 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt @@ -663,9 +663,10 @@ class SearchActivity : VMBaseActivity { putExtra("key", key) + putExtra("searchScope", searchScope) } } diff --git a/app/src/main/java/io/legado/app/ui/login/SourceLoginDialog.kt b/app/src/main/java/io/legado/app/ui/login/SourceLoginDialog.kt index c4afe452e..b9de6c6a9 100644 --- a/app/src/main/java/io/legado/app/ui/login/SourceLoginDialog.kt +++ b/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() - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - 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() - 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)) + private var lastClickTime: Long = 0 + private var oKToClose = false + private var rowUis: List? = null + private var rowUiName = arrayListOf() + 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?) { + activity?.runOnUiThread { // 在主线程中更新 UI + handleUpUiData(data) } + } - 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) - } + override fun reUiView() { + activity?.runOnUiThread { + handleReUiView() } } } - } catch (e: NullPointerException) { - AppLog.put("登录UI JSON 数据错误", e, true) + ) + } + + private fun handleReUiView() { + val source = viewModel.source ?: return + val loginUiStr = source.loginUi ?: return + val codeStr = loginUiStr.let { + when { + it.startsWith("@js:") -> it.substring(4) + it.startsWith("") -> it.substring(4, it.lastIndexOf("<")) + else -> null + } } - binding.toolBar.inflateMenu(R.menu.source_login) - binding.toolBar.menu.applyTint(requireContext()) + 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?) { + hasChange = true + if (data == null) { + val newLoginInfo: MutableMap = mutableMapOf() + rowUis?.forEachIndexed { index, rowUi -> + val default = rowUi.default + when (val rowView = binding.root.findViewById(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(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(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(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 { + 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? { + return GSON.fromJsonArray(json).onFailure { + it.printOnDebug() + }.getOrNull() + } + + @SuppressLint("SetTextI18n", "ClickableViewAccessibility") + private fun rowUiBuilder(source: BaseSource, rowUis: List?) { + 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 + } + } + } + } + } + + private fun buttonUi(source: BaseSource, rowUis: List?) { + 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) { + 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("") -> 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, + 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?): HashMap { + private fun getLoginData( + rowUis: List?, + save: Boolean = false + ): MutableMap { val loginData = hashMapOf() - loginUi?.forEachIndexed { index, rowUi -> + rowUis?.forEachIndexed { index, rowUi -> when (rowUi.type) { - "text", "password" -> { + Type.text, Type.password -> { val rowView = binding.root.findViewById(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) { + private fun login(source: BaseSource, loginData: MutableMap) { 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() } diff --git a/app/src/main/java/io/legado/app/ui/login/SourceLoginJsExtensions.kt b/app/src/main/java/io/legado/app/ui/login/SourceLoginJsExtensions.kt new file mode 100644 index 000000000..1a62a6993 --- /dev/null +++ b/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?) + fun reUiView() + } + + fun upLoginData(data: Map?) { + 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 + ) + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt b/app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt index 73742fd47..ff2bca0b0 100644 --- a/app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt +++ b/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 = emptyMap() + var book: Book? = null + var bookType: Int = 0 + var chapter: BookChapter? = null + var loginInfo: MutableMap = 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) { diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index e2111d093..17208eca5 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/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() diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt index 9f4d9d2f0..d3f26d2a9 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt index 18b39c8ba..5fba65e8e 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt +++ b/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 { diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt index 8408fd9a4..362ea227a 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt +++ b/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 { + putExtra("sortUrl", sortUrl) + putExtra("sourceUrl", sourceUrl) + putExtra("key", key) + } + } + } } diff --git a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt index e799f4544..a0e06cd0e 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt +++ b/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 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 } } + companion object { + fun start(context: Context, title: String?, url: String, origin: String) { + context.startActivity { + putExtra("title", title ?: "") + putExtra("origin", origin) + putExtra("openUrl", url) + } + } + + private val webCookieManager by lazy { android.webkit.CookieManager.getInstance() } + } + } diff --git a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt index 58ac63024..f201b0187 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt +++ b/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!!) diff --git a/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt b/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt index 5df98b9b7..f18456aa1 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt +++ b/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 = WeakReference(activity) + val sourceRef: WeakReference = 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 { + putExtra("type", "bookSource") + putExtra("key", toSource.bookSourceUrl) + } + } + } + + is RssSource -> { + withContext(Main) { + activity.startActivity { + 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 { + putExtra("exploreName", title) + putExtra("sourceUrl", sourceUrl) + putExtra("exploreUrl", url) + } + } + } + } + } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt index ff325cc67..3b37a3eda 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt +++ b/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 BatchImportDialog( .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f), containerColor = Color.Transparent, topBar = { - TopAppBar( + CenterAlignedTopAppBar( title = { AnimatedText( if (selectedCount > 0) diff --git a/app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt b/app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt new file mode 100644 index 000000000..7d0262306 --- /dev/null +++ b/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(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("")) { + html.replaceFirst( + "", + "" + ) + } else { + "$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, + 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 { 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() + } + } +} diff --git a/app/src/main/java/io/legado/app/utils/StringExtensions.kt b/app/src/main/java/io/legado/app/utils/StringExtensions.kt index 5de0b9086..4872c4ae3 100644 --- a/app/src/main/java/io/legado/app/utils/StringExtensions.kt +++ b/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? = + 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() +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/ViewExtensions.kt b/app/src/main/java/io/legado/app/utils/ViewExtensions.kt index 2ee4c9711..b7df33178 100644 --- a/app/src/main/java/io/legado/app/utils/ViewExtensions.kt +++ b/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)) + } +} diff --git a/app/src/main/res/layout/dialog_web_view.xml b/app/src/main/res/layout/dialog_web_view.xml new file mode 100644 index 000000000..b0bba099c --- /dev/null +++ b/app/src/main/res/layout/dialog_web_view.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_selector_single.xml b/app/src/main/res/layout/item_selector_single.xml new file mode 100644 index 000000000..90e0fc954 --- /dev/null +++ b/app/src/main/res/layout/item_selector_single.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8f1698c25..2a47b3ed9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1382,4 +1382,5 @@ 音频缓存保留时间(分钟) 当前%d, 输入 0 代表退出即刻清理。 启用滑动时动画 + 点击图片行为 \ No newline at end of file diff --git a/app/src/main/res/values/array_values.xml b/app/src/main/res/values/array_values.xml index b5a1a0485..87a29b5dc 100644 --- a/app/src/main/res/values/array_values.xml +++ b/app/src/main/res/values/array_values.xml @@ -52,6 +52,13 @@ 4 + + 0 + 1 + 2 + 3 + + 0 1 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 04715765b..275f7d4eb 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -116,7 +116,14 @@ @string/screen_portrait_reversed - + + 新包名阅读行为 + 预览图片 + 经典阅读行为 + 不响应 + + + Single page Double page Double page in landscape diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 651760092..d65a64f78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1386,4 +1386,5 @@ 音频缓存保留时间(分钟) 当前%d, 输入 0 代表退出即刻清理。 启用滑动时动画 + 点击图片行为 diff --git a/app/src/main/res/xml/pref_config_read.xml b/app/src/main/res/xml/pref_config_read.xml index 7200fdae8..8beb351a6 100644 --- a/app/src/main/res/xml/pref_config_read.xml +++ b/app/src/main/res/xml/pref_config_read.xml @@ -196,10 +196,13 @@ android:title="@string/no_anim_scroll_page" app:isBottomBackground="true" /> -